coxpit 4.1.0 → 4.3.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
@@ -110,7 +110,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
110
110
 
111
111
  ## Status
112
112
 
113
- `v4.1` — fleet, two providers (Claude Code · Codex) with per-launch model choice, compare/merge + AI review + doc mode with settle-time snapshots, 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 that render documents, a close guard, and per-repo base branch override — all shipped and e2e-tested (35 checks). Roadmap: ROADMAP.md.
113
+ `v4.3` — everything in v4.2 plus **the logbook**: the board is Active-first (closed tasks leave the grid), an Archive view lists them as searchable rows, and `/api/fleet` now scopes to active work and caps events per run so it stays fast at hundreds of runs. Opening an archived task still renders its documents from the v4.1 snapshot. All shipped and e2e-tested (38 checks). ⚠️ `/api/fleet` defaults to `view=active` now pass `?view=all` for the previous everything-payload. Roadmap: ROADMAP.md.
114
114
 
115
115
  ## License
116
116
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "4.1.0",
3
+ "version": "4.3.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,33 @@ 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}
155
+ /* ── active/archive view seg + archive list ── */
156
+ .view-seg{padding:2px;gap:2px}
157
+ .view-seg .seg-opt{padding:4px 12px;font-size:11.5px}
158
+ .view-seg .seg-hint{color:var(--faint);margin-left:4px}
159
+ .arch-bar{display:flex;gap:8px;margin-bottom:12px}
160
+ .arch-bar input{flex:1}
161
+ .arch-bar select{flex:0 0 auto}
162
+ .arch-row{display:flex;align-items:center;gap:14px;padding:10px 14px;border:1px solid var(--line);
163
+ border-radius:9px;background:var(--surface);margin-bottom:7px;cursor:pointer;transition:border-color .15s}
164
+ .arch-row:hover{border-color:var(--line-hi)}
165
+ .ar-title{flex:1;font-weight:600;font-size:13px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
166
+ .ar-runs{font-family:var(--mono);font-size:11px;color:var(--muted);flex:0 1 auto;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:34%}
167
+ .ar-repo{font-family:var(--mono);font-size:11px;color:var(--faint);flex:0 0 auto}
168
+ .ar-date{font-family:var(--mono);font-size:11px;color:var(--faint);flex:0 0 auto}
169
+ .arch-empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:30px 0;text-align:center}
170
+ @media (max-width:860px){.ar-repo{display:none}.ar-runs{max-width:44%}}
144
171
  .card{border:1px solid var(--line);border-radius:var(--r-card);background:var(--surface);
145
172
  overflow:hidden;display:flex;flex-direction:column;cursor:pointer;
146
173
  transition:border-color .18s,transform .18s,box-shadow .18s}
@@ -372,6 +399,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
372
399
  <button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
373
400
  <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
374
401
  <span class="daemon-badge" id="daemonBadge" style="display:none"></span>
402
+ <div class="seg view-seg" id="viewSeg">
403
+ <button type="button" class="seg-opt on" data-view="active">Active</button>
404
+ <button type="button" class="seg-opt" data-view="archive">Archive <span id="archN" class="seg-hint"></span></button>
405
+ </div>
375
406
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
376
407
  <button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
377
408
  <div class="machines" id="machines"></div>
@@ -457,6 +488,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
457
488
  <span>No runs yet</span>
458
489
  <span style="color:#3d4657">register a repo, write a task, hit Run fleet</span>
459
490
  </div>
491
+ <div id="archive" hidden>
492
+ <div class="arch-bar">
493
+ <input id="archQ" placeholder="search title…" autocomplete="off" />
494
+ <select id="archRepo"><option value="">all repos</option></select>
495
+ </div>
496
+ <div id="archList"></div>
497
+ <div style="text-align:center;margin-top:14px"><button class="btn-ghost sm" id="archMore" hidden>load 50 more</button></div>
498
+ </div>
460
499
  </main>
461
500
  </div>
462
501
 
@@ -625,6 +664,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
625
664
  <script>
626
665
  const runs = new Map(); // runId -> run object
627
666
  const tasks = new Map(); // taskId -> task
667
+ const groups = new Map(); // groupId -> {id, kind, title}
628
668
  let repos = [], machines = [], captures = [];
629
669
 
630
670
  const $ = (id) => document.getElementById(id);
@@ -828,15 +868,127 @@ function chipHTML(status){
828
868
  return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
829
869
  + '<i></i>'+esc(s)+'</span>';
830
870
  }
871
+ /* 같은 태스크에 run 이 여럿이면 "run i/n" — 같은 제목 카드가 중복이 아니라 시도로 읽히게 */
872
+ function attemptHTML(r){
873
+ const sib = [...runs.values()].filter(x=>x.taskId===r.taskId).sort((a,b)=>a.id-b.id);
874
+ if (sib.length<2) return '';
875
+ const i = sib.findIndex(x=>x.id===r.id)+1;
876
+ return '<span class="attempt" title="attempt within this task">run '+i+'/'+sib.length+'</span>';
877
+ }
831
878
 
879
+ /* ── group fold 상태(기기별, WS 재렌더에도 유지) ── */
880
+ let gfold = new Set();
881
+ try{ gfold = new Set(JSON.parse(localStorage.getItem('coxpit.gfold')||'[]')); }catch{}
882
+ function saveGfold(){ try{ localStorage.setItem('coxpit.gfold', JSON.stringify([...gfold])); }catch{} }
883
+ /* 태스크의 모든 run 이 정착했는가 */
884
+ function taskSettled(taskId){
885
+ const rs = [...runs.values()].filter(r=>r.taskId===taskId);
886
+ return rs.length>0 && rs.every(r=>['done','failed','stopped','merged'].includes(r.status));
887
+ }
888
+ function bandHTML(g, grpRuns){
889
+ const glyph = g.kind==='swarm' ? '↳' : '⌁';
890
+ const title = g.kind==='swarm' ? 'swarm of: '+esc(g.title) : esc(g.title);
891
+ const taskIds = [...new Set(grpRuns.map(r=>r.taskId))];
892
+ const settled = taskIds.filter(taskSettled).length;
893
+ const folded = gfold.has(g.id);
894
+ const cards = grpRuns.slice().sort((a,b)=>a.id-b.id).map(cardHTML).join('');
895
+ return '<div class="gband'+(folded?' folded':'')+'" data-g="'+g.id+'">'
896
+ + '<div class="gband-h"><span class="gband-glyph">'+glyph+'</span>'
897
+ + '<span class="gband-t">'+title+'</span>'
898
+ + '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
899
+ + '<span class="gband-sp"></span>'
900
+ + '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
901
+ + '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
902
+ + '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
903
+ + '<div class="gband-grid">'+cards+'</div></div>';
904
+ }
832
905
  function render(){
906
+ if (boardView==='archive') return; // 아카이브 뷰는 자체 리스트 — grid 안 건드림
833
907
  const list = [...runs.values()].sort((a,b)=>b.id-a.id);
834
908
  $('empty').style.display = list.length ? 'none' : 'flex';
835
- if (!list.length) paintOnboarding();
836
- $('grid').innerHTML = list.map(cardHTML).join('');
909
+ if (!list.length){ paintOnboarding(); $('grid').innerHTML=''; return; }
910
+ // 그룹 파티션 — grouped run 은 밴드로 클러스터, ungrouped 는 뒤에 flat.
911
+ const byGroup = new Map(); const flat = [];
912
+ for (const r of list){
913
+ const t = tasks.get(r.taskId);
914
+ const gid = t && t.groupId!=null && groups.has(t.groupId) ? t.groupId : null;
915
+ if (gid==null) flat.push(r);
916
+ else { if(!byGroup.has(gid)) byGroup.set(gid, []); byGroup.get(gid).push(r); }
917
+ }
918
+ let html = '';
919
+ for (const gid of [...byGroup.keys()].sort((a,b)=>b-a)) html += bandHTML(groups.get(gid), byGroup.get(gid));
920
+ html += flat.map(cardHTML).join('');
921
+ $('grid').innerHTML = html;
837
922
  if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
838
923
  }
839
924
 
925
+ /* ── active / archive view ─────────────────────────────── */
926
+ let boardView = 'active';
927
+ let archOffset = 0, archTotal = 0;
928
+ const ARCH_LIMIT = 50;
929
+ function setView(v){
930
+ boardView = v;
931
+ document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>{
932
+ const on = b.dataset.view===v;
933
+ b.classList.toggle('on', on); b.setAttribute('aria-pressed', on?'true':'false');
934
+ });
935
+ const archive = v==='archive';
936
+ $('archive').hidden = !archive;
937
+ $('grid').style.display = archive ? 'none' : '';
938
+ $('empty').style.display = archive ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
939
+ document.querySelector('.toolbar').style.display = archive ? 'none' : '';
940
+ if (archive){ paintArchRepos(); archFetch(true); }
941
+ else render();
942
+ }
943
+ document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
944
+ function paintArchRepos(){
945
+ const sel = $('archRepo'); const cur = sel.value;
946
+ sel.innerHTML = '<option value="">all repos</option>' + repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('');
947
+ sel.value = cur;
948
+ }
949
+ function archRowHTML(row){
950
+ const runs = row.runs.map(r=>'r'+r.id+' '+r.status).join(' · ');
951
+ const d = row.closedAt ? new Date(row.closedAt*1000) : null;
952
+ const date = d ? (String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')) : '';
953
+ const grp = row.groupTitle ? '<span class="ar-repo" title="group">⌁ '+esc(row.groupTitle.slice(0,24))+'</span>' : '';
954
+ return '<div class="arch-row" data-t="'+row.taskId+'">'
955
+ + '<span class="ar-title">'+esc(row.title)+'</span>'
956
+ + '<span class="ar-runs">'+esc(runs)+'</span>'+grp
957
+ + '<span class="ar-repo">'+esc(row.repoName)+'</span>'
958
+ + '<span class="ar-date">'+date+'</span></div>';
959
+ }
960
+ async function archFetch(reset){
961
+ if (reset){ archOffset = 0; $('archList').innerHTML = '<div class="arch-empty">loading…</div>'; }
962
+ const q = encodeURIComponent($('archQ').value.trim());
963
+ const repo = $('archRepo').value;
964
+ const url = '/api/archive?offset='+archOffset+'&limit='+ARCH_LIMIT
965
+ + (q?'&q='+q:'') + (repo?'&repo='+repo:'');
966
+ try{
967
+ const j = await fetch(url).then(x=>x.json());
968
+ archTotal = j.total||0;
969
+ const rows = (j.rows||[]).map(archRowHTML).join('');
970
+ if (reset) $('archList').innerHTML = rows || '<div class="arch-empty">no closed tasks match</div>';
971
+ else $('archList').insertAdjacentHTML('beforeend', rows);
972
+ archOffset += (j.rows||[]).length;
973
+ $('archMore').hidden = archOffset >= archTotal || !(j.rows||[]).length;
974
+ }catch{ $('archList').innerHTML = '<div class="arch-empty">archive failed</div>'; }
975
+ }
976
+ $('archMore').addEventListener('click', ()=>archFetch(false));
977
+ let archTimer = null;
978
+ $('archQ').addEventListener('input', ()=>{ clearTimeout(archTimer); archTimer = setTimeout(()=>archFetch(true), 300); });
979
+ $('archRepo').addEventListener('change', ()=>archFetch(true));
980
+ $('archList').addEventListener('click', async (e)=>{
981
+ const row = e.target.closest('.arch-row'); if(!row) return;
982
+ const tid = Number(row.dataset.t);
983
+ try{
984
+ const j = await fetch('/api/tasks/'+tid).then(x=>x.json());
985
+ if (!j.task || !(j.runs||[]).length){ toast('task has no runs', 'error'); return; }
986
+ tasks.set(j.task.id, j.task);
987
+ j.runs.forEach(rn => { if(!runs.has(rn.id)) runs.set(rn.id, { ...rn, events: [] }); });
988
+ openModal(j.runs.map(r=>r.id).sort((a,b)=>a-b)[0]);
989
+ }catch{ toast('could not open task', 'error'); }
990
+ });
991
+
840
992
  /* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
841
993
  let readiness = null, probing = false;
842
994
  async function probeFirstMachine(){
@@ -898,6 +1050,7 @@ function cardHTML(r){
898
1050
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
899
1051
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
900
1052
  + '<span>'+esc(r.agent||'')+'</span>'
1053
+ + attemptHTML(r)
901
1054
  + (r.model ? '<span title="model">⚙ '+esc(r.model)+'</span>' : '')
902
1055
  + (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
903
1056
  + (r.sessionId && ['done','failed','stopped'].includes(r.status)
@@ -916,10 +1069,13 @@ function upsertRun(patch){
916
1069
  }
917
1070
 
918
1071
  async function hydrate(){
919
- const r = await fetch('/api/fleet').then(x=>x.json());
1072
+ const r = await fetch('/api/fleet?view=active').then(x=>x.json());
920
1073
  machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
1074
+ if (r.counts){ const n = r.counts.closedTasks||0; $('archN').textContent = n ? '· '+n : ''; }
921
1075
  tasks.clear();
922
1076
  (r.tasks||[]).forEach(t => tasks.set(t.id, t));
1077
+ groups.clear();
1078
+ (r.groups||[]).forEach(g => groups.set(g.id, g));
923
1079
  runs.clear();
924
1080
  (r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
925
1081
  if (r.daemon) {
@@ -1033,22 +1189,27 @@ function connectWS(){
1033
1189
  ws.onclose = ()=>{ $('wsdot').classList.remove('on'); $('wstext').textContent='reconnecting'; setTimeout(connectWS,1500); };
1034
1190
  ws.onmessage = (m)=>{
1035
1191
  let ev; try{ ev = JSON.parse(m.data); }catch{ return; }
1192
+ // 아카이브 뷰는 정적 스냅샷 — 라이브 갱신으로 뒤엎지 않는다.
1193
+ if (boardView==='archive' && (ev.type==='run'||ev.type==='event'||ev.type==='task')) return;
1036
1194
  if (ev.type==='run'){
1037
- const known = runs.has(ev.runId ?? ev.id);
1195
+ const rid = ev.runId ?? ev.id;
1196
+ const known = runs.has(rid);
1197
+ // 아카이브된(맵에 없는) run 의 뒤늦은 echo 는 무시 — 신규는 항상 pending 으로 먼저 온다.
1198
+ if (!known && ev.status && ev.status!=='pending') return;
1038
1199
  upsertRun(ev);
1039
1200
  if (!known && ev.taskId==null){ hydrate(); return; }
1040
1201
  if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
1041
- render(); flash(ev.runId ?? ev.id); paintModal();
1042
- if (openRunId===(ev.runId??ev.id) && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
1202
+ render(); flash(rid); paintModal();
1203
+ if (openRunId===rid && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
1043
1204
  if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
1044
1205
  if (['done','failed','error','stopped'].includes(ev.status)) notifySettleUI(ev);
1045
1206
  } else if (ev.type==='event'){
1046
- const r = runs.get(ev.runId); if(!r){ hydrate(); return; }
1207
+ const r = runs.get(ev.runId); if(!r) return; // 아카이브 run 의 이벤트는 무시(맵에 없으면 resurrect X)
1047
1208
  r.events = r.events||[]; r.events.push({ kind:ev.kind, payload:ev.payload });
1048
1209
  render(); flash(ev.runId); paintModal();
1049
1210
  } else if (ev.type==='task'){
1050
1211
  const t = tasks.get(ev.taskId);
1051
- if (t){ t.status = ev.status; render(); paintModal(); } else { hydrate(); }
1212
+ if (t){ if (ev.status!=null) t.status = ev.status; if (ev.groupId!=null) t.groupId = ev.groupId; render(); paintModal(); } else { hydrate(); }
1052
1213
  } else if (ev.type==='capture'){
1053
1214
  captures.push(ev.capture); paintSidebar();
1054
1215
  }
@@ -1139,11 +1300,16 @@ $('mDiffWrap').addEventListener('click', (e)=>{
1139
1300
  $('steerInput').value = (f ? f+': ' : '') + '"'+line+'" — ';
1140
1301
  $('steerInput').focus();
1141
1302
  });
1142
- function openModal(id){
1303
+ async function openModal(id){
1143
1304
  openRunId = id;
1144
1305
  if (docMode){ docMode=false; $('mDocsTgl').textContent='Rendered'; $('mDiffWrap').innerHTML='<pre class="diff" id="mDiff">loading…</pre>'; }
1145
1306
  $('mDocsTgl').hidden = true;
1146
1307
  paintModal(); $('overlay').classList.add('open'); loadDiff();
1308
+ // fleet 는 run 당 최근 40 이벤트만 내림 — 모달은 전체 타임라인을 다시 가져온다.
1309
+ try{
1310
+ const j = await fetch('/api/runs/'+id).then(x=>x.json());
1311
+ if (j.run){ const r = runs.get(id); if (r){ r.events = j.events||[]; if (openRunId===id) paintModal(); } }
1312
+ }catch{}
1147
1313
  }
1148
1314
  function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
1149
1315
  /* ── select mode (integrate) ── */
@@ -1174,6 +1340,51 @@ $('selGo').addEventListener('click', async ()=>{
1174
1340
  } else toast('integrate: '+(j.error||res.status), 'error');
1175
1341
  });
1176
1342
 
1343
+ /* ── 그룹 밴드 액션 (fold / Select runs / Close group) ── */
1344
+ $('grid').addEventListener('click', async (e)=>{
1345
+ const fold = e.target.closest('[data-gfold]');
1346
+ if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
1347
+ const gsel = e.target.closest('[data-gsel]');
1348
+ if (gsel){
1349
+ const g=Number(gsel.dataset.gsel);
1350
+ if (!selectMode) setSelectMode(true);
1351
+ const tids = new Set([...tasks.values()].filter(t=>t.groupId===g).map(t=>t.id));
1352
+ for (const r of [...runs.values()].sort((a,b)=>a.id-b.id)){
1353
+ if (!tids.has(r.taskId)) continue;
1354
+ const ok = ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
1355
+ if (ok && !selected.has(r.id)){ selected.add(r.id); selOrder.push(r.id); }
1356
+ }
1357
+ $('selCnt').textContent = selected.size + ' selected';
1358
+ render();
1359
+ if (!selected.size) toast('no settled runs with changes in this group yet', 'error');
1360
+ return;
1361
+ }
1362
+ const gclose = e.target.closest('[data-gclose]');
1363
+ if (gclose){ await closeGroup(Number(gclose.dataset.gclose)); return; }
1364
+ });
1365
+ async function closeGroup(g){
1366
+ const tids = [...tasks.values()].filter(t=>t.groupId===g && t.status!=='closed').map(t=>t.id);
1367
+ if (!tids.length){ toast('nothing open in this group', 'ok'); return; }
1368
+ const grp = groups.get(g)||{};
1369
+ const yes = await confirmUI('Close this whole group?',
1370
+ { sub: (grp.title||'group')+' — stops and cleans every worktree/branch of '+tids.length+' task(s).', danger:true, okLabel:'Close group' });
1371
+ if (!yes) return;
1372
+ const closeOne = (id,force)=>fetch('/api/tasks/'+id+'/close',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({force})});
1373
+ const risky = [];
1374
+ for (const id of tids){
1375
+ const res = await closeOne(id,false);
1376
+ if (res.status===409){ const j=await res.json().catch(()=>({})); (j.atRisk||[]).forEach(a=>risky.push(a)); }
1377
+ }
1378
+ if (risky.length){
1379
+ const list = risky.map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
1380
+ const ok = await confirmUI('Close and delete unmerged output?',
1381
+ { danger:true, sub: list+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
1382
+ if (!ok){ await hydrate(); return; }
1383
+ for (const id of tids) await closeOne(id,true);
1384
+ }
1385
+ toast('closed '+tids.length+' task'+(tids.length>1?'s':'')+' in the group', 'ok');
1386
+ await hydrate();
1387
+ }
1177
1388
  $('grid').addEventListener('click',(e)=>{
1178
1389
  const card = e.target.closest('.card'); if(!card) return;
1179
1390
  const id = Number(card.id.replace('card-',''));
package/src/db/index.ts CHANGED
@@ -83,6 +83,12 @@ export async function ensureSchema(): Promise<void> {
83
83
  content TEXT NOT NULL DEFAULT '',
84
84
  created_at INTEGER DEFAULT (unixepoch())
85
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
+ );
86
92
  `);
87
93
  // 기존 DB 마이그레이션(멱등)
88
94
  try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
@@ -90,4 +96,6 @@ export async function ensureSchema(): Promise<void> {
90
96
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
91
97
  try { await client.execute('ALTER TABLE tasks ADD COLUMN parent_run_id INTEGER'); } catch { /* exists */ }
92
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 */ }
100
+ try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
93
101
  }
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,8 @@ 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
+ closedAt: integer('closed_at', { mode: 'timestamp' }), // 닫힌 시각(아카이브 정렬·표시)
44
54
  createdAt: integer('created_at', { mode: 'timestamp' }),
45
55
  });
46
56
 
@@ -8,7 +8,7 @@ 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, docSnapshots } 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';
@@ -134,9 +134,17 @@ 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[] = [];
@@ -714,9 +722,13 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
714
722
  if (plan.length < 1) return { ok: false, detail: 'planner returned no tasks' };
715
723
  }
716
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
+
717
729
  const created: Array<{ id: number; title: string; runId: number }> = [];
718
730
  for (const t of plan) {
719
- 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();
720
732
  const task = tIns[0]!;
721
733
  const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
722
734
  const run = rIns[0]!;
package/src/server.ts CHANGED
@@ -6,11 +6,11 @@ import { createRequire } from 'node:module';
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import Fastify, { type FastifyInstance } from 'fastify';
8
8
  import websocket from '@fastify/websocket';
9
- import { eq } from 'drizzle-orm';
9
+ import { eq, inArray, and, like, desc } 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
16
  import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
@@ -170,15 +170,27 @@ export async function buildServer(): Promise<FastifyInstance> {
170
170
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
171
171
 
172
172
  // 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
173
- app.get('/api/fleet', async () => {
174
- const [ms, rs, ts, rns, evs, dcs] = await Promise.all([
173
+ // 기본 view=active: 닫힌 태스크·그 run·이벤트 전량을 내리지 않는다(수백 run 시 페이로드 폭발 방지).
174
+ // 이벤트는 활성 run 최근 40개만(카드는 8, 모달은 전량 refetch). view=all 구버전 전량.
175
+ const EVENT_CAP = 40;
176
+ app.get('/api/fleet', async (req) => {
177
+ const view = ((req.query ?? {}) as { view?: string }).view === 'all' ? 'all' : 'active';
178
+ const [ms, rs, allTasks, dcs, gs] = await Promise.all([
175
179
  db.select().from(machines),
176
180
  db.select().from(repos),
177
181
  db.select().from(tasks),
178
- db.select().from(agentRuns),
179
- db.select().from(agentEvents),
180
182
  db.select().from(designCaptures),
183
+ db.select().from(taskGroups),
181
184
  ]);
185
+ const activeTasks = allTasks.filter((t) => t.status !== 'closed');
186
+ const closedCount = allTasks.length - activeTasks.length;
187
+ const ts = view === 'all' ? allTasks : activeTasks;
188
+ const taskIds = new Set(ts.map((t) => t.id));
189
+ const allRuns = await db.select().from(agentRuns);
190
+ const rns = view === 'all' ? allRuns : allRuns.filter((r) => taskIds.has(r.taskId));
191
+ const runIds = rns.map((r) => r.id);
192
+ // 이벤트는 대상 run 으로 스코프한 뒤 로드(전량 로드 후 슬라이스 = 고치려는 그 버그).
193
+ const evs = runIds.length ? await db.select().from(agentEvents).where(inArray(agentEvents.runId, runIds)) : [];
182
194
  const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
183
195
  for (const e of evs) {
184
196
  const arr = byRun.get(e.runId) ?? [];
@@ -186,14 +198,45 @@ export async function buildServer(): Promise<FastifyInstance> {
186
198
  byRun.set(e.runId, arr);
187
199
  }
188
200
  return {
189
- machines: ms, repos: rs, tasks: ts, captures: dcs,
190
- runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
201
+ machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
202
+ runs: rns.map((r) => ({ ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP) })),
203
+ counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
191
204
  // 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
192
205
  daemon: { version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath },
193
206
  providers: listProviders(),
194
207
  };
195
208
  });
196
209
 
210
+ // 아카이브 — 닫힌 태스크 목록(최신순, 페이지네이션·필터). 카드가 아니라 한 줄 행.
211
+ app.get('/api/archive', async (req) => {
212
+ const q = (req.query ?? {}) as { offset?: string; limit?: string; q?: string; repo?: string; status?: string };
213
+ const offset = Math.max(0, Number(q.offset) || 0);
214
+ const limit = Math.min(100, Math.max(1, Number(q.limit) || 50));
215
+ const conds = [eq(tasks.status, 'closed')];
216
+ if (q.q) conds.push(like(tasks.title, `%${q.q}%`));
217
+ if (q.repo) conds.push(eq(tasks.repoId, Number(q.repo)));
218
+ const where = conds.length === 1 ? conds[0] : and(...conds);
219
+ let closed = await db.select().from(tasks).where(where).orderBy(desc(tasks.id));
220
+ // status 필터 = 태스크의 run 중 그 상태를 가진 게 있어야 함(런 로드 후 필터)
221
+ const repoName = new Map((await db.select().from(repos)).map((r) => [r.id, r.name]));
222
+ const grpTitle = new Map((await db.select().from(taskGroups)).map((g) => [g.id, g.title]));
223
+ const total0 = closed.length;
224
+ const page = closed.slice(offset, offset + limit);
225
+ const rows = [];
226
+ for (const t of page) {
227
+ const rs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
228
+ if (q.status && !rs.some((r) => r.status === q.status)) continue;
229
+ rows.push({
230
+ taskId: t.id, title: t.title, repoName: repoName.get(t.repoId) ?? '?',
231
+ groupTitle: t.groupId != null ? grpTitle.get(t.groupId) ?? null : null,
232
+ closedAt: t.closedAt ? Math.floor(t.closedAt.getTime() / 1000) : (t.createdAt ? Math.floor(t.createdAt.getTime() / 1000) : 0),
233
+ runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, agent: r.agent, model: r.model })),
234
+ });
235
+ }
236
+ // status 필터가 있으면 total 은 근사(페이지 내 필터) — UI 는 rows 로만 판단하니 total0 유지.
237
+ return { total: total0, rows };
238
+ });
239
+
197
240
  // ─── 머신 레지스트리 ────────────────────────────────────────────
198
241
  app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
199
242
 
@@ -539,7 +582,7 @@ export async function buildServer(): Promise<FastifyInstance> {
539
582
  const cleanups = [];
540
583
  for (const r of trs) cleanups.push({ runId: r.id, ...(await cleanupRun(r.id)) });
541
584
 
542
- await db.update(tasks).set({ status: 'closed' }).where(eq(tasks.id, id));
585
+ await db.update(tasks).set({ status: 'closed', closedAt: new Date() }).where(eq(tasks.id, id));
543
586
  broadcast({ type: 'task', taskId: id, status: 'closed' });
544
587
  return { ok: true, taskId: id, cleanups };
545
588
  });