coxpit 5.27.6 → 6.1.0

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/db/index.ts CHANGED
@@ -113,4 +113,6 @@ export async function ensureSchema(): Promise<void> {
113
113
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_status TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
114
114
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_output TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
115
115
  try { await client.execute("ALTER TABLE repos ADD COLUMN kind TEXT NOT NULL DEFAULT 'git'"); } catch { /* exists */ }
116
+ try { await client.execute("ALTER TABLE agent_runs ADD COLUMN title TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
117
+ try { await client.execute('ALTER TABLE agent_runs ADD COLUMN in_place INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
116
118
  }
package/src/db/schema.ts CHANGED
@@ -82,8 +82,12 @@ export const agentRuns = sqliteTable('agent_runs', {
82
82
  taskId: integer('task_id').notNull(),
83
83
  machineId: integer('machine_id').notNull(),
84
84
  agent: text('agent').notNull().default('claude-code'),
85
+ title: text('title').notNull().default(''), // v6.0 — 작업 안에서의 역할 이름(예: 구현·기타). 빈값 = 프로바이더 이름/main 으로 표시
85
86
  worktreePath: text('worktree_path').notNull().default(''),
86
87
  branch: text('branch').notNull().default(''),
88
+ // v6.0 Part P — 격리 없이 repo 체크아웃에서 그대로 도는 에이전트(worktree·브랜치 없음).
89
+ // 결과물은 루트 세션 마커와 같다(worktree_path=repo.path, branch=''); 이 플래그는 "그렇게 띄워라"는 의도다.
90
+ inPlace: integer('in_place', { mode: 'boolean' }).notNull().default(false),
87
91
  tmuxWindow: text('tmux_window').notNull().default(''),
88
92
  status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
89
93
  sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
package/src/files.ts CHANGED
@@ -28,6 +28,12 @@ function startDir(root: string): string {
28
28
  return (HOME === root || HOME.startsWith(root + '/')) ? HOME : root;
29
29
  }
30
30
 
31
+ // 어떤 절대경로가 지금의 뷰어 루트 안인가 — 호출자가 "평소 파일 뷰어로 열면 되는지"를
32
+ // 미리 물어보는 창구(v6.0 W2: WORK.md 는 ~/.coxpit 아래라 기본 루트(홈) 안이다).
33
+ export function withinFilesRoot(p: string): boolean {
34
+ return withinRoot(presolve(p), currentRoot());
35
+ }
36
+
31
37
  export type FileKind = 'md' | 'html' | 'pdf' | 'image' | 'text' | 'binary';
32
38
 
33
39
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']);
@@ -12,6 +12,7 @@ import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnap
12
12
  import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
13
13
  import { broadcast } from './hub';
14
14
  import { getProvider, type Provider } from './providers';
15
+ import { workContextBlock, removeWorkDoc } from './workdoc';
15
16
 
16
17
  // ── 산출물 계약(deliverable contract) ─────────────────────────
17
18
  /** 산출물 타입 5종 — 태스크가 선언할 수 있는 계약 항목. */
@@ -223,6 +224,35 @@ export function isRunLive(runId: number): boolean {
223
224
  return liveChildren.has(runId) || adoptedRuns.has(runId);
224
225
  }
225
226
 
227
+ /**
228
+ * 발사 창 — launchRun 이 착수했지만 아직 자식 프로세스가 안 생긴 구간.
229
+ * launchRun 의 첫 문장이라 요청 핸들러가 응답을 돌려주기 **전에** 동기적으로 들어간다.
230
+ * 메모리에만 있어 데몬이 죽으면 같이 사라진다(= 재시작 후 유령이 체크아웃을 물고 있는 일이 없다).
231
+ */
232
+ const launching = new Set<number>();
233
+
234
+ /**
235
+ * v6.0 P3 — 이 repo 체크아웃에서 지금 일하고 있는 in-place **에이전트** run 이 있으면 그 id.
236
+ * 한 체크아웃에 에이전트 둘은 서로의 편집을 덮어쓴다 → 발사 전에 이걸로 막는다(409).
237
+ *
238
+ * 살아있음 = 자식 보유/재-adopt(isRunLive) **또는** 발사 창(launching). 후자가 없으면
239
+ * 연달아 들어온 두 요청이 둘 다 통과한다 — launchRun 은 fire-and-forget 이라
240
+ * 두 번째 검사 시점엔 첫 run 의 자식이 아직 안 생겼기 때문.
241
+ * 손 터미널(agent='session'·'workbench')은 세지 않는다 — 사람이 자기 체크아웃에
242
+ * 터미널을 몇 개 열든 그건 사람의 선택이다(D3).
243
+ */
244
+ export async function liveInPlaceRun(repoId: number): Promise<number | null> {
245
+ const repoTasks = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.repoId, repoId));
246
+ if (!repoTasks.length) return null;
247
+ const ids = repoTasks.map((t) => t.id);
248
+ const rs = (await db.select().from(agentRuns).where(inArray(agentRuns.taskId, ids)))
249
+ .filter((r) => r.inPlace && r.agent !== 'session' && r.agent !== 'workbench');
250
+ for (const r of rs) {
251
+ if (isRunLive(r.id) || launching.has(r.id)) return r.id;
252
+ }
253
+ return null;
254
+ }
255
+
226
256
  /** 에이전트 프롬프트에 붙는 능력 고지 — 독립 하위작업을 병렬 서브런으로 뺄 수 있다.
227
257
  * 파일 기반: 기본 권한(claude acceptEdits · codex workspace-write)이 네트워크를 막아도
228
258
  * 파일 쓰기는 되므로, spawn 요청을 워크트리의 .coxpit/spawn.json 으로 받는다. */
@@ -362,6 +392,7 @@ function remoteKillScript(worktreePath: string): string {
362
392
 
363
393
  interface RunContext {
364
394
  runId: number;
395
+ taskId: number;
365
396
  machine: MachineTarget;
366
397
  machineId: number;
367
398
  repoId: number;
@@ -371,6 +402,7 @@ interface RunContext {
371
402
  real: boolean;
372
403
  agent: string;
373
404
  model: string;
405
+ inPlace: boolean; // v6.0 P1 — 격리 worktree 없이 repo 체크아웃에서 그대로 돈다
374
406
  }
375
407
 
376
408
  async function loadContext(runId: number): Promise<RunContext | null> {
@@ -405,8 +437,15 @@ async function loadContext(runId: number): Promise<RunContext | null> {
405
437
  const declared = parseOutputs(task.outputs);
406
438
  if (declared.length) prompt += deliverablesNote(declared);
407
439
 
440
+ // v6.0 W3 — 작업의 공유 컨텍스트(WORK.md). 격리된 run 은 과거에서 갈라져 나오지만,
441
+ // 여기 적힌 결정은 이 작업 아래 모든 세션에 따라붙는다(파일 컨텍스트는 Part P 의 몫,
442
+ // 결정 컨텍스트는 이쪽 몫). 디자인 캡처·산출물 계약과 같은 시임이라 발사 경로 전부가 덮인다.
443
+ // ⚠️ 편집은 **다음 발사·steer** 부터 닿는다 — 돌고 있는 턴에 끼어드는 마법은 없다.
444
+ prompt += workContextBlock(task.id);
445
+
408
446
  return {
409
447
  runId,
448
+ taskId: task.id,
410
449
  machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
411
450
  machineId: m.id,
412
451
  repoId: repo.id,
@@ -416,6 +455,7 @@ async function loadContext(runId: number): Promise<RunContext | null> {
416
455
  real: config.agent.real,
417
456
  agent: run.agent,
418
457
  model: run.model,
458
+ inPlace: run.inPlace,
419
459
  };
420
460
  }
421
461
 
@@ -423,30 +463,41 @@ async function loadContext(runId: number): Promise<RunContext | null> {
423
463
  * 한 AgentRun 실행: worktree 생성 → tmux 창(best-effort) → 에이전트 spawn →
424
464
  * stdout 라인 파싱하며 이벤트 적재 → 종료 시 files_changed 집계 + status 전이.
425
465
  * fire-and-forget. 실패는 status='error' 로 봉인.
466
+ *
467
+ * v6.0 P1 — in-place run 은 1) 을 통째로 건너뛴다: worktree 도 브랜치도 만들지 않고
468
+ * repo 체크아웃(ctx.repoPath)에서 그대로 돈다. 남는 자국은 **루트 세션 마커 그대로**
469
+ * (branch='' · worktreePath=repo.path) 라 merge/PR/sync/cleanup 은 이미 있는 판정이
470
+ * 알아서 비껴간다 — 새 개념이 아니라 이미 있던 표식의 재사용이다.
426
471
  */
427
472
  export async function launchRun(runId: number, real?: boolean): Promise<void> {
473
+ // 발사 창 진입 — 이 한 줄은 호출 즉시(첫 await 전에) 실행되므로, 뒤이어 들어온
474
+ // in-place 요청이 아직 자식이 없는 이 run 을 "없는 것"으로 볼 수 없다(P3 가드).
475
+ launching.add(runId);
428
476
  const ctx = await loadContext(runId);
429
- if (!ctx) return;
477
+ if (!ctx) { launching.delete(runId); return; }
430
478
  const useReal = real ?? ctx.real;
431
479
 
432
- const branch = `coxpit/r${runId}`;
480
+ const inPlace = ctx.inPlace;
481
+ const branch = inPlace ? '' : `coxpit/r${runId}`;
433
482
  const wtParent = ppath.join(ppath.dirname(ctx.repoPath), '.coxpit-worktrees');
434
- const wtPath = ppath.join(wtParent, `r${runId}`);
483
+ const wtPath = inPlace ? ctx.repoPath : ppath.join(wtParent, `r${runId}`);
435
484
  const session = `coxpit-r${runId}`;
436
485
 
437
486
  try {
438
487
  await setRun(runId, { status: 'preparing', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
439
488
 
440
- // 1) worktree 생성(격리 브랜치)
441
- const prep = await runShellOn(
442
- ctx.machine,
443
- `mkdir -p ${shq(wtParent)} && git -C ${shq(ctx.repoPath)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(ctx.baseBranch)}`,
444
- 20000,
445
- );
446
- if (!prep.ok) {
447
- await recordEvent(runId, 'error', (prep.stderr || prep.stdout).trim().slice(0, 500));
448
- await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'worktree add failed' });
449
- return;
489
+ // 1) worktree 생성(격리 브랜치) — in-place 는 건너뛴다(격리가 없는 것이 요점).
490
+ if (!inPlace) {
491
+ const prep = await runShellOn(
492
+ ctx.machine,
493
+ `mkdir -p ${shq(wtParent)} && git -C ${shq(ctx.repoPath)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(ctx.baseBranch)}`,
494
+ 20000,
495
+ );
496
+ if (!prep.ok) {
497
+ await recordEvent(runId, 'error', (prep.stderr || prep.stdout).trim().slice(0, 500));
498
+ await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'worktree add failed' });
499
+ return;
500
+ }
450
501
  }
451
502
 
452
503
  // 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort. 동명 잔재는 선제 정리('=' 정확 일치).
@@ -456,7 +507,7 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
456
507
  `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);
457
508
 
458
509
  await setRun(runId, { status: 'running' });
459
- await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
510
+ await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal, ...(inPlace ? { inPlace: true } : {}) }));
460
511
 
461
512
  // 3) 에이전트 spawn(스트리밍)
462
513
  // 원격은 ssh 채널이 죽어도 프로세스가 남을 수 있어 pid 파일을 남긴다(stop 시 원격 kill).
@@ -490,6 +541,8 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
490
541
  } catch (e) {
491
542
  await recordEvent(runId, 'error', String(e).slice(0, 500));
492
543
  await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'orchestrator error' });
544
+ } finally {
545
+ launching.delete(runId); // 발사 창 해제 — 여기까지 오면 살아있음 판정은 liveChildren 이 이어받는다
493
546
  }
494
547
  }
495
548
 
@@ -645,9 +698,11 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
645
698
  await recordEvent(runId, mode === 'ask' ? 'ask' : 'steer', message.slice(0, 2000));
646
699
 
647
700
  // Ask 모드 — 세션에 질문만: 파일 수정 없이 답변만 하도록 래핑
648
- const finalMessage = mode === 'ask'
701
+ // 뒤에 붙는 WORK CONTEXT(W3): 작업 중간에 steer 를 받는 에이전트도 그동안 적힌 결정을 본다.
702
+ // 여기서도 편집은 **이 steer 부터** 닿는다(이미 돌고 있던 턴은 건드리지 않는다).
703
+ const finalMessage = (mode === 'ask'
649
704
  ? `Question about your work in this session (do NOT modify any files, do NOT run write commands — answer concisely):\n${message}`
650
- : message;
705
+ : message) + workContextBlock(ctx.taskId);
651
706
 
652
707
  const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
653
708
  const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
@@ -740,6 +795,25 @@ export async function getScrollback(runId: number, lines: number): Promise<{ ok:
740
795
  return { ok: true, text: r.stdout };
741
796
  }
742
797
 
798
+ /**
799
+ * 이 run 의 페인이 **지금 서 있는 폴더** (v5.28 D-fix).
800
+ * 터미널이 찍은 상대경로를 열려면 worktree 루트가 아니라 페인의 cwd 가 기준이어야 한다 —
801
+ * 모노레포 하위 패키지나 `cd` 뒤에는 루트가 답이 아니고, 그때 링크는 없는 파일을 가리켰다.
802
+ * 타깃은 언제나 '=' 정확 일치(coxpit-r5 가 coxpit-r50 을 집는 사고를 두 번 겪었다).
803
+ * display-message 가 페인 타깃으로 '=' 를 못 받는 tmux 가 있어 list-panes -s(타깃-세션) 로 한 번 더 받친다.
804
+ * 못 알아내면 빈 문자열 — 클라이언트는 worktree 로 폴백한다. 지어내지 않는다.
805
+ */
806
+ export async function getRunPwd(runId: number): Promise<{ ok: boolean; pwd: string }> {
807
+ const info = await getRunTermInfo(runId);
808
+ if (!info) return { ok: false, pwd: '' };
809
+ const t = shq('=' + info.session);
810
+ 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`;
811
+ const r = await runShellOn(info.machine, cmd, 8000);
812
+ const pwd = ((r.stdout || '').split('\n')[0] || '').trim();
813
+ if (!r.ok || !pwd) return { ok: false, pwd: '' };
814
+ return { ok: true, pwd };
815
+ }
816
+
743
817
  /**
744
818
  * 실행 중 run 중지 — 자식 프로세스 SIGTERM. close 핸들러가 status='stopped' 로 봉인.
745
819
  */
@@ -1175,7 +1249,8 @@ export async function deleteSession(runId: number): Promise<{ ok: boolean; detai
1175
1249
  await db.delete(agentRuns).where(eq(agentRuns.id, runId));
1176
1250
  // 세션 task 는 run 과 1:1 — 남은 run 이 없으면 task 도 제거해 트리에서 사라지게.
1177
1251
  const siblings = await db.select().from(agentRuns).where(eq(agentRuns.taskId, run.taskId));
1178
- if (task && siblings.length === 0) await db.delete(tasks).where(eq(tasks.id, task.id));
1252
+ // 작업이 사라지면 그 작업의 WORK.md 같이 사라진다(W1 — 정본은 task 수명에 매인다).
1253
+ if (task && siblings.length === 0) { await db.delete(tasks).where(eq(tasks.id, task.id)); removeWorkDoc(task.id); }
1179
1254
  broadcast({ type: 'run', runId, deleted: true }); // 모든 콘솔이 재하이드레이트 → 행 제거
1180
1255
  return { ok: true, detail: 'session deleted (folder preserved)' };
1181
1256
  }
@@ -1981,33 +2056,51 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
1981
2056
  // cleanupRun 으로 이미 정리하지만, 실패·에러·데몬 재시작으로 고아가 된 run 은
1982
2057
  // (검수용으로) worktree 를 남겨두므로 쌓인다. 이를 안전하게 되찾는 길.
1983
2058
  //
1984
- // 안전 규칙(핵심): running/preparing/pending/'done' run 은 절대 대상 아님 —
1985
- // 활성 작업이거나(진행 중), 성공했지만 아직 머지 안 됐을 수 있는 작업이므로.
2059
+ // 안전 규칙(핵심): running/preparing/pending run 은 절대 대상 아님 — 활성 작업이므로.
2060
+ //
2061
+ // v6.0 T6b — 빚이 실제로 쌓이는 자리는 **끝났는데 머지도 닫지도 않은 run** 이었다.
2062
+ // 그래서 done/merged 도 목록에는 올린다. 다만 하나를 갈라 본다:
2063
+ // 산출물이 이미 빠져나갔나(머지됐거나 export·PR 됐나) = 지워도 되는 사본
2064
+ // 아직 아무 데도 없나 = 이 worktree 가 **유일한 사본** → reclaimRisk:true
2065
+ // 위험한 것은 **보여주되 미리 고르지 않고**, 전체 회수(runIds 없음)에서도 빠진다.
2066
+ // 사람이 직접 찍어 보낸 runIds 만 그 선을 넘는다.
1986
2067
 
1987
2068
  /** 회수 대상 판정용 안전 상태 집합 — task 가 closed 이거나 run 상태가 이 중 하나. */
1988
2069
  const RECLAIM_STATUSES = new Set(['failed', 'error', 'stopped']);
2070
+ /** v6.0 T6b — 정착한 성공 run. 격리 worktree 를 가진 것만 목록에 든다(위험 표시와 함께). */
2071
+ const RECLAIM_SETTLED_STATUSES = new Set(['done', 'merged']);
2072
+ /** taskCloseRisk 와 **같은** 신호를 쓰는 상태 집합 — 정착 + 변경있음 + 미탈출 = 위험. */
2073
+ const RISK_STATUSES = new Set(['done', 'failed', 'stopped']);
1989
2074
 
1990
2075
  export interface ReclaimableWorktree {
1991
2076
  runId: number;
1992
2077
  path: string;
1993
2078
  branch: string;
1994
2079
  taskId: number;
1995
- reason: string; // 'task closed' | 'failed' | 'error' | 'stopped'
2080
+ reason: string; // 'task closed' | 'failed' | 'error' | 'stopped' | 'done' | 'merged'
1996
2081
  exists: boolean; // worktree dir 가 아직 디스크에 있나(false = 이미 수동 삭제됨)
1997
2082
  sizeKb?: number; // best-effort du -sk (실패 시 생략)
2083
+ /** v6.0 T6b — 이 worktree 가 미머지·미탈출 산출물의 **유일한 사본**인가(v4.1 close 가드와 같은 신호). */
2084
+ reclaimRisk: boolean;
1998
2085
  }
1999
2086
 
2000
2087
  /**
2001
2088
  * 안전하게 회수 가능한 worktree 목록. worktreePath 가 비어있지 않은 모든 run 중
2002
- * (a) task 가 closed 이거나 (b) run 상태 ∈ {failed, error, stopped} 인 것만.
2003
- * running/preparing/pending/'done'/'open'/'merged' 절대 포함하지 않는다
2004
- * (활성 또는 성공-미머지 가능성). exists=디스크 잔존 여부, sizeKb=best-effort du.
2089
+ * (a) task 가 closed 이거나 (b) run 상태 ∈ {failed, error, stopped} 이거나
2090
+ * (c) 격리 worktree 를 가진 채 정착한 done/merged(T6b 쌓이던 빚).
2091
+ * running/preparing/pending/'open' 절대 포함하지 않는다(활성 작업).
2092
+ * exists=디스크 잔존 여부, sizeKb=best-effort du, reclaimRisk=유일 사본 경고.
2005
2093
  */
2006
2094
  export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]> {
2007
2095
  const allRuns = (await db.select().from(agentRuns)).filter((r) => !!r.worktreePath);
2008
2096
  // task 상태 룩업(closed 판정용)
2009
2097
  const taskById = new Map<number, typeof tasks.$inferSelect>();
2010
2098
  for (const t of await db.select().from(tasks)) taskById.set(t.id, t);
2099
+ // 산출물이 이미 빠져나간 run — export·PR 이벤트 한 번이면 worktree 는 유일 사본이 아니다.
2100
+ const escaped = new Set<number>();
2101
+ for (const e of await db.select().from(agentEvents).where(inArray(agentEvents.kind, ['export', 'pr']))) {
2102
+ escaped.add(e.runId);
2103
+ }
2011
2104
 
2012
2105
  const out: ReclaimableWorktree[] = [];
2013
2106
  for (const run of allRuns) {
@@ -2015,11 +2108,18 @@ export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]>
2015
2108
  const task = taskById.get(run.taskId);
2016
2109
  const taskClosed = task?.status === 'closed';
2017
2110
  const statusReclaim = RECLAIM_STATUSES.has(run.status);
2018
- if (!taskClosed && !statusReclaim) continue; // done/open/merged/running/preparing/pending 제외
2019
- const reason = taskClosed ? 'task closed' : run.status;
2111
+ const settled = RECLAIM_SETTLED_STATUSES.has(run.status);
2112
+ if (!taskClosed && !statusReclaim && !settled) continue; // open/running/preparing/pending 제외
2020
2113
 
2021
2114
  // worktree 잔존 여부 + best-effort 사이즈(로컬만 정확; 원격은 machine 경유).
2022
2115
  const ctx = await loadContext(run.id).catch(() => null);
2116
+ // done/merged 로 새로 들어온 run 은 **격리 worktree 를 가진 것만** — 루트 세션·in-place run 의
2117
+ // worktreePath 는 repo 체크아웃 그 자체라 회수할 디스크가 애초에 없다(기존 경로는 그대로 둔다).
2118
+ if (settled && !taskClosed && !statusReclaim && (!run.branch || (ctx && run.worktreePath === ctx.repoPath))) continue;
2119
+ const reason = taskClosed ? 'task closed' : run.status;
2120
+ // v4.1 close 가드와 같은 판정 — 정착 ∧ 변경있음 ∧ export·PR 없음. merged 는 이미 빠져나갔다.
2121
+ const reclaimRisk = RISK_STATUSES.has(run.status) && run.filesChanged > 0 && !escaped.has(run.id);
2122
+
2023
2123
  let exists = false;
2024
2124
  let sizeKb: number | undefined;
2025
2125
  if (ctx) {
@@ -2034,24 +2134,82 @@ export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]>
2034
2134
  if (du.ok && Number.isFinite(n) && n > 0) sizeKb = n;
2035
2135
  }
2036
2136
  }
2037
- out.push({ runId: run.id, path: run.worktreePath, branch: run.branch, taskId: run.taskId, reason, exists, sizeKb });
2137
+ out.push({ runId: run.id, path: run.worktreePath, branch: run.branch, taskId: run.taskId, reason, exists, sizeKb, reclaimRisk });
2038
2138
  }
2039
2139
  return out;
2040
2140
  }
2041
2141
 
2142
+ // ── v6.0 T6b — 디스크 빚을 눈에 보이게 ──────────────────────────
2143
+ // 볼 수 없는 것은 관리할 수 없다. `.coxpit-worktrees` 의 총량·개수를 /api/health 와
2144
+ // 회수 판에 한 줄로 싣는다. du 는 큰 트리에서 느리므로 **health 는 절대 기다리지 않는다** —
2145
+ // 값은 캐시에서 나오고, 낡았으면 배경에서 다시 잰다(첫 호출은 값 없이 지나간다).
2146
+
2147
+ export interface WorktreeDisk { count: number; sizeKb: number }
2148
+ const DISK_TTL_MS = 30_000;
2149
+ let diskCache: { at: number; value: WorktreeDisk } | null = null;
2150
+ let diskInflight: Promise<WorktreeDisk> | null = null;
2151
+
2152
+ /** 로컬 머신 repo 들의 `.coxpit-worktrees` 부모 폴더를 한 번에 du -sk. 원격은 세지 않는다(health 는 이 머신의 디스크다). */
2153
+ async function measureWorktreeDisk(): Promise<WorktreeDisk> {
2154
+ const machineRows = await db.select().from(machines);
2155
+ const localIds = new Set(machineRows.filter((m) => m.kind === 'local' || m.address === '').map((m) => m.id));
2156
+ const dirs: string[] = [...new Set<string>(
2157
+ (await db.select().from(repos))
2158
+ .filter((r) => localIds.has(r.machineId))
2159
+ .map((r) => ppath.join(ppath.dirname(r.path), '.coxpit-worktrees')),
2160
+ )];
2161
+ if (!dirs.length) return { count: 0, sizeKb: 0 };
2162
+ // 폴더마다 "<KB> <개수>" 한 줄. 없는 폴더는 건너뛴다.
2163
+ const script = `for d in ${dirs.map(shq).join(' ')}; do [ -d "$d" ] || continue; ` +
2164
+ `s=$(du -sk "$d" 2>/dev/null | tail -1 | cut -f1); [ -n "$s" ] || s=0; ` +
2165
+ `c=$(ls -1 "$d" 2>/dev/null | wc -l); [ -n "$c" ] || c=0; echo "$s $c"; done`;
2166
+ const out = await runShellOn(LOCAL_MACHINE, script, 20000).catch(() => ({ ok: false as boolean, stdout: '' as string }));
2167
+ let count = 0, sizeKb = 0;
2168
+ for (const line of String(out.stdout || '').split('\n')) {
2169
+ const [s = '', c = ''] = line.trim().split(/\s+/);
2170
+ const kb = parseInt(s, 10), n = parseInt(c, 10);
2171
+ if (Number.isFinite(kb) && kb > 0) sizeKb += kb;
2172
+ if (Number.isFinite(n) && n > 0) count += n;
2173
+ }
2174
+ return { count, sizeKb };
2175
+ }
2176
+
2177
+ /**
2178
+ * 캐시된 worktree 디스크 사용량. darwin/linux 만(du 전제) — 그 밖은 null.
2179
+ * **절대 기다리지 않는다** — 낡았으면 배경에서 다시 재고 마지막 값을 그대로 돌려준다.
2180
+ * 아직 한 번도 못 쟀으면 null(=health 에 싣지 않는다). 디스크가 꽉 찬 머신에서 du 가
2181
+ * 오래 걸릴수록 health 는 더 빨라야 하지, 같이 멈추면 안 된다.
2182
+ */
2183
+ export async function worktreeDisk(): Promise<WorktreeDisk | null> {
2184
+ if (process.platform !== 'darwin' && process.platform !== 'linux') return null;
2185
+ const cur = diskCache;
2186
+ if ((!cur || Date.now() - cur.at >= DISK_TTL_MS) && !diskInflight) {
2187
+ const p: Promise<WorktreeDisk> = measureWorktreeDisk()
2188
+ .then((v) => { diskCache = { at: Date.now(), value: v }; return v; })
2189
+ .catch(() => diskCache?.value ?? { count: 0, sizeKb: 0 })
2190
+ .finally(() => { if (diskInflight === p) diskInflight = null; });
2191
+ diskInflight = p;
2192
+ }
2193
+ return cur ? cur.value : null;
2194
+ }
2195
+
2042
2196
  /**
2043
2197
  * 회수 실행 — 대상 run(전체 또는 runIds 부분집합)의 worktree 를 되찾는다.
2044
2198
  * dir 가 아직 있으면 cleanupRun 재사용(tmux kill + git worktree remove + branch -D + 포인터 blank).
2045
2199
  * dir 가 이미 수동 삭제됐으면 git worktree prune + branch -D + DB 포인터 blank 만.
2046
2200
  * 마지막에 영향받은 repo 마다 git worktree prune 1회(스테일 메타데이터 정리).
2047
2201
  * 멱등 — 다시 돌려도 안전(이미 회수된 run 은 worktreePath 가 비어 목록에서 빠짐).
2202
+ *
2203
+ * v6.0 T6b — **전체 회수(runIds 없음)는 위험 표시가 없는 것만** 지운다. 미머지·미탈출
2204
+ * 산출물의 유일한 사본이 "전부 지우기" 한 번에 사라지는 일은 없다. 사람이 그 id 를
2205
+ * 직접 찍어 보냈다면(runIds) 그건 고른 것이므로 그대로 따른다. 라이브 run 은 어느 쪽도 아니다.
2048
2206
  */
2049
2207
  export async function pruneWorktrees(runIds?: number[]): Promise<{
2050
2208
  removed: Array<{ runId: number; detail: string }>; count: number;
2051
2209
  }> {
2052
- const reclaimable = await listReclaimableWorktrees();
2210
+ const reclaimable = (await listReclaimableWorktrees()).filter((r) => !isRunLive(r.runId));
2053
2211
  const want = runIds && runIds.length ? new Set(runIds) : null;
2054
- const targets = want ? reclaimable.filter((r) => want.has(r.runId)) : reclaimable;
2212
+ const targets = want ? reclaimable.filter((r) => want.has(r.runId)) : reclaimable.filter((r) => !r.reclaimRisk);
2055
2213
 
2056
2214
  const removed: Array<{ runId: number; detail: string }> = [];
2057
2215
  const affectedRepoPaths = new Map<string, MachineTarget>(); // repoPath -> machine (prune 대상)
@@ -2087,3 +2245,78 @@ export async function pruneWorktrees(runIds?: number[]): Promise<{
2087
2245
 
2088
2246
  return { removed, count: removed.length };
2089
2247
  }
2248
+
2249
+ // ── 고아 tmux 세션 수거(reaper) ─────────────────────────────────
2250
+ // run 을 지워도 tmux 세션은 남는다(데몬 재시작·DB 초기화·수동 삭제). 2026-09-17 에 손으로
2251
+ // 13개를 걷어낸 그 일을 제품의 동작으로 만든다. 규칙 셋만 지키면 안전하다:
2252
+ // ① DB 에 **없는** run id 의 `coxpit-r<N>` 만 후보다(살아 있는 run 의 세션은 목록에 아예 안 든다)
2253
+ // ② 페인이 빈 셸 이상을 돌리고 있으면 **표시만** 하고 절대 미리 고르지 않는다
2254
+ // ③ 죽일 때도 '=' 정확 일치 — coxpit-r5 가 coxpit-r50 을 물면 안 된다(전에 물었다)
2255
+
2256
+ /** tmux 이름에서 run id 를 읽는 유일한 형태. 숫자가 아니면 우리 것으로 치지 않는다. */
2257
+ const ORPHAN_SESSION_RE = /^coxpit-r(\d+)$/;
2258
+ /** "빈 셸" 로 볼 pane_current_command 들 — 이 밖이면 뭔가 돌고 있는 것으로 본다. */
2259
+ const IDLE_SHELLS = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh', 'login', '-sh', '-bash', '-zsh']);
2260
+ const LOCAL_MACHINE: MachineTarget = { slug: 'local', kind: 'local', address: '', sshUser: '' };
2261
+
2262
+ export interface OrphanTmuxSession {
2263
+ name: string; // coxpit-r<N>
2264
+ runId: number; // 이름에서 읽은 id (DB 에는 없다 — 그래서 고아다)
2265
+ command: string; // 페인에서 지금 돌고 있는 것(빈 셸이면 셸 이름)
2266
+ idle: boolean; // 모든 페인이 빈 셸인가 = 미리 체크해도 되는가
2267
+ }
2268
+
2269
+ /**
2270
+ * 로컬 머신의 고아 tmux 세션 목록. `tmux list-panes -a` 한 번으로 세션별 페인 명령까지 읽고,
2271
+ * DB 에 run 레코드가 있는 이름은 전부 빼고 돌려준다(= 살아 있는 세션은 절대 제안되지 않는다).
2272
+ * tmux 서버가 안 떠 있으면 빈 배열.
2273
+ */
2274
+ export async function listOrphanTmux(): Promise<OrphanTmuxSession[]> {
2275
+ const r = await runShellOn(
2276
+ LOCAL_MACHINE,
2277
+ `tmux list-panes -a -F '#{session_name}\t#{pane_current_command}' 2>/dev/null || true`,
2278
+ 8000,
2279
+ ).catch(() => ({ stdout: '' as string }));
2280
+
2281
+ const panes = new Map<string, string[]>();
2282
+ for (const line of String(r.stdout || '').split('\n')) {
2283
+ const [name, cmd] = line.split('\t');
2284
+ if (!name || !ORPHAN_SESSION_RE.test(name)) continue;
2285
+ const arr = panes.get(name) ?? [];
2286
+ arr.push((cmd ?? '').trim());
2287
+ panes.set(name, arr);
2288
+ }
2289
+ if (!panes.size) return [];
2290
+
2291
+ const known = new Set((await db.select().from(agentRuns)).map((x) => x.id));
2292
+ const out: OrphanTmuxSession[] = [];
2293
+ for (const [name, cmds] of panes) {
2294
+ const runId = Number(ORPHAN_SESSION_RE.exec(name)![1]);
2295
+ if (known.has(runId)) continue; // 기록이 있는 run = 고아가 아니다
2296
+ const busy = cmds.find((c) => c && !IDLE_SHELLS.has(c));
2297
+ out.push({ name, runId, command: busy || cmds[0] || '', idle: !busy });
2298
+ }
2299
+ out.sort((a, b) => a.runId - b.runId);
2300
+ return out;
2301
+ }
2302
+
2303
+ /**
2304
+ * 선택한 고아 세션 종료. 클라이언트가 보낸 이름을 믿지 않고 **지금 다시 고아 목록을 떠서**
2305
+ * 그 안에 있는 것만 죽인다(그 사이 run 이 생겼거나 이름이 지어졌으면 건너뛴다).
2306
+ * 타깃은 언제나 '=' 정확 일치.
2307
+ */
2308
+ export async function killTmuxSessions(names: string[]): Promise<{
2309
+ killed: string[]; skipped: Array<{ name: string; reason: string }>; count: number;
2310
+ }> {
2311
+ const allowed = new Set((await listOrphanTmux()).map((o) => o.name));
2312
+ const killed: string[] = [];
2313
+ const skipped: Array<{ name: string; reason: string }> = [];
2314
+ for (const raw of names) {
2315
+ const name = String(raw).trim();
2316
+ if (!allowed.has(name)) { skipped.push({ name, reason: 'not an orphan session (live run, or already gone)' }); continue; }
2317
+ await runShellOn(LOCAL_MACHINE, `tmux kill-session -t ${shq('=' + name)} 2>/dev/null || true`, 8000)
2318
+ .catch(() => { /* best-effort — 이미 사라졌을 수 있다 */ });
2319
+ killed.push(name);
2320
+ }
2321
+ return { killed, skipped, count: killed.length };
2322
+ }