coxpit 5.3.2 → 5.5.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.5.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,364 @@
1
+ // Cockpit — 터미널 우선 셸 (병행 개발, /cockpit). board.ts 처럼 자가완결 단일 HTML(빌드 0).
2
+ // 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
3
+ // Phase 2 = 워크스페이스 트리(/api/fleet 라이브) + 페인 그리드 터미널(오토타일=창분할, 각 페인 /ws/term attach).
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
+ <link rel="stylesheet" href="/vendor/xterm.css" />
14
+ <style>
15
+ @font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
16
+ :root{
17
+ --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --panel:#0e1118;
18
+ --line:#222835; --line-hi:#2f3648;
19
+ --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
20
+ --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
21
+ --running:#55a7e0; --done:#58b368; --blocked:#d6a249; --failed:#e25b67; --merged:#4ec9b0; --open:#7f9cf5; --stopped:#b58be0;
22
+ --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
23
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
24
+ }
25
+ *{box-sizing:border-box}
26
+ html,body{height:100%}
27
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
28
+ -webkit-font-smoothing:antialiased;overflow:hidden}
29
+ button{font-family:var(--sans)}
30
+ :focus-visible{outline:2px solid rgba(78,201,176,.5);outline-offset:1px;border-radius:4px}
31
+
32
+ header{display:flex;align-items:center;gap:13px;height:46px;padding:0 14px;
33
+ border-bottom:1px solid var(--line);background:rgba(18,21,28,.92);font-family:var(--mono)}
34
+ .brand{display:inline-flex;align-items:center;gap:8px;text-decoration:none}
35
+ .brand img{height:22px;width:auto;display:block}
36
+ .brand .wm{font-family:'Pixelify';font-weight:600;font-size:19px;color:var(--ink);letter-spacing:.01em;margin-left:-2px}
37
+ .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}
38
+ .mach .dot{width:6px;height:6px;border-radius:50%;background:var(--done)}
39
+ .vtabs{display:flex;gap:4px;margin-left:4px}
40
+ .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)}
41
+ .vtab.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.28)}
42
+ .vtab .g{color:var(--brand)}
43
+ .vtab[disabled]{opacity:.5;cursor:default}
44
+ .right{margin-left:auto;display:flex;align-items:center;gap:9px}
45
+ .ws{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}
46
+ .ws .dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}
47
+ .ws.on .dot{background:var(--done)}
48
+ .wip{font-size:10.5px;color:var(--blocked);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:2px 9px}
49
+ .toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px}
50
+ .toggle:hover{color:var(--ink);border-color:var(--line-hi)}
51
+
52
+ .layout{display:grid;grid-template-columns:270px 1fr;height:calc(100vh - 46px)}
53
+ .layout > *{min-height:0;min-width:0}
54
+
55
+ /* ── workspace tree ── */
56
+ .rail{border-right:1px solid var(--line);overflow:auto;padding:10px 8px;font-family:var(--mono);font-size:12.5px;display:flex;flex-direction:column}
57
+ .lbl{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);padding:6px 8px 10px;display:flex;justify-content:space-between}
58
+ .tnode{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;color:var(--muted);white-space:nowrap;cursor:default}
59
+ .tnode .car{color:var(--faint);width:9px;display:inline-block;text-align:center;cursor:pointer}
60
+ .tnode .n{overflow:hidden;text-overflow:ellipsis;flex:1}
61
+ .tnode .meta{color:var(--faint);font-size:11px}
62
+ .tnode.repo{color:var(--ink)}
63
+ .tnode.goal{padding-left:20px} .tnode.goal .gi{color:var(--brand)}
64
+ .tnode.task{padding-left:30px}
65
+ .tnode.run{padding-left:44px;font-size:12px;cursor:pointer}
66
+ .tnode.run:hover{background:var(--surface)}
67
+ .tnode.run.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
68
+ .tnode.empty{color:var(--faint);padding:8px}
69
+ .st{width:6px;height:6px;border-radius:50%;flex:none}
70
+ .st.running{background:var(--running);box-shadow:0 0 0 3px rgba(85,167,224,.16)}
71
+ .st.done{background:var(--done)} .st.blocked{background:var(--blocked)} .st.failed,.st.error{background:var(--failed)}
72
+ .st.merged{background:var(--merged)} .st.open{background:var(--open)} .st.stopped{background:var(--stopped)}
73
+ .st.preparing,.st.pending,.st.starting{background:var(--blocked)}
74
+ .tree-sep{height:1px;background:var(--line);margin:8px 4px}
75
+ .railfoot{margin-top:auto;padding:8px;color:var(--faint);font-size:11px;border-top:1px solid var(--line)}
76
+
77
+ /* ── pane grid (창분할) ── */
78
+ .stage{display:flex;flex-direction:column;min-width:0;background:var(--bg)}
79
+ .panebar{display:flex;align-items:center;gap:8px;height:36px;padding:0 12px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono);font-size:11.5px;color:var(--faint)}
80
+ .panebar .grow{flex:1}
81
+ .pb-btn{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:11px;padding:3px 9px;cursor:pointer;font-family:var(--mono)}
82
+ .pb-btn:hover:not([disabled]){color:var(--ink);border-color:var(--line-hi)}
83
+ .pb-btn[disabled]{opacity:.4;cursor:default}
84
+ .panes{flex:1;display:grid;gap:1px;background:var(--line);padding:1px;min-height:0}
85
+ .pane{display:flex;flex-direction:column;background:var(--bg);min-width:0;min-height:0;overflow:hidden}
86
+ .pane.focus .pane-h{background:var(--brand-dim)}
87
+ .pane.focus{box-shadow:inset 0 0 0 1px rgba(78,201,176,.4)}
88
+ .pane-h{display:flex;align-items:center;gap:8px;height:28px;padding:0 10px;background:var(--surface);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:11px;color:var(--muted);cursor:pointer;flex:none}
89
+ .pane-h .rid{color:var(--ink);font-weight:600}
90
+ .pane-h .chip{font-size:9px;text-transform:uppercase;letter-spacing:.04em;padding:1px 6px;border-radius:999px}
91
+ .chip.running{color:var(--running);background:rgba(85,167,224,.14)}
92
+ .chip.done,.chip.merged{color:var(--done);background:rgba(88,179,104,.14)}
93
+ .chip.blocked,.chip.preparing,.chip.pending,.chip.starting{color:var(--blocked);background:rgba(214,162,73,.16)}
94
+ .chip.failed,.chip.error{color:var(--failed);background:rgba(226,91,103,.14)}
95
+ .chip.open{color:var(--open);background:rgba(127,156,245,.14)}
96
+ .chip.stopped{color:var(--stopped);background:rgba(181,139,224,.14)}
97
+ .pane-h .title{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint)}
98
+ .pane-h .x{color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;padding:0 3px}
99
+ .pane-h .x:hover{color:var(--failed)}
100
+ .pane-term{flex:1;min-height:0;padding:4px 2px 2px 8px}
101
+ .pane-term .xterm{height:100%}
102
+
103
+ .empty{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
104
+ .empty .card{max-width:440px}
105
+ .empty .glyph{font-family:var(--mono);font-size:24px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
106
+ .empty h1{font-family:var(--mono);font-size:16px;margin:0 0 8px;color:var(--ink)}
107
+ .empty p{color:var(--muted);font-size:13px;margin:0}
108
+
109
+ .reqbar{display:flex;align-items:center;gap:10px;border-top:1px solid var(--line);background:var(--surface);padding:11px 14px;font-family:var(--mono)}
110
+ .reqbar .ctx{font-size:11px;color:var(--brand-ink);background:var(--brand);border-radius:6px;padding:3px 9px;font-weight:600}
111
+ .reqbar .caret{color:var(--brand)}
112
+ .reqbar .ph{color:var(--faint);font-size:13px;flex:1}
113
+ .reqbar .kbd{border:1px solid var(--line-hi);border-radius:5px;padding:1px 6px;color:var(--faint);font-size:11px}
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <header>
118
+ <a class="brand" href="/"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></a>
119
+ <span class="mach"><span class="dot"></span><span id="mach">local</span></span>
120
+ <div class="vtabs">
121
+ <button type="button" class="vtab on"><span class="g">⌗</span>Terminal</button>
122
+ <button type="button" class="vtab" disabled title="Phase 3"><span class="g">⧉</span>Review</button>
123
+ <button type="button" class="vtab" disabled title="Phase 3"><span class="g">▤</span>Docs</button>
124
+ </div>
125
+ <div class="right">
126
+ <span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
127
+ <span class="wip">preview · Phase 2</span>
128
+ <a class="toggle" href="/">← Board</a>
129
+ </div>
130
+ </header>
131
+
132
+ <div class="layout">
133
+ <aside class="rail">
134
+ <div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
135
+ <div id="tree"></div>
136
+ <div class="railfoot">클릭한 run 은 오른쪽 페인에서 열립니다 · 분할(⊞)로 여러 에이전트를 나란히</div>
137
+ </aside>
138
+
139
+ <main class="stage">
140
+ <div class="panebar">
141
+ <span id="paneCount">0 panes</span>
142
+ <span style="color:var(--faint)">· run 을 열수록 자동 분할(타일)</span>
143
+ <span class="grow"></span>
144
+ <button class="pb-btn" id="closeBtn" disabled title="포커스 페인 닫기">× close focused</button>
145
+ </div>
146
+ <div class="panes" id="panes"></div>
147
+ <div class="empty" id="empty">
148
+ <div class="card">
149
+ <div class="glyph">⌗ ⌗ ⌗</div>
150
+ <h1>페인이 비어 있습니다</h1>
151
+ <p>왼쪽 트리에서 <b>run</b> 을 클릭하면 그 에이전트 터미널이 여기 페인으로 열립니다. 여러 개 열면 자동으로 분할(타일)됩니다.</p>
152
+ </div>
153
+ </div>
154
+ <div class="reqbar">
155
+ <span class="ctx">cockpit</span><span class="caret">›</span>
156
+ <span class="ph">요청바 — Phase 3 (repo 문맥에서 팬아웃)</span>
157
+ <span class="kbd">⏎</span>
158
+ </div>
159
+ </main>
160
+ </div>
161
+
162
+ <script src="/vendor/xterm.js"></script>
163
+ <script src="/vendor/addon-fit.js"></script>
164
+ <script src="/vendor/addon-unicode11.js"></script>
165
+ <script>
166
+ // 모바일은 터미널 우선 대신 보드(모니터)로.
167
+ if (window.innerWidth <= 860 && matchMedia('(pointer:coarse)').matches) location.replace('/');
168
+ var esc = function(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]; }); };
169
+ var $ = function(id){ return document.getElementById(id); };
170
+
171
+ // ── fleet 상태 ──
172
+ var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[] };
173
+ var runById = {};
174
+ var fold = {}; // 접힘 상태(repo/goal/task 노드)
175
+ function isFold(k){ return fold[k]===true; }
176
+
177
+ async function hydrate(){
178
+ try{
179
+ var d = await (await fetch('/api/fleet?view=all')).json();
180
+ fleet = d; runById = {};
181
+ (d.runs||[]).forEach(function(r){ runById[r.id]=r; });
182
+ if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
183
+ renderTree();
184
+ syncPanes();
185
+ }catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
186
+ }
187
+
188
+ // ── 트리 렌더: Machine ▸ Repo ▸ Goal ▸ Task ▸ Run ──
189
+ function renderTree(){
190
+ var el = $('tree');
191
+ var repos = fleet.repos||[], tasks = fleet.tasks||[], groups = fleet.groups||[], runs = fleet.runs||[];
192
+ var groupById = {}; groups.forEach(function(g){ groupById[g.id]=g; });
193
+ var tasksByRepo = {}; tasks.forEach(function(t){ (tasksByRepo[t.repoId]=tasksByRepo[t.repoId]||[]).push(t); });
194
+ var runsByTask = {}; runs.forEach(function(r){ (runsByTask[r.taskId]=runsByTask[r.taskId]||[]).push(r); });
195
+ if (!repos.length){ el.innerHTML = '<div class="tnode empty">등록된 repo 가 없습니다 — 보드에서 추가하세요.</div>'; return; }
196
+ var html = '';
197
+ repos.forEach(function(repo){
198
+ var rk = 'repo'+repo.id;
199
+ var rTasks = (tasksByRepo[repo.id]||[]).filter(function(t){ return t.status!=='closed'; });
200
+ var runCount = rTasks.reduce(function(n,t){ return n+((runsByTask[t.id]||[]).length); }, 0);
201
+ html += '<div class="tnode repo" data-fold="'+rk+'"><span class="car">'+(isFold(rk)?'▸':'▾')+'</span>'
202
+ + '<span class="n">'+esc(repo.name)+'</span><span class="meta">'+runCount+' run'+(runCount===1?'':'s')+'</span></div>';
203
+ if (isFold(rk)) return;
204
+ // goal(group) 로 묶기
205
+ var byGroup = {}, ungrouped = [];
206
+ rTasks.forEach(function(t){ if (t.groupId && groupById[t.groupId]) (byGroup[t.groupId]=byGroup[t.groupId]||[]).push(t); else ungrouped.push(t); });
207
+ Object.keys(byGroup).forEach(function(gid){
208
+ var g = groupById[gid]; var gk='goal'+gid;
209
+ html += '<div class="tnode goal" data-fold="'+gk+'"><span class="car">'+(isFold(gk)?'▸':'▾')+'</span>'
210
+ + '<span class="gi">⌁</span><span class="n">'+esc(g.title)+'</span><span class="meta">'+(g.kind||'goal')+'</span></div>';
211
+ if (!isFold(gk)) byGroup[gid].forEach(function(t){ html += taskHTML(t, runsByTask[t.id]||[]); });
212
+ });
213
+ ungrouped.forEach(function(t){ html += taskHTML(t, runsByTask[t.id]||[]); });
214
+ if (!rTasks.length) html += '<div class="tnode empty" style="padding-left:30px">태스크 없음</div>';
215
+ });
216
+ el.innerHTML = html;
217
+ }
218
+ function taskHTML(t, rns){
219
+ var tk='task'+t.id;
220
+ var s = '<div class="tnode task" data-fold="'+tk+'"><span class="car">'+(rns.length?(isFold(tk)?'▸':'▾'):' ')+'</span>'
221
+ + '<span class="n">'+esc(t.title)+'</span></div>';
222
+ if (!isFold(tk)) rns.sort(function(a,b){return a.id-b.id;}).forEach(function(r){
223
+ var open = paneByRun[r.id] ? ' open' : '';
224
+ s += '<div class="tnode run'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
225
+ + '<span class="n">r'+r.id+' · '+esc(r.status)+'</span></div>';
226
+ });
227
+ return s;
228
+ }
229
+ $('tree').addEventListener('click', function(e){
230
+ var run = e.target.closest('.tnode.run');
231
+ if (run){ openRunPane(+run.dataset.run); return; }
232
+ var fn = e.target.closest('[data-fold]');
233
+ if (fn){ var k = fn.dataset.fold; fold[k] = !isFold(k); renderTree(); }
234
+ });
235
+
236
+ // ── 페인 그리드(창분할, 오토타일) ──
237
+ var panes = []; // { id, runId, el, term, fit, ws, retry, closing, ro }
238
+ var paneByRun = {}; // runId -> pane
239
+ var focusId = null;
240
+ var paneSeq = 0;
241
+ var MAX_PANES = 8;
242
+
243
+ function layoutPanes(){
244
+ var host = $('panes'); var n = panes.length;
245
+ $('empty').style.display = n ? 'none' : 'flex';
246
+ host.style.display = n ? 'grid' : 'none';
247
+ $('paneCount').textContent = n + ' pane' + (n===1?'':'s');
248
+ $('closeBtn').disabled = !focusId;
249
+ if (!n) return;
250
+ var cols = Math.ceil(Math.sqrt(n));
251
+ var rows = Math.ceil(n / cols);
252
+ host.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
253
+ host.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
254
+ // 레이아웃 후 각 터미널 리핏
255
+ requestAnimationFrame(function(){ panes.forEach(fitPane); });
256
+ }
257
+ function fitPane(p){
258
+ if (!p.fit || !p.term) return;
259
+ try{ p.fit.fit(); if (p.ws && p.ws.readyState===1) p.ws.send(JSON.stringify({t:'r',cols:p.term.cols,rows:p.term.rows})); }catch(e){}
260
+ }
261
+ function setFocus(id){
262
+ focusId = id;
263
+ panes.forEach(function(p){ p.el.classList.toggle('focus', p.id===id); });
264
+ $('closeBtn').disabled = !focusId;
265
+ var p = panes.find(function(x){return x.id===id;});
266
+ if (p && p.term) try{ p.term.focus(); }catch(e){}
267
+ }
268
+ function openRunPane(runId){
269
+ var existing = paneByRun[runId];
270
+ if (existing){ setFocus(existing.id); return; }
271
+ if (panes.length >= MAX_PANES){ return; }
272
+ var r = runById[runId];
273
+ var p = { id: ++paneSeq, runId: runId, retry: 0, closing: false };
274
+ var el = document.createElement('div'); el.className = 'pane'; p.el = el;
275
+ el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
276
+ + '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
277
+ + '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
278
+ + '<button class="x" title="close">×</button></div>'
279
+ + '<div class="pane-term"></div>';
280
+ $('panes').appendChild(el);
281
+ el.querySelector('.pane-h').addEventListener('click', function(ev){
282
+ if (ev.target.closest('.x')) return; setFocus(p.id);
283
+ });
284
+ el.querySelector('.x').addEventListener('click', function(){ closePane(p.id); });
285
+ // xterm
286
+ var term = new window.Terminal({
287
+ fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', monospace",
288
+ fontSize: 12, cursorBlink: true, allowProposedApi: true, scrollback: 4000,
289
+ theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
290
+ });
291
+ p.term = term;
292
+ p.fit = new window.FitAddon.FitAddon(); term.loadAddon(p.fit);
293
+ try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
294
+ term.open(el.querySelector('.pane-term'));
295
+ term.onData(function(d){ if (p.ws && p.ws.readyState===1) p.ws.send(JSON.stringify({t:'i',d:d})); });
296
+ p.ro = new ResizeObserver(function(){ fitPane(p); }); p.ro.observe(el.querySelector('.pane-term'));
297
+ panes.push(p); paneByRun[runId] = p;
298
+ layoutPanes();
299
+ connectPane(p);
300
+ setFocus(p.id);
301
+ renderTree();
302
+ }
303
+ function connectPane(p){
304
+ if (p.closing || !p.term) return;
305
+ try{ p.fit.fit(); }catch(e){}
306
+ var proto = location.protocol==='https:'?'wss':'ws';
307
+ var sock = new WebSocket(proto+'://'+location.host+'/ws/term/'+p.runId+'?cols='+p.term.cols+'&rows='+p.term.rows);
308
+ p.ws = sock;
309
+ sock.onopen = function(){ if (sock!==p.ws){ try{sock.close();}catch(e){} return; } p.retry=0; };
310
+ sock.onmessage = function(m){
311
+ if (sock!==p.ws || !p.term) return;
312
+ try{ var d = JSON.parse(m.data);
313
+ if (d.t==='o') p.term.write(d.d);
314
+ else if (d.t==='err') p.term.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
315
+ else if (d.t==='exit') p.term.write('\\r\\n\\x1b[90m[session ended — 재연결 시 소생]\\x1b[0m\\r\\n');
316
+ }catch(e){}
317
+ };
318
+ sock.onclose = function(){
319
+ if (sock!==p.ws || p.closing) return;
320
+ var delay = Math.min(8000, 800 * Math.pow(2, p.retry++));
321
+ setTimeout(function(){ connectPane(p); }, delay);
322
+ };
323
+ }
324
+ function closePane(id){
325
+ var i = panes.findIndex(function(p){return p.id===id;});
326
+ if (i<0) return;
327
+ var p = panes[i]; p.closing = true;
328
+ try{ if (p.ws) p.ws.close(); }catch(e){}
329
+ try{ if (p.ro) p.ro.disconnect(); }catch(e){}
330
+ try{ if (p.term) p.term.dispose(); }catch(e){}
331
+ try{ p.el.remove(); }catch(e){}
332
+ delete paneByRun[p.runId];
333
+ panes.splice(i,1);
334
+ if (focusId===id) focusId = panes.length ? panes[panes.length-1].id : null;
335
+ layoutPanes(); if (focusId) setFocus(focusId);
336
+ renderTree();
337
+ }
338
+ // 페인 상태/타이틀을 fleet 갱신에 맞춰 갱신 + 사라진 run 페인 정리
339
+ function syncPanes(){
340
+ panes.forEach(function(p){
341
+ var r = runById[p.runId];
342
+ var chip = p.el.querySelector('[data-role=chip]'); var title = p.el.querySelector('[data-role=title]');
343
+ if (r){ if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; } if(title) title.textContent = r.tmuxWindow||'terminal'; }
344
+ });
345
+ }
346
+ $('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
347
+
348
+ // ── /ws 라이브 구독 → 트리·페인 갱신 ──
349
+ function wsConnect(){
350
+ var proto = location.protocol==='https:'?'wss':'ws';
351
+ var ws = new WebSocket(proto+'://'+location.host+'/ws');
352
+ ws.onopen = function(){ $('ws').classList.add('on'); $('wstext').textContent='live'; };
353
+ ws.onclose = function(){ $('ws').classList.remove('on'); $('wstext').textContent='reconnecting'; setTimeout(wsConnect, 1500); };
354
+ ws.onmessage = function(){ /* 델타는 종류가 많아 단순히 전체 리하이드레이트(디바운스) */ scheduleHydrate(); };
355
+ }
356
+ var hydT=null;
357
+ function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
358
+
359
+ hydrate();
360
+ wsConnect();
361
+ window.addEventListener('resize', function(){ panes.forEach(fitPane); });
362
+ </script>
363
+ </body>
364
+ </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 쿠키 여부 결정용.