coxpit 4.0.0 → 4.2.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/README.md CHANGED
@@ -22,7 +22,8 @@ Your machines. Your auth. Your code never leaves your network.
22
22
  - **Design Mode** — drag the `⌖ coxpit inspect` bookmarklet to your bar, click it on your running app, click any element: its selector, HTML and computed styles are captured and injected into the agents' prompt as design context.
23
23
  - **Self-orchestrating agents** — every local run can spawn its own sub-agents by writing `.coxpit/spawn.json` in its worktree (works under default permissions — no network, no escalation). The daemon launches each subtask as an isolated sub-run and maintains `.coxpit/subtasks.json` with live status. Orchestration moves inside the agent's own reasoning loop.
24
24
  - **Start from GitHub** — paste an issue/PR URL and the task form drafts itself from its title and body (gh CLI for private repos, public API otherwise). You review, pick a provider, Run fleet.
25
- - **Share a run** — one click mints a read-only snapshot link (timeline + diff, no auth, no actions). Show your fleet's work without opening your cockpit.
25
+ - **Share a run** — one click mints a read-only snapshot link (timeline + diff, and the rendered docs, no auth, no actions). Show your fleet's work without opening your cockpit.
26
+ - **The library** — a run's changed documents (Markdown/HTML) are snapshotted when it settles, so the Rendered view survives merge and Close task. Pick a model per launch (any name your CLI accepts), and a close guard warns before it deletes unmerged, unexported output.
26
27
 
27
28
  External tools are spawned, never vendored: `git`, `tmux`, your agent CLI. No editor bundled — terminal-first.
28
29
 
@@ -109,7 +110,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
109
110
 
110
111
  ## Status
111
112
 
112
- `v4.0` — fleet, two providers (Claude Code · Codex), compare/merge + AI review + doc mode, terminal (full-screen, session tabs, mobile input bar), swarm (plan fan-out · integrate · agent self-orchestration), sessions (steer/ask · resume), mobile board with deep links, GitHub import, read-only share links all shipped and e2e-tested (30 checks). Roadmap: ROADMAP.md.
113
+ `v4.2` — everything in v4.1 plus **run grouping**: goal fan-outs and agent swarms cluster into bands on the board (title · N tasks · M settled, with fold, group Select runs, and Close group), and multi-run tasks get `run i/n` attempt counters so same-title cards read as attempts, not duplicates. All shipped and e2e-tested (36 checks). Roadmap: ROADMAP.md.
113
114
 
114
115
  ## License
115
116
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "4.0.0",
3
+ "version": "4.2.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
@@ -141,6 +141,17 @@ export const BOARD_HTML = /* html */ `<!doctype html>
141
141
 
142
142
  /* ── run cards ──────────────────────────── */
143
143
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
144
+ /* ── group bands (goal/swarm 형제 묶음) — 점선 클러스터, 좌측 액센트 바·채움 금지 ── */
145
+ .gband{grid-column:1/-1;border:1px dashed var(--line-hi);border-radius:14px;padding:12px;margin-bottom:0}
146
+ .gband-h{display:flex;align-items:center;gap:10px;margin-bottom:10px;font-family:var(--mono)}
147
+ .gband-glyph{color:var(--brand);font-size:13px}
148
+ .gband-t{color:var(--ink);font-size:12.5px;font-weight:600;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:40%}
149
+ .gband-n{color:var(--faint);font-size:11px}
150
+ .gband-sp{flex:1}
151
+ .gband-fold{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px}
152
+ .gband.folded .gband-grid{display:none}
153
+ .gband-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
154
+ .attempt{color:var(--brand);opacity:.8}
144
155
  .card{border:1px solid var(--line);border-radius:var(--r-card);background:var(--surface);
145
156
  overflow:hidden;display:flex;flex-direction:column;cursor:pointer;
146
157
  transition:border-color .18s,transform .18s,box-shadow .18s}
@@ -164,6 +175,15 @@ export const BOARD_HTML = /* html */ `<!doctype html>
164
175
  .ev{display:flex;gap:9px;align-items:baseline;min-width:0}
165
176
  .ev .k{color:var(--brand);min-width:78px;flex:none;opacity:.85}
166
177
  .ev .t{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
178
+ /* ── closed card — 죽은 카드는 확실히 죽어 보이게(빗금 + CLOSED 스탬프) ── */
179
+ .card.closed{opacity:.6;filter:saturate(.45)}
180
+ .card.closed .log{position:relative}
181
+ .card.closed .log::after{content:'';position:absolute;inset:0;pointer-events:none;
182
+ background:repeating-linear-gradient(135deg,transparent 0 9px,rgba(255,255,255,.035) 9px 11px)}
183
+ .card.closed .log::before{content:'CLOSED';position:absolute;top:50%;left:50%;z-index:1;
184
+ transform:translate(-50%,-50%) rotate(-7deg);font-family:var(--mono);font-size:15px;
185
+ letter-spacing:.34em;color:var(--faint);border:1px solid var(--line-hi);
186
+ border-radius:6px;padding:4px 14px;background:rgba(11,13,18,.72)}
167
187
  /* ── select mode (integrate) ── */
168
188
  .toolbar{display:flex;justify-content:flex-end;gap:8px;margin-bottom:12px}
169
189
  .card.selmode{cursor:copy}
@@ -291,6 +311,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
291
311
  .cmp-review p{margin:0 0 8px}
292
312
 
293
313
  /* ── doc mode (rendered output) ─────────── */
314
+ .doc-src{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-bottom:10px}
294
315
  .doc-h{font-family:var(--mono);font-size:10.5px;color:var(--brand);padding:8px 0 4px;
295
316
  border-bottom:1px solid var(--line);margin-bottom:8px;word-break:break-all}
296
317
  .doc-md{font-size:13px;line-height:1.65;color:var(--muted);margin-bottom:16px;font-family:var(--sans)}
@@ -378,6 +399,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
378
399
  <div class="row">
379
400
  <button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
380
401
  <button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
402
+ <button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it">⎇</button>
381
403
  <button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
382
404
  </div>
383
405
  <form id="repoForm" hidden>
@@ -404,6 +426,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
404
426
  <button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
405
427
  <button type="button" class="seg-opt" data-agent="codex">Codex</button>
406
428
  </div>
429
+ <p class="flabel">model · optional</p>
430
+ <input id="taskModel" placeholder="CLI default" list="modelHist" autocomplete="off" />
431
+ <datalist id="modelHist"></datalist>
407
432
  <p class="flabel">design capture · optional</p>
408
433
  <select id="taskCapture"><option value="">no design capture</option></select>
409
434
  </div>
@@ -565,6 +590,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
565
590
  </div>
566
591
  </div>
567
592
 
593
+ <div class="overlay" id="brOverlay">
594
+ <div class="cfm">
595
+ <div class="cfm-b">
596
+ <div class="m">Base branch for this repository</div>
597
+ <div class="s">Merge, Sync base and PR mode all target this branch. Set it to match your repo's flow (e.g. <span style="color:var(--brand);font-family:var(--mono)">develop</span>). Must already exist in the repo.</div>
598
+ <p class="flabel" style="margin-top:12px">branch name</p>
599
+ <input id="brInput" placeholder="main" />
600
+ </div>
601
+ <div class="cfm-f">
602
+ <button class="btn-ghost sm" id="brCancel">Cancel</button>
603
+ <button class="btn sm" id="brOk">Save</button>
604
+ </div>
605
+ </div>
606
+ </div>
607
+
568
608
  <div class="overlay" id="expOverlay">
569
609
  <div class="cfm">
570
610
  <div class="cfm-b">
@@ -596,6 +636,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
596
636
  <script>
597
637
  const runs = new Map(); // runId -> run object
598
638
  const tasks = new Map(); // taskId -> task
639
+ const groups = new Map(); // groupId -> {id, kind, title}
599
640
  let repos = [], machines = [], captures = [];
600
641
 
601
642
  const $ = (id) => document.getElementById(id);
@@ -799,12 +840,56 @@ function chipHTML(status){
799
840
  return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
800
841
  + '<i></i>'+esc(s)+'</span>';
801
842
  }
843
+ /* 같은 태스크에 run 이 여럿이면 "run i/n" — 같은 제목 카드가 중복이 아니라 시도로 읽히게 */
844
+ function attemptHTML(r){
845
+ const sib = [...runs.values()].filter(x=>x.taskId===r.taskId).sort((a,b)=>a.id-b.id);
846
+ if (sib.length<2) return '';
847
+ const i = sib.findIndex(x=>x.id===r.id)+1;
848
+ return '<span class="attempt" title="attempt within this task">run '+i+'/'+sib.length+'</span>';
849
+ }
802
850
 
851
+ /* ── group fold 상태(기기별, WS 재렌더에도 유지) ── */
852
+ let gfold = new Set();
853
+ try{ gfold = new Set(JSON.parse(localStorage.getItem('coxpit.gfold')||'[]')); }catch{}
854
+ function saveGfold(){ try{ localStorage.setItem('coxpit.gfold', JSON.stringify([...gfold])); }catch{} }
855
+ /* 태스크의 모든 run 이 정착했는가 */
856
+ function taskSettled(taskId){
857
+ const rs = [...runs.values()].filter(r=>r.taskId===taskId);
858
+ return rs.length>0 && rs.every(r=>['done','failed','stopped','merged'].includes(r.status));
859
+ }
860
+ function bandHTML(g, grpRuns){
861
+ const glyph = g.kind==='swarm' ? '↳' : '⌁';
862
+ const title = g.kind==='swarm' ? 'swarm of: '+esc(g.title) : esc(g.title);
863
+ const taskIds = [...new Set(grpRuns.map(r=>r.taskId))];
864
+ const settled = taskIds.filter(taskSettled).length;
865
+ const folded = gfold.has(g.id);
866
+ const cards = grpRuns.slice().sort((a,b)=>a.id-b.id).map(cardHTML).join('');
867
+ return '<div class="gband'+(folded?' folded':'')+'" data-g="'+g.id+'">'
868
+ + '<div class="gband-h"><span class="gband-glyph">'+glyph+'</span>'
869
+ + '<span class="gband-t">'+title+'</span>'
870
+ + '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
871
+ + '<span class="gband-sp"></span>'
872
+ + '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
873
+ + '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
874
+ + '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
875
+ + '<div class="gband-grid">'+cards+'</div></div>';
876
+ }
803
877
  function render(){
804
878
  const list = [...runs.values()].sort((a,b)=>b.id-a.id);
805
879
  $('empty').style.display = list.length ? 'none' : 'flex';
806
- if (!list.length) paintOnboarding();
807
- $('grid').innerHTML = list.map(cardHTML).join('');
880
+ if (!list.length){ paintOnboarding(); $('grid').innerHTML=''; return; }
881
+ // 그룹 파티션 — grouped run 은 밴드로 클러스터, ungrouped 는 뒤에 flat.
882
+ const byGroup = new Map(); const flat = [];
883
+ for (const r of list){
884
+ const t = tasks.get(r.taskId);
885
+ const gid = t && t.groupId!=null && groups.has(t.groupId) ? t.groupId : null;
886
+ if (gid==null) flat.push(r);
887
+ else { if(!byGroup.has(gid)) byGroup.set(gid, []); byGroup.get(gid).push(r); }
888
+ }
889
+ let html = '';
890
+ for (const gid of [...byGroup.keys()].sort((a,b)=>b-a)) html += bandHTML(groups.get(gid), byGroup.get(gid));
891
+ html += flat.map(cardHTML).join('');
892
+ $('grid').innerHTML = html;
808
893
  if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
809
894
  }
810
895
 
@@ -862,13 +947,15 @@ function cardHTML(r){
862
947
  const evs = humanLines(r.events).slice(-8).map(h =>
863
948
  '<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t).slice(0,140)+'</span></div>'
864
949
  ).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
865
- const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
950
+ const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
866
951
  return '<div class="card'+selCls+'" id="card-'+r.id+'">'
867
952
  + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
868
953
  + '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
869
954
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
870
955
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
871
956
  + '<span>'+esc(r.agent||'')+'</span>'
957
+ + attemptHTML(r)
958
+ + (r.model ? '<span title="model">⚙ '+esc(r.model)+'</span>' : '')
872
959
  + (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
873
960
  + (r.sessionId && ['done','failed','stopped'].includes(r.status)
874
961
  ? '<span class="resumable" title="agent session preserved — open the run and Send a next instruction to continue">↻ resumable</span>' : '')
@@ -890,6 +977,8 @@ async function hydrate(){
890
977
  machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
891
978
  tasks.clear();
892
979
  (r.tasks||[]).forEach(t => tasks.set(t.id, t));
980
+ groups.clear();
981
+ (r.groups||[]).forEach(g => groups.set(g.id, g));
893
982
  runs.clear();
894
983
  (r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
895
984
  if (r.daemon) {
@@ -942,6 +1031,26 @@ $('repoRemove').addEventListener('click', async ()=>{
942
1031
  if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
943
1032
  else toast('remove: '+(j.detail||res.status), 'error');
944
1033
  });
1034
+ /* ── 기본 브랜치 변경 ── */
1035
+ $('repoBranch').addEventListener('click', ()=>{
1036
+ const rid = $('taskRepo').value;
1037
+ if (!rid){ toast('no repository selected', 'error'); return; }
1038
+ const repo = repos.find(r=>String(r.id)===String(rid));
1039
+ $('brInput').value = repo ? repo.defaultBranch : '';
1040
+ $('brOverlay').classList.add('open'); $('brInput').focus();
1041
+ });
1042
+ $('brCancel').addEventListener('click', ()=>$('brOverlay').classList.remove('open'));
1043
+ $('brOverlay').addEventListener('click',(e)=>{ if(e.target===$('brOverlay')) $('brOverlay').classList.remove('open'); });
1044
+ async function brSave(){
1045
+ const rid = $('taskRepo').value; const branch = $('brInput').value.trim();
1046
+ if (!rid || !branch) return;
1047
+ const res = await fetch('/api/repos/'+rid,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({defaultBranch:branch})});
1048
+ const j = await res.json().catch(()=>({}));
1049
+ if (res.ok){ $('brOverlay').classList.remove('open'); toast('base branch → '+j.defaultBranch, 'ok'); hydrate(); }
1050
+ else toast('branch: '+(j.error||res.status), 'error');
1051
+ }
1052
+ $('brOk').addEventListener('click', brSave);
1053
+ $('brInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') brSave(); });
945
1054
  $('repoManual').addEventListener('click', ()=>{
946
1055
  const f = $('repoForm'); f.hidden = !f.hidden;
947
1056
  if (!f.hidden) $('repoPath').focus();
@@ -998,7 +1107,7 @@ function connectWS(){
998
1107
  render(); flash(ev.runId); paintModal();
999
1108
  } else if (ev.type==='task'){
1000
1109
  const t = tasks.get(ev.taskId);
1001
- if (t){ t.status = ev.status; render(); paintModal(); } else { hydrate(); }
1110
+ if (t){ if (ev.status!=null) t.status = ev.status; if (ev.groupId!=null) t.groupId = ev.groupId; render(); paintModal(); } else { hydrate(); }
1002
1111
  } else if (ev.type==='capture'){
1003
1112
  captures.push(ev.capture); paintSidebar();
1004
1113
  }
@@ -1028,17 +1137,31 @@ function paintModal(){
1028
1137
  async function loadDiff(){
1029
1138
  if (openRunId==null || docMode) return;
1030
1139
  const pre = $('mDiff'); if (!pre) return;
1140
+ const rid = openRunId;
1031
1141
  pre.textContent = 'loading…'; $('mStat').textContent='';
1032
1142
  try{
1033
- const d = await fetch('/api/runs/'+openRunId+'/diff').then(x=>x.json());
1034
- if (!d.ok){ pre.textContent = d.stat||'no worktree'; return; }
1143
+ const d = await fetch('/api/runs/'+rid+'/diff').then(x=>x.json());
1144
+ if (!d.ok){
1145
+ pre.textContent = d.stat||'no worktree';
1146
+ // worktree 는 없지만 스냅샷 문서가 있으면 Rendered 토글 노출(머지·Close 후 뷰어)
1147
+ maybeShowDocsToggle(rid);
1148
+ return;
1149
+ }
1035
1150
  const files = d.stat ? d.stat.split('\\n').filter(Boolean).length : 0;
1036
1151
  $('mStat').textContent = files ? '· '+files+' file'+(files>1?'s':'') : '· clean';
1037
- // 변경분에 문서(md/html)가 있으면 Rendered 토글 노출
1038
- $('mDocsTgl').hidden = !/\\.(md|markdown|html?|htm)$/im.test(d.stat||'');
1039
1152
  pre.innerHTML = diffHTML(d.diff||'');
1153
+ // 변경분에 문서(md/html)가 있으면 즉시 노출, 없으면 스냅샷 확인
1154
+ if (/\\.(md|markdown|html?|htm)$/im.test(d.stat||'')) $('mDocsTgl').hidden = false;
1155
+ else maybeShowDocsToggle(rid);
1040
1156
  }catch{ pre.textContent = 'diff failed'; }
1041
1157
  }
1158
+ /* worktree 에 라이브 문서가 없을 때만 — 스냅샷이라도 있으면 토글을 켠다 */
1159
+ async function maybeShowDocsToggle(rid){
1160
+ try{
1161
+ const j = await fetch('/api/runs/'+rid+'/docs').then(x=>x.json());
1162
+ if (rid===openRunId && (j.docs||[]).length) $('mDocsTgl').hidden = false;
1163
+ }catch{}
1164
+ }
1042
1165
  /* ── doc 모드 — diff 대신 렌더된 문서 산출물 ── */
1043
1166
  let docMode = false;
1044
1167
  function docsHTML(docs){
@@ -1053,7 +1176,9 @@ async function paintDocs(){
1053
1176
  $('mDiffWrap').innerHTML = '<span style="color:var(--faint)">rendering…</span>';
1054
1177
  try{
1055
1178
  const d = await fetch('/api/runs/'+openRunId+'/docs').then(x=>x.json());
1056
- $('mDiffWrap').innerHTML = docsHTML(d.docs||[]);
1179
+ const src = d.source==='snapshot'
1180
+ ? '<div class="doc-src">worktree gone — snapshot taken at settle</div>' : '';
1181
+ $('mDiffWrap').innerHTML = src + docsHTML(d.docs||[]);
1057
1182
  }catch{ $('mDiffWrap').textContent = 'docs failed'; }
1058
1183
  }
1059
1184
  function setDocMode(on){
@@ -1108,6 +1233,51 @@ $('selGo').addEventListener('click', async ()=>{
1108
1233
  } else toast('integrate: '+(j.error||res.status), 'error');
1109
1234
  });
1110
1235
 
1236
+ /* ── 그룹 밴드 액션 (fold / Select runs / Close group) ── */
1237
+ $('grid').addEventListener('click', async (e)=>{
1238
+ const fold = e.target.closest('[data-gfold]');
1239
+ if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
1240
+ const gsel = e.target.closest('[data-gsel]');
1241
+ if (gsel){
1242
+ const g=Number(gsel.dataset.gsel);
1243
+ if (!selectMode) setSelectMode(true);
1244
+ const tids = new Set([...tasks.values()].filter(t=>t.groupId===g).map(t=>t.id));
1245
+ for (const r of [...runs.values()].sort((a,b)=>a.id-b.id)){
1246
+ if (!tids.has(r.taskId)) continue;
1247
+ const ok = ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
1248
+ if (ok && !selected.has(r.id)){ selected.add(r.id); selOrder.push(r.id); }
1249
+ }
1250
+ $('selCnt').textContent = selected.size + ' selected';
1251
+ render();
1252
+ if (!selected.size) toast('no settled runs with changes in this group yet', 'error');
1253
+ return;
1254
+ }
1255
+ const gclose = e.target.closest('[data-gclose]');
1256
+ if (gclose){ await closeGroup(Number(gclose.dataset.gclose)); return; }
1257
+ });
1258
+ async function closeGroup(g){
1259
+ const tids = [...tasks.values()].filter(t=>t.groupId===g && t.status!=='closed').map(t=>t.id);
1260
+ if (!tids.length){ toast('nothing open in this group', 'ok'); return; }
1261
+ const grp = groups.get(g)||{};
1262
+ const yes = await confirmUI('Close this whole group?',
1263
+ { sub: (grp.title||'group')+' — stops and cleans every worktree/branch of '+tids.length+' task(s).', danger:true, okLabel:'Close group' });
1264
+ if (!yes) return;
1265
+ const closeOne = (id,force)=>fetch('/api/tasks/'+id+'/close',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({force})});
1266
+ const risky = [];
1267
+ for (const id of tids){
1268
+ const res = await closeOne(id,false);
1269
+ if (res.status===409){ const j=await res.json().catch(()=>({})); (j.atRisk||[]).forEach(a=>risky.push(a)); }
1270
+ }
1271
+ if (risky.length){
1272
+ const list = risky.map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
1273
+ const ok = await confirmUI('Close and delete unmerged output?',
1274
+ { danger:true, sub: list+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
1275
+ if (!ok){ await hydrate(); return; }
1276
+ for (const id of tids) await closeOne(id,true);
1277
+ }
1278
+ toast('closed '+tids.length+' task'+(tids.length>1?'s':'')+' in the group', 'ok');
1279
+ await hydrate();
1280
+ }
1111
1281
  $('grid').addEventListener('click',(e)=>{
1112
1282
  const card = e.target.closest('.card'); if(!card) return;
1113
1283
  const id = Number(card.id.replace('card-',''));
@@ -1125,7 +1295,7 @@ $('grid').addEventListener('click',(e)=>{
1125
1295
  });
1126
1296
  $('mClose').addEventListener('click', closeModal);
1127
1297
  $('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
1128
- document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
1298
+ document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
1129
1299
  $('mRefreshDiff').addEventListener('click', loadDiff);
1130
1300
  $('mExport').addEventListener('click', ()=>{
1131
1301
  if (openRunId==null) return;
@@ -1195,7 +1365,18 @@ $('mCloseTask').addEventListener('click', async ()=>{
1195
1365
  const yes = await confirmUI('Close this task?',
1196
1366
  { sub: 'Stops any live runs and removes every worktree and branch of the task.', danger: true, okLabel: 'Close task' });
1197
1367
  if (!yes) return;
1198
- await fetch('/api/tasks/'+r.taskId+'/close',{method:'POST'});
1368
+ const close = (force)=>fetch('/api/tasks/'+r.taskId+'/close',{method:'POST',
1369
+ headers:{'content-type':'application/json'}, body:JSON.stringify({force})});
1370
+ let res = await close(false);
1371
+ if (res.status===409){
1372
+ const j = await res.json().catch(()=>({}));
1373
+ const risk = (j.atRisk||[]).map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
1374
+ const ok = await confirmUI('Close and delete unmerged output?',
1375
+ { danger:true, sub: risk+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
1376
+ if (!ok) return;
1377
+ res = await close(true);
1378
+ }
1379
+ if (!res.ok){ toast('close failed ('+res.status+')', 'error'); return; }
1199
1380
  toast('task closed — all runs cleaned', 'ok');
1200
1381
  closeModal(); hydrate();
1201
1382
  });
@@ -1511,11 +1692,28 @@ $('taskForm').addEventListener('submit', async (e)=>{
1511
1692
  body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
1512
1693
  if (!t.ok){ toast('task create failed', 'error'); return; }
1513
1694
  tasks.set(t.task.id, t.task);
1695
+ const model = $('taskModel').value.trim();
1514
1696
  await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
1515
- body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent})});
1697
+ body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
1698
+ if (model) rememberModel(model);
1516
1699
  $('taskTitle').value=''; $('taskPrompt').value='';
1517
1700
  });
1518
1701
 
1702
+ /* ── model 최근값 기억(기기별, 최대 5) ── */
1703
+ function rememberModel(m){
1704
+ try{
1705
+ const h = JSON.parse(localStorage.getItem('coxpit.models')||'[]');
1706
+ localStorage.setItem('coxpit.models', JSON.stringify([m, ...h.filter(x=>x!==m)].slice(0,5)));
1707
+ }catch{}
1708
+ paintModelHist();
1709
+ }
1710
+ function paintModelHist(){
1711
+ let h = [];
1712
+ try{ h = JSON.parse(localStorage.getItem('coxpit.models')||'[]'); }catch{}
1713
+ $('modelHist').innerHTML = h.map(m=>'<option value="'+escA(m)+'"></option>').join('');
1714
+ }
1715
+ paintModelHist();
1716
+
1519
1717
  /* ── agent mode segmented control (mirrors hidden #taskReal) ── */
1520
1718
  const segOpts = Array.from(document.querySelectorAll('#modeSeg .seg-opt'));
1521
1719
  function setMode(real, persist){
package/src/db/index.ts CHANGED
@@ -75,10 +75,26 @@ export async function ensureSchema(): Promise<void> {
75
75
  token TEXT NOT NULL UNIQUE,
76
76
  created_at INTEGER DEFAULT (unixepoch())
77
77
  );
78
+ CREATE TABLE IF NOT EXISTS doc_snapshots (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ run_id INTEGER NOT NULL,
81
+ path TEXT NOT NULL,
82
+ kind TEXT NOT NULL,
83
+ content TEXT NOT NULL DEFAULT '',
84
+ created_at INTEGER DEFAULT (unixepoch())
85
+ );
86
+ CREATE TABLE IF NOT EXISTS task_groups (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ kind TEXT NOT NULL DEFAULT 'goal',
89
+ title TEXT NOT NULL,
90
+ created_at INTEGER DEFAULT (unixepoch())
91
+ );
78
92
  `);
79
93
  // 기존 DB 마이그레이션(멱등)
80
94
  try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
81
95
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
82
96
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
83
97
  try { await client.execute('ALTER TABLE tasks ADD COLUMN parent_run_id INTEGER'); } catch { /* exists */ }
98
+ try { await client.execute("ALTER TABLE agent_runs ADD COLUMN model TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
99
+ try { await client.execute('ALTER TABLE tasks ADD COLUMN group_id INTEGER'); } catch { /* exists */ }
84
100
  }
package/src/db/schema.ts CHANGED
@@ -32,6 +32,14 @@ export const designCaptures = sqliteTable('design_captures', {
32
32
  createdAt: integer('created_at', { mode: 'timestamp' }),
33
33
  });
34
34
 
35
+ /** 태스크 그룹 — 한 goal(plan fan-out)·한 swarm(에이전트 서브태스크)에서 난 형제들. */
36
+ export const taskGroups = sqliteTable('task_groups', {
37
+ id: integer('id').primaryKey({ autoIncrement: true }),
38
+ kind: text('kind').notNull().default('goal'), // 'goal' | 'swarm'
39
+ title: text('title').notNull(),
40
+ createdAt: integer('created_at', { mode: 'timestamp' }),
41
+ });
42
+
35
43
  /** 하나의 요청. 여러 AgentRun 으로 병렬 시도됨. */
36
44
  export const tasks = sqliteTable('tasks', {
37
45
  id: integer('id').primaryKey({ autoIncrement: true }),
@@ -41,6 +49,17 @@ export const tasks = sqliteTable('tasks', {
41
49
  status: text('status').notNull().default('open'), // open | done
42
50
  designCaptureId: integer('design_capture_id'), // 선택 — 프롬프트에 DESIGN CONTEXT 주입
43
51
  parentRunId: integer('parent_run_id'), // 에이전트 셀프 오케스트레이션 — 이 태스크를 발사한 run
52
+ groupId: integer('group_id'), // task_groups — goal/swarm 형제 묶음(수동 태스크는 NULL)
53
+ createdAt: integer('created_at', { mode: 'timestamp' }),
54
+ });
55
+
56
+ /** 정착·정리 시점에 회수한 문서(md/html) 스냅샷 — worktree 소멸 후에도 렌더 뷰 유지. */
57
+ export const docSnapshots = sqliteTable('doc_snapshots', {
58
+ id: integer('id').primaryKey({ autoIncrement: true }),
59
+ runId: integer('run_id').notNull(),
60
+ path: text('path').notNull(),
61
+ kind: text('kind').notNull(), // 'md' | 'html'
62
+ content: text('content').notNull().default(''),
44
63
  createdAt: integer('created_at', { mode: 'timestamp' }),
45
64
  });
46
65
 
@@ -64,6 +83,7 @@ export const agentRuns = sqliteTable('agent_runs', {
64
83
  status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
65
84
  sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
66
85
  prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
86
+ model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
67
87
  filesChanged: integer('files_changed').notNull().default(0),
68
88
  startedAt: integer('started_at', { mode: 'timestamp' }),
69
89
  endedAt: integer('ended_at', { mode: 'timestamp' }),
@@ -8,14 +8,14 @@ import type { ChildProcess } from 'node:child_process';
8
8
  import { eq } from 'drizzle-orm';
9
9
  import { config } from './config';
10
10
  import { db } from './db';
11
- import { agentRuns, agentEvents, tasks, repos, machines, designCaptures } from './db/schema';
11
+ import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups } from './db/schema';
12
12
  import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
13
13
  import { broadcast } from './hub';
14
14
  import { getProvider, type Provider } from './providers';
15
15
 
16
16
  /** 에이전트 실행 커맨드. 드라이런=모의 stream-json + 실제 파일 1건 변경. */
17
- function agentCommand(provider: Provider, prompt: string, real: boolean): string {
18
- if (real) return provider.launchCmd(prompt);
17
+ function agentCommand(provider: Provider, prompt: string, real: boolean, model = ''): string {
18
+ if (real) return provider.launchCmd(prompt, model || undefined);
19
19
  // 모의: init → assistant → (파일 변경) → result. claude stream-json 라인 형태
20
20
  // (드라이런은 프로바이더 불문 배관 리허설 — claude 파서가 처리한다).
21
21
  return [
@@ -134,15 +134,23 @@ export async function spawnSubtasks(parentRunId: number, title: string, prompt:
134
134
  // 폭주 가드 — 한 부모가 만들 수 있는 하위 태스크 상한
135
135
  const siblings = await db.select().from(tasks).where(eq(tasks.parentRunId, parentRunId));
136
136
  if (siblings.length >= 8) return { ok: false, detail: 'subtask limit reached (8 per run)' };
137
+ // 그룹 — 부모가 이미 그룹에 속하면 그 그룹, 아니면 swarm 그룹 생성 후 부모까지 백필.
138
+ let groupId = pt.groupId ?? null;
139
+ if (groupId == null) {
140
+ const gIns = await db.insert(taskGroups).values({ kind: 'swarm', title: pt.title.slice(0, 140) }).returning();
141
+ groupId = gIns[0]!.id;
142
+ await db.update(tasks).set({ groupId }).where(eq(tasks.id, pt.id));
143
+ broadcast({ type: 'task', taskId: pt.id, groupId });
144
+ }
137
145
  const n = Math.max(1, Math.min(4, count || 1));
138
146
  const tIns = await db.insert(tasks).values({
139
- repoId: pt.repoId, title: title.slice(0, 140), prompt, parentRunId,
147
+ repoId: pt.repoId, title: title.slice(0, 140), prompt, parentRunId, groupId,
140
148
  }).returning();
141
149
  const task = tIns[0]!;
142
150
  const runIds: number[] = [];
143
151
  for (let i = 0; i < n; i++) {
144
152
  const rIns = await db.insert(agentRuns).values({
145
- taskId: task.id, machineId: pr.machineId, agent: pr.agent, status: 'pending',
153
+ taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending',
146
154
  }).returning();
147
155
  const run = rIns[0]!;
148
156
  broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
@@ -198,6 +206,7 @@ interface RunContext {
198
206
  prompt: string;
199
207
  real: boolean;
200
208
  agent: string;
209
+ model: string;
201
210
  }
202
211
 
203
212
  async function loadContext(runId: number): Promise<RunContext | null> {
@@ -237,6 +246,7 @@ async function loadContext(runId: number): Promise<RunContext | null> {
237
246
  prompt,
238
247
  real: config.agent.real,
239
248
  agent: run.agent,
249
+ model: run.model,
240
250
  };
241
251
  }
242
252
 
@@ -293,7 +303,7 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
293
303
  envPrefix = `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(tok)}; `;
294
304
  prompt += orchestrationNote();
295
305
  }
296
- const cmd = `cd ${shq(wtPath)} && ${envPrefix}${pidPrefix}{ ${agentCommand(provider, prompt, useReal)}; }`;
306
+ const cmd = `cd ${shq(wtPath)} && ${envPrefix}${pidPrefix}{ ${agentCommand(provider, prompt, useReal, ctx.model)}; }`;
297
307
  // 파일 오케스트레이션 — 로컬 run 이 사는 동안 .coxpit/spawn.json 감시.
298
308
  // .coxpit/ 는 repo exclude 에 넣어 diff/머지를 오염시키지 않는다(멱등).
299
309
  let orchTimer: NodeJS.Timeout | null = null;
@@ -353,9 +363,33 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
353
363
  const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
354
364
  const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
355
365
  await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary });
366
+ // 문서 산출물을 정착 시점에 스냅샷 — worktree 소멸(머지·Close) 후에도 렌더 뷰 유지. best-effort.
367
+ if (filesChanged > 0) void snapshotRunDocs(runId);
356
368
  void notifySettle(runId, status, filesChanged, exitSummary);
357
369
  }
358
370
 
371
+ /**
372
+ * 변경 문서(md/html)를 DB 에 스냅샷. 최신 우선(기존 행 삭제 후 재삽입).
373
+ * 빈 읽기(worktree 이미 소멸 등)는 기존 스냅샷을 지우지 않는다.
374
+ */
375
+ export async function snapshotRunDocs(runId: number): Promise<void> {
376
+ const d = await getRunDocs(runId).catch(() => null);
377
+ if (!d?.ok || d.docs.length === 0) return;
378
+ await db.delete(docSnapshots).where(eq(docSnapshots.runId, runId));
379
+ for (const doc of d.docs) await db.insert(docSnapshots).values({ runId, path: doc.path, kind: doc.kind, content: doc.content });
380
+ }
381
+
382
+ /** worktree(라이브) → 스냅샷 폴백 공용 로더. server 의 /api/runs/:id/docs·/share 가 사용. */
383
+ export async function loadRunDocs(runId: number): Promise<{
384
+ docs: Array<{ path: string; kind: string; content: string }>; source: 'worktree' | 'snapshot';
385
+ }> {
386
+ const live = await getRunDocs(runId).catch(() => null);
387
+ if (live?.ok && live.docs.length > 0) return { docs: live.docs, source: 'worktree' };
388
+ const snap = await db.select().from(docSnapshots).where(eq(docSnapshots.runId, runId));
389
+ if (snap.length > 0) return { docs: snap.map((s) => ({ path: s.path, kind: s.kind, content: s.content })), source: 'snapshot' };
390
+ return { docs: [], source: 'worktree' };
391
+ }
392
+
359
393
  /** run 정착 웹훅(선택) — COXPIT_WEBHOOK_URL 로 JSON POST. 실패는 무해. */
360
394
  async function notifySettle(runId: number, status: string, filesChanged: number, exitSummary: string): Promise<void> {
361
395
  if (!config.webhookUrl) return;
@@ -409,7 +443,7 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
409
443
  const envPrefix = (!isRemote && config.agentOrch)
410
444
  ? `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(issueAgentToken(runId))}; `
411
445
  : '';
412
- const resume = provider.resumeCmd(run.sessionId, finalMessage);
446
+ const resume = provider.resumeCmd(run.sessionId, finalMessage, run.model || undefined);
413
447
  const cmd = `cd ${shq(wt)} && ${envPrefix}${pidPrefix}{ ${resume}; }`;
414
448
  if (!isRemote && config.agentOrch) {
415
449
  const orchTimer = startOrchWatch(runId, wt, true);
@@ -688,9 +722,13 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
688
722
  if (plan.length < 1) return { ok: false, detail: 'planner returned no tasks' };
689
723
  }
690
724
 
725
+ // 이 goal 의 형제들을 한 그룹으로 묶는다(보드 밴드). 드라이 리허설도 동일.
726
+ const gIns = await db.insert(taskGroups).values({ kind: 'goal', title: goal.slice(0, 140) }).returning();
727
+ const groupId = gIns[0]!.id;
728
+
691
729
  const created: Array<{ id: number; title: string; runId: number }> = [];
692
730
  for (const t of plan) {
693
- const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt }).returning();
731
+ const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt, groupId }).returning();
694
732
  const task = tIns[0]!;
695
733
  const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
696
734
  const run = rIns[0]!;
@@ -907,11 +945,30 @@ export async function reconcileOrphanRuns(): Promise<number> {
907
945
  return stale.length;
908
946
  }
909
947
 
948
+ /**
949
+ * Close 가드 — 태스크 닫으면 worktree 가 삭제되므로, 아직 살릴 곳 없는 산출물을 경고.
950
+ * 위험 = 정착(done/failed/stopped) ∧ 변경있음 ∧ 미머지 ∧ export·PR 이벤트 없음.
951
+ */
952
+ export async function taskCloseRisk(taskId: number): Promise<Array<{ runId: number; filesChanged: number }>> {
953
+ const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, taskId));
954
+ const atRisk: Array<{ runId: number; filesChanged: number }> = [];
955
+ for (const r of trs) {
956
+ if (!['done', 'failed', 'stopped'].includes(r.status)) continue;
957
+ if (r.filesChanged <= 0) continue;
958
+ const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, r.id));
959
+ if (evs.some((e) => e.kind === 'export' || e.kind === 'pr')) continue; // 산출물이 이미 탈출함
960
+ atRisk.push({ runId: r.id, filesChanged: r.filesChanged });
961
+ }
962
+ return atRisk;
963
+ }
964
+
910
965
  export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail: string }> {
911
966
  const ctx = await loadContext(runId);
912
967
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
913
968
  const run = rr[0];
914
969
  if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
970
+ // worktree 를 지우기 전에 문서 스냅샷(정착 안 하는 워크벤치·수정편집도 포착). best-effort.
971
+ await snapshotRunDocs(runId).catch(() => { /* 스냅샷 실패는 정리를 막지 않음 */ });
915
972
  // 원격에 잔존 에이전트가 있으면 worktree 제거 전에 죽인다(파일 잠금·좀비 방지).
916
973
  if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
917
974
  await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
package/src/providers.ts CHANGED
@@ -24,8 +24,9 @@ export interface Provider {
24
24
  id: string;
25
25
  label: string;
26
26
  bin: string;
27
- launchCmd(prompt: string): string;
28
- resumeCmd(sessionId: string, message: string): string;
27
+ /** model 비었으면 CLI 기본값 사용(플래그 미첨부). */
28
+ launchCmd(prompt: string, model?: string): string;
29
+ resumeCmd(sessionId: string, message: string, model?: string): string;
29
30
  /** null = 저장하지 않는 라인(스트림 잡음) */
30
31
  parseLine(raw: string): ParsedEvent | null;
31
32
  }
@@ -52,13 +53,14 @@ const claudeProvider: Provider = {
52
53
  id: 'claude-code',
53
54
  label: 'Claude Code',
54
55
  get bin() { return config.agent.bin; },
55
- launchCmd(prompt: string): string {
56
+ launchCmd(prompt: string, model?: string): string {
56
57
  return `${config.agent.bin} -p ${shq(prompt)} --output-format stream-json --verbose` +
57
- ` --permission-mode ${config.agent.perm}`;
58
+ ` --permission-mode ${config.agent.perm}` + (model ? ` --model ${shq(model)}` : '');
58
59
  },
59
- resumeCmd(sessionId: string, message: string): string {
60
+ resumeCmd(sessionId: string, message: string, model?: string): string {
60
61
  return `${config.agent.bin} -p --resume ${shq(sessionId)} ${shq(message)}` +
61
- ` --output-format stream-json --verbose --permission-mode ${config.agent.perm}`;
62
+ ` --output-format stream-json --verbose --permission-mode ${config.agent.perm}` +
63
+ (model ? ` --model ${shq(model)}` : '');
62
64
  },
63
65
  parseLine(raw: string): ParsedEvent | null {
64
66
  const s = raw.trim();
@@ -116,12 +118,14 @@ const codexProvider: Provider = {
116
118
  id: 'codex',
117
119
  label: 'Codex',
118
120
  get bin() { return config.codex.bin; },
119
- launchCmd(prompt: string): string {
120
- return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox} ${shq(prompt)}`;
121
+ launchCmd(prompt: string, model?: string): string {
122
+ return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}` +
123
+ (model ? ` -m ${shq(model)}` : '') + ` ${shq(prompt)}`;
121
124
  },
122
- resumeCmd(sessionId: string, message: string): string {
123
- // --sandbox 는 exec 의 플래그(resume 서브커맨드는 안 받음) — 반드시 resume 앞에.
124
- return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox} resume ${shq(sessionId)} ${shq(message)}`;
125
+ resumeCmd(sessionId: string, message: string, model?: string): string {
126
+ // --sandbox·-m 는 exec 의 플래그(resume 서브커맨드는 안 받음) — 반드시 resume 앞에.
127
+ return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}` +
128
+ (model ? ` -m ${shq(model)}` : '') + ` resume ${shq(sessionId)} ${shq(message)}`;
125
129
  },
126
130
  parseLine(raw: string): ParsedEvent | null {
127
131
  const s = raw.trim();
package/src/server.ts CHANGED
@@ -10,10 +10,10 @@ import { eq } from 'drizzle-orm';
10
10
  import { authGate } from './auth';
11
11
  import { config } from './config';
12
12
  import { db } from './db';
13
- import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks } from './db/schema';
13
+ import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
14
14
  import { BOOKMARKLET_JS } from './design';
15
15
  import { runShellOn, shq } from './exec';
16
- import { launchRun, cleanupRun, stopRun, getRunDiff, getRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken } from './orchestrator';
16
+ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
17
17
  import { openTerm } from './term';
18
18
  import { addSink, removeSink, broadcast } from './hub';
19
19
  import { getProvider, listProviders } from './providers';
@@ -33,6 +33,31 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
33
33
  const escH = (x: unknown): string =>
34
34
  String(x ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]!));
35
35
 
36
+ /** 경량 마크다운 → HTML (보드 mdLite 의 서버측 판, 동일 문법). 입력은 먼저 escH. */
37
+ function mdLiteHTML(src: string): string {
38
+ let s = escH(src);
39
+ s = s.replace(/```[a-z]*\n([\s\S]*?)```/g, (_m, c: string) =>
40
+ '<pre style="background:#0e1118;border:1px solid #222835;border-radius:7px;padding:8px 10px;overflow-x:auto">' + c + '</pre>');
41
+ s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
42
+ s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
43
+ s = s.replace(/^# (.+)$/gm, '<h2>$1</h2>');
44
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
45
+ s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
46
+ s = s.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
47
+ s = s.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, '<ul>$1</ul>');
48
+ s = s.split(/\n{2,}/).map((b) => /^<(h2|h3|ul|pre)/.test(b.trim()) ? b : (b.trim() ? '<p>' + b.replace(/\n/g, '<br>') + '</p>' : '')).join('');
49
+ return s;
50
+ }
51
+
52
+ /** 공유 페이지 Documents 섹션 — md 는 mdLiteHTML, html 은 sandbox iframe. */
53
+ function shareDocsHTML(docs: Array<{ path: string; kind: string; content: string }>): string {
54
+ if (!docs.length) return '';
55
+ const body = docs.map((d) => d.kind === 'md'
56
+ ? `<div class="doc"><div class="doc-h">${escH(d.path)}</div><div class="doc-b">${mdLiteHTML(d.content)}</div></div>`
57
+ : `<div class="doc"><div class="doc-h">${escH(d.path)}</div><iframe sandbox="" class="doc-frame" srcdoc="${escH(d.content)}"></iframe></div>`).join('');
58
+ return `<div class="sec">Documents</div>${body}`;
59
+ }
60
+
36
61
  /** 보드 humanize 의 서버측 축약판 — 이벤트 한 줄을 {k, t} 로. null = 잡음. */
37
62
  function shareLine(kind: string, payload: string): { k: string; t: string } | null {
38
63
  if (kind === 'steer') return { k: 'steer', t: '→ ' + payload };
@@ -82,6 +107,7 @@ function sharePageHTML(
82
107
  taskTitle: string,
83
108
  events: Array<{ kind: string; payload: string }>,
84
109
  diff: string,
110
+ docs: Array<{ path: string; kind: string; content: string }> = [],
85
111
  ): string {
86
112
  const lines = events.map((e) => shareLine(e.kind, e.payload)).filter((x): x is { k: string; t: string } => !!x);
87
113
  const sc: Record<string, string> = { done: '#3fb970', merged: '#4ec9b0', failed: '#e5534b', error: '#e5534b', stopped: '#a371f7', running: '#4184e4', open: '#4ec9b0' };
@@ -107,6 +133,15 @@ function sharePageHTML(
107
133
  font-family:ui-monospace,monospace;font-size:11.5px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
108
134
  .f{color:#4ec9b0;font-weight:600}.h{color:#4184e4}.a{color:#3fb970}.d{color:#e5534b}
109
135
  .sum{background:#12151c;border:1px solid #222835;border-radius:10px;padding:12px 14px;color:#8792a2;font-size:13px}
136
+ .doc{margin-bottom:18px}
137
+ .doc-h{font-family:ui-monospace,monospace;font-size:10.5px;color:#4ec9b0;border-bottom:1px solid #222835;padding-bottom:5px;margin-bottom:8px;word-break:break-all}
138
+ .doc-b{font-size:13.5px;line-height:1.65;color:#8792a2}
139
+ .doc-b h1,.doc-b h2{font-size:15px;color:#dee4ec;margin:12px 0 6px}
140
+ .doc-b h3{font-size:13px;color:#dee4ec;margin:10px 0 4px}
141
+ .doc-b ul{margin:4px 0 8px;padding-left:18px}.doc-b li{margin-bottom:3px}
142
+ .doc-b strong{color:#dee4ec}.doc-b p{margin:0 0 8px}
143
+ .doc-b code{font-family:ui-monospace,monospace;font-size:.9em;background:#0e1118;padding:1px 5px;border-radius:4px;color:#4ec9b0}
144
+ .doc-frame{width:100%;height:420px;border:1px solid #222835;border-radius:8px;background:#fff}
110
145
  .ft{margin-top:40px;color:#3d4657;font-size:12px;font-family:ui-monospace,monospace}
111
146
  .ft a{color:#4ec9b0;text-decoration:none}
112
147
  </style></head><body><div class="wrap">
@@ -114,6 +149,7 @@ function sharePageHTML(
114
149
  <h1>${escH(taskTitle)}</h1>
115
150
  <div class="meta">branch ${escH(run.branch || '—')} · ${run.filesChanged} file(s) changed · agent ${escH(run.agent)}</div>
116
151
  ${run.exitSummary ? `<div class="sum">${escH(run.exitSummary)}</div>` : ''}
152
+ ${shareDocsHTML(docs)}
117
153
  <div class="sec">Timeline</div>
118
154
  <div class="tl">${lines.map((l) => `<div><span class="k">${escH(l.k)}</span><span class="t">${escH(l.t.slice(0, 220))}</span></div>`).join('') || '<span style="color:#5c6675">no events</span>'}</div>
119
155
  <div class="sec">Diff</div>
@@ -135,13 +171,14 @@ export async function buildServer(): Promise<FastifyInstance> {
135
171
 
136
172
  // 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
137
173
  app.get('/api/fleet', async () => {
138
- const [ms, rs, ts, rns, evs, dcs] = await Promise.all([
174
+ const [ms, rs, ts, rns, evs, dcs, gs] = await Promise.all([
139
175
  db.select().from(machines),
140
176
  db.select().from(repos),
141
177
  db.select().from(tasks),
142
178
  db.select().from(agentRuns),
143
179
  db.select().from(agentEvents),
144
180
  db.select().from(designCaptures),
181
+ db.select().from(taskGroups),
145
182
  ]);
146
183
  const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
147
184
  for (const e of evs) {
@@ -150,7 +187,7 @@ export async function buildServer(): Promise<FastifyInstance> {
150
187
  byRun.set(e.runId, arr);
151
188
  }
152
189
  return {
153
- machines: ms, repos: rs, tasks: ts, captures: dcs,
190
+ machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
154
191
  runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
155
192
  // 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
156
193
  daemon: { version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath },
@@ -304,6 +341,26 @@ export async function buildServer(): Promise<FastifyInstance> {
304
341
  return { ok: true };
305
342
  });
306
343
 
344
+ // 기본 브랜치 변경 — merge·Sync base·PR 이 향할 대상. develop-flow repo 대응.
345
+ app.patch('/api/repos/:id', async (req, reply) => {
346
+ const id = Number((req.params as { id: string }).id);
347
+ const b = (req.body ?? {}) as { defaultBranch?: string };
348
+ const branch = (b.defaultBranch ?? '').trim();
349
+ if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
350
+ const rp = await db.select().from(repos).where(eq(repos.id, id)).limit(1);
351
+ const repo = rp[0];
352
+ if (!repo) return reply.code(404).send({ error: 'not found' });
353
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
354
+ const m = mr[0];
355
+ if (!m) return reply.code(404).send({ error: 'machine not found' });
356
+ // branch 는 charset 가드 통과(셸 메타문자 없음). 전체 ref 를 인용해 전달.
357
+ const check = await runShellOn(m,
358
+ `git -C ${shq(repo.path)} rev-parse --verify --quiet ${shq('refs/heads/' + branch)} >/dev/null && echo OK`, 10000);
359
+ if (!check.stdout.includes('OK')) return reply.code(400).send({ error: `branch '${branch}' not found in the repository` });
360
+ await db.update(repos).set({ defaultBranch: branch }).where(eq(repos.id, id));
361
+ return { ok: true, defaultBranch: branch };
362
+ });
363
+
307
364
  // 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
308
365
  app.get('/api/browse', async (req) => {
309
366
  const q = (req.query ?? {}) as { path?: string };
@@ -405,7 +462,7 @@ export async function buildServer(): Promise<FastifyInstance> {
405
462
  // N개의 에이전트 run 을 만들고 각자 오케스트레이션 시작(fire-and-forget).
406
463
  app.post('/api/tasks/:id/run', async (req, reply) => {
407
464
  const id = Number((req.params as { id: string }).id);
408
- const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean };
465
+ const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string };
409
466
  const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
410
467
  const task = tr[0];
411
468
  if (!task) return reply.code(404).send({ error: 'task not found' });
@@ -415,10 +472,15 @@ export async function buildServer(): Promise<FastifyInstance> {
415
472
  const count = Math.max(1, Math.min(8, Number(b.count) || 1));
416
473
  // 미지의 값은 기본 프로바이더로 정규화(런처 조작·API 오타 방어)
417
474
  const agent = getProvider(b.agent).id;
475
+ // 모델 지정(선택) — 셸 안전 문자만, 빈값 = CLI 기본
476
+ const model = (b.model ?? '').trim();
477
+ if (model && (model.length > 64 || !/^[\w.\-:/]*$/.test(model))) {
478
+ return reply.code(400).send({ error: 'invalid model name' });
479
+ }
418
480
  const created: Array<typeof agentRuns.$inferSelect> = [];
419
481
  for (let i = 0; i < count; i++) {
420
482
  const ins = await db.insert(agentRuns)
421
- .values({ taskId: id, machineId: rp[0].machineId, agent, status: 'pending' })
483
+ .values({ taskId: id, machineId: rp[0].machineId, agent, model, status: 'pending' })
422
484
  .returning();
423
485
  created.push(ins[0]!);
424
486
  }
@@ -458,8 +520,14 @@ export async function buildServer(): Promise<FastifyInstance> {
458
520
  // 태스크 닫기 — 살아있는 run 중지 후 소속 run 전체 worktree/브랜치 정리.
459
521
  app.post('/api/tasks/:id/close', async (req, reply) => {
460
522
  const id = Number((req.params as { id: string }).id);
523
+ const b = (req.body ?? {}) as { force?: boolean };
461
524
  const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
462
525
  if (!tr[0]) return reply.code(404).send({ error: 'task not found' });
526
+ // Close 가드 — 아직 살릴 곳 없는 산출물(미머지·미export·무PR)이 있으면 확인 요구.
527
+ if (!b.force) {
528
+ const atRisk = await taskCloseRisk(id);
529
+ if (atRisk.length) return reply.code(409).send({ error: 'unmerged output', atRisk });
530
+ }
463
531
  const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
464
532
 
465
533
  let anyStopped = false;
@@ -603,12 +671,13 @@ export async function buildServer(): Promise<FastifyInstance> {
603
671
  return getRunDiff(id);
604
672
  });
605
673
 
606
- // Doc 모드 — 변경된 문서(md/html) 내용째 (렌더 비교용, 읽기 전용)
674
+ // Doc 모드 — 변경된 문서(md/html) 내용째 (렌더 뷰). worktree 라이브 → 스냅샷 폴백.
607
675
  app.get('/api/runs/:id/docs', async (req, reply) => {
608
676
  const id = Number((req.params as { id: string }).id);
609
677
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
610
678
  if (!rr[0]) return reply.code(404).send({ error: 'not found' });
611
- return getRunDocs(id);
679
+ const { docs, source } = await loadRunDocs(id);
680
+ return { ok: true, docs, source };
612
681
  });
613
682
 
614
683
  // ─── 에이전트 셀프 오케스트레이션 (run 별 Bearer 토큰 — authGate 예외, 여기서 자체 검증) ──
@@ -693,7 +762,8 @@ export async function buildServer(): Promise<FastifyInstance> {
693
762
  const task = (await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1))[0];
694
763
  const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
695
764
  const d = await getRunDiff(run.id).catch(() => ({ ok: false, diff: '', stat: '' }));
696
- return reply.type('text/html').send(sharePageHTML(run, task?.title ?? `task ${run.taskId}`, evs, d.ok ? d.diff : ''));
765
+ const { docs } = await loadRunDocs(run.id);
766
+ return reply.type('text/html').send(sharePageHTML(run, task?.title ?? `task ${run.taskId}`, evs, d.ok ? d.diff : '', docs));
697
767
  });
698
768
 
699
769
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.