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/README.md +1 -0
- package/package.json +1 -1
- package/src/.!31514!cockpit.ts +0 -0
- package/src/activity.ts +29 -0
- package/src/auth.ts +4 -1
- package/src/claudeshim.ts +110 -0
- package/src/cockpit.ts +322 -59
- package/src/config.ts +7 -0
- package/src/db/index.ts +21 -0
- package/src/db/schema.ts +5 -0
- package/src/hud.ts +672 -0
- package/src/index.ts +8 -0
- package/src/orchestrator.ts +265 -35
- package/src/server.ts +94 -15
- package/src/uistate.ts +65 -0
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 });
|
package/src/orchestrator.ts
CHANGED
|
@@ -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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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;
|
|
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
|
|
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
|
|
781
|
-
const
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
|
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 -
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
` &&
|
|
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
|
-
` &&
|
|
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
|
-
|
|
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
|
-
` &&
|
|
1488
|
+
` && ${tmuxNewSession(`-d${sEnv} -s ${shq(session)} -c ${shq(dir)}`)}`,
|
|
1259
1489
|
15000,
|
|
1260
1490
|
);
|
|
1261
1491
|
if (!prep.ok) {
|