coxpit 5.3.2 → 5.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.3.2",
3
+ "version": "5.4.0",
4
4
  "description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/board.ts CHANGED
@@ -57,6 +57,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
57
57
  .daemon-badge{font-family:var(--mono);font-size:10.5px;color:var(--faint);padding:3px 9px;
58
58
  border:1px solid var(--line);border-radius:999px;background:var(--surface);cursor:default}
59
59
  .daemon-badge b{color:var(--muted);font-weight:500}
60
+ .cockpit-link{text-decoration:none;color:var(--muted);font-family:var(--mono);font-size:11.5px;
61
+ border:1px solid var(--line);border-radius:7px;padding:5px 10px;display:inline-flex;align-items:center;gap:4px}
62
+ .cockpit-link:hover{color:var(--brand);border-color:var(--line-hi)}
60
63
  .ws{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;color:var(--muted);
61
64
  padding:4px 10px;border:1px solid var(--line);border-radius:999px;background:var(--surface)}
62
65
  .dot{width:7px;height:7px;border-radius:50%;background:var(--s-failed);transition:background .3s}
@@ -842,7 +845,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
842
845
  .fab{display:inline-flex}
843
846
  #newBtn,.newnote{display:none}
844
847
  header{padding:0 12px;gap:9px}
845
- .brand .sub,.daemon-badge,.machines{display:none}
848
+ .brand .sub,.daemon-badge,.machines,.cockpit-link{display:none}
846
849
  main{padding:14px}
847
850
  .grid{grid-template-columns:1fr}
848
851
  /* 모달 = 풀블리드 */
@@ -879,6 +882,7 @@ ${ICON_SPRITE}
879
882
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
880
883
  <button class="btn-ghost sm" id="bell" title="notify when a run settles"><svg class="ic"><use href="#i-bell-off"/></svg></button>
881
884
  <button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)"><svg class="ic"><use href="#i-external-link"/></svg></button>
885
+ <a class="cockpit-link" href="/cockpit" title="터미널 우선 셸 (preview)">◨ Cockpit</a>
882
886
  <div class="machines" id="machines"></div>
883
887
  </header>
884
888
  <div class="scrim" id="scrim"></div>
package/src/cockpit.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Cockpit — 터미널 우선 셸 (병행 개발용, /cockpit). board.ts 처럼 자가완결 단일 HTML(빌드 0).
2
+ // 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
3
+ // Phase 0 = 스캐폴드(레이아웃 골격 + Board 토글 + 모바일 보드 리다이렉트). 트리·페인 그리드·요청바는 Phase 2~3.
4
+ export const COCKPIT_HTML = /* html */ `<!doctype html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="utf-8" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
9
+ <title>coxpit · cockpit</title>
10
+ <link rel="icon" href="/brand/favicon.ico" sizes="any" />
11
+ <link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
12
+ <link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
13
+ <style>
14
+ @font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
15
+ :root{
16
+ --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --panel:#0e1118;
17
+ --line:#222835; --line-hi:#2f3648;
18
+ --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
19
+ --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
20
+ --running:#55a7e0; --done:#58b368; --blocked:#d6a249; --failed:#e25b67;
21
+ --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
22
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
23
+ }
24
+ *{box-sizing:border-box}
25
+ html,body{height:100%}
26
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
27
+ -webkit-font-smoothing:antialiased;overflow:hidden}
28
+
29
+ /* ── topbar ── */
30
+ header{display:flex;align-items:center;gap:13px;height:46px;padding:0 14px;
31
+ border-bottom:1px solid var(--line);background:rgba(18,21,28,.92);font-family:var(--mono)}
32
+ .brand{display:inline-flex;align-items:center;gap:8px;text-decoration:none}
33
+ .brand img{height:22px;width:auto;display:block}
34
+ .brand .wm{font-family:'Pixelify';font-weight:600;font-size:19px;color:var(--ink);letter-spacing:.01em;margin-left:-2px}
35
+ .mach{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:7px;padding:4px 10px}
36
+ .mach .dot{width:6px;height:6px;border-radius:50%;background:var(--done)}
37
+ .vtabs{display:flex;gap:4px;margin-left:4px}
38
+ .vtab{font-size:12px;color:var(--muted);padding:6px 11px;border-radius:7px;display:inline-flex;align-items:center;gap:7px;cursor:pointer;background:none;border:none;font-family:var(--mono)}
39
+ .vtab.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.28)}
40
+ .vtab .g{color:var(--brand)}
41
+ .right{margin-left:auto;display:flex;align-items:center;gap:9px}
42
+ .wip{font-size:10.5px;color:var(--blocked);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:2px 9px}
43
+ .toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px}
44
+ .toggle:hover{color:var(--ink);border-color:var(--line-hi)}
45
+
46
+ /* ── body: tree | main ── */
47
+ .layout{display:grid;grid-template-columns:264px 1fr;height:calc(100vh - 46px)}
48
+ .rail{border-right:1px solid var(--line);overflow:auto;padding:12px 10px}
49
+ .lbl{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);padding:5px 8px 10px}
50
+ .skel{height:11px;border-radius:5px;background:linear-gradient(90deg,var(--surface) 0%,var(--surface2) 50%,var(--surface) 100%);
51
+ background-size:200% 100%;animation:sh 1.4s linear infinite;margin:9px 8px}
52
+ @keyframes sh{to{background-position:-200% 0}}
53
+ @media (prefers-reduced-motion:reduce){.skel{animation:none}}
54
+
55
+ .main{display:flex;flex-direction:column;min-width:0;background:var(--bg)}
56
+ .stage{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
57
+ .stage .card{max-width:460px}
58
+ .stage .glyph{font-family:var(--mono);font-size:26px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
59
+ .stage h1{font-family:var(--mono);font-size:17px;margin:0 0 8px;color:var(--ink);font-weight:700}
60
+ .stage p{color:var(--muted);font-size:13.5px;margin:0 0 6px;line-height:1.6}
61
+ .stage .steps{font-family:var(--mono);font-size:12px;color:var(--faint);margin-top:16px;text-align:left;
62
+ border:1px solid var(--line);border-radius:10px;background:var(--surface);padding:14px 16px;line-height:1.9}
63
+ .stage .steps b{color:var(--brand)}
64
+ .reqbar{display:flex;align-items:center;gap:10px;border-top:1px solid var(--line);background:var(--surface);padding:11px 14px;font-family:var(--mono)}
65
+ .reqbar .ctx{font-size:11px;color:var(--brand-ink);background:var(--brand);border-radius:6px;padding:3px 9px;font-weight:600}
66
+ .reqbar .caret{color:var(--brand)}
67
+ .reqbar .ph{color:var(--faint);font-size:13px;flex:1}
68
+ .reqbar .kbd{border:1px solid var(--line-hi);border-radius:5px;padding:1px 6px;color:var(--faint);font-size:11px}
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <header>
73
+ <a class="brand" href="/"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></a>
74
+ <span class="mach"><span class="dot"></span><span id="mach">local</span></span>
75
+ <div class="vtabs">
76
+ <button type="button" class="vtab on"><span class="g">⌗</span>Terminal</button>
77
+ <button type="button" class="vtab"><span class="g">⧉</span>Review</button>
78
+ <button type="button" class="vtab"><span class="g">▤</span>Docs</button>
79
+ </div>
80
+ <div class="right">
81
+ <span class="wip">preview · Phase 0</span>
82
+ <a class="toggle" href="/">← Board</a>
83
+ </div>
84
+ </header>
85
+
86
+ <div class="layout">
87
+ <aside class="rail">
88
+ <div class="lbl">Workspace</div>
89
+ <div class="skel" style="width:70%"></div>
90
+ <div class="skel" style="width:52%"></div>
91
+ <div class="skel" style="width:60%"></div>
92
+ <div class="skel" style="width:44%"></div>
93
+ </aside>
94
+
95
+ <main class="main">
96
+ <div class="stage">
97
+ <div class="card">
98
+ <div class="glyph">⌗ ⌗ ⌗</div>
99
+ <h1>Cockpit — 터미널 우선 셸</h1>
100
+ <p>이 화면은 새 셸의 골격입니다. 백엔드는 보드와 공유하고, 여기에 트리·페인 그리드 터미널·요청바를 단계로 얹습니다.</p>
101
+ <div class="steps">
102
+ <div><b>Phase 1</b> · tmux 재-adopt (재시작 내성 토대)</div>
103
+ <div><b>Phase 2</b> · 워크스페이스 트리 + 페인 그리드 터미널</div>
104
+ <div><b>Phase 3</b> · 요청바 · 팬아웃 타일 · Review · 브로드캐스트</div>
105
+ <div><b>Phase 4</b> · verify in-loop</div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ <div class="reqbar">
110
+ <span class="ctx">cockpit</span><span class="caret">›</span>
111
+ <span class="ph">요청바 — Phase 3</span>
112
+ <span class="kbd">⏎</span>
113
+ </div>
114
+ </main>
115
+ </div>
116
+
117
+ <script>
118
+ // 모바일은 터미널 우선 대신 보드(모니터)로 — Cockpit 은 데스크톱 우선.
119
+ // (뷰포트 폭 + 터치 힌트로 판정 — screen.width 는 헤드리스/멀티모니터서 부정확)
120
+ if (window.innerWidth <= 860 && matchMedia('(pointer:coarse)').matches) location.replace('/');
121
+ // health 로 머신명 정도만 채운다(스캐폴드).
122
+ fetch('/api/health').then(function(r){return r.json()}).then(function(){}).catch(function(){});
123
+ </script>
124
+ </body>
125
+ </html>`;
package/src/db/index.ts CHANGED
@@ -101,4 +101,6 @@ export async function ensureSchema(): Promise<void> {
101
101
  try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
102
102
  try { await client.execute("ALTER TABLE task_groups ADD COLUMN coord_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
103
103
  try { await client.execute("ALTER TABLE tasks ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'"); } catch { /* exists */ }
104
+ try { await client.execute('ALTER TABLE agent_runs ADD COLUMN agent_pid INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
105
+ try { await client.execute('ALTER TABLE agent_runs ADD COLUMN log_offset INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
104
106
  }
package/src/db/schema.ts CHANGED
@@ -88,6 +88,8 @@ export const agentRuns = sqliteTable('agent_runs', {
88
88
  prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
89
89
  model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
90
90
  filesChanged: integer('files_changed').notNull().default(0),
91
+ agentPid: integer('agent_pid').notNull().default(0), // detached sh pgid — 재시작 후 생존 판정/stop
92
+ logOffset: integer('log_offset').notNull().default(0), // 내구 로그에서 tail 이 소비한 바이트(재-adopt 시 여기부터)
91
93
  startedAt: integer('started_at', { mode: 'timestamp' }),
92
94
  endedAt: integer('ended_at', { mode: 'timestamp' }),
93
95
  exitSummary: text('exit_summary').notNull().default(''),
@@ -1,8 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { posix as ppath } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
- import { existsSync } from 'node:fs';
5
- import { mkdir, copyFile, readFile, writeFile, rm } from 'node:fs/promises';
4
+ import { existsSync, statSync, openSync, readSync, closeSync, mkdirSync } from 'node:fs';
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
8
  import { eq, inArray } from 'drizzle-orm';
@@ -111,6 +111,80 @@ async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInser
111
111
  // 실행 중 run 의 자식 프로세스(stop 용). stoppedRuns = 사용자가 멈춘 run 표식.
112
112
  const liveChildren = new Map<number, ChildProcess>();
113
113
  const stoppedRuns = new Set<number>();
114
+ // 재시작 후 재-adopt 되어 로그를 tail 중인 run(자식 객체는 없지만 살아있음).
115
+ const adoptedRuns = new Set<number>();
116
+
117
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
118
+ function pidAlive(pid: number): boolean {
119
+ if (!pid) return false;
120
+ try { process.kill(pid, 0); return true; } catch { return false; }
121
+ }
122
+
123
+ // ── 내구 실행 로그(재시작 내성 토대) ──────────────────────────
124
+ // 에이전트 출력은 데몬 파이프가 아니라 이 파일로 리다이렉트된다 → 데몬이 죽어도
125
+ // detached 에이전트가 계속 파일에 쓰고, 데몬은 부팅 후 offset 부터 재-tail 한다.
126
+ function runsDir(): string {
127
+ const d = ppath.join(ppath.dirname(config.dbPath), 'runs');
128
+ try { mkdirSync(d, { recursive: true }); } catch { /* best effort */ }
129
+ return d;
130
+ }
131
+ function runLogPath(runId: number): string { return ppath.join(runsDir(), `r${runId}.log`); }
132
+ const DONE_RE = /^__COXPIT_DONE__:(-?\d+)\s*$/;
133
+ /** 로그 파일 tail — offset 부터 새 바이트를 읽어 라인 파싱→이벤트, 종료 센티넬(__COXPIT_DONE__) 감지.
134
+ * aliveCheck 가 false 면(센티넬 없이 프로세스 사망) code=-1 로 종료. offset 은 주기적으로 영속. */
135
+ async function tailRunLog(
136
+ runId: number, provider: Provider, fromOffset: number,
137
+ aliveCheck?: () => boolean,
138
+ ): Promise<{ code: number; lastResult: string; offset: number }> {
139
+ const log = runLogPath(runId);
140
+ let offset = Math.max(0, fromOffset);
141
+ let buf = '';
142
+ let lastResult = '';
143
+ let code = 0;
144
+ let done = false;
145
+ let sincePersist = 0;
146
+ const consume = () => {
147
+ if (!existsSync(log)) return;
148
+ const size = statSync(log).size;
149
+ if (size <= offset) return;
150
+ const fd = openSync(log, 'r');
151
+ try {
152
+ const b = Buffer.alloc(size - offset);
153
+ readSync(fd, b, 0, b.length, offset);
154
+ offset = size; buf += b.toString('utf8');
155
+ } finally { closeSync(fd); }
156
+ let nl: number;
157
+ while ((nl = buf.indexOf('\n')) >= 0) {
158
+ const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
159
+ const m = DONE_RE.exec(line);
160
+ if (m) { code = parseInt(m[1] ?? '0', 10) || 0; done = true; continue; }
161
+ const p = provider.parseLine(line);
162
+ if (p) {
163
+ if (p.sessionId) void setRun(runId, { sessionId: p.sessionId });
164
+ if (p.resultText != null) lastResult = p.resultText;
165
+ void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
166
+ continue;
167
+ }
168
+ // stdout+stderr 를 한 로그로 합쳤으므로(2>&1), stream-json 이 아닌 non-json 라인 = stderr 로 살린다.
169
+ const t = line.trim();
170
+ if (t && t[0] !== '{') void recordEvent(runId, 'stderr', t.slice(0, 2000));
171
+ }
172
+ };
173
+ while (!done) {
174
+ consume();
175
+ if (done) break;
176
+ if (aliveCheck && !aliveCheck()) { consume(); if (!done) { code = -1; } break; }
177
+ if (++sincePersist >= 8) { sincePersist = 0; void persistLogOffset(runId, offset); }
178
+ await sleep(250);
179
+ }
180
+ consume();
181
+ void persistLogOffset(runId, offset);
182
+ return { code, lastResult, offset };
183
+ }
184
+ /** offset 만 가볍게 영속(broadcast 없이 — setRun 은 매번 broadcast 라 부적합). */
185
+ async function persistLogOffset(runId: number, offset: number): Promise<void> {
186
+ try { await db.update(agentRuns).set({ logOffset: offset }).where(eq(agentRuns.id, runId)); } catch { /* best effort */ }
187
+ }
114
188
 
115
189
  // ── 에이전트 셀프 오케스트레이션 ──────────────────────────────
116
190
  // run 마다 1회용 토큰을 발급해 에이전트 env 로 준다. 에이전트는 그 토큰으로
@@ -130,9 +204,9 @@ export function resolveAgentToken(token: string): number | null {
130
204
  return agentTokens.get(token) ?? null;
131
205
  }
132
206
 
133
- /** run 이 지금 살아 있는가(자식 프로세스 보유). aggregate 뷰의 live/steerable 판정용. */
207
+ /** run 이 지금 살아 있는가(자식 프로세스 보유 or 재-adopt tail 중). aggregate 뷰의 live/steerable 판정용. */
134
208
  export function isRunLive(runId: number): boolean {
135
- return liveChildren.has(runId);
209
+ return liveChildren.has(runId) || adoptedRuns.has(runId);
136
210
  }
137
211
 
138
212
  /** 에이전트 프롬프트에 붙는 능력 고지 — 독립 하위작업을 병렬 서브런으로 뺄 수 있다.
@@ -408,34 +482,48 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
408
482
  * 에이전트 자식 프로세스 배선(공용) — 프로바이더가 stdout 라인을 정규화 이벤트로
409
483
  * 파싱, session 캡처, 종료 시 files_changed 집계 + 상태 전이. launchRun/steerRun 공유.
410
484
  */
411
- async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string, provider: Provider): Promise<void> {
412
- const child = spawnShellOn(machine, cmd);
413
- liveChildren.set(runId, child);
414
-
485
+ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string, provider: Provider, opts?: { fresh?: boolean }): Promise<void> {
486
+ const isRemote = machine.kind !== 'local' && machine.address !== '';
487
+ let code: number;
415
488
  let lastResult = '';
416
- if (child.stdout) {
417
- const rl = createInterface({ input: child.stdout });
418
- rl.on('line', (line: string) => {
419
- const p = provider.parseLine(line);
420
- if (!p) return;
421
- if (p.sessionId) void setRun(runId, { sessionId: p.sessionId }); // steer(resume)용 세션 키
422
- if (p.resultText != null) lastResult = p.resultText;
423
- void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
424
- });
425
- }
426
- if (child.stderr) {
427
- const rle = createInterface({ input: child.stderr });
428
- rle.on('line', (line: string) => {
429
- const s = line.trim();
430
- if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000));
431
- });
432
- }
433
489
 
434
- const code: number = await new Promise((resolve) => {
435
- child.on('close', (c) => resolve(c ?? 0));
436
- child.on('error', () => resolve(-1));
437
- });
438
- liveChildren.delete(runId);
490
+ if (isRemote) {
491
+ // 원격: ssh stdout 파이프를 실시간 파싱(기존 동작). 로그 파일/재-adopt 로컬 전용.
492
+ const child = spawnShellOn(machine, cmd);
493
+ liveChildren.set(runId, child);
494
+ if (child.stdout) {
495
+ const rl = createInterface({ input: child.stdout });
496
+ rl.on('line', (line: string) => {
497
+ const p = provider.parseLine(line);
498
+ if (!p) return;
499
+ if (p.sessionId) void setRun(runId, { sessionId: p.sessionId });
500
+ if (p.resultText != null) lastResult = p.resultText;
501
+ void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
502
+ });
503
+ }
504
+ if (child.stderr) {
505
+ const rle = createInterface({ input: child.stderr });
506
+ rle.on('line', (line: string) => { const s = line.trim(); if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000)); });
507
+ }
508
+ code = await new Promise((resolve) => { child.on('close', (c) => resolve(c ?? 0)); child.on('error', () => resolve(-1)); });
509
+ liveChildren.delete(runId);
510
+ } else {
511
+ // 로컬: 에이전트 출력을 내구 로그로 리다이렉트 + 파일 tail(재시작 내성). 종료는 __COXPIT_DONE__ 센티넬.
512
+ const fresh = opts?.fresh !== false; // launch=새 로그 / steer=이어쓰기
513
+ const log = runLogPath(runId);
514
+ const wrapped = `{ ${cmd} ; } ${fresh ? '>' : '>>'} ${shq(log)} 2>&1; printf '\\n__COXPIT_DONE__:%s\\n' "$?" >> ${shq(log)}`;
515
+ let fromOffset = 0;
516
+ if (fresh) { await setRun(runId, { logOffset: 0, agentPid: 0 }); }
517
+ else { const r = (await db.select({ o: agentRuns.logOffset }).from(agentRuns).where(eq(agentRuns.id, runId)).limit(1))[0]; fromOffset = r?.o ?? 0; }
518
+ const child = spawnShellOn(machine, wrapped);
519
+ liveChildren.set(runId, child);
520
+ child.on('error', () => { /* 파이프 미사용 — 로그 tail 이 진실 */ });
521
+ const pid = child.pid ?? 0;
522
+ if (pid) await setRun(runId, { agentPid: pid });
523
+ const r = await tailRunLog(runId, provider, fromOffset, () => pidAlive(pid));
524
+ code = r.code; lastResult = r.lastResult;
525
+ liveChildren.delete(runId);
526
+ }
439
527
 
440
528
  const stat = await runShellOn(machine, `git -C ${shq(wtPath)} status --porcelain | wc -l`, 10000);
441
529
  const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
@@ -443,7 +531,7 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
443
531
  const wasStopped = stoppedRuns.delete(runId);
444
532
  const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
445
533
  const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
446
- await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary });
534
+ await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary, agentPid: 0 });
447
535
  // 문서 산출물을 정착 시점에 스냅샷 — worktree 소멸(머지·Close) 후에도 렌더 뷰 유지. best-effort.
448
536
  if (filesChanged > 0) void snapshotRunDocs(runId);
449
537
  void notifySettle(runId, status, filesChanged, exitSummary);
@@ -528,9 +616,9 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
528
616
  const cmd = `cd ${shq(wt)} && ${envPrefix}${pidPrefix}{ ${resume}; }`;
529
617
  if (!isRemote && config.agentOrch) {
530
618
  const orchTimer = startOrchWatch(runId, wt, true);
531
- void runAgentChild(runId, ctx.machine, wt, cmd, provider).finally(() => clearInterval(orchTimer));
619
+ void runAgentChild(runId, ctx.machine, wt, cmd, provider, { fresh: false }).finally(() => clearInterval(orchTimer));
532
620
  } else {
533
- void runAgentChild(runId, ctx.machine, wt, cmd, provider);
621
+ void runAgentChild(runId, ctx.machine, wt, cmd, provider, { fresh: false });
534
622
  }
535
623
  return { ok: true, detail: 'steering' };
536
624
  }
@@ -554,6 +642,12 @@ export async function stopRun(runId: number): Promise<{ ok: boolean; detail: str
554
642
  // stop 요청을 정산으로 처리해 카드가 영원히 '진행 중'으로 남지 않게 한다.
555
643
  const zr = (await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1))[0];
556
644
  if (zr && (zr.status === 'running' || zr.status === 'starting')) {
645
+ // 재-adopt 되어 tail 중인 살아있는 로컬 에이전트 — 프로세스 그룹 종료. tailer 가 stopped 로 정산.
646
+ if (zr.agentPid && pidAlive(zr.agentPid)) {
647
+ stoppedRuns.add(runId);
648
+ try { process.kill(-zr.agentPid, 'SIGTERM'); } catch { try { process.kill(zr.agentPid, 'SIGTERM'); } catch { /* gone */ } }
649
+ return { ok: true, detail: 'stopping (re-adopted agent)' };
650
+ }
557
651
  await setRun(runId, { status: 'stopped', endedAt: new Date(), exitSummary: 'orphaned (daemon restarted) — settled by stop' });
558
652
  await recordEvent(runId, 'meta', JSON.stringify({ orphanSettled: true }));
559
653
  return { ok: true, detail: 'no live process — settled as stopped' };
@@ -1446,15 +1540,67 @@ export async function listDocuments(): Promise<{ runs: Array<{
1446
1540
  * 부팅 정산 — 데몬 재시작 후 살아있는 자식이 있을 수 없는데 DB 가 running/starting 인
1447
1541
  * run(고아)을 failed 로 정리한다. workbench('open')는 에이전트가 없으므로 대상 아님.
1448
1542
  */
1543
+ /** 재-adopt: 살아있는 로컬 run 의 로그를 offset 부터 이어 tail → 완료 시 정산. status 는 running 유지. */
1544
+ async function reAdoptRun(run: typeof agentRuns.$inferSelect): Promise<void> {
1545
+ adoptedRuns.add(run.id);
1546
+ await recordEvent(run.id, 'meta', JSON.stringify({ reAdopted: true }));
1547
+ const ctx = await loadContext(run.id);
1548
+ const provider = getProvider(run.agent || 'claude-code');
1549
+ const machine: MachineTarget = ctx?.machine ?? { slug: 'local', kind: 'local', address: '', sshUser: '' };
1550
+ const wasStopped0 = stoppedRuns.has(run.id);
1551
+ const { code, lastResult } = await tailRunLog(run.id, provider, run.logOffset ?? 0, () => pidAlive(run.agentPid));
1552
+ adoptedRuns.delete(run.id);
1553
+ const stat = await runShellOn(machine, `git -C ${shq(run.worktreePath)} status --porcelain | wc -l`, 10000);
1554
+ const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
1555
+ const wasStopped = stoppedRuns.delete(run.id) || wasStopped0;
1556
+ const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
1557
+ const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
1558
+ await setRun(run.id, { status, endedAt: new Date(), filesChanged, exitSummary, agentPid: 0 });
1559
+ if (filesChanged > 0) void snapshotRunDocs(run.id);
1560
+ void notifySettle(run.id, status, filesChanged, exitSummary);
1561
+ }
1562
+
1563
+ /**
1564
+ * 부팅 시 이전 인스턴스의 라이브 run 정산 — 이제 무조건 failed 가 아니라:
1565
+ * ① 에이전트 프로세스(agentPid)가 살아있으면 로그를 이어 tail(재-adopt) → running 유지
1566
+ * ② 이미 로그에 __COXPIT_DONE__ 이 있거나 프로세스 죽음 → 그 결과로 정산
1567
+ * ③ 그 외(원격·pid 없음) → orphaned failed (기존 동작, worktree/diff 보존)
1568
+ * 반환 = 재-adopt 된 run 수(정산된 고아는 미포함).
1569
+ */
1449
1570
  export async function reconcileOrphanRuns(): Promise<number> {
1450
1571
  const stale = (await db.select().from(agentRuns)).filter(
1451
1572
  (r) => r.status === 'running' || r.status === 'starting',
1452
1573
  );
1574
+ let adopted = 0;
1453
1575
  for (const r of stale) {
1454
- await setRun(r.id, { status: 'failed', endedAt: new Date(), exitSummary: 'orphaned by daemon restart' });
1576
+ if (r.agentPid && pidAlive(r.agentPid)) {
1577
+ // 아직 살아있는 로컬 에이전트 — 이어받는다(백그라운드 tail).
1578
+ void reAdoptRun(r);
1579
+ adopted++;
1580
+ continue;
1581
+ }
1582
+ // 죽었지만 로그에 완료 센티넬이 있으면 그 결과로 정산(다운타임 중 끝난 경우).
1583
+ const log = runLogPath(r.id);
1584
+ let doneCode: number | null = null;
1585
+ try {
1586
+ if (existsSync(log)) {
1587
+ const tail = (await readFile(log, 'utf8')).slice(-4000);
1588
+ const m = /__COXPIT_DONE__:(-?\d+)/.exec(tail);
1589
+ if (m) doneCode = parseInt(m[1] ?? '0', 10) || 0;
1590
+ }
1591
+ } catch { /* ignore */ }
1592
+ if (doneCode !== null) {
1593
+ const stat = await runShellOn({ slug: 'local', kind: 'local', address: '', sshUser: '' }, `git -C ${shq(r.worktreePath)} status --porcelain | wc -l`, 10000);
1594
+ const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
1595
+ const status = doneCode === 0 ? 'done' : 'failed';
1596
+ await setRun(r.id, { status, endedAt: new Date(), filesChanged, exitSummary: `settled on restart (exit ${doneCode})`, agentPid: 0 });
1597
+ if (filesChanged > 0) void snapshotRunDocs(r.id);
1598
+ continue;
1599
+ }
1600
+ await setRun(r.id, { status: 'failed', endedAt: new Date(), exitSummary: 'orphaned by daemon restart', agentPid: 0 });
1455
1601
  await recordEvent(r.id, 'error', 'daemon restarted while this run was live — settled as failed (worktree/branch preserved; diff still reviewable)');
1456
1602
  }
1457
- return stale.length;
1603
+ return adopted;
1458
1604
  }
1459
1605
 
1460
1606
  /**
@@ -1614,6 +1760,8 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
1614
1760
  if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
1615
1761
  // worktree 를 지우기 전에 문서 스냅샷(정착 안 하는 워크벤치·수정편집도 포착). best-effort.
1616
1762
  await snapshotRunDocs(runId).catch(() => { /* 스냅샷 실패는 정리를 막지 않음 */ });
1763
+ // 내구 실행 로그도 정리(무한 증가 방지). best-effort. (아래 지역변수 rm 과 이름 충돌 회피로 unlink 사용)
1764
+ try { await unlink(runLogPath(runId)); } catch { /* gone */ }
1617
1765
  // 원격에 잔존 에이전트가 있으면 worktree 제거 전에 죽인다(파일 잠금·좀비 방지).
1618
1766
  if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
1619
1767
  await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
package/src/server.ts CHANGED
@@ -25,6 +25,7 @@ import { addSink, removeSink, broadcast } from './hub';
25
25
  import { getProvider, listProviders } from './providers';
26
26
  import { remoteState, setServe, setFunnel } from './remote';
27
27
  import { BOARD_HTML } from './board';
28
+ import { COCKPIT_HTML } from './cockpit';
28
29
 
29
30
  const require_ = createRequire(import.meta.url);
30
31
 
@@ -203,6 +204,8 @@ export async function buildServer(): Promise<FastifyInstance> {
203
204
 
204
205
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
205
206
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
207
+ // 터미널 우선 셸(병행 개발) — 백엔드는 보드와 공유. Phase 5에서 데스크톱 기본을 여기로 플립 예정.
208
+ app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(COCKPIT_HTML));
206
209
 
207
210
  // ─── 접근키 인증(access-key) ────────────────────────────────────
208
211
  // 요청이 tunnel/https 를 탔나 — Secure 쿠키 여부 결정용.