coxpit 6.3.0 → 6.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { config } from './config';
2
2
  import { authMode, ensureSetupToken, isExposedBind } from './authkey';
3
+ import { ensureClaudeShim } from './claudeshim';
3
4
  import { db, ensureSchema } from './db';
4
5
  import { machines } from './db/schema';
5
6
  import { acquireDaemonLock, updateLockPort } from './lock';
@@ -27,6 +28,13 @@ await acquireDaemonLock();
27
28
 
28
29
  await ensureSchema();
29
30
 
31
+ // 세션마다 claude 대화를 정확히 묶기 위한 얇은 심(PATH 맨 앞) — 부팅 1회.
32
+ // claude 가 없는 기계면 조용히 건너뛴다(세션은 지금까지와 같이 동작하고, 뷰어는 폴백으로 찾는다).
33
+ {
34
+ const shim = ensureClaudeShim();
35
+ if (shim.real) console.log(`[coxpit] claude shim ready at ${shim.path} (tags each session's conversation with --session-id)`);
36
+ }
37
+
30
38
  // 첫 실행 시 로컬 머신 시드(데몬이 도는 이 기계).
31
39
  if ((await db.select().from(machines)).length === 0) {
32
40
  await db.insert(machines).values({ slug: 'local', name: 'This machine', kind: 'local', online: true });
@@ -1,11 +1,12 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { posix as ppath } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { existsSync, statSync, openSync, readSync, closeSync, mkdirSync } from 'node:fs';
5
5
  import { mkdir, copyFile, readFile, writeFile, rm, unlink } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import type { ChildProcess } from 'node:child_process';
8
- import { eq, inArray, and } from 'drizzle-orm';
8
+ import { eq, inArray, and, desc } from 'drizzle-orm';
9
+ import { CLAUDE_SID_RE, claudeTagEnvArgs } from './claudeshim';
9
10
  import { config } from './config';
10
11
  import { db } from './db';
11
12
  import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups, secrets } from './db/schema';
@@ -104,8 +105,17 @@ async function recordEvent(runId: number, kind: string, payload: string): Promis
104
105
  broadcast({ type: 'event', runId, kind, payload });
105
106
  }
106
107
 
108
+ /**
109
+ * run 레코드가 움직인 횟수. 오케스트레이션 워처(1.5초 간격)가 "다시 읽을 일이 생겼나"를
110
+ * 쿼리 없이 O(1) 로 판단하는 데 쓴다. setRun 은 run 의 수명 전환에서만 불린다(줄당이 아니다)
111
+ * — 그래서 이 카운터는 드물게 움직이고, 조용한 틱은 DB 를 아예 건드리지 않는다.
112
+ * 부모별로 좁히지 않고 하나로 두는 건 의도다: 넘치게 칠해 한 번 더 읽는 쪽이 놓치는 쪽보다 낫다.
113
+ */
114
+ let runRev = 0;
115
+
107
116
  async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInsert>): Promise<void> {
108
117
  await db.update(agentRuns).set(patch).where(eq(agentRuns.id, runId));
118
+ runRev++;
109
119
  broadcast({ type: 'run', runId, ...patch });
110
120
  }
111
121
 
@@ -123,6 +133,45 @@ export async function secretEnvArgs(): Promise<string> {
123
133
  } catch { return ''; }
124
134
  }
125
135
 
136
+ /** 이 머신이 데몬이 사는 그 기계인가(심 파일에 닿을 수 있나). */
137
+ function isLocalMachine(m: MachineTarget): boolean {
138
+ return m.kind === 'local' || m.address === '';
139
+ }
140
+
141
+ /**
142
+ * 인터랙티브 세션(사람이 `claude` 를 쳐 넣는 자리)의 claude 대화 태그.
143
+ * 세션을 열기 전에 id 를 정해 run 에 적고, 그 세션 tmux 에는 심 폴더를 앞세운 PATH 와
144
+ * COXPIT_CLAUDE_SID 를 넣는다 — 그러면 사람이 그냥 `claude` 를 쳐도 그 대화가 이 id 로 묶이고,
145
+ * 뷰어는 대본 파일명을 추측하지 않는다(§getSessionChat ①).
146
+ * id 는 심이 없어도 적어 둔다: 그 이름의 파일이 실제로 있을 때만 쓰이므로 거짓이 새지 않고,
147
+ * 컬럼의 뜻이 "이 세션이 쓰기로 한 대화" 하나로 단순해진다.
148
+ */
149
+ function sessionClaudeTag(machine: MachineTarget): { sid: string; env: string } {
150
+ const sid = randomUUID();
151
+ return { sid, env: claudeTagEnvArgs(sid, isLocalMachine(machine)) };
152
+ }
153
+
154
+ /**
155
+ * 죽은 세션을 그 자리에서 되살릴 때 다시 얹는 태깅 env(터미널 소생 경로 — server.ts).
156
+ * 셸이 exit 한 뒤 사람이 다시 들어와 `claude` 를 치는 자리가 정확히 여기라, 여기서 빠지면 태그가 끊긴다.
157
+ */
158
+ export function claudeTagEnvForRun(machine: MachineTarget, sid: string): string {
159
+ return claudeTagEnvArgs(sid, isLocalMachine(machine));
160
+ }
161
+
162
+ /**
163
+ * coxpit 세션의 tmux 스크롤백 한도. tmux 기본값(2000줄)으로는 뷰어의 "터미널" 탭이 금방 잘린다.
164
+ * history-limit 은 **pane 이 만들어지는 순간**의 값이 적용된다(기존 pane 은 안 늘어난다) —
165
+ * new-session 뒤에 세션 옵션으로 올리면 첫 pane 에는 안 먹는다. 그래서 같은 tmux 호출 안에서
166
+ * 전역 옵션을 먼저 올리고 세션을 만든다(start-server 로 서버를 띄워 둬야 set -g 가 산다).
167
+ */
168
+ export const TMUX_HISTORY_LIMIT = 100000;
169
+
170
+ /** `tmux new-session <rest>` 대신 쓰는 셸 조각 — history-limit 을 올린 뒤 세션을 만든다. rest 는 호출부가 인용한다. */
171
+ export function tmuxNewSession(rest: string): string {
172
+ return `tmux start-server \\; set-option -g history-limit ${TMUX_HISTORY_LIMIT} \\; new-session ${rest}`;
173
+ }
174
+
126
175
  // 실행 중 run 의 자식 프로세스(stop 용). stoppedRuns = 사용자가 멈춘 run 표식.
127
176
  const liveChildren = new Map<number, ChildProcess>();
128
177
  const stoppedRuns = new Set<number>();
@@ -277,14 +326,17 @@ export function startOrchWatch(runId: number, wtPath: string, real: boolean): No
277
326
  const dir = ppath.join(wtPath, '.coxpit');
278
327
  let last = '';
279
328
  let busy = false;
329
+ let seenRev = -1; // 현황을 마지막으로 읽은 시점의 runRev. -1 = 아직 한 번도 안 읽음(첫 틱은 읽는다)
280
330
  return setInterval(() => {
281
331
  if (busy) return;
282
332
  busy = true;
283
333
  void (async () => {
334
+ let consumed = false;
284
335
  try {
285
336
  const spawnPath = ppath.join(dir, 'spawn.json');
286
337
  const txt = await readFile(spawnPath, 'utf8').catch(() => null);
287
338
  if (txt !== null) {
339
+ consumed = true;
288
340
  await rm(spawnPath).catch(() => { /* consumed */ });
289
341
  try {
290
342
  const req = JSON.parse(txt) as unknown;
@@ -298,14 +350,22 @@ export function startOrchWatch(runId: number, wtPath: string, real: boolean): No
298
350
  await recordEvent(runId, 'error', 'spawn.json was not valid JSON — nothing spawned');
299
351
  }
300
352
  }
301
- // 현황 파일 — 내용이 바뀔 때만 다시 쓴다
302
- const subs = await listSubtasks(runId);
303
- if (subs.length) {
304
- const j = JSON.stringify(subs, null, 2);
305
- if (j !== last) {
306
- last = j;
307
- await mkdir(dir, { recursive: true });
308
- await writeFile(ppath.join(dir, 'subtasks.json'), j);
353
+ // 현황 파일 — **이번 틱에 뭔가 실제로 벌어졌을 때만** 읽고 쓴다.
354
+ // 조건 둘: spawn.json 을 소비했거나(), run 레코드가 움직였거나(runRev).
355
+ // 조용한 틱(대부분)에는 쿼리조차 돌지 않는다 — 1.5초마다 도는 워처라 이 절약이
356
+ // run 사는 동안 계속 쌓인다. rev 를 **읽기 전에 떠 두고 성공 후에 저장**하는 건 의도다:
357
+ // 실패하면 여전히 더러운 상태로 남아 다음 틱에 재시도되고, 읽는 동안 생긴 변화도 안 놓친다.
358
+ const rev = runRev;
359
+ if (consumed || rev !== seenRev) {
360
+ const subs = await listSubtasks(runId);
361
+ seenRev = rev;
362
+ if (subs.length) {
363
+ const j = JSON.stringify(subs, null, 2);
364
+ if (j !== last) {
365
+ last = j;
366
+ await mkdir(dir, { recursive: true });
367
+ await writeFile(ppath.join(dir, 'subtasks.json'), j);
368
+ }
309
369
  }
310
370
  }
311
371
  } catch { /* 워처 오류는 조용히 — 다음 틱에 재시도 */ }
@@ -351,6 +411,8 @@ export async function spawnSubtasks(parentRunId: number, title: string, prompt:
351
411
  void launchRun(run.id, real);
352
412
  runIds.push(run.id);
353
413
  }
414
+ // 새 자식이 생겼다 — 부모 워처가 다음 틱에 현황을 다시 읽도록 표시한다(HTTP 쌍둥이 경로 포함).
415
+ runRev++;
354
416
  await recordEvent(parentRunId, 'meta', JSON.stringify({ subtask: task.id, title: task.title, runs: runIds }));
355
417
  return { ok: true, detail: `spawned task #${task.id} (${runIds.length} run(s))`, taskId: task.id, runIds };
356
418
  }
@@ -506,7 +568,7 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
506
568
  // export LANG: 이 명령이 tmux 서버를 처음 띄우는 경우(특히 원격) C 로케일로 뜨면 CJK 가 깨진다.
507
569
  const runEnv = await secretEnvArgs();
508
570
  await runShellOn(ctx.machine,
509
- `export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d${runEnv} -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
571
+ `export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; ${tmuxNewSession(`-d${runEnv} -s ${shq(session)} -c ${shq(wtPath)}`)} 2>/dev/null || true`, 8000);
510
572
 
511
573
  await setRun(runId, { status: 'running' });
512
574
  await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal, ...(inPlace ? { inPlace: true } : {}) }));
@@ -766,9 +828,117 @@ export async function getRunTermInfo(runId: number): Promise<{ machine: MachineT
766
828
  return { machine: ctx.machine, session: run.tmuxWindow };
767
829
  }
768
830
 
831
+ /** 대본 꼬리 상한 — runShellOn 의 maxBuffer(1MB)를 넘기면 읽기 자체가 실패한다. */
832
+ const CHAT_TAIL_BYTES = 800000;
833
+
834
+ /**
835
+ * 이름으로 대본 한 장 읽기 — 후보 폴더를 순서대로 보고 **처음 있는** `<sid>.jsonl` 의 꼬리.
836
+ * 없으면 빈 문자열(그러면 호출부가 다음 수단으로 내려간다).
837
+ */
838
+ async function readTranscriptBySid(machine: MachineTarget, cands: string[], sid: string): Promise<string> {
839
+ const name = shq(`${sid}.jsonl`);
840
+ const cmd = `for d in ${cands.map((d) => shq(d)).join(' ')}; do f="$HOME/.claude/projects/$d/"${name};` +
841
+ ` if [ -f "$f" ]; then tail -c ${CHAT_TAIL_BYTES} "$f"; exit 0; fi; done`;
842
+ const r = await runShellOn(machine, cmd, 15000);
843
+ return r.ok ? r.stdout : '';
844
+ }
845
+
846
+ /**
847
+ * 페인 화면에서 "이 대화에만 있을 법한" 긴 조각 몇 개.
848
+ * TUI 테두리·불릿 같은 장식은 공백으로 벗기고, 대본(JSON) 안에서 **그대로** 찾을 수 있는 구간만 남긴다
849
+ * (따옴표·역슬래시는 JSON 에서 escape 되므로 조각에 넣지 않는다). 긴 것부터 앞에 온다.
850
+ */
851
+ function paneNeedles(screen: string[], want = 4): string[] {
852
+ const out: string[] = [];
853
+ const seen = new Set<string>();
854
+ for (const raw of screen) {
855
+ const t = raw.replace(/[\u2500-\u257F\u2022\u25A0-\u25FF]/g, ' ').replace(/\s+/g, ' ').trim();
856
+ if (t.length < 24) continue;
857
+ const piece = (t.match(/[^"\\]{24,}/g) ?? []).sort((a, b) => b.length - a.length)[0];
858
+ if (!piece) continue;
859
+ const s = piece.trim();
860
+ // 글자가 섞여 있어야 한다 — 기호만 남은 줄(프롬프트·구분선)은 아무 대본에나 걸린다.
861
+ if (s.length < 24 || seen.has(s) || !/[A-Za-z0-9\u3131-\uD79D\u3040-\u30FF\u4E00-\u9FFF]{4,}/.test(s)) continue;
862
+ seen.add(s);
863
+ out.push(s);
864
+ }
865
+ out.sort((a, b) => b.length - a.length);
866
+ return out.slice(0, want);
867
+ }
868
+
869
+ /**
870
+ * 태그 없이 이미 돌고 있는 세션의 대본 찾기 (best-effort) — **화면에 뜬 말**로 맞춰 본다.
871
+ * 사후에 페인↔.jsonl 을 잇는 파일시스템·프로세스 신호가 없기 때문에(열린 fd 도, argv 도 없다)
872
+ * 남는 단서는 그 페인이 지금 보여주고 있는 내용뿐이다.
873
+ * 규율: 페인이 claude 를 물고 있을 때만, 최신 25장만, 확실한 1등만.
874
+ * - 동점이면 페인의 마지막 활동 시각에 mtime 이 가장 가까운 쪽을 고르고,
875
+ * - 그래도 갈리면 답하지 않는다(찾지 못한 것을 찾았다고 하지 않는다).
876
+ * 다른 run 이 이미 자기 대화라고 적어 둔 id 는 후보에서 뺀다 — 남의 대본을 집지 않는 정확한 울타리다
877
+ * ("지금 가장 빨리 자라는 대본" 같은 추정보다 이게 싸고 확실하다).
878
+ */
879
+ async function matchTranscriptByPane(
880
+ machine: MachineTarget, session: string, cands: string[], runId: number,
881
+ ): Promise<string> {
882
+ const t = shq(session);
883
+ const probe = await runShellOn(
884
+ machine,
885
+ `printf 'CMD:%s\\n' "$(tmux display -p -t ${t} '#{pane_current_command}' 2>/dev/null)"; ` +
886
+ `printf 'ACT:%s\\n' "$(tmux display -p -t ${t} '#{session_activity}' 2>/dev/null)"; ` +
887
+ `tmux capture-pane -t ${t} -p -J -S -200 2>/dev/null || true`,
888
+ 12000,
889
+ );
890
+ if (!probe.ok) return '';
891
+ let cmd = '';
892
+ let act = 0;
893
+ const screen: string[] = [];
894
+ for (const l of probe.stdout.split('\n')) {
895
+ if (l.startsWith('CMD:')) { cmd = l.slice(4).trim(); continue; }
896
+ if (l.startsWith('ACT:')) { act = Number(l.slice(4).trim()) || 0; continue; }
897
+ screen.push(l);
898
+ }
899
+ // claude TUI 는 node 로 뜬다(래퍼에 따라 이름이 다르다) — 평범한 셸 페인에서 남의 대본을 집지 않기 위한 문지기.
900
+ if (!/claude|^(node|bun|deno)$/.test(cmd)) return '';
901
+ const needles = paneNeedles(screen);
902
+ if (needles.length < 2) return '';
903
+ const greps = needles.map((n) => `grep -qF ${shq(n)} "$f" 2>/dev/null && n=$((n+1));`).join(' ');
904
+ const scan = `for d in ${cands.map((d) => shq(d)).join(' ')}; do p="$HOME/.claude/projects/$d";` +
905
+ ` c=$(ls -t "$p"/*.jsonl 2>/dev/null | head -1); if [ -n "$c" ]; then cd "$p" || exit 0;` +
906
+ ` for f in $(ls -t *.jsonl 2>/dev/null | head -25); do n=0; ${greps}` +
907
+ ` m=$(date -r "$f" +%s 2>/dev/null || echo 0); printf '%s %s %s\\n' "$n" "$m" "$f"; done; exit 0; fi; done`;
908
+ const sc = await runShellOn(machine, scan, 20000);
909
+ if (!sc.ok) return '';
910
+ const claimed = await db.select({ id: agentRuns.id, sid: agentRuns.claudeSessionId }).from(agentRuns);
911
+ const exclude = new Set(claimed.filter((o) => o.id !== runId && o.sid).map((o) => o.sid));
912
+ const rows: Array<{ sid: string; score: number; mtime: number }> = [];
913
+ for (const l of sc.stdout.split('\n')) {
914
+ const m = /^(\d+) (\d+) (.+)\.jsonl$/.exec(l.trim());
915
+ if (!m) continue;
916
+ const sid = m[3]!;
917
+ if (exclude.has(sid) || !CLAUDE_SID_RE.test(sid)) continue;
918
+ rows.push({ sid, score: Number(m[1]), mtime: Number(m[2]) });
919
+ }
920
+ const best = rows.reduce((a, r) => Math.max(a, r.score), 0);
921
+ if (best < 1) return '';
922
+ const top = rows.filter((r) => r.score === best);
923
+ if (top.length === 1) return top[0]!.sid;
924
+ if (!act) return '';
925
+ top.sort((a, b) => Math.abs(a.mtime - act) - Math.abs(b.mtime - act));
926
+ return Math.abs(top[0]!.mtime - act) < Math.abs(top[1]!.mtime - act) ? top[0]!.sid : '';
927
+ }
928
+
769
929
  /**
770
930
  * 세션의 Claude Code 대화 로그(JSONL)를 대화형으로 파싱 — 뷰어의 "대화" 모드.
771
- * cwd(worktreePath)를 [^a-zA-Z0-9]→'-' 로 바꾼 ~/.claude/projects/<...>/ 폴더명 → 최신 .jsonl.
931
+ * 대본은 claude 가 **자기 cwd** 를 [^a-zA-Z0-9]→'-' 로 인코딩한 ~/.claude/projects/<...>/ 밑에 쌓인다.
932
+ * worktreePath 를 기준으로 삼으면 빈손이 되는 경우가 둘이고, 폰에서 "대화 탭이 비어 있다"던 게 정확히 이것이다:
933
+ * ① 루트·메인 세션은 worktreePath 가 아예 비어 있다(그래도 페인엔 멀쩡한 cwd 가 있다 — 그래서 여기서 포기하면 안 된다).
934
+ * ② 사람이 페인에서 cd 한 뒤엔 worktree 루트가 claude 의 cwd 가 아니다.
935
+ * 그래서 기준은 **페인이 지금 서 있는 폴더**(tmux #{pane_current_path} — getRunPwd)이고, worktreePath 는 폴백이다.
936
+ *
937
+ * 폴더가 정해져도 그 안의 **어느 .jsonl 이냐**가 남는다. 한 폴더(워크스페이스 루트)에 세션이 여럿이면
938
+ * "가장 최근 파일"은 거의 항상 남의 대화다 — 그래서 셋을 순서대로 본다:
939
+ * ① 이 세션에 태깅해 둔 id 의 파일(claudeSessionId, 정확) — §sessionClaudeTag 가 심어 둔 이름.
940
+ * ② 없으면 화면 내용으로 찾아(best-effort) **한 번만** 찾고 그 id 를 적어 둔다.
941
+ * ③ 그래도 못 찾으면 예전대로 최신 .jsonl (틀릴 수 있다는 것을 아는 폴백).
772
942
  * user/assistant turn 만 추출(tool_result 노이즈 제외, tool_use 는 칩으로).
773
943
  */
774
944
  export async function getSessionChat(runId: number, maxTurns = 200): Promise<{
@@ -777,12 +947,35 @@ export async function getSessionChat(runId: number, maxTurns = 200): Promise<{
777
947
  const info = await getRunTermInfo(runId);
778
948
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
779
949
  const run = rr[0];
780
- if (!info || !run || !run.worktreePath) return { ok: false, turns: [], note: 'no session' };
781
- const proj = run.worktreePath.replace(/[^a-zA-Z0-9]/g, '-');
782
- const cmd = `d=${shq(proj)}; f=$(ls -t "$HOME/.claude/projects/$d"/*.jsonl 2>/dev/null | head -1); [ -n "$f" ] && tail -n 8000 "$f" || true`;
783
- const r = await runShellOn(info.machine, cmd, 15000);
784
- if (!r.ok) return { ok: true, turns: [], note: 'no transcript' };
785
- if (!r.stdout.trim()) return { ok: true, turns: [], note: 'no Claude Code transcript for this folder' };
950
+ if (!info || !run) return { ok: false, turns: [], note: 'no session' };
951
+ const pwd = (await getRunPwd(runId)).pwd; // tmux #{pane_current_path} — 대본의 진짜 기준
952
+ const encoded = [pwd, run.worktreePath || '']
953
+ .filter((p) => !!p)
954
+ .map((p) => p.replace(/[^a-zA-Z0-9]/g, '-'));
955
+ const cands = encoded.filter((d, i) => encoded.indexOf(d) === i);
956
+ if (!cands.length) return { ok: false, turns: [], note: 'no session' };
957
+
958
+ // ① 태깅된 대본 — 이름을 미리 정해 뒀으면 추측할 것이 없다.
959
+ const tagged = CLAUDE_SID_RE.test(run.claudeSessionId) ? run.claudeSessionId : '';
960
+ let raw = tagged ? await readTranscriptBySid(info.machine, cands, tagged) : '';
961
+
962
+ // ② 태그가 (아직) 실물과 안 맞는 세션 — 화면에 뜬 말로 찾고, 찾았으면 적어 둬 다시 찾지 않는다.
963
+ if (!raw.trim()) {
964
+ const found = await matchTranscriptByPane(info.machine, info.session, cands, runId);
965
+ if (found) {
966
+ raw = await readTranscriptBySid(info.machine, cands, found);
967
+ if (raw.trim()) await setRun(runId, { claudeSessionId: found });
968
+ }
969
+ }
970
+
971
+ // ③ 폴백 — 이 폴더의 최신 .jsonl (페인 cwd 우선, worktree 폴백). 여러 세션이 한 폴더를 쓰면 남의 것일 수 있다.
972
+ if (!raw.trim()) {
973
+ const cmd = `f=''; for d in ${cands.map((d) => shq(d)).join(' ')}; do c=$(ls -t "$HOME/.claude/projects/$d"/*.jsonl 2>/dev/null | head -1); if [ -n "$c" ]; then f="$c"; break; fi; done; [ -n "$f" ] && tail -c ${CHAT_TAIL_BYTES} "$f" || true`;
974
+ const fb = await runShellOn(info.machine, cmd, 15000);
975
+ if (!fb.ok) return { ok: true, turns: [], note: 'no transcript' };
976
+ raw = fb.stdout;
977
+ }
978
+ if (!raw.trim()) return { ok: true, turns: [], note: 'no Claude Code transcript for this folder' };
786
979
  const userText = (content: unknown): string => {
787
980
  if (typeof content === 'string') return content;
788
981
  if (!Array.isArray(content)) return '';
@@ -801,7 +994,7 @@ export async function getSessionChat(runId: number, maxTurns = 200): Promise<{
801
994
  return { text: text.join('\n'), tools };
802
995
  };
803
996
  const turns: Array<{ role: string; text: string; tools?: string[] }> = [];
804
- for (const line of r.stdout.split('\n')) {
997
+ for (const line of raw.split('\n')) {
805
998
  if (!line.trim()) continue;
806
999
  let j: { type?: string; message?: { content?: unknown } };
807
1000
  try { j = JSON.parse(line); } catch { continue; }
@@ -818,16 +1011,44 @@ export async function getSessionChat(runId: number, maxTurns = 200): Promise<{
818
1011
 
819
1012
  /**
820
1013
  * tmux 페인 스크롤백 스냅샷 — 모바일 "위 내용 읽기"(뷰어의 터미널 모드)용.
821
- * capture-pane -S -N 으로 N 위부터 현재까지 텍스트를 통째로 반환(읽기 전용).
1014
+ * capture-pane -S - **히스토리 전체**(맨 처음 줄부터 현재 화면까지)를 떠서, 꼬리 N 줄만 돌려준다(읽기 전용).
1015
+ * 예전엔 -S -N 이었는데, 세션이 tmux 기본 history-limit(2000) 으로 떠 있어 N 을 아무리 키워도 소용없었다
1016
+ * → 세션 생성 때 한도를 올리고(tmuxNewSession), 여기선 범위를 잘라 묻지 않는다.
1017
+ * -J 는 화면 폭에서 접힌 줄을 **논리적 한 줄로 도로 붙인다**. 이게 없으면 캡처가 디스플레이 행 그대로 와서
1018
+ * 폰에서 긴 경로·명령어가 단어 중간에서 끊겼다("Internal Solutio / n %") — 읽기도 집기도 안 되던 원인.
1019
+ * 한계 둘(버그 아님):
1020
+ * - 전체 화면 TUI(claude CLI·vim·less 등)는 tmux 의 **대체 화면**을 쓴다. 대체 화면엔 스크롤백이 없어서
1021
+ * TUI 가 떠 있는 동안 캡처되는 건 지금 보이는 화면뿐이다(뷰어 터미널 탭이 그렇게 말해 준다). 일반 셸 출력은 전부 남는다.
1022
+ * - 헤드리스 에이전트 run 은 tmux 밖에서 돈다 — 그 tmux 는 빈 worktree 셸이 정상이고, 볼 곳은 "대화" 탭이다.
822
1023
  */
823
1024
  export async function getScrollback(runId: number, lines: number): Promise<{ ok: boolean; text: string }> {
824
1025
  const info = await getRunTermInfo(runId);
825
1026
  if (!info) return { ok: false, text: 'no terminal session' };
826
- const n = Math.max(50, Math.min(20000, Math.floor(lines) || 3000));
1027
+ const n = Math.max(50, Math.min(TMUX_HISTORY_LIMIT, Math.floor(lines) || TMUX_HISTORY_LIMIT));
827
1028
  // capture-pane 은 '=' 접두사(정확일치) 를 pane 타깃으로 못 받는다 → 세션명 그대로(존재 시 정확일치 우선).
828
- const r = await runShellOn(info.machine, `tmux capture-pane -t ${shq(info.session)} -p -S -${n}`, 15000);
1029
+ const r = await runShellOn(info.machine, `tmux capture-pane -t ${shq(info.session)} -p -J -S -`, 15000);
829
1030
  if (!r.ok) return { ok: false, text: (r.stderr || r.stdout).trim().slice(0, 500) };
830
- return { ok: true, text: r.stdout };
1031
+ const all = r.stdout.split('\n');
1032
+ return { ok: true, text: all.length > n ? all.slice(-n).join('\n') : r.stdout };
1033
+ }
1034
+
1035
+ /**
1036
+ * 살아 있는 tmux 페인에 한 줄 써 넣기 (v5.28 K) — getScrollback 의 **쓰기 쌍둥이**.
1037
+ * HUD 가 코크핏을 열지 않고 "대기 중인 에이전트"에게 답하는 유일한 길이다(steer 는 정착한 run 전용).
1038
+ * 타깃 규율은 capture-pane 과 똑같이 맞춘다: send-keys 도 '=' 접두사를 못 받는 tmux 가 있어
1039
+ * 세션명을 그대로 쓴다. 사람이 친 것과 같게 하려고 Enter 를 따로 한 번 더 보낸다.
1040
+ * 세션이 없거나(정리됨) 죽었으면 ok:false — 라우트가 409 로 돌려준다. 지어내지 않는다.
1041
+ */
1042
+ export async function sendRunInput(runId: number, text: string): Promise<{ ok: boolean; detail: string }> {
1043
+ const info = await getRunTermInfo(runId);
1044
+ if (!info) return { ok: false, detail: 'no terminal session' };
1045
+ const r = await runShellOn(
1046
+ info.machine,
1047
+ `tmux send-keys -t ${shq(info.session)} ${shq(text)} Enter`,
1048
+ 10000,
1049
+ );
1050
+ if (!r.ok) return { ok: false, detail: (r.stderr || r.stdout).trim().slice(0, 300) || 'send-keys failed' };
1051
+ return { ok: true, detail: 'sent' };
831
1052
  }
832
1053
 
833
1054
  /**
@@ -841,7 +1062,7 @@ export async function getScrollback(runId: number, lines: number): Promise<{ ok:
841
1062
  export async function getRunPwd(runId: number): Promise<{ ok: boolean; pwd: string }> {
842
1063
  const info = await getRunTermInfo(runId);
843
1064
  if (!info) return { ok: false, pwd: '' };
844
- const t = shq('=' + info.session);
1065
+ const t = shq(info.session);
845
1066
  const cmd = `tmux display -p -t ${t} '#{pane_current_path}' 2>/dev/null || tmux list-panes -s -t ${t} -F '#{pane_current_path}' 2>/dev/null | head -1`;
846
1067
  const r = await runShellOn(info.machine, cmd, 8000);
847
1068
  const pwd = ((r.stdout || '').split('\n')[0] || '').trim();
@@ -946,11 +1167,16 @@ interface OutputsManifestItem { path?: string; type?: string; title?: string }
946
1167
 
947
1168
  /** run 의 최종 답변 텍스트 — result 이벤트(payload JSON) 우선, 없으면 exitSummary. */
948
1169
  async function runAnswerText(run: typeof agentRuns.$inferSelect): Promise<string> {
949
- const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
950
- for (let i = evs.length - 1; i >= 0; i--) {
951
- if (evs[i]!.kind !== 'result') continue;
1170
+ // result 이벤트만, 최신 순으로 — 전량 로드 후 뒤에서 훑던 것과 결과는 같고 읽는 행은 훨씬 적다
1171
+ // (run result 는 보통 한 줄, resume 한 run 이면 몇 줄). LIMIT 1 쓰지 않는 의도다:
1172
+ // 가장 최신 result 의 payload 에 쓸 만한 result 문자열이 없으면 예전 result 로 내려가야 하고,
1173
+ // 그게 원래 동작이다 — 같은 답을 내는 것이 이 변경의 조건이다.
1174
+ const evs = await db.select().from(agentEvents)
1175
+ .where(and(eq(agentEvents.runId, run.id), eq(agentEvents.kind, 'result')))
1176
+ .orderBy(desc(agentEvents.id));
1177
+ for (const e of evs) {
952
1178
  try {
953
- const o = JSON.parse(evs[i]!.payload) as { result?: string };
1179
+ const o = JSON.parse(e.payload) as { result?: string };
954
1180
  if (typeof o.result === 'string' && o.result.trim()) return o.result.trim();
955
1181
  } catch { /* 비-JSON result — exitSummary 로 폴백 */ }
956
1182
  }
@@ -1180,9 +1406,10 @@ export async function openWorkbench(repoId: number, title: string, root = false)
1180
1406
  // branch='' 로 남겨 merge 는 자동 거부(=이미 base). cleanup 도 worktree remove 를 건너뛴다.
1181
1407
  // root=false: 기존 workbench — 격리 worktree + 브랜치(수동 변경 후 Review 에서 merge).
1182
1408
  const agent = root ? 'session' : 'workbench';
1409
+ const tag = sessionClaudeTag(machine); // 이 작업방의 claude 대화 id(뷰어가 대본을 정확히 찾는 근거)
1183
1410
  const tIns = await db.insert(tasks).values({ repoId, title: title || (root ? 'Session' : 'Workbench'), prompt: root ? '(root session)' : '(interactive workbench)' }).returning();
1184
1411
  const task = tIns[0]!;
1185
- const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent, status: 'pending' }).returning();
1412
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent, status: 'pending', claudeSessionId: tag.sid }).returning();
1186
1413
  const run = rIns[0]!;
1187
1414
  const runId = run.id;
1188
1415
  broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent, branch: '', filesChanged: 0 });
@@ -1194,15 +1421,16 @@ export async function openWorkbench(repoId: number, title: string, root = false)
1194
1421
 
1195
1422
  // export LANG: tmux 서버 첫 기동이 C 로케일이면 세션 셸의 CJK 입력·표시가 깨진다.
1196
1423
  // 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 — '=' 정확 일치만.
1197
- const wbEnv = await secretEnvArgs(); // 시크릿 볼트 env 주입
1424
+ // 태깅 env 시크릿 뒤에 둔다 볼트에 PATH 가 들어 있어도 심이 앞을 잡아야 태깅이 산다.
1425
+ const wbEnv = (await secretEnvArgs()) + tag.env; // 시크릿 볼트 + claude 태깅 → env 주입
1198
1426
  const prep = await runShellOn(
1199
1427
  machine,
1200
1428
  root
1201
1429
  ? `export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
1202
- ` && tmux new-session -d${wbEnv} -s ${shq(session)} -c ${shq(repo.path)}`
1430
+ ` && ${tmuxNewSession(`-d${wbEnv} -s ${shq(session)} -c ${shq(repo.path)}`)}`
1203
1431
  : `export LANG=${shq(config.lang)}; mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
1204
1432
  ` && { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
1205
- ` && tmux new-session -d${wbEnv} -s ${shq(session)} -c ${shq(wtPath)}`,
1433
+ ` && ${tmuxNewSession(`-d${wbEnv} -s ${shq(session)} -c ${shq(wtPath)}`)}`,
1206
1434
  20000,
1207
1435
  );
1208
1436
  if (!prep.ok) {
@@ -1243,19 +1471,21 @@ export async function openSessionAt(machineSlug: string, path: string, title: st
1243
1471
 
1244
1472
  const bucket = await ensureSessionsRepo(m.id);
1245
1473
  const name = title || dir.split('/').filter(Boolean).pop() || dir;
1474
+ const tag = sessionClaudeTag(machine); // 이 세션의 claude 대화 id
1246
1475
  const tIns = await db.insert(tasks).values({ repoId: bucket.id, title: name, prompt: '(session)' }).returning();
1247
1476
  const task = tIns[0]!;
1248
- const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'session', status: 'pending' }).returning();
1477
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'session', status: 'pending', claudeSessionId: tag.sid }).returning();
1249
1478
  const run = rIns[0]!;
1250
1479
  const runId = run.id;
1251
1480
  broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'session', branch: '', filesChanged: 0 });
1252
1481
 
1253
1482
  const session = `coxpit-r${runId}`;
1254
- const sEnv = await secretEnvArgs(); // 시크릿 볼트 env 주입
1483
+ // 태깅 env 시크릿 뒤에( openWorkbench 같은 이유 — 심이 PATH 앞을 잡아야 한다).
1484
+ const sEnv = (await secretEnvArgs()) + tag.env; // 시크릿 볼트 + claude 태깅 → env 주입
1255
1485
  const prep = await runShellOn(
1256
1486
  machine,
1257
1487
  `export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
1258
- ` && tmux new-session -d${sEnv} -s ${shq(session)} -c ${shq(dir)}`,
1488
+ ` && ${tmuxNewSession(`-d${sEnv} -s ${shq(session)} -c ${shq(dir)}`)}`,
1259
1489
  15000,
1260
1490
  );
1261
1491
  if (!prep.ok) {