coxpit 5.27.6 → 6.0.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 && ` : '';
@@ -1175,7 +1230,8 @@ export async function deleteSession(runId: number): Promise<{ ok: boolean; detai
1175
1230
  await db.delete(agentRuns).where(eq(agentRuns.id, runId));
1176
1231
  // 세션 task 는 run 과 1:1 — 남은 run 이 없으면 task 도 제거해 트리에서 사라지게.
1177
1232
  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));
1233
+ // 작업이 사라지면 그 작업의 WORK.md 같이 사라진다(W1 — 정본은 task 수명에 매인다).
1234
+ if (task && siblings.length === 0) { await db.delete(tasks).where(eq(tasks.id, task.id)); removeWorkDoc(task.id); }
1179
1235
  broadcast({ type: 'run', runId, deleted: true }); // 모든 콘솔이 재하이드레이트 → 행 제거
1180
1236
  return { ok: true, detail: 'session deleted (folder preserved)' };
1181
1237
  }
@@ -2087,3 +2143,78 @@ export async function pruneWorktrees(runIds?: number[]): Promise<{
2087
2143
 
2088
2144
  return { removed, count: removed.length };
2089
2145
  }
2146
+
2147
+ // ── 고아 tmux 세션 수거(reaper) ─────────────────────────────────
2148
+ // run 을 지워도 tmux 세션은 남는다(데몬 재시작·DB 초기화·수동 삭제). 2026-09-17 에 손으로
2149
+ // 13개를 걷어낸 그 일을 제품의 동작으로 만든다. 규칙 셋만 지키면 안전하다:
2150
+ // ① DB 에 **없는** run id 의 `coxpit-r<N>` 만 후보다(살아 있는 run 의 세션은 목록에 아예 안 든다)
2151
+ // ② 페인이 빈 셸 이상을 돌리고 있으면 **표시만** 하고 절대 미리 고르지 않는다
2152
+ // ③ 죽일 때도 '=' 정확 일치 — coxpit-r5 가 coxpit-r50 을 물면 안 된다(전에 물었다)
2153
+
2154
+ /** tmux 이름에서 run id 를 읽는 유일한 형태. 숫자가 아니면 우리 것으로 치지 않는다. */
2155
+ const ORPHAN_SESSION_RE = /^coxpit-r(\d+)$/;
2156
+ /** "빈 셸" 로 볼 pane_current_command 들 — 이 밖이면 뭔가 돌고 있는 것으로 본다. */
2157
+ const IDLE_SHELLS = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh', 'login', '-sh', '-bash', '-zsh']);
2158
+ const LOCAL_MACHINE: MachineTarget = { slug: 'local', kind: 'local', address: '', sshUser: '' };
2159
+
2160
+ export interface OrphanTmuxSession {
2161
+ name: string; // coxpit-r<N>
2162
+ runId: number; // 이름에서 읽은 id (DB 에는 없다 — 그래서 고아다)
2163
+ command: string; // 페인에서 지금 돌고 있는 것(빈 셸이면 셸 이름)
2164
+ idle: boolean; // 모든 페인이 빈 셸인가 = 미리 체크해도 되는가
2165
+ }
2166
+
2167
+ /**
2168
+ * 로컬 머신의 고아 tmux 세션 목록. `tmux list-panes -a` 한 번으로 세션별 페인 명령까지 읽고,
2169
+ * DB 에 run 레코드가 있는 이름은 전부 빼고 돌려준다(= 살아 있는 세션은 절대 제안되지 않는다).
2170
+ * tmux 서버가 안 떠 있으면 빈 배열.
2171
+ */
2172
+ export async function listOrphanTmux(): Promise<OrphanTmuxSession[]> {
2173
+ const r = await runShellOn(
2174
+ LOCAL_MACHINE,
2175
+ `tmux list-panes -a -F '#{session_name}\t#{pane_current_command}' 2>/dev/null || true`,
2176
+ 8000,
2177
+ ).catch(() => ({ stdout: '' as string }));
2178
+
2179
+ const panes = new Map<string, string[]>();
2180
+ for (const line of String(r.stdout || '').split('\n')) {
2181
+ const [name, cmd] = line.split('\t');
2182
+ if (!name || !ORPHAN_SESSION_RE.test(name)) continue;
2183
+ const arr = panes.get(name) ?? [];
2184
+ arr.push((cmd ?? '').trim());
2185
+ panes.set(name, arr);
2186
+ }
2187
+ if (!panes.size) return [];
2188
+
2189
+ const known = new Set((await db.select().from(agentRuns)).map((x) => x.id));
2190
+ const out: OrphanTmuxSession[] = [];
2191
+ for (const [name, cmds] of panes) {
2192
+ const runId = Number(ORPHAN_SESSION_RE.exec(name)![1]);
2193
+ if (known.has(runId)) continue; // 기록이 있는 run = 고아가 아니다
2194
+ const busy = cmds.find((c) => c && !IDLE_SHELLS.has(c));
2195
+ out.push({ name, runId, command: busy || cmds[0] || '', idle: !busy });
2196
+ }
2197
+ out.sort((a, b) => a.runId - b.runId);
2198
+ return out;
2199
+ }
2200
+
2201
+ /**
2202
+ * 선택한 고아 세션 종료. 클라이언트가 보낸 이름을 믿지 않고 **지금 다시 고아 목록을 떠서**
2203
+ * 그 안에 있는 것만 죽인다(그 사이 run 이 생겼거나 이름이 지어졌으면 건너뛴다).
2204
+ * 타깃은 언제나 '=' 정확 일치.
2205
+ */
2206
+ export async function killTmuxSessions(names: string[]): Promise<{
2207
+ killed: string[]; skipped: Array<{ name: string; reason: string }>; count: number;
2208
+ }> {
2209
+ const allowed = new Set((await listOrphanTmux()).map((o) => o.name));
2210
+ const killed: string[] = [];
2211
+ const skipped: Array<{ name: string; reason: string }> = [];
2212
+ for (const raw of names) {
2213
+ const name = String(raw).trim();
2214
+ if (!allowed.has(name)) { skipped.push({ name, reason: 'not an orphan session (live run, or already gone)' }); continue; }
2215
+ await runShellOn(LOCAL_MACHINE, `tmux kill-session -t ${shq('=' + name)} 2>/dev/null || true`, 8000)
2216
+ .catch(() => { /* best-effort — 이미 사라졌을 수 있다 */ });
2217
+ killed.push(name);
2218
+ }
2219
+ return { killed, skipped, count: killed.length };
2220
+ }
package/src/server.ts CHANGED
@@ -13,6 +13,7 @@ import { loginPageHTML } from './login';
13
13
  import {
14
14
  authMode, authIsOpen, verifyKey, storeKey, clearStored, isExposedBind, signSession, SESSION_COOKIE,
15
15
  clientKey, rateCheck, rateFail, rateReset, setupAllowed,
16
+ captureKey, captureKeyIsFixed, rotateCaptureKey, verifyCaptureKey,
16
17
  } from './authkey';
17
18
  import { config } from './config';
18
19
  import { readSettings, writeSettings } from './settings';
@@ -20,14 +21,16 @@ import { db } from './db';
20
21
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
21
22
  import { BOOKMARKLET_JS } from './design';
22
23
  import { runShellOn, shq } from './exec';
23
- import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getSessionChat } from './orchestrator';
24
+ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getSessionChat } from './orchestrator';
24
25
  import { openTerm } from './term';
26
+ import { attach as agentAttach, feed as agentFeed, input as agentInput, onExit as agentExit, detach as agentDetach, allAgentStates } from './agentstate';
25
27
  import { addSink, removeSink, broadcast } from './hub';
26
28
  import { getProvider, listProviders } from './providers';
27
29
  import { remoteState, setServe, setFunnel } from './remote';
28
30
  import { BOARD_HTML } from './board';
29
31
  import { COCKPIT_HTML } from './cockpit';
30
- import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile } from './files';
32
+ import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile, withinFilesRoot } from './files';
33
+ import { ensureWorkDoc, readWorkDoc, writeWorkDoc, removeWorkDoc, workDocPath, workDocSize } from './workdoc';
31
34
 
32
35
  const require_ = createRequire(import.meta.url);
33
36
 
@@ -201,7 +204,14 @@ function ptyMax(): number | null {
201
204
  }
202
205
 
203
206
  export async function buildServer(): Promise<FastifyInstance> {
204
- const app = Fastify({ logger: true });
207
+ // 로거: 쿼리스트링의 캡처 키(?k=…)가 데몬 로그에 평문으로 남지 않게 가린다(issue #13).
208
+ // (프록시/엣지 로그는 데몬이 못 막지만, 캡처 키는 저가치·회전가능이라 감수 가능.)
209
+ const app = Fastify({ logger: {
210
+ redact: {
211
+ paths: ['req.url'],
212
+ censor: (v: unknown) => (typeof v === 'string' ? v.replace(/([?&]k=)[^&]*/g, '$1REDACTED') : v),
213
+ },
214
+ } });
205
215
  // 살아있는 웹 터미널 수 — pty 압력 조기경보(/api/health)용. openTerm 성공 시 +1, 소켓 close 시 -1.
206
216
  // 누수가 재발하면 이 값이 실제 열린 탭보다 커지지 않아도(닫을 때 감소), machine-wide ptmx 대비
207
217
  // 이 데몬의 부하를 노출한다. leak 재발 자체는 회귀 테스트(test/pty-fd.mjs)가 잡는다.
@@ -368,6 +378,9 @@ export async function buildServer(): Promise<FastifyInstance> {
368
378
  return { ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
369
379
  }),
370
380
  counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
381
+ // 지금 터미널이 붙어 있는 run 의 에이전트 상태(runId → {state,detail,ts}).
382
+ // 하이드레이션용 — 새로 뜬 코크핏이 다음 agentstate 델타를 기다리지 않게. 붙어 있는 동안만 존재한다.
383
+ agentStates: allAgentStates(),
371
384
  // 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
372
385
  // authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
373
386
  daemon: {
@@ -503,6 +516,17 @@ export async function buildServer(): Promise<FastifyInstance> {
503
516
  return pruneWorktrees(runIds);
504
517
  });
505
518
 
519
+ // v6.0 T6 — 고아 tmux 세션(위 worktree 회수와 같은 유지보수 가족). DB 에 run 기록이 없는 `coxpit-r*` 만 목록에 든다.
520
+ // 살아 있는 run 의 세션은 애초에 나오지 않고, 빈 셸이 아닌 것은 idle:false 로 표시만 된다.
521
+ app.get('/api/tmux/orphans', async () => ({ sessions: await listOrphanTmux() }));
522
+
523
+ // 선택 종료 — 이름 배열만 받는다. 서버가 고아 목록을 다시 떠서 그 안의 것만, '=' 정확 일치로 죽인다.
524
+ app.post('/api/tmux/orphans/kill', async (req, reply) => {
525
+ const b = (req.body ?? {}) as { sessions?: unknown };
526
+ if (!Array.isArray(b.sessions)) return reply.code(400).send({ error: 'sessions must be an array of session names' });
527
+ return killTmuxSessions(b.sessions.map((s) => String(s)));
528
+ });
529
+
506
530
  // ─── 머신 레지스트리 ────────────────────────────────────────────
507
531
  app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
508
532
 
@@ -822,14 +846,13 @@ export async function buildServer(): Promise<FastifyInstance> {
822
846
  });
823
847
 
824
848
  // ─── Design Mode ───────────────────────────────────────────────
825
- // 캡처 키: 인증 off 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
849
+ // 캡처 = 마스터 접근키와 분리된 저가치 전용 (issue #13). 오직 이 캡처 엔드포인트만 허가한다.
850
+ // 인증 꺼짐이면 자유, 아니면 ?k=<capture key> 를 검증(마스터 접근키가 아니라 캡처 키로).
851
+ // setup(마스터 키 미설정) 상태여도 캡처 키는 독립적으로 존재하므로 캡처는 동작한다.
826
852
  const captureKeyOk = (req: { query?: unknown }): boolean => {
827
- const m = authMode();
828
- // 인증 꺼짐 → 자유. 아직 키 미설정(setup) → 캡처 불가(키가 없으니 증명 수단 없음).
829
- if (m.mode === 'disabled') return true;
830
- if (m.mode === 'setup') return false;
853
+ if (authMode().mode === 'disabled') return true;
831
854
  const k = ((req.query ?? {}) as { k?: string }).k ?? '';
832
- return verifyKey(k, m);
855
+ return verifyCaptureKey(k);
833
856
  };
834
857
  const cors = (reply: { header: (k: string, v: string) => unknown }) => {
835
858
  reply.header('access-control-allow-origin', '*');
@@ -856,6 +879,11 @@ export async function buildServer(): Promise<FastifyInstance> {
856
879
 
857
880
  app.get('/api/design', async () => ({ captures: await db.select().from(designCaptures) }));
858
881
 
882
+ // 캡처 키 조회/회전 — 인증 게이트 뒤(EXEMPT 아님)라 인증된 보드 사용자만 접근한다.
883
+ // 보드가 이걸로 북마클릿 href(?k=…)를 만들고, 회전 버튼으로 새 키를 발급한다.
884
+ app.get('/api/design/capture-key', async () => ({ key: captureKey(), fixed: captureKeyIsFixed() }));
885
+ app.post('/api/design/capture-key/rotate', async () => ({ key: rotateCaptureKey(), fixed: captureKeyIsFixed() }));
886
+
859
887
  app.delete('/api/design/:id', async (req, reply) => {
860
888
  const id = Number((req.params as { id: string }).id);
861
889
  await db.delete(designCaptures).where(eq(designCaptures.id, id));
@@ -925,24 +953,100 @@ export async function buildServer(): Promise<FastifyInstance> {
925
953
  return { task: tr[0], runs };
926
954
  });
927
955
 
928
- // 태스크 이름 변경(=세션 이름 변경). title 갱신.
956
+ // 태스크 이름 변경(=세션 이름 변경) + v6.0 S2 승격(repoId 재부모화).
957
+ // 둘 다 선택 — title 만 보내던 기존 호출은 그대로 동작한다. repoId 는 "이 작업이 어느
958
+ // 프로젝트 것인가"만 바꾼다: run 의 worktreePath 는 손대지 않으므로 터미널은 제 폴더에서 계속 돈다.
929
959
  app.patch('/api/tasks/:id', async (req, reply) => {
930
960
  const id = Number((req.params as { id: string }).id);
931
- const b = (req.body ?? {}) as { title?: string };
961
+ const b = (req.body ?? {}) as { title?: string; repoId?: unknown };
962
+ const wantTitle = b.title !== undefined;
963
+ const wantRepo = b.repoId !== undefined && b.repoId !== null;
964
+ if (!wantTitle && !wantRepo) return reply.code(400).send({ error: 'title or repoId required' });
965
+
932
966
  const title = (b.title ?? '').trim();
933
- if (!title) return reply.code(400).send({ error: 'title required' });
934
- if (title.length > 140) return reply.code(400).send({ error: 'title too long (max 140)' });
967
+ if (wantTitle) {
968
+ if (!title) return reply.code(400).send({ error: 'title required' });
969
+ if (title.length > 140) return reply.code(400).send({ error: 'title too long (max 140)' });
970
+ }
935
971
  const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
936
- if (!tr[0]) return reply.code(404).send({ error: 'not found' });
937
- await db.update(tasks).set({ title }).where(eq(tasks.id, id));
938
- broadcast({ type: 'task', taskId: id, title });
939
- return { ok: true, title };
972
+ const task = tr[0];
973
+ if (!task) return reply.code(404).send({ error: 'not found' });
974
+
975
+ let repoId: number | undefined;
976
+ if (wantRepo) {
977
+ const wanted = Number(b.repoId);
978
+ if (!Number.isInteger(wanted)) return reply.code(400).send({ error: 'repoId must be an integer' });
979
+ const rr = await db.select().from(repos).where(eq(repos.id, wanted)).limit(1);
980
+ const repo = rr[0];
981
+ if (!repo) return reply.code(404).send({ error: 'repo not found' });
982
+ // 승격은 한 방향이다 — 프로젝트로 나가는 길만 있고, Scratch 버킷으로 밀어 넣는 길은 없다.
983
+ if (repo.kind === 'sessions') {
984
+ return reply.code(400).send({
985
+ error: 'cannot re-parent into the scratch bucket',
986
+ code: 'SCRATCH_BUCKET',
987
+ detail: 'the sessions bucket is not a project — pick a registered repo',
988
+ });
989
+ }
990
+ repoId = repo.id;
991
+ }
992
+ const patch: { title?: string; repoId?: number } = {};
993
+ if (wantTitle) patch.title = title;
994
+ if (repoId !== undefined) patch.repoId = repoId;
995
+ await db.update(tasks).set(patch).where(eq(tasks.id, id));
996
+ const outTitle = wantTitle ? title : task.title;
997
+ const outRepo = repoId ?? task.repoId;
998
+ broadcast({ type: 'task', taskId: id, title: outTitle, repoId: outRepo });
999
+ return { ok: true, title: outTitle, repoId: outRepo };
1000
+ });
1001
+
1002
+ // ─── v6.0 Part W — WORK.md (작업의 공유 컨텍스트) ───────────────
1003
+ // 정본은 데몬 데이터 디렉터리(~/.coxpit/work/<taskId>.md)에 산다 — git 트리 밖이라
1004
+ // 어떤 worktree 의 diff 에도 뜨지 않고 브랜치가 갈려도 내용이 갈라지지 않는다.
1005
+ // 읽기·쓰기는 **기존 파일 뷰어**(/api/fs/read·write)가 그대로 맡는 것이 기본이다:
1006
+ // ~/.coxpit 은 기본 뷰어 루트(홈) 안이라 이미 통과한다(새 파일 창구를 만들지 않는다).
1007
+ // 여기 셋은 그 앞뒤만 맡는다 — ① 처음 열 때 빈 파일을 만들어 주고(POST),
1008
+ // ② COXPIT_DB/COXPIT_FILES_ROOT 조합 때문에 그 길이 막힌 경우의 대체 경로(GET/PUT).
1009
+ const WORK_DOC_MAX = 512 * 1024;
1010
+ const workTask = async (id: number) => (await db.select().from(tasks).where(eq(tasks.id, id)).limit(1))[0];
1011
+
1012
+ app.post('/api/tasks/:id/work', async (req, reply) => {
1013
+ const id = Number((req.params as { id: string }).id);
1014
+ if (!(await workTask(id))) return reply.code(404).send({ error: 'task not found' });
1015
+ try {
1016
+ const path = ensureWorkDoc(id);
1017
+ // inRoot=true 면 클라이언트는 평소 쓰던 파일 뷰어 페인으로 그냥 연다.
1018
+ return { ok: true, path, inRoot: withinFilesRoot(path), size: workDocSize(id) };
1019
+ } catch (e: any) { return reply.code(500).send({ error: String(e?.message || e) }); }
1020
+ });
1021
+
1022
+ app.get('/api/tasks/:id/work', async (req, reply) => {
1023
+ const id = Number((req.params as { id: string }).id);
1024
+ if (!(await workTask(id))) return reply.code(404).send({ error: 'task not found' });
1025
+ const path = workDocPath(id);
1026
+ const text = readWorkDoc(id);
1027
+ // 뷰어가 /api/fs/read 에서 받던 모양 그대로 — 같은 렌더러가 손 안 대고 붙는다.
1028
+ return { path, name: 'WORK.md', kind: 'md', size: Buffer.byteLength(text, 'utf8'), editable: true, text };
1029
+ });
1030
+
1031
+ app.put('/api/tasks/:id/work', async (req, reply) => {
1032
+ const id = Number((req.params as { id: string }).id);
1033
+ const b = (req.body ?? {}) as { content?: string };
1034
+ if (typeof b.content !== 'string') return reply.code(400).send({ error: 'content required' });
1035
+ if (Buffer.byteLength(b.content, 'utf8') > WORK_DOC_MAX) return reply.code(400).send({ error: 'content exceeds edit cap (512KB)' });
1036
+ if (!(await workTask(id))) return reply.code(404).send({ error: 'task not found' });
1037
+ try {
1038
+ const { path, size } = writeWorkDoc(id, b.content);
1039
+ return { ok: true, path, name: 'WORK.md', size };
1040
+ } catch (e: any) { return reply.code(500).send({ error: String(e?.message || e) }); }
940
1041
  });
941
1042
 
1043
+ // 역할 이름(run.title) 상한 — 탭 한 칸에 들어가는 길이. 작업 이름(140)보다 짧게 둔다.
1044
+ const RUN_TITLE_MAX = 60;
1045
+
942
1046
  // N개의 에이전트 run 을 만들고 각자 오케스트레이션 시작(fire-and-forget).
943
1047
  app.post('/api/tasks/:id/run', async (req, reply) => {
944
1048
  const id = Number((req.params as { id: string }).id);
945
- const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string };
1049
+ const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string; title?: string; inPlace?: boolean };
946
1050
  const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
947
1051
  const task = tr[0];
948
1052
  if (!task) return reply.code(404).send({ error: 'task not found' });
@@ -957,16 +1061,36 @@ export async function buildServer(): Promise<FastifyInstance> {
957
1061
  if (model && (model.length > 64 || !/^[\w.\-:/]*$/.test(model))) {
958
1062
  return reply.code(400).send({ error: 'invalid model name' });
959
1063
  }
1064
+ // v6.0 T4 — 역할 이름(선택). 작업 안에서 이 run 이 무엇인지(구현·기타). 빈값 = 프로바이더 이름으로 표시.
1065
+ const title = (b.title ?? '').trim().slice(0, RUN_TITLE_MAX);
1066
+ // v6.0 P — 격리는 선택이다. in-place = repo 체크아웃 공유(순차) · worktree = 병렬(나중에 머지).
1067
+ const inPlace = b.inPlace === true;
1068
+ if (inPlace && count > 1) {
1069
+ return reply.code(400).send({
1070
+ error: 'IN_PLACE_FANOUT',
1071
+ detail: 'in-place is sequential — one agent per checkout (count must be 1). launch in a worktree to fan out.',
1072
+ });
1073
+ }
1074
+ // P3 — 한 체크아웃에 에이전트 둘은 서로를 덮어쓴다. 만들기 **전에** 막고 이유를 말한다.
1075
+ if (inPlace) {
1076
+ const busy = await liveInPlaceRun(task.repoId);
1077
+ if (busy !== null) {
1078
+ return reply.code(409).send({
1079
+ error: 'IN_PLACE_BUSY',
1080
+ detail: `r${busy} is already working in this checkout — steer it, or launch in a worktree`,
1081
+ });
1082
+ }
1083
+ }
960
1084
  const created: Array<typeof agentRuns.$inferSelect> = [];
961
1085
  for (let i = 0; i < count; i++) {
962
1086
  const ins = await db.insert(agentRuns)
963
- .values({ taskId: id, machineId: rp[0].machineId, agent, model, status: 'pending' })
1087
+ .values({ taskId: id, machineId: rp[0].machineId, agent, model, title, inPlace, status: 'pending' })
964
1088
  .returning();
965
1089
  created.push(ins[0]!);
966
1090
  }
967
1091
  // 보드가 taskId 를 알도록 생성 브로드캐스트 후 백그라운드 시작.
968
1092
  for (const r of created) {
969
- broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, branch: '', filesChanged: 0 });
1093
+ broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, title, inPlace, branch: '', filesChanged: 0 });
970
1094
  void launchRun(r.id, b.real);
971
1095
  }
972
1096
  return reply.code(202).send({ ok: true, runs: created.map((r) => ({ id: r.id, status: r.status })) });
@@ -1021,6 +1145,8 @@ export async function buildServer(): Promise<FastifyInstance> {
1021
1145
  for (const r of trs) cleanups.push({ runId: r.id, ...(await cleanupRun(r.id)) });
1022
1146
 
1023
1147
  await db.update(tasks).set({ status: 'closed', closedAt: new Date() }).where(eq(tasks.id, id));
1148
+ // 작업이 닫히면 그 작업의 WORK.md 도 같이 사라진다(W1) — 공유 컨텍스트는 작업의 수명을 산다.
1149
+ removeWorkDoc(id);
1024
1150
  broadcast({ type: 'task', taskId: id, status: 'closed' });
1025
1151
  return { ok: true, taskId: id, cleanups };
1026
1152
  });
@@ -1034,6 +1160,22 @@ export async function buildServer(): Promise<FastifyInstance> {
1034
1160
  return { run: rr[0], events };
1035
1161
  });
1036
1162
 
1163
+ // v6.0 T4 — run 의 역할 이름 변경(탭 더블클릭). title 만 받는다.
1164
+ // (세션 버킷 run 은 지금까지처럼 태스크 이름을 바꾼다 — 클라이언트가 갈라 부른다.)
1165
+ app.patch('/api/runs/:id', async (req, reply) => {
1166
+ const id = Number((req.params as { id: string }).id);
1167
+ const b = (req.body ?? {}) as { title?: unknown };
1168
+ const title = typeof b.title === 'string' ? b.title.trim() : '';
1169
+ if (!title) return reply.code(400).send({ error: 'title required' });
1170
+ if (title.length > RUN_TITLE_MAX) return reply.code(400).send({ error: `title too long (max ${RUN_TITLE_MAX})` });
1171
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1172
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1173
+ await db.update(agentRuns).set({ title }).where(eq(agentRuns.id, id));
1174
+ // status 를 싣지 않는다 — 이름 변경은 정착 이벤트가 아니다(보드의 settle 알림을 깨우지 않게).
1175
+ broadcast({ type: 'run', runId: id, taskId: rr[0].taskId, title });
1176
+ return { ok: true, title };
1177
+ });
1178
+
1037
1179
  app.post('/api/runs/:id/cleanup', async (req, reply) => {
1038
1180
  const id = Number((req.params as { id: string }).id);
1039
1181
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
@@ -1643,10 +1785,12 @@ export async function buildServer(): Promise<FastifyInstance> {
1643
1785
  return;
1644
1786
  }
1645
1787
  liveTerminals++; // pty 압력 지표(/api/health) — close 에서 정확히 1회 감소
1788
+ agentAttach(id); // 에이전트 상태 추적 시작(refcount — 같은 run 에 여러 클라이언트가 붙어도 tracker 는 하나)
1646
1789
  let closed = false;
1647
1790
  // 백프레셔 — WS 송신 버퍼가 차면 pty 를 잠시 멈춰 폭주 방지
1648
1791
  let paused = false;
1649
1792
  term.onData((d) => {
1793
+ agentFeed(id, d);
1650
1794
  try {
1651
1795
  socket.send(JSON.stringify({ t: 'o', d }));
1652
1796
  if (!paused && socket.bufferedAmount > 800_000) { paused = true; try { term.pause(); } catch { /* n/a */ } }
@@ -1657,16 +1801,16 @@ export async function buildServer(): Promise<FastifyInstance> {
1657
1801
  }, 200);
1658
1802
  const keepalive = setInterval(() => { try { socket.ping(); } catch { /* closed */ } }, 30_000);
1659
1803
 
1660
- term.onExit(() => { try { socket.send(JSON.stringify({ t: 'exit' })); socket.close(); } catch { /* closed */ } });
1804
+ term.onExit(() => { agentExit(id); try { socket.send(JSON.stringify({ t: 'exit' })); socket.close(); } catch { /* closed */ } });
1661
1805
  socket.on('message', (raw: Buffer) => {
1662
1806
  try {
1663
1807
  const m = JSON.parse(raw.toString()) as { t?: string; d?: string; cols?: number; rows?: number };
1664
- if (m.t === 'i' && typeof m.d === 'string') term.write(m.d);
1808
+ if (m.t === 'i' && typeof m.d === 'string') { agentInput(id); term.write(m.d); }
1665
1809
  else if (m.t === 'r' && m.cols && m.rows) term.resize(Math.max(20, Math.min(500, m.cols)), Math.max(5, Math.min(200, m.rows)));
1666
1810
  } catch { /* ignore */ }
1667
1811
  });
1668
1812
  socket.on('close', () => {
1669
- if (!closed) { closed = true; liveTerminals = Math.max(0, liveTerminals - 1); }
1813
+ if (!closed) { closed = true; liveTerminals = Math.max(0, liveTerminals - 1); agentDetach(id); }
1670
1814
  clearInterval(drain); clearInterval(keepalive);
1671
1815
  try { term.kill(); } catch { /* gone */ }
1672
1816
  });