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/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,17 @@ 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, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getRunPwd, 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, spottedPorts } from './agentstate';
27
+ import { scanListeners, scanPort, killPid, dropCache as dropListenerCache } from './procscan';
25
28
  import { addSink, removeSink, broadcast } from './hub';
26
29
  import { getProvider, listProviders } from './providers';
27
30
  import { remoteState, setServe, setFunnel } from './remote';
28
31
  import { BOARD_HTML } from './board';
29
32
  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';
33
+ import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile, withinFilesRoot } from './files';
34
+ import { ensureWorkDoc, readWorkDoc, writeWorkDoc, removeWorkDoc, workDocPath, workDocSize } from './workdoc';
31
35
 
32
36
  const require_ = createRequire(import.meta.url);
33
37
 
@@ -201,7 +205,14 @@ function ptyMax(): number | null {
201
205
  }
202
206
 
203
207
  export async function buildServer(): Promise<FastifyInstance> {
204
- const app = Fastify({ logger: true });
208
+ // 로거: 쿼리스트링의 캡처 키(?k=…)가 데몬 로그에 평문으로 남지 않게 가린다(issue #13).
209
+ // (프록시/엣지 로그는 데몬이 못 막지만, 캡처 키는 저가치·회전가능이라 감수 가능.)
210
+ const app = Fastify({ logger: {
211
+ redact: {
212
+ paths: ['req.url'],
213
+ censor: (v: unknown) => (typeof v === 'string' ? v.replace(/([?&]k=)[^&]*/g, '$1REDACTED') : v),
214
+ },
215
+ } });
205
216
  // 살아있는 웹 터미널 수 — pty 압력 조기경보(/api/health)용. openTerm 성공 시 +1, 소켓 close 시 -1.
206
217
  // 누수가 재발하면 이 값이 실제 열린 탭보다 커지지 않아도(닫을 때 감소), machine-wide ptmx 대비
207
218
  // 이 데몬의 부하를 노출한다. leak 재발 자체는 회귀 테스트(test/pty-fd.mjs)가 잡는다.
@@ -226,7 +237,14 @@ export async function buildServer(): Promise<FastifyInstance> {
226
237
  // 무인증 헬스(외부 감시용)
227
238
  app.get('/api/health', async () => {
228
239
  const max = ptyMax();
229
- return { ok: true, name: 'coxpit', version: config.version, terminals: liveTerminals, ...(max ? { ptyMax: max } : {}) };
240
+ // v6.0 T6b worktree 디스크 빚을 바깥(모니터링)에서도 있게. 값은 캐시에서 나오고
241
+ // du 는 배경에서 돈다(health 는 기다리지 않는다). 없거나 0 이면 아예 싣지 않는다.
242
+ const wt = await worktreeDisk().catch(() => null);
243
+ return {
244
+ ok: true, name: 'coxpit', version: config.version, terminals: liveTerminals,
245
+ ...(max ? { ptyMax: max } : {}),
246
+ ...(wt && wt.count ? { worktrees: { count: wt.count, sizeKb: wt.sizeKb } } : {}),
247
+ };
230
248
  });
231
249
 
232
250
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
@@ -368,6 +386,9 @@ export async function buildServer(): Promise<FastifyInstance> {
368
386
  return { ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
369
387
  }),
370
388
  counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
389
+ // 지금 터미널이 붙어 있는 run 의 에이전트 상태(runId → {state,detail,ts}).
390
+ // 하이드레이션용 — 새로 뜬 코크핏이 다음 agentstate 델타를 기다리지 않게. 붙어 있는 동안만 존재한다.
391
+ agentStates: allAgentStates(),
371
392
  // 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
372
393
  // authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
373
394
  daemon: {
@@ -503,6 +524,17 @@ export async function buildServer(): Promise<FastifyInstance> {
503
524
  return pruneWorktrees(runIds);
504
525
  });
505
526
 
527
+ // v6.0 T6 — 고아 tmux 세션(위 worktree 회수와 같은 유지보수 가족). DB 에 run 기록이 없는 `coxpit-r*` 만 목록에 든다.
528
+ // 살아 있는 run 의 세션은 애초에 나오지 않고, 빈 셸이 아닌 것은 idle:false 로 표시만 된다.
529
+ app.get('/api/tmux/orphans', async () => ({ sessions: await listOrphanTmux() }));
530
+
531
+ // 선택 종료 — 이름 배열만 받는다. 서버가 고아 목록을 다시 떠서 그 안의 것만, '=' 정확 일치로 죽인다.
532
+ app.post('/api/tmux/orphans/kill', async (req, reply) => {
533
+ const b = (req.body ?? {}) as { sessions?: unknown };
534
+ if (!Array.isArray(b.sessions)) return reply.code(400).send({ error: 'sessions must be an array of session names' });
535
+ return killTmuxSessions(b.sessions.map((s) => String(s)));
536
+ });
537
+
506
538
  // ─── 머신 레지스트리 ────────────────────────────────────────────
507
539
  app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
508
540
 
@@ -822,14 +854,13 @@ export async function buildServer(): Promise<FastifyInstance> {
822
854
  });
823
855
 
824
856
  // ─── Design Mode ───────────────────────────────────────────────
825
- // 캡처 키: 인증 off 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
857
+ // 캡처 = 마스터 접근키와 분리된 저가치 전용 (issue #13). 오직 이 캡처 엔드포인트만 허가한다.
858
+ // 인증 꺼짐이면 자유, 아니면 ?k=<capture key> 를 검증(마스터 접근키가 아니라 캡처 키로).
859
+ // setup(마스터 키 미설정) 상태여도 캡처 키는 독립적으로 존재하므로 캡처는 동작한다.
826
860
  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;
861
+ if (authMode().mode === 'disabled') return true;
831
862
  const k = ((req.query ?? {}) as { k?: string }).k ?? '';
832
- return verifyKey(k, m);
863
+ return verifyCaptureKey(k);
833
864
  };
834
865
  const cors = (reply: { header: (k: string, v: string) => unknown }) => {
835
866
  reply.header('access-control-allow-origin', '*');
@@ -856,6 +887,11 @@ export async function buildServer(): Promise<FastifyInstance> {
856
887
 
857
888
  app.get('/api/design', async () => ({ captures: await db.select().from(designCaptures) }));
858
889
 
890
+ // 캡처 키 조회/회전 — 인증 게이트 뒤(EXEMPT 아님)라 인증된 보드 사용자만 접근한다.
891
+ // 보드가 이걸로 북마클릿 href(?k=…)를 만들고, 회전 버튼으로 새 키를 발급한다.
892
+ app.get('/api/design/capture-key', async () => ({ key: captureKey(), fixed: captureKeyIsFixed() }));
893
+ app.post('/api/design/capture-key/rotate', async () => ({ key: rotateCaptureKey(), fixed: captureKeyIsFixed() }));
894
+
859
895
  app.delete('/api/design/:id', async (req, reply) => {
860
896
  const id = Number((req.params as { id: string }).id);
861
897
  await db.delete(designCaptures).where(eq(designCaptures.id, id));
@@ -925,24 +961,100 @@ export async function buildServer(): Promise<FastifyInstance> {
925
961
  return { task: tr[0], runs };
926
962
  });
927
963
 
928
- // 태스크 이름 변경(=세션 이름 변경). title 갱신.
964
+ // 태스크 이름 변경(=세션 이름 변경) + v6.0 S2 승격(repoId 재부모화).
965
+ // 둘 다 선택 — title 만 보내던 기존 호출은 그대로 동작한다. repoId 는 "이 작업이 어느
966
+ // 프로젝트 것인가"만 바꾼다: run 의 worktreePath 는 손대지 않으므로 터미널은 제 폴더에서 계속 돈다.
929
967
  app.patch('/api/tasks/:id', async (req, reply) => {
930
968
  const id = Number((req.params as { id: string }).id);
931
- const b = (req.body ?? {}) as { title?: string };
969
+ const b = (req.body ?? {}) as { title?: string; repoId?: unknown };
970
+ const wantTitle = b.title !== undefined;
971
+ const wantRepo = b.repoId !== undefined && b.repoId !== null;
972
+ if (!wantTitle && !wantRepo) return reply.code(400).send({ error: 'title or repoId required' });
973
+
932
974
  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)' });
975
+ if (wantTitle) {
976
+ if (!title) return reply.code(400).send({ error: 'title required' });
977
+ if (title.length > 140) return reply.code(400).send({ error: 'title too long (max 140)' });
978
+ }
935
979
  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 };
980
+ const task = tr[0];
981
+ if (!task) return reply.code(404).send({ error: 'not found' });
982
+
983
+ let repoId: number | undefined;
984
+ if (wantRepo) {
985
+ const wanted = Number(b.repoId);
986
+ if (!Number.isInteger(wanted)) return reply.code(400).send({ error: 'repoId must be an integer' });
987
+ const rr = await db.select().from(repos).where(eq(repos.id, wanted)).limit(1);
988
+ const repo = rr[0];
989
+ if (!repo) return reply.code(404).send({ error: 'repo not found' });
990
+ // 승격은 한 방향이다 — 프로젝트로 나가는 길만 있고, Scratch 버킷으로 밀어 넣는 길은 없다.
991
+ if (repo.kind === 'sessions') {
992
+ return reply.code(400).send({
993
+ error: 'cannot re-parent into the scratch bucket',
994
+ code: 'SCRATCH_BUCKET',
995
+ detail: 'the sessions bucket is not a project — pick a registered repo',
996
+ });
997
+ }
998
+ repoId = repo.id;
999
+ }
1000
+ const patch: { title?: string; repoId?: number } = {};
1001
+ if (wantTitle) patch.title = title;
1002
+ if (repoId !== undefined) patch.repoId = repoId;
1003
+ await db.update(tasks).set(patch).where(eq(tasks.id, id));
1004
+ const outTitle = wantTitle ? title : task.title;
1005
+ const outRepo = repoId ?? task.repoId;
1006
+ broadcast({ type: 'task', taskId: id, title: outTitle, repoId: outRepo });
1007
+ return { ok: true, title: outTitle, repoId: outRepo };
1008
+ });
1009
+
1010
+ // ─── v6.0 Part W — WORK.md (작업의 공유 컨텍스트) ───────────────
1011
+ // 정본은 데몬 데이터 디렉터리(~/.coxpit/work/<taskId>.md)에 산다 — git 트리 밖이라
1012
+ // 어떤 worktree 의 diff 에도 뜨지 않고 브랜치가 갈려도 내용이 갈라지지 않는다.
1013
+ // 읽기·쓰기는 **기존 파일 뷰어**(/api/fs/read·write)가 그대로 맡는 것이 기본이다:
1014
+ // ~/.coxpit 은 기본 뷰어 루트(홈) 안이라 이미 통과한다(새 파일 창구를 만들지 않는다).
1015
+ // 여기 셋은 그 앞뒤만 맡는다 — ① 처음 열 때 빈 파일을 만들어 주고(POST),
1016
+ // ② COXPIT_DB/COXPIT_FILES_ROOT 조합 때문에 그 길이 막힌 경우의 대체 경로(GET/PUT).
1017
+ const WORK_DOC_MAX = 512 * 1024;
1018
+ const workTask = async (id: number) => (await db.select().from(tasks).where(eq(tasks.id, id)).limit(1))[0];
1019
+
1020
+ app.post('/api/tasks/:id/work', async (req, reply) => {
1021
+ const id = Number((req.params as { id: string }).id);
1022
+ if (!(await workTask(id))) return reply.code(404).send({ error: 'task not found' });
1023
+ try {
1024
+ const path = ensureWorkDoc(id);
1025
+ // inRoot=true 면 클라이언트는 평소 쓰던 파일 뷰어 페인으로 그냥 연다.
1026
+ return { ok: true, path, inRoot: withinFilesRoot(path), size: workDocSize(id) };
1027
+ } catch (e: any) { return reply.code(500).send({ error: String(e?.message || e) }); }
1028
+ });
1029
+
1030
+ app.get('/api/tasks/:id/work', async (req, reply) => {
1031
+ const id = Number((req.params as { id: string }).id);
1032
+ if (!(await workTask(id))) return reply.code(404).send({ error: 'task not found' });
1033
+ const path = workDocPath(id);
1034
+ const text = readWorkDoc(id);
1035
+ // 뷰어가 /api/fs/read 에서 받던 모양 그대로 — 같은 렌더러가 손 안 대고 붙는다.
1036
+ return { path, name: 'WORK.md', kind: 'md', size: Buffer.byteLength(text, 'utf8'), editable: true, text };
940
1037
  });
941
1038
 
1039
+ app.put('/api/tasks/:id/work', async (req, reply) => {
1040
+ const id = Number((req.params as { id: string }).id);
1041
+ const b = (req.body ?? {}) as { content?: string };
1042
+ if (typeof b.content !== 'string') return reply.code(400).send({ error: 'content required' });
1043
+ if (Buffer.byteLength(b.content, 'utf8') > WORK_DOC_MAX) return reply.code(400).send({ error: 'content exceeds edit cap (512KB)' });
1044
+ if (!(await workTask(id))) return reply.code(404).send({ error: 'task not found' });
1045
+ try {
1046
+ const { path, size } = writeWorkDoc(id, b.content);
1047
+ return { ok: true, path, name: 'WORK.md', size };
1048
+ } catch (e: any) { return reply.code(500).send({ error: String(e?.message || e) }); }
1049
+ });
1050
+
1051
+ // 역할 이름(run.title) 상한 — 탭 한 칸에 들어가는 길이. 작업 이름(140)보다 짧게 둔다.
1052
+ const RUN_TITLE_MAX = 60;
1053
+
942
1054
  // N개의 에이전트 run 을 만들고 각자 오케스트레이션 시작(fire-and-forget).
943
1055
  app.post('/api/tasks/:id/run', async (req, reply) => {
944
1056
  const id = Number((req.params as { id: string }).id);
945
- const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string };
1057
+ const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string; title?: string; inPlace?: boolean };
946
1058
  const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
947
1059
  const task = tr[0];
948
1060
  if (!task) return reply.code(404).send({ error: 'task not found' });
@@ -957,16 +1069,36 @@ export async function buildServer(): Promise<FastifyInstance> {
957
1069
  if (model && (model.length > 64 || !/^[\w.\-:/]*$/.test(model))) {
958
1070
  return reply.code(400).send({ error: 'invalid model name' });
959
1071
  }
1072
+ // v6.0 T4 — 역할 이름(선택). 작업 안에서 이 run 이 무엇인지(구현·기타). 빈값 = 프로바이더 이름으로 표시.
1073
+ const title = (b.title ?? '').trim().slice(0, RUN_TITLE_MAX);
1074
+ // v6.0 P — 격리는 선택이다. in-place = repo 체크아웃 공유(순차) · worktree = 병렬(나중에 머지).
1075
+ const inPlace = b.inPlace === true;
1076
+ if (inPlace && count > 1) {
1077
+ return reply.code(400).send({
1078
+ error: 'IN_PLACE_FANOUT',
1079
+ detail: 'in-place is sequential — one agent per checkout (count must be 1). launch in a worktree to fan out.',
1080
+ });
1081
+ }
1082
+ // P3 — 한 체크아웃에 에이전트 둘은 서로를 덮어쓴다. 만들기 **전에** 막고 이유를 말한다.
1083
+ if (inPlace) {
1084
+ const busy = await liveInPlaceRun(task.repoId);
1085
+ if (busy !== null) {
1086
+ return reply.code(409).send({
1087
+ error: 'IN_PLACE_BUSY',
1088
+ detail: `r${busy} is already working in this checkout — steer it, or launch in a worktree`,
1089
+ });
1090
+ }
1091
+ }
960
1092
  const created: Array<typeof agentRuns.$inferSelect> = [];
961
1093
  for (let i = 0; i < count; i++) {
962
1094
  const ins = await db.insert(agentRuns)
963
- .values({ taskId: id, machineId: rp[0].machineId, agent, model, status: 'pending' })
1095
+ .values({ taskId: id, machineId: rp[0].machineId, agent, model, title, inPlace, status: 'pending' })
964
1096
  .returning();
965
1097
  created.push(ins[0]!);
966
1098
  }
967
1099
  // 보드가 taskId 를 알도록 생성 브로드캐스트 후 백그라운드 시작.
968
1100
  for (const r of created) {
969
- broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, branch: '', filesChanged: 0 });
1101
+ broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, title, inPlace, branch: '', filesChanged: 0 });
970
1102
  void launchRun(r.id, b.real);
971
1103
  }
972
1104
  return reply.code(202).send({ ok: true, runs: created.map((r) => ({ id: r.id, status: r.status })) });
@@ -1021,6 +1153,8 @@ export async function buildServer(): Promise<FastifyInstance> {
1021
1153
  for (const r of trs) cleanups.push({ runId: r.id, ...(await cleanupRun(r.id)) });
1022
1154
 
1023
1155
  await db.update(tasks).set({ status: 'closed', closedAt: new Date() }).where(eq(tasks.id, id));
1156
+ // 작업이 닫히면 그 작업의 WORK.md 도 같이 사라진다(W1) — 공유 컨텍스트는 작업의 수명을 산다.
1157
+ removeWorkDoc(id);
1024
1158
  broadcast({ type: 'task', taskId: id, status: 'closed' });
1025
1159
  return { ok: true, taskId: id, cleanups };
1026
1160
  });
@@ -1034,6 +1168,22 @@ export async function buildServer(): Promise<FastifyInstance> {
1034
1168
  return { run: rr[0], events };
1035
1169
  });
1036
1170
 
1171
+ // v6.0 T4 — run 의 역할 이름 변경(탭 더블클릭). title 만 받는다.
1172
+ // (세션 버킷 run 은 지금까지처럼 태스크 이름을 바꾼다 — 클라이언트가 갈라 부른다.)
1173
+ app.patch('/api/runs/:id', async (req, reply) => {
1174
+ const id = Number((req.params as { id: string }).id);
1175
+ const b = (req.body ?? {}) as { title?: unknown };
1176
+ const title = typeof b.title === 'string' ? b.title.trim() : '';
1177
+ if (!title) return reply.code(400).send({ error: 'title required' });
1178
+ if (title.length > RUN_TITLE_MAX) return reply.code(400).send({ error: `title too long (max ${RUN_TITLE_MAX})` });
1179
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1180
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1181
+ await db.update(agentRuns).set({ title }).where(eq(agentRuns.id, id));
1182
+ // status 를 싣지 않는다 — 이름 변경은 정착 이벤트가 아니다(보드의 settle 알림을 깨우지 않게).
1183
+ broadcast({ type: 'run', runId: id, taskId: rr[0].taskId, title });
1184
+ return { ok: true, title };
1185
+ });
1186
+
1037
1187
  app.post('/api/runs/:id/cleanup', async (req, reply) => {
1038
1188
  const id = Number((req.params as { id: string }).id);
1039
1189
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
@@ -1111,6 +1261,84 @@ export async function buildServer(): Promise<FastifyInstance> {
1111
1261
  return res;
1112
1262
  });
1113
1263
 
1264
+ // 페인이 지금 서 있는 폴더 (v5.28 D-fix) — 터미널이 찍은 상대경로를 무엇 기준으로 풀지.
1265
+ // 못 알아내면 200 + { ok:false, pwd:'' } — 클라이언트가 worktree 로 폴백한다(지어낸 경로는 없다).
1266
+ app.get('/api/runs/:id/pwd', async (req, reply) => {
1267
+ const id = Number((req.params as { id: string }).id);
1268
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1269
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1270
+ return await getRunPwd(id);
1271
+ });
1272
+
1273
+ // ─── 무엇이 듣고 있나 (v5.28 B) — 증거만 보이고, 판정하지 않는다 ──────────────
1274
+ // 이 페인 체크아웃이 남긴 LISTEN 소켓 + 언제부터 떠 있는지(etime). stale 판정은 없다.
1275
+ // pwd 는 §D-fix 의 getRunPwd(페인이 지금 서 있는 폴더) 를 그대로 쓰고, 못 알아내면
1276
+ // worktree 로 물러선다 — 그러면 underPane 은 그 기준으로 읽힌다(기준을 응답에 같이 싣는다).
1277
+ app.get('/api/runs/:id/listeners', async (req, reply) => {
1278
+ const id = Number((req.params as { id: string }).id);
1279
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1280
+ const run = rr[0];
1281
+ if (!run) return reply.code(404).send({ error: 'not found' });
1282
+ const mr = await db.select().from(machines).where(eq(machines.id, run.machineId)).limit(1);
1283
+ const m = mr[0];
1284
+ if (!m) return reply.code(404).send({ error: 'machine gone' });
1285
+ const live = await getRunPwd(id);
1286
+ const pwd = live.pwd || run.worktreePath || '';
1287
+ const res = await scanListeners(m, { pwd });
1288
+ // 수동 포착 — Part A 가 이미 모으고 있는 꼬리를 한 번 훑을 뿐(탭을 더 달지 않는다).
1289
+ return { ...res, machine: m.slug, pwd, spotted: spottedPorts(id) };
1290
+ });
1291
+
1292
+ // 머신 + 포트 겨냥 조회 — "8210 은 누가 물고 있나". 어디서 왔든 그대로 보여준다.
1293
+ // 주의: 파라미터 이름은 위의 `/api/machines/:slug` 와 같은 자리라 `:slug` 로 맞춘다(라우터 충돌 회피).
1294
+ // 값은 slug 또는 숫자 id 둘 다 받는다.
1295
+ const findMachine = async (key: string) => {
1296
+ const bySlug = await db.select().from(machines).where(eq(machines.slug, key)).limit(1);
1297
+ if (bySlug[0]) return bySlug[0];
1298
+ const n = Number(key);
1299
+ if (!Number.isInteger(n)) return null;
1300
+ const byId = await db.select().from(machines).where(eq(machines.id, n)).limit(1);
1301
+ return byId[0] ?? null;
1302
+ };
1303
+
1304
+ app.get('/api/machines/:slug/port/:port', async (req, reply) => {
1305
+ const { slug, port } = req.params as { slug: string; port: string };
1306
+ const m = await findMachine(slug);
1307
+ if (!m) return reply.code(404).send({ error: 'machine not found' });
1308
+ const p = Number(port);
1309
+ if (!Number.isInteger(p) || p < 1 || p > 65535) return reply.code(400).send({ error: 'port must be 1..65535' });
1310
+ const res = await scanPort(m, p);
1311
+ return { ...res, machine: m.slug, port: p };
1312
+ });
1313
+
1314
+ // 정확히 그 pid 하나만, 이 머신에서만. 가드는 **서버 쪽**이다(클라이언트를 믿지 않는다):
1315
+ // ① pid 1 거부 ② 데몬 자신 거부(로컬) ③ **지금 다시 훑은 목록에 없으면 거부**.
1316
+ // ③ 이 있어서 이건 "임의 pid kill 엔드포인트"가 아니다 — 사람이 본 그 행만 죽는다.
1317
+ // "포트 위 전부 죽이기" 같은 편의는 두지 않는다(무관한 프로세스를 같이 데려간다).
1318
+ app.post('/api/machines/:slug/kill', async (req, reply) => {
1319
+ const { slug } = req.params as { slug: string };
1320
+ const m = await findMachine(slug);
1321
+ if (!m) return reply.code(404).send({ error: 'machine not found' });
1322
+ const b = (req.body ?? {}) as { pid?: unknown };
1323
+ const pid = Math.floor(Number(b.pid));
1324
+ if (!Number.isInteger(pid) || pid <= 0) return reply.code(400).send({ error: 'pid must be a positive integer' });
1325
+ if (pid === 1) return reply.code(403).send({ error: 'refused: pid 1 is init' });
1326
+ const isLocal = m.kind === 'local' || (m.address ?? '') === '';
1327
+ if (isLocal && pid === process.pid) return reply.code(403).send({ error: 'refused: that is the coxpit daemon itself' });
1328
+ // 지금 다시 훑는다 — 목록에 없는 pid 는 사람이 본 적 없는 pid 다.
1329
+ dropListenerCache(m.slug);
1330
+ const fresh = await scanListeners(m);
1331
+ const row = fresh.listeners.find((l) => l.pid === pid);
1332
+ if (!row) {
1333
+ return reply.code(409).send({
1334
+ error: 'refused: that pid is not listening on this machine right now',
1335
+ detail: fresh.note || 're-scan found no such listener — nothing was signalled',
1336
+ });
1337
+ }
1338
+ const r = await killPid(m, pid);
1339
+ return reply.code(r.ok ? 200 : 409).send({ ...r, pid, port: row.port, machine: m.slug });
1340
+ });
1341
+
1114
1342
  // 수동 재검증 — repo.verifyCmd 를 이 run 의 worktree 에서 다시 실행(정착 자동검증과 동일 경로).
1115
1343
  app.post('/api/runs/:id/verify', async (req, reply) => {
1116
1344
  const id = Number((req.params as { id: string }).id);
@@ -1643,10 +1871,12 @@ export async function buildServer(): Promise<FastifyInstance> {
1643
1871
  return;
1644
1872
  }
1645
1873
  liveTerminals++; // pty 압력 지표(/api/health) — close 에서 정확히 1회 감소
1874
+ agentAttach(id); // 에이전트 상태 추적 시작(refcount — 같은 run 에 여러 클라이언트가 붙어도 tracker 는 하나)
1646
1875
  let closed = false;
1647
1876
  // 백프레셔 — WS 송신 버퍼가 차면 pty 를 잠시 멈춰 폭주 방지
1648
1877
  let paused = false;
1649
1878
  term.onData((d) => {
1879
+ agentFeed(id, d);
1650
1880
  try {
1651
1881
  socket.send(JSON.stringify({ t: 'o', d }));
1652
1882
  if (!paused && socket.bufferedAmount > 800_000) { paused = true; try { term.pause(); } catch { /* n/a */ } }
@@ -1657,16 +1887,16 @@ export async function buildServer(): Promise<FastifyInstance> {
1657
1887
  }, 200);
1658
1888
  const keepalive = setInterval(() => { try { socket.ping(); } catch { /* closed */ } }, 30_000);
1659
1889
 
1660
- term.onExit(() => { try { socket.send(JSON.stringify({ t: 'exit' })); socket.close(); } catch { /* closed */ } });
1890
+ term.onExit(() => { agentExit(id); try { socket.send(JSON.stringify({ t: 'exit' })); socket.close(); } catch { /* closed */ } });
1661
1891
  socket.on('message', (raw: Buffer) => {
1662
1892
  try {
1663
1893
  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);
1894
+ if (m.t === 'i' && typeof m.d === 'string') { agentInput(id); term.write(m.d); }
1665
1895
  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
1896
  } catch { /* ignore */ }
1667
1897
  });
1668
1898
  socket.on('close', () => {
1669
- if (!closed) { closed = true; liveTerminals = Math.max(0, liveTerminals - 1); }
1899
+ if (!closed) { closed = true; liveTerminals = Math.max(0, liveTerminals - 1); agentDetach(id); }
1670
1900
  clearInterval(drain); clearInterval(keepalive);
1671
1901
  try { term.kill(); } catch { /* gone */ }
1672
1902
  });
package/src/workdoc.ts ADDED
@@ -0,0 +1,65 @@
1
+ // v6.0 Part W — WORK.md, 한 작업(work)의 공유 컨텍스트.
2
+ //
3
+ // 격리된 run 은 **과거에서 갈라져 나온다** — 형제가 방금 정한 것을 모른 채 base 에서 시작하고,
4
+ // 그 run 의 세션은 내가 옆 터미널에 말한 내용을 들은 적이 없다. 파일 컨텍스트는 Part P(in-place)
5
+ // 의 몫이고, **결정 컨텍스트는 이 파일의 몫**이다: 여기 적힌 것은 이 작업 아래 모든 세션에 따라붙는다.
6
+ //
7
+ // 정본은 git 트리 **밖**(데몬 데이터 디렉터리 = DB 옆)에 둔다 — 어떤 worktree 의 diff 도
8
+ // 더럽히지 않고, 브랜치가 갈려도 내용이 갈라지지 않는다. 작업이 닫히면 같이 사라진다.
9
+ //
10
+ // ⚠️ 편집은 **다음 발사·steer** 부터 닿는다. 돌고 있는 턴에 끼어드는 마법은 없다.
11
+ // (W4 harvest — 살아있는 run 이 자기 worktree 에 적은 줄을 watcher 가 여기로 모으는 일 — 은
12
+ // 이번 단계에서 의도적으로 미룬다: 주입만으로도 사람이 큐레이션한 컨텍스트는 이미 전달된다.)
13
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, statSync } from 'node:fs';
14
+ import { dirname as pdirname, join as pjoin, resolve as presolve } from 'node:path';
15
+ import { config } from './config';
16
+
17
+ /** 데몬 데이터 디렉터리(= DB 가 사는 곳, 기본 ~/.coxpit) 아래의 work 폴더. */
18
+ export function workDir(): string {
19
+ return pjoin(pdirname(presolve(config.dbPath)), 'work');
20
+ }
21
+
22
+ /** 한 작업의 정본 경로. 파일은 첫 쓰기 때 생긴다(없는 것과 빈 것은 같은 뜻). */
23
+ export function workDocPath(taskId: number): string {
24
+ return pjoin(workDir(), `${taskId}.md`);
25
+ }
26
+
27
+ /** 내용(없으면 ''). 읽기 실패는 '' 로 — 컨텍스트 하나 때문에 발사가 막히면 안 된다. */
28
+ export function readWorkDoc(taskId: number): string {
29
+ try { return readFileSync(workDocPath(taskId), 'utf8'); } catch { return ''; }
30
+ }
31
+
32
+ export function workDocSize(taskId: number): number {
33
+ try { return statSync(workDocPath(taskId)).size; } catch { return 0; }
34
+ }
35
+
36
+ /** 처음 열 때 빈 파일로 만들어 둔다 — 뷰어가 "없는 파일"에 걸려 넘어지지 않도록.
37
+ * 빈 파일은 주입되지 않으므로(아래 workContextBlock) 보일러플레이트가 새어나가지 않는다. */
38
+ export function ensureWorkDoc(taskId: number): string {
39
+ const p = workDocPath(taskId);
40
+ mkdirSync(workDir(), { recursive: true });
41
+ if (!existsSync(p)) writeFileSync(p, '', 'utf8');
42
+ return p;
43
+ }
44
+
45
+ export function writeWorkDoc(taskId: number, content: string): { path: string; size: number } {
46
+ const p = workDocPath(taskId);
47
+ mkdirSync(workDir(), { recursive: true });
48
+ writeFileSync(p, content, 'utf8');
49
+ return { path: p, size: Buffer.byteLength(content, 'utf8') };
50
+ }
51
+
52
+ /** 작업이 닫히거나 지워질 때 함께 사라진다(정리 경로에서 호출). */
53
+ export function removeWorkDoc(taskId: number): void {
54
+ try { rmSync(workDocPath(taskId), { force: true }); } catch { /* 없으면 그만 */ }
55
+ }
56
+
57
+ /**
58
+ * 프롬프트에 붙일 블록. **비어 있으면 아무것도 붙이지 않는다** — 없음은 깔끔해야지,
59
+ * 빈 블록으로 에이전트를 헷갈리게 하면 안 된다.
60
+ */
61
+ export function workContextBlock(taskId: number): string {
62
+ const text = readWorkDoc(taskId).trim();
63
+ if (!text) return '';
64
+ return `\n\n## WORK CONTEXT (shared across this work's sessions — decisions recorded here bind you)\n${text}`;
65
+ }