Joyuno's Dev Blog

2026-10-29 (목) 개발일지

dev-blog

오늘은 세 가지를 손댔다. Lepons에서 Claude 사용량이 왜 UI에 안 뜨는지, Tailscale 없이 폰과 PC를 잇는 중계 서버를 새로 세우는 일, 그리고 게임의 빈칸 모드를 에디터 3지선다로 갈아엎는 설계. 셋 다 “처음 짚은 원인이 틀렸다”로 시작해서 다른 곳에서 답이 나온 하루였다.

Lepons — Claude 사용량이 UI에서 조용히 사라지던 문제, statusLine 체인으로 잡았다

증상은 단순했다. 맥에서만 Claude 사용량이 UI에 아예 안 뜬다. 같은 함수가 Codex 사용량은 8% / 5%로 정상으로 뽑아내는데, Claude만 빈칸이었다.

6줄 진단을 돌렸다.

  • claude --version → 2.1.312, 정상
  • ~/.lepons-claude-usage.json → 없음
  • claude-statusline.json → 없음
  • --settings 프로세스 → 없음
  • disableAllHooks / allowManagedHooksOnly → 둘 다 없음
  • billingType → stripe_subscription, 정상

처음엔 OS 권한이나 훅 차단, 구독 문제를 의심했다. 셋 다 아니었다. 2번이 증상의 직접 원인이었고, 3번이 진짜 원인이었다.

Claude Code는 rate_limits를 status line 명령에만 넘겨준다. claude --help의 서브커맨드를 전부 확인했지만 usage 같은 건 없다. 그래서 Lepons는 자기가 띄우는 세션에 “입력을 파일로 저장만 하는” status line을 --settings로 꽂아 넣는다. 그런데 index.ts:1370-1375에 이런 분기가 있다.

if (own) return;   // ← 사용자 status line이 있으면 여기서 끝

사용자의 ~/.claude/settings.json에 이미 GSD statusline이 있었고, Lepons는 “남의 status line은 덮지 않는다”는 의도된 설계를 따라 조용히 물러났다.

연쇄는 3번(자기 statusline 안 꽂음) → 4번(--settings 없음) → 2번(파일 없음) → UI 공백이었다. 버그라기보다 설계상 사각지대다. 읽는 쪽 배관은 멀쩡했다 — Codex는 같은 함수가 정상으로 뽑아낸다.

해결 A는 사용자 설정 한 줄이었다. statusLine.command 앞에 저장 단계를 체인했다.

t=$(cat); case "$t" in *'"rate_limits"'*) printf '%s' "$t" > ~/.lepons-claude-usage.json;; esac; printf '%s' "$t" | .../gsd-statusline.js

임시 HOME으로 검증했다. GSD 출력은 그대로 나오고, 321B 파일이 생성되고, Lepons 파싱이 {"claude":{"fiveHour":{"usedPercent":42},"week":{"usedPercent":17}}}로 성공했다. rate_limits가 없는 입력은 case 가드가 덮어쓰지 않았다.

시행착오가 하나 있었다. 검증 중에 “GSD statusline이 낡아서 Opus 5가 나온다”고 잘못 결론지었다. 내가 만든 fixture에 "display_name":"Opus 5"를 박아둔 게 원인이었다. gsd-statusline.js 804줄에 하드코딩 모델명은 하나도 없고, compactModelName()은 (1M context)→(1M) 축약만 한다. fixture를 <MODEL-PLACEHOLDER>로 바꿨다.

B안 명세도 같이 썼다. --settings는 문서상 additional settings — 병합이고 최우선이다. 그래서 hasOwnStatusLine()의 포기가 애초에 불필요했다. ownStatusLineCommand()로 사용자 명령을 읽어 뒤에 체인하고, if (own) return;을 지우는 게 핵심이다. 원격 PC 경로는 그대로 뒀다 — SSH로 JSON을 먼저 읽어야 해서 한 줄 셸로 못 묶는다.

지금 되돌리지 않은 이유는 명확했다. 1.7.6은 아직 없고(최신 1.7.5, 설치본 1.7.2), 되돌리면 파일이 남아 main.ts:4196-4198의 resetsAt 가드 때문에 조용히 틀린 숫자가 된다. 41%가 2.8시간 고정된 뒤 0%가 된다.

Lepons 중계 서버 — 3안 3심사에서 ops/Go가 이겼고, close code 1005를 잡았다

Tailscale 없이 폰과 PC를 잇는 서버다. 둘 다 밖으로 나가는 WebSocket을 열고, 같은 방의 메시지를 그대로 넘긴다. 제약은 메모리 128MB, CPU 0.5코어.

설계 3안을 독립으로 쓰고 3명이 교차 채점했다.

설계 런타임 합
minimal Go 129
abuse Bun 134
ops Go 134

2:1로 ops / Go + coder/websocket이 이겼다. Bun을 고른 심사위원도 “인터넷에 닿는 컨테이너에 셸이 없어야 한다면 Go”라고 스스로 조건을 달았다.

심사가 지적한 치명 결함은 전부 반영했다.

  • minimal의 “먼저 온 쪽이 이김” → 새로 온 쪽이 이기도록 뒤집었다. 와이파이→LTE 전환마다 40초 잠금이 생기던 문제.
  • abuse의 “식별 없는 교체” → leave()가 자리가 아직 나를 가리킬 때만 비우게 고쳤다(main.go:103-124). 밀려난 소켓이 뒤늦게 자기 자리를 지우던 버그.
  • ops의 “조용한 절단” → 쓰기 실패 시 반쯤 보낸 메시지를 얌전히 닫으면 상대는 형식은 멀쩡한 짧은 암호문을 받는다. watchRead로 어느 쪽이 터졌는지 구분해 상대를 끊는다.
  • peer-joined 없음 → 제어는 텍스트 프레임, 페이로드는 바이너리 프레임으로 갈랐다.

라이브러리 동시성은 코드로 확인했다. conn.go:30 “All methods may be called concurrently except Reader/Read”, write.go:27 “Only one writer open at a time”. 쓰기 뮤텍스를 만들 필요가 없었다.

실측은 20 통과 / 0 실패, 메모리 2MiB / 128MiB, 이미지 2.4MB, 75초 유휴 후에도 양쪽 생존. compose는 memory: 134217728, cpus: 0.5, internal: true, volumes 0개, ports 0개로 검증됐다.

로그를 보다 작은 버그를 하나 잡았다. code=1005를 그대로 close 프레임에 실어 보내려 하고 있었다 — RFC 6455가 금지하는 값이다(-1, 1006도 같다). 수정했다.

시행착오는 smoke 스크립트 쪽이었다. pc2가 아직 슬롯 하나를 쥐고 있는 걸 안 세서 7번째 연결이 정상적으로 503을 받았다. relay는 설계대로 동작했다.

남은 결정은 네 개다. 메시지 상한 1MiB, 같은 역할 충돌 시 새로 온 쪽 승인, 로그에 close code를 남기는 것, 도메인 이름.

게임 — 자동완성과 빈칸은 같은 화면이 아니었다

빈칸 유형을 완전히 없애고 모든 문제를 코드 에디터로 푸는 전환이다. 자동완성은 정답에서 뽑은 후보 3개, 토글 기본 on.

처음엔 자동완성이 빈칸 화면에 뜬다고 봤다. 틀렸다. coding_workspace.gd:618의 _is_blank_mode()는 모드를 유저 선택이 아니라 문제 속성(masked_template 유무)으로 가른다.

  문제 수 화면 자동완성
템플릿 있음 130 빈칸 격자 없음
템플릿 없음 54 자유 코딩 있음

내가 본 검은 패널은 54문제 쪽이었다. 130문제는 지금도 타이핑으로 빈칸을 채우는 중이고, 거기엔 자동완성이 뜬 적이 없다. “받아쓰기”도 모드 전환이 아니라 커닝 선언이다 — 켜면 서버에서 정답을 받아 채우고 _dictation_used = true로 표시한다.

그래서 후보 3개는 빈칸 쪽에 붙여야 한다. 정답·힌트 798쌍이 거기에만 있다. 다만 blank_grid는 등폭 LineEdit을 코드 줄에 인라인으로 맞물려 그리고 테스트가 좌표로 재서, 행 안에 버튼을 넣으면 칸 계산이 깨진다(:283). 답 보기처럼 포커스된 빈칸을 대상으로 액션 영역에 띄우는 쪽으로 정했다.

마커는 한 칸 늘리면 된다. ⟦정답‖힌트⟧ → ⟦정답‖힌트‖오답1‖오답2⟧. parse_template의 partition 2분할을 4분할로 바꾸고, 기존 798개는 오답 칸이 비면 자동으로 토글 OFF로 떨어지게 했다.

BOJ 쪽에서 예상 밖의 사실이 나왔다. 삼성 기출 예제를 확보하려고 curl에 브라우저 UA를 붙여도, WebFetch로도 404였다. 봇 차단인 줄 알고 aside로 열어보니 페이지 제목이 “BOJ 서비스 재개 일정 연기 안내”였다. 10/22 공지에 운영사가 바뀌었고, 10/26 재오픈이 보안 점검으로 무기한 연기된 상태다. 대신 인터넷 아카이브에 원본 스냅샷이 남아 있어서 「벽 부수고 이동하기」 예제를 공백까지 그대로 뽑아 확인했다.

테스트 기준선도 이번에 확보했다. 컨테이너에서 2269 passed, 6 failed — 실패 6개는 전부 test_local_topology.py다. 이 모듈은 compose 파일이 없는 컨테이너에서 원래 통과할 수 없고, 호스트에서 따로 돌리니 9 passed였다. 일회용 Postgres를 띄워 호스트에서 Piston 제외 부분집합 2079 passed도 확인했다.

남은 건 슬라이스 3문제(anagram-check, boj-1012, boj-2206)로 끝까지 돌려보는 것까지다. 1만 개가 넘는 오답은 직접 써 보신 뒤 형식이 굳으면 별도 계획으로 쓰기로 했다.

오늘의 한줄

세 개 다 첫 가설이 틀렸고, 답은 내가 안 본 곳이 아니라 당연하다고 넘긴 분기에 있었다 — if (own) return;, 모드가 유저 선택이라는 가정, 봇 차단이라는 가정.