coxpit 5.5.0 → 5.7.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.5.0",
3
+ "version": "5.7.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/cockpit.ts CHANGED
@@ -97,6 +97,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
97
97
  .pane-h .title{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint)}
98
98
  .pane-h .x{color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;padding:0 3px}
99
99
  .pane-h .x:hover{color:var(--failed)}
100
+ .vbadge{font-size:9px;font-weight:600;letter-spacing:.03em;padding:1px 6px;border-radius:999px;white-space:nowrap}
101
+ .vbadge.pass{color:var(--done);background:rgba(88,179,104,.16)}
102
+ .vbadge.fail{color:var(--failed);background:rgba(226,91,103,.16)}
103
+ .vbadge.running{color:var(--blocked);background:rgba(214,162,73,.16)}
104
+ .vbadge.error{color:var(--failed);background:rgba(226,91,103,.12)}
100
105
  .pane-term{flex:1;min-height:0;padding:4px 2px 2px 8px}
101
106
  .pane-term .xterm{height:100%}
102
107
 
@@ -106,11 +111,54 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
106
111
  .empty h1{font-family:var(--mono);font-size:16px;margin:0 0 8px;color:var(--ink)}
107
112
  .empty p{color:var(--muted);font-size:13px;margin:0}
108
113
 
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
+ .reqbar{display:flex;align-items:center;gap:9px;border-top:1px solid var(--line);background:var(--surface);padding:9px 12px;font-family:var(--mono)}
115
+ .modes{display:inline-flex;gap:2px;border:1px solid var(--line);border-radius:8px;padding:2px;background:var(--panel)}
116
+ .mode{font-size:11px;color:var(--muted);padding:4px 9px;border-radius:6px;cursor:pointer;background:none;border:none;font-family:var(--mono);white-space:nowrap}
117
+ .mode.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.3)}
118
+ .reqbar select{font-family:var(--mono);font-size:11.5px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:5px 7px;max-width:150px}
119
+ .reqbar select:disabled{opacity:.35}
120
+ .reqbar .tgt{font-size:11px;color:var(--brand);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
121
+ .rlbl{display:inline-flex;align-items:center;gap:5px}
122
+ .rchk{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);cursor:pointer;white-space:nowrap}
123
+ .reqinput{flex:1;min-width:80px;font-family:var(--mono);font-size:13px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 11px}
124
+ .reqinput:focus{outline:none;border-color:var(--line-hi);box-shadow:0 0 0 2px rgba(78,201,176,.18)}
125
+ .reqinput::placeholder{color:var(--faint)}
126
+ .reqgo{font-family:var(--mono);font-size:12px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:8px 13px;cursor:pointer}
127
+ .reqgo:disabled{opacity:.4;cursor:default}
128
+ .reqgo.bcast{background:var(--open);color:#0b0d12}
129
+ .reqgo.steer{background:var(--running);color:#04121e}
130
+ .toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
131
+ font-family:var(--mono);font-size:12px;padding:8px 14px;border-radius:9px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:40;max-width:80vw}
132
+ .toast.show{opacity:1}
133
+
134
+ /* ── Review (compare/merge) ── */
135
+ .review{position:absolute;inset:46px 0 0 0;background:var(--bg);display:none;flex-direction:column;overflow:hidden;z-index:20}
136
+ .review.on{display:flex}
137
+ .rv-head{display:flex;align-items:center;gap:10px;height:40px;padding:0 14px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono);font-size:12px;color:var(--muted)}
138
+ .rv-head select{font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:5px 8px;max-width:340px}
139
+ .rv-cols{flex:1;display:flex;gap:1px;background:var(--line);overflow-x:auto;min-height:0}
140
+ .rv-col{flex:1 0 340px;min-width:300px;display:flex;flex-direction:column;background:var(--bg)}
141
+ .rv-col-h{display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid var(--line);background:var(--surface);font-family:var(--mono);font-size:12px}
142
+ .rv-col-h .rid{color:var(--ink);font-weight:600}
143
+ .rv-col-h .stat{color:var(--faint);font-size:11px;margin-left:auto}
144
+ .rv-merge{font-family:var(--mono);font-size:10.5px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:6px;padding:4px 9px;cursor:pointer}
145
+ .rv-merge:disabled{opacity:.35;cursor:default;background:var(--line-hi);color:var(--faint)}
146
+ .rv-merge.caution{background:var(--blocked);color:#211803}
147
+ .rv-verify{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono);font-size:11px;color:var(--muted)}
148
+ .rv-verify .vbadge{flex:none}
149
+ .rv-verify .vout{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint);cursor:pointer}
150
+ .rv-reverify{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:10px;padding:2px 7px;cursor:pointer;font-family:var(--mono)}
151
+ .rv-reverify:hover{color:var(--ink);border-color:var(--line-hi)}
152
+ .rv-vcmd{display:flex;align-items:center;gap:7px;margin-left:14px}
153
+ .rv-vcmd input{font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:5px 8px;width:230px}
154
+ .rv-vcmd input::placeholder{color:var(--faint)}
155
+ .rv-vcmd button{font-family:var(--mono);font-size:10.5px;color:var(--muted);background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;cursor:pointer}
156
+ .rv-vcmd button:hover{color:var(--ink);border-color:var(--line-hi)}
157
+ .rv-diff{flex:1;overflow:auto;padding:8px 10px;font-family:var(--mono);font-size:11.5px;line-height:1.5;white-space:pre;color:var(--muted)}
158
+ .rv-diff > span{display:block;min-height:1.2em}
159
+ .dl-file{color:var(--brand)} .dl-hunk{color:var(--open)} .dl-ctx{color:var(--muted)}
160
+ .dl-add{color:#7fdca0;background:rgba(88,179,104,.08)} .dl-del{color:#e58a92;background:rgba(226,91,103,.08)}
161
+ .rv-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--faint);font-family:var(--mono);font-size:13px;text-align:center;padding:24px}
114
162
  </style>
115
163
  </head>
116
164
  <body>
@@ -118,13 +166,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
118
166
  <a class="brand" href="/"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></a>
119
167
  <span class="mach"><span class="dot"></span><span id="mach">local</span></span>
120
168
  <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>
169
+ <button type="button" class="vtab on" id="vtTerm"><span class="g">⌗</span>Terminal</button>
170
+ <button type="button" class="vtab" id="vtReview"><span class="g">⧉</span>Review</button>
171
+ <button type="button" class="vtab" disabled title="Docs = 보드"><span class="g">▤</span>Docs</button>
124
172
  </div>
125
173
  <div class="right">
126
174
  <span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
127
- <span class="wip">preview · Phase 2</span>
175
+ <span class="wip">preview · Phase 4</span>
128
176
  <a class="toggle" href="/">← Board</a>
129
177
  </div>
130
178
  </header>
@@ -152,13 +200,43 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
152
200
  </div>
153
201
  </div>
154
202
  <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>
203
+ <div class="modes">
204
+ <button type="button" class="mode on" data-mode="new" title="새 태스크를 만들고 N개 에이전트로 팬아웃">⌗ New</button>
205
+ <button type="button" class="mode" data-mode="steer" title="포커스한 run 에 후속 지시">➤ Steer</button>
206
+ <button type="button" class="mode" data-mode="bcast" title="열린 모든 페인 터미널에 그대로 입력">⊞ Broadcast</button>
207
+ </div>
208
+ <span class="rlbl" id="newCtl">
209
+ <select id="reqRepo" title="대상 repo"></select>
210
+ <select id="reqAgent" title="에이전트"></select>
211
+ <select id="reqCount" title="팬아웃 개수">
212
+ <option value="1">×1</option><option value="2">×2</option><option value="3" selected>×3</option><option value="4">×4</option>
213
+ </select>
214
+ <label class="rchk" title="실제 CLI 실행 (기본=드라이런)"><input type="checkbox" id="reqReal" /> real</label>
215
+ </span>
216
+ <span class="tgt" id="reqTgt" style="display:none"></span>
217
+ <input class="reqinput" id="reqInput" placeholder="무엇을 만들까요? — 요청을 적고 ⏎ 로 3개 에이전트에 팬아웃" autocomplete="off" />
218
+ <button type="button" class="reqgo" id="reqGo">Run ⏎</button>
158
219
  </div>
159
220
  </main>
160
221
  </div>
161
222
 
223
+ <section class="review" id="review">
224
+ <div class="rv-head">
225
+ <span style="color:var(--brand)">⧉ Review</span>
226
+ <span>·</span>
227
+ <select id="rvTask" title="비교할 태스크"></select>
228
+ <span class="rv-vcmd">
229
+ <span style="color:var(--faint)">verify:</span>
230
+ <input id="rvVcmd" placeholder="예: npm test — 정착 시 자동 실행" autocomplete="off" />
231
+ <button type="button" id="rvVsave">save</button>
232
+ </span>
233
+ <span id="rvHint" style="margin-left:auto;color:var(--faint)">정착하면 자동 검증 · 승자를 base 에 merge</span>
234
+ </div>
235
+ <div class="rv-cols" id="rvCols"></div>
236
+ </section>
237
+
238
+ <div class="toast" id="toast"></div>
239
+
162
240
  <script src="/vendor/xterm.js"></script>
163
241
  <script src="/vendor/addon-fit.js"></script>
164
242
  <script src="/vendor/addon-unicode11.js"></script>
@@ -168,23 +246,48 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
168
246
  var esc = function(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]; }); };
169
247
  var $ = function(id){ return document.getElementById(id); };
170
248
 
249
+ function toast(msg){ var t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toast._h); toast._h=setTimeout(function(){ t.classList.remove('show'); }, 2600); }
250
+ var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'⚠ verify' };
251
+ function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
252
+
171
253
  // ── fleet 상태 ──
172
- var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[] };
173
- var runById = {};
254
+ var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
255
+ var runById = {}, taskById = {}, repoById = {};
174
256
  var fold = {}; // 접힘 상태(repo/goal/task 노드)
175
257
  function isFold(k){ return fold[k]===true; }
258
+ function repoOfRun(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; return t?t.repoId:null; }
176
259
 
177
260
  async function hydrate(){
178
261
  try{
179
262
  var d = await (await fetch('/api/fleet?view=all')).json();
180
- fleet = d; runById = {};
263
+ fleet = d; runById = {}; taskById = {}; repoById = {};
181
264
  (d.runs||[]).forEach(function(r){ runById[r.id]=r; });
265
+ (d.tasks||[]).forEach(function(t){ taskById[t.id]=t; });
266
+ (d.repos||[]).forEach(function(r){ repoById[r.id]=r; });
182
267
  if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
183
268
  renderTree();
184
269
  syncPanes();
270
+ populateReq();
271
+ if (reviewOn) renderReviewPicker();
185
272
  }catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
186
273
  }
187
274
 
275
+ // ── 요청바 셀렉트 채우기(선택 유지) ──
276
+ function fillSelect(sel, items, val, label, keep){
277
+ var cur = keep && sel.value; sel.innerHTML='';
278
+ items.forEach(function(it){ var o=document.createElement('option'); o.value=val(it); o.textContent=label(it); sel.appendChild(o); });
279
+ if (cur){ for (var i=0;i<sel.options.length;i++){ if (sel.options[i].value===cur){ sel.value=cur; break; } } }
280
+ }
281
+ function populateReq(){
282
+ var repos = fleet.repos||[];
283
+ fillSelect($('reqRepo'), repos, function(r){return String(r.id);}, function(r){return r.name;}, true);
284
+ // repo 미선택 상태면 포커스 페인의 repo 로 기본
285
+ if (focusId){ var rp = repoOfRun((panes.find(function(p){return p.id===focusId;})||{}).runId); if (rp!=null) $('reqRepo').value=String(rp); }
286
+ var provs = fleet.providers||[];
287
+ if (provs.length) fillSelect($('reqAgent'), provs, function(p){return p.id;}, function(p){return p.label||p.id;}, true);
288
+ else if (!$('reqAgent').options.length){ var o=document.createElement('option'); o.value='claude-code'; o.textContent='claude-code'; $('reqAgent').appendChild(o); }
289
+ }
290
+
188
291
  // ── 트리 렌더: Machine ▸ Repo ▸ Goal ▸ Task ▸ Run ──
189
292
  function renderTree(){
190
293
  var el = $('tree');
@@ -246,6 +349,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
246
349
  host.style.display = n ? 'grid' : 'none';
247
350
  $('paneCount').textContent = n + ' pane' + (n===1?'':'s');
248
351
  $('closeBtn').disabled = !focusId;
352
+ if (typeof reqMode!=='undefined' && reqMode==='bcast') setMode('bcast');
249
353
  if (!n) return;
250
354
  var cols = Math.ceil(Math.sqrt(n));
251
355
  var rows = Math.ceil(n / cols);
@@ -264,6 +368,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
264
368
  $('closeBtn').disabled = !focusId;
265
369
  var p = panes.find(function(x){return x.id===id;});
266
370
  if (p && p.term) try{ p.term.focus(); }catch(e){}
371
+ if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
267
372
  }
268
373
  function openRunPane(runId){
269
374
  var existing = paneByRun[runId];
@@ -274,6 +379,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
274
379
  var el = document.createElement('div'); el.className = 'pane'; p.el = el;
275
380
  el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
276
381
  + '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
382
+ + '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
277
383
  + '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
278
384
  + '<button class="x" title="close">×</button></div>'
279
385
  + '<div class="pane-term"></div>';
@@ -340,11 +446,188 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
340
446
  panes.forEach(function(p){
341
447
  var r = runById[p.runId];
342
448
  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'; }
449
+ var vslot = p.el.querySelector('[data-role=vslot]');
450
+ if (r){ if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; } if(title) title.textContent = r.tmuxWindow||'terminal';
451
+ if(vslot) vslot.innerHTML = vbadge(r.verifyStatus); }
344
452
  });
345
453
  }
346
454
  $('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
347
455
 
456
+ // ── 요청바: New(팬아웃) / Steer / Broadcast ──
457
+ var reqMode = 'new';
458
+ function focusRun(){ if (!focusId) return null; var p = panes.find(function(x){return x.id===focusId;}); return p?p.runId:null; }
459
+ function setMode(m){
460
+ reqMode = m;
461
+ Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
462
+ var go=$('reqGo'), inp=$('reqInput');
463
+ $('newCtl').style.display = m==='new'?'inline-flex':'none';
464
+ $('reqTgt').style.display = m==='new'?'none':'inline';
465
+ go.className = 'reqgo' + (m==='bcast'?' bcast':m==='steer'?' steer':'');
466
+ if (m==='new'){ go.textContent='Run ⏎'; inp.placeholder='무엇을 만들까요? — 요청을 적고 ⏎ 로 팬아웃'; }
467
+ else if (m==='steer'){ var rid=focusRun(); go.textContent='Steer ⏎';
468
+ $('reqTgt').textContent = rid?('➤ r'+rid+' 에 후속 지시'):'포커스한 페인 없음';
469
+ inp.placeholder = rid?('r'+rid+' 에이전트에게 다음 지시…'):'왼쪽 트리에서 run 을 열어 포커스하세요'; }
470
+ else { go.textContent='Send ⏎'; var n=panes.length;
471
+ $('reqTgt').textContent = '⊞ 열린 페인 '+n+'개에 브로드캐스트';
472
+ inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린 페인이 없습니다'; }
473
+ }
474
+ Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.addEventListener('click', function(){ setMode(b.dataset.mode); $('reqInput').focus(); }); });
475
+
476
+ var submitting = false;
477
+ async function submitReq(){
478
+ var inp = $('reqInput'); var text = inp.value.trim();
479
+ if (reqMode==='new'){
480
+ if (!text || submitting) return;
481
+ var repoId = Number($('reqRepo').value); if (!repoId){ toast('repo 를 먼저 등록/선택하세요'); return; }
482
+ submitting=true; $('reqGo').disabled=true;
483
+ try{
484
+ var title = text.length>72 ? text.slice(0,69)+'…' : text;
485
+ var tk = await (await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
486
+ body:JSON.stringify({repoId:repoId, title:title, prompt:text})})).json();
487
+ if (!tk || !tk.task){ toast('태스크 생성 실패'); return; }
488
+ var count = Number($('reqCount').value)||1;
489
+ var rr = await (await fetch('/api/tasks/'+tk.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
490
+ body:JSON.stringify({agent:$('reqAgent').value, count:count, real:$('reqReal').checked})})).json();
491
+ var ids = (rr&&rr.runs||[]).map(function(x){return x.id;});
492
+ inp.value='';
493
+ await hydrate();
494
+ // 팬아웃 자동 타일: 생성된 run 을 전부 페인으로
495
+ ids.forEach(function(id){ openRunPane(id); });
496
+ toast(ids.length+'개 에이전트 팬아웃 · r'+ids.join(' r'));
497
+ }catch(e){ toast('요청 실패: '+e); }
498
+ finally{ submitting=false; $('reqGo').disabled=false; }
499
+ } else if (reqMode==='steer'){
500
+ var rid = focusRun(); if (!rid){ toast('포커스한 페인이 없습니다'); return; }
501
+ if (!text || submitting) return;
502
+ submitting=true; $('reqGo').disabled=true;
503
+ try{
504
+ var res = await fetch('/api/runs/'+rid+'/steer',{method:'POST',headers:{'content-type':'application/json'},
505
+ body:JSON.stringify({message:text})});
506
+ if (res.ok){ inp.value=''; toast('r'+rid+' 에 후속 지시 전송'); }
507
+ else { var j = await res.json().catch(function(){return{};}); toast('steer 불가: '+(j.error||res.status)); }
508
+ }catch(e){ toast('steer 실패: '+e); }
509
+ finally{ submitting=false; $('reqGo').disabled=false; }
510
+ } else { // broadcast
511
+ if (!panes.length){ toast('열린 페인이 없습니다'); return; }
512
+ var payload = text + '\\r';
513
+ var sent=0;
514
+ panes.forEach(function(p){ if (p.ws && p.ws.readyState===1){ p.ws.send(JSON.stringify({t:'i',d:payload})); sent++; } });
515
+ inp.value='';
516
+ toast('브로드캐스트 → '+sent+'개 페인');
517
+ }
518
+ }
519
+ $('reqGo').addEventListener('click', submitReq);
520
+ $('reqInput').addEventListener('keydown', function(e){ if (e.key==='Enter' && !e.isComposing){ e.preventDefault(); submitReq(); } });
521
+
522
+ // ── Review 탭: compare(diff 나란히) + merge 승자 ──
523
+ var reviewOn = false, rvTaskId = null;
524
+ function showTerminal(){ reviewOn=false; $('review').classList.remove('on'); $('vtReview').classList.remove('on'); $('vtTerm').classList.add('on'); }
525
+ function showReview(){ reviewOn=true; $('review').classList.add('on'); $('vtReview').classList.add('on'); $('vtTerm').classList.remove('on'); renderReviewPicker(); }
526
+ $('vtTerm').addEventListener('click', showTerminal);
527
+ $('vtReview').addEventListener('click', showReview);
528
+ function reviewableTasks(){
529
+ var byTask = {}; (fleet.runs||[]).forEach(function(r){ byTask[r.taskId]=(byTask[r.taskId]||0)+1; });
530
+ return (fleet.tasks||[]).filter(function(t){ return (byTask[t.id]||0)>=1; })
531
+ .sort(function(a,b){ return b.id-a.id; });
532
+ }
533
+ function renderReviewPicker(){
534
+ var tasks = reviewableTasks(); var sel=$('rvTask');
535
+ var cur = rvTaskId!=null ? String(rvTaskId) : (focusRun()!=null && runById[focusRun()] ? String(runById[focusRun()].taskId) : (tasks[0]?String(tasks[0].id):''));
536
+ sel.innerHTML='';
537
+ tasks.forEach(function(t){ var rp=repoById[t.repoId]; var o=document.createElement('option'); o.value=String(t.id);
538
+ o.textContent = (rp?rp.name+' · ':'')+t.title; sel.appendChild(o); });
539
+ if (!tasks.length){ $('rvCols').innerHTML='<div class="rv-empty">비교할 run 이 있는 태스크가 아직 없습니다. Terminal 에서 팬아웃해 보세요.</div>'; rvTaskId=null; return; }
540
+ if (cur){ sel.value=cur; } rvTaskId = Number(sel.value);
541
+ syncVcmd();
542
+ loadCompare(rvTaskId);
543
+ }
544
+ $('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); syncVcmd(); loadCompare(rvTaskId); });
545
+ function diffHTML(text){
546
+ if (!text || !text.trim()) return '<span style="color:var(--faint)">no changes</span>';
547
+ return text.split('\\n').map(function(l){
548
+ var e = esc(l) || '&nbsp;';
549
+ if (l.indexOf('diff --git')===0 || l.indexOf('+++')===0 || l.indexOf('---')===0) return '<span class="dl-file">'+e+'</span>';
550
+ if (l.indexOf('@@')===0) return '<span class="dl-hunk">'+e+'</span>';
551
+ if (l.charAt(0)==='+') return '<span class="dl-add">'+e+'</span>';
552
+ if (l.charAt(0)==='-') return '<span class="dl-del">'+e+'</span>';
553
+ return '<span class="dl-ctx">'+e+'</span>';
554
+ }).join('');
555
+ }
556
+ async function loadCompare(taskId){
557
+ var host=$('rvCols'); host.innerHTML='<div class="rv-empty">불러오는 중…</div>';
558
+ var task = taskById[taskId]; var repo = task && repoById[task.repoId];
559
+ var hasVerify = !!(repo && repo.verifyCmd && repo.verifyCmd.trim());
560
+ try{
561
+ var d = await (await fetch('/api/tasks/'+taskId+'/compare')).json();
562
+ var rns = (d.runs||[]).sort(function(a,b){return a.id-b.id;});
563
+ if (!rns.length){ host.innerHTML='<div class="rv-empty">run 이 없습니다.</div>'; return; }
564
+ host.innerHTML='';
565
+ rns.forEach(function(r){
566
+ var col=document.createElement('div'); col.className='rv-col';
567
+ var st = typeof r.stat==='string' ? r.stat.split('\\n').pop().trim() : '';
568
+ var mergeable = (r.status==='done'||r.status==='open'||r.status==='merged') && r.filesChanged>0;
569
+ // green-gate: verifyCmd 있는데 pass 아니면 "merge anyway"(주의색) — 막지 않고 경고
570
+ var gated = hasVerify && r.verifyStatus!=='pass';
571
+ var mlabel = r.status==='merged' ? 'merged' : (gated ? 'merge anyway' : 'merge ▸');
572
+ var vline = '';
573
+ if (hasVerify){
574
+ var vs = r.verifyStatus||''; var out = (r.verifyOutput||'').split('\\n').pop();
575
+ var txt = vs==='pass'?'통과' : vs==='fail'?'실패' : vs==='running'?'검증 중…' : vs==='error'?'검증 오류' : '미검증';
576
+ vline = '<div class="rv-verify">'+(vbadge(vs)||'<span class="vbadge error">· verify</span>')
577
+ + '<span class="vout" data-vout="'+r.id+'" title="클릭=전체 출력">'+esc(txt+(out?' — '+out:''))+'</span>'
578
+ + '<button class="rv-reverify" data-reverify="'+r.id+'">re-verify</button></div>';
579
+ }
580
+ col.innerHTML = '<div class="rv-col-h"><span class="st '+esc(r.status)+'" style="width:7px;height:7px;border-radius:50%;display:inline-block"></span>'
581
+ + '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
582
+ + '<span class="stat">'+esc(st)+'</span>'
583
+ + '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
584
+ + (gated?' title="검증 미통과 — 그래도 머지"':'')+'>'+mlabel+'</button></div>'
585
+ + vline
586
+ + '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
587
+ col._vout = { text: r.verifyOutput||'' };
588
+ host.appendChild(col);
589
+ });
590
+ // 전체 verify 출력 저장(툴팁·토스트용)
591
+ voutById = {}; rns.forEach(function(r){ voutById[r.id]=r.verifyOutput||''; });
592
+ }catch(e){ host.innerHTML='<div class="rv-empty">compare 실패: '+esc(String(e))+'</div>'; }
593
+ }
594
+ var voutById = {};
595
+ $('rvCols').addEventListener('click', async function(e){
596
+ var vo = e.target.closest('[data-vout]');
597
+ if (vo){ var t = voutById[vo.dataset.vout]||''; toast(t? t.slice(-600) : '검증 출력 없음'); return; }
598
+ var rv = e.target.closest('[data-reverify]');
599
+ if (rv){ var id=rv.dataset.reverify; rv.disabled=true; rv.textContent='…';
600
+ try{ await fetch('/api/runs/'+id+'/verify',{method:'POST'}); toast('r'+id+' 재검증 시작'); }
601
+ catch(err){ toast('재검증 실패: '+err); rv.disabled=false; rv.textContent='re-verify'; }
602
+ return; }
603
+ var b = e.target.closest('[data-merge]'); if (!b) return;
604
+ var rid = b.dataset.merge; var prev=b.textContent; b.disabled=true; b.textContent='merging…';
605
+ try{
606
+ var res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
607
+ var j = await res.json().catch(function(){return{};});
608
+ if (res.ok){ toast('r'+rid+' merge 완료'); await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId); }
609
+ else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent=prev; }
610
+ }catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent=prev; }
611
+ });
612
+ // verify 명령 저장(선택 태스크의 repo 에)
613
+ function syncVcmd(){
614
+ var t = rvTaskId!=null?taskById[rvTaskId]:null; var rp = t&&repoById[t.repoId];
615
+ $('rvVcmd').value = rp ? (rp.verifyCmd||'') : '';
616
+ $('rvVcmd').dataset.repo = rp ? String(rp.id) : '';
617
+ }
618
+ async function saveVcmd(){
619
+ var rid = $('rvVcmd').dataset.repo; if (!rid){ toast('repo 를 알 수 없습니다'); return; }
620
+ var cmd = $('rvVcmd').value.trim();
621
+ try{
622
+ var res = await fetch('/api/repos/'+rid,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({verifyCmd:cmd})});
623
+ var j = await res.json().catch(function(){return{};});
624
+ if (res.ok){ if (repoById[rid]) repoById[rid].verifyCmd=cmd; toast(cmd?'verify 명령 저장':'verify 끔'); await hydrate(); if (rvTaskId!=null){ syncVcmd(); loadCompare(rvTaskId); } }
625
+ else toast('저장 실패: '+(j.error||res.status));
626
+ }catch(err){ toast('저장 실패: '+err); }
627
+ }
628
+ $('rvVsave').addEventListener('click', saveVcmd);
629
+ $('rvVcmd').addEventListener('keydown', function(e){ if (e.key==='Enter'){ e.preventDefault(); saveVcmd(); } });
630
+
348
631
  // ── /ws 라이브 구독 → 트리·페인 갱신 ──
349
632
  function wsConnect(){
350
633
  var proto = location.protocol==='https:'?'wss':'ws';
@@ -356,6 +639,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
356
639
  var hydT=null;
357
640
  function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
358
641
 
642
+ setMode('new');
359
643
  hydrate();
360
644
  wsConnect();
361
645
  window.addEventListener('resize', function(){ panes.forEach(fitPane); });
package/src/db/index.ts CHANGED
@@ -103,4 +103,7 @@ export async function ensureSchema(): Promise<void> {
103
103
  try { await client.execute("ALTER TABLE tasks ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'"); } catch { /* exists */ }
104
104
  try { await client.execute('ALTER TABLE agent_runs ADD COLUMN agent_pid INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
105
105
  try { await client.execute('ALTER TABLE agent_runs ADD COLUMN log_offset INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
106
+ try { await client.execute("ALTER TABLE repos ADD COLUMN verify_cmd TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
107
+ try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_status TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
108
+ try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_output TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
106
109
  }
package/src/db/schema.ts CHANGED
@@ -19,6 +19,7 @@ export const repos = sqliteTable('repos', {
19
19
  path: text('path').notNull(),
20
20
  name: text('name').notNull(),
21
21
  defaultBranch: text('default_branch').notNull().default('main'),
22
+ verifyCmd: text('verify_cmd').notNull().default(''), // 정착한 run 을 검증하는 명령(테스트·빌드). 빈값 = 검증 없음
22
23
  });
23
24
 
24
25
  /** Design Mode 캡처 — 북마클릿 인스펙터가 보낸 UI 요소 컨텍스트. */
@@ -90,6 +91,8 @@ export const agentRuns = sqliteTable('agent_runs', {
90
91
  filesChanged: integer('files_changed').notNull().default(0),
91
92
  agentPid: integer('agent_pid').notNull().default(0), // detached sh pgid — 재시작 후 생존 판정/stop
92
93
  logOffset: integer('log_offset').notNull().default(0), // 내구 로그에서 tail 이 소비한 바이트(재-adopt 시 여기부터)
94
+ verifyStatus: text('verify_status').notNull().default(''), // '' none | running | pass | fail | error — 정착 후 repo.verifyCmd 결과
95
+ verifyOutput: text('verify_output').notNull().default(''), // 검증 출력 tail(실패 진단용)
93
96
  startedAt: integer('started_at', { mode: 'timestamp' }),
94
97
  endedAt: integer('ended_at', { mode: 'timestamp' }),
95
98
  exitSummary: text('exit_summary').notNull().default(''),
@@ -534,9 +534,38 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
534
534
  await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary, agentPid: 0 });
535
535
  // 문서 산출물을 정착 시점에 스냅샷 — worktree 소멸(머지·Close) 후에도 렌더 뷰 유지. best-effort.
536
536
  if (filesChanged > 0) void snapshotRunDocs(runId);
537
+ // verify in-loop — repo.verifyCmd 가 있으면 정착한(변경 있는) run 을 자동 검증. best-effort.
538
+ if (status === 'done' && filesChanged > 0) void verifyRun(runId);
537
539
  void notifySettle(runId, status, filesChanged, exitSummary);
538
540
  }
539
541
 
542
+ /**
543
+ * Verify in-loop — repo.verifyCmd 를 run 의 worktree 에서 실행해 pass/fail 을 기록.
544
+ * verifyCmd 미설정이면 상태를 비우고 no-op. 정착 훅이 자동 호출(done+변경), 수동 재검증도 지원.
545
+ */
546
+ export async function verifyRun(runId: number): Promise<{ ok: boolean; status: string; detail?: string }> {
547
+ const ctx = await loadContext(runId);
548
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
549
+ const run = rr[0];
550
+ if (!ctx || !run) return { ok: false, status: '', detail: 'run not found' };
551
+ const rp = await db.select().from(repos).where(eq(repos.id, ctx.repoId)).limit(1);
552
+ const cmd = (rp[0]?.verifyCmd ?? '').trim();
553
+ if (!cmd) { await setRun(runId, { verifyStatus: '', verifyOutput: '' }); return { ok: true, status: '' }; }
554
+ if (!run.worktreePath) return { ok: false, status: 'error', detail: 'worktree gone' };
555
+ const exists = await runShellOn(ctx.machine, `test -d ${shq(run.worktreePath)} && echo yes`, 8000);
556
+ if (!/yes/.test(exists.stdout)) {
557
+ await setRun(runId, { verifyStatus: 'error', verifyOutput: 'worktree missing' });
558
+ return { ok: false, status: 'error', detail: 'worktree missing' };
559
+ }
560
+ await setRun(runId, { verifyStatus: 'running', verifyOutput: '' });
561
+ const r = await runShellOn(ctx.machine, `cd ${shq(run.worktreePath)} && ( ${cmd} )`, 180000);
562
+ const merged = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
563
+ const tail = merged.length > 6000 ? '…' + merged.slice(-6000) : merged;
564
+ const status = r.ok ? 'pass' : r.code === -1 ? 'error' : 'fail';
565
+ await setRun(runId, { verifyStatus: status, verifyOutput: tail });
566
+ return { ok: true, status };
567
+ }
568
+
540
569
  /**
541
570
  * 변경 문서(md/html)를 DB 에 스냅샷. 최신 우선(기존 행 삭제 후 재삽입).
542
571
  * 빈 읽기(worktree 이미 소멸 등)는 기존 스냅샷을 지우지 않는다.
package/src/server.ts CHANGED
@@ -19,7 +19,7 @@ import { db } from './db';
19
19
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
20
20
  import { BOOKMARKLET_JS } from './design';
21
21
  import { runShellOn, shq } from './exec';
22
- 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 } from './orchestrator';
22
+ 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 } from './orchestrator';
23
23
  import { openTerm } from './term';
24
24
  import { addSink, removeSink, broadcast } from './hub';
25
25
  import { getProvider, listProviders } from './providers';
@@ -685,21 +685,33 @@ export async function buildServer(): Promise<FastifyInstance> {
685
685
  // 기본 브랜치 변경 — merge·Sync base·PR 이 향할 대상. develop-flow repo 대응.
686
686
  app.patch('/api/repos/:id', async (req, reply) => {
687
687
  const id = Number((req.params as { id: string }).id);
688
- const b = (req.body ?? {}) as { defaultBranch?: string };
689
- const branch = (b.defaultBranch ?? '').trim();
690
- if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
688
+ const b = (req.body ?? {}) as { defaultBranch?: string; verifyCmd?: string };
691
689
  const rp = await db.select().from(repos).where(eq(repos.id, id)).limit(1);
692
690
  const repo = rp[0];
693
691
  if (!repo) return reply.code(404).send({ error: 'not found' });
694
- const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
695
- const m = mr[0];
696
- if (!m) return reply.code(404).send({ error: 'machine not found' });
697
- // branch charset 가드 통과( 메타문자 없음). 전체 ref 를 인용해 전달.
698
- const check = await runShellOn(m,
699
- `git -C ${shq(repo.path)} rev-parse --verify --quiet ${shq('refs/heads/' + branch)} >/dev/null && echo OK`, 10000);
700
- if (!check.stdout.includes('OK')) return reply.code(400).send({ error: `branch '${branch}' not found in the repository` });
701
- await db.update(repos).set({ defaultBranch: branch }).where(eq(repos.id, id));
702
- return { ok: true, defaultBranch: branch };
692
+ const patch: { defaultBranch?: string; verifyCmd?: string } = {};
693
+ // defaultBranch(선택) repo 에 존재하는 ref 로 검증
694
+ if (b.defaultBranch !== undefined) {
695
+ const branch = (b.defaultBranch ?? '').trim();
696
+ if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
697
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
698
+ const m = mr[0];
699
+ if (!m) return reply.code(404).send({ error: 'machine not found' });
700
+ const check = await runShellOn(m,
701
+ `git -C ${shq(repo.path)} rev-parse --verify --quiet ${shq('refs/heads/' + branch)} >/dev/null && echo OK`, 10000);
702
+ if (!check.stdout.includes('OK')) return reply.code(400).send({ error: `branch '${branch}' not found in the repository` });
703
+ patch.defaultBranch = branch;
704
+ }
705
+ // verifyCmd(선택) — 정착 run 검증 명령. 빈문자 = 검증 끔. 제어문자만 금지, 길이 캡.
706
+ if (b.verifyCmd !== undefined) {
707
+ const vc = String(b.verifyCmd ?? '').trim();
708
+ if (vc.length > 500) return reply.code(400).send({ error: 'verify command too long (max 500)' });
709
+ if (/[\x00-\x1f]/.test(vc)) return reply.code(400).send({ error: "verify command has control characters" });
710
+ patch.verifyCmd = vc;
711
+ }
712
+ if (!Object.keys(patch).length) return reply.code(400).send({ error: 'nothing to update' });
713
+ await db.update(repos).set(patch).where(eq(repos.id, id));
714
+ return { ok: true, ...patch };
703
715
  });
704
716
 
705
717
  // 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
@@ -978,6 +990,16 @@ export async function buildServer(): Promise<FastifyInstance> {
978
990
  return reply.code(202).send(res);
979
991
  });
980
992
 
993
+ // 수동 재검증 — repo.verifyCmd 를 이 run 의 worktree 에서 다시 실행(정착 자동검증과 동일 경로).
994
+ app.post('/api/runs/:id/verify', async (req, reply) => {
995
+ const id = Number((req.params as { id: string }).id);
996
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
997
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
998
+ const res = await verifyRun(id);
999
+ if (!res.ok) return reply.code(409).send(res);
1000
+ return reply.code(202).send(res);
1001
+ });
1002
+
981
1003
  // base 동기화 — 오래 사는 세션 worktree 에 base 최신 머지.
982
1004
  app.post('/api/runs/:id/sync', async (req, reply) => {
983
1005
  const id = Number((req.params as { id: string }).id);