coxpit 4.5.0 → 4.6.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
@@ -114,7 +114,15 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
114
114
 
115
115
  ## Status
116
116
 
117
- `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 documentsfrom 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.
117
+ `v4.5` — **greenfield + remote access**. Point Coxpit at an empty folder and a fleet scaffolds a brand-new project across N agents on an empty initial commit compare the foundations, keep the best (existing folders are never touched). Remote access detects your Tailscale and puts the board on `https://<machine>.<tailnet>.ts.net` in one click (Funnel for public, behind a warning; copy-paste Cloudflare/Caddy recipes otherwise) Coxpit drives the tool, never hosts a relay. Builds on v4.3's Active-first board + Archive. All shipped and e2e-tested (45 checks). Roadmap: ROADMAP.md.
118
+
119
+ ## Contributing
120
+
121
+ Issues and PRs are welcome. Start with **[CONTRIBUTING.md](CONTRIBUTING.md)** for
122
+ dev setup (`COXPIT_AUTH_DISABLED=1 npm run dev`), the verify gate (`npm run
123
+ typecheck` + `bash test/e2e.sh`), and the house rules. Please read the
124
+ [non-goals](ROADMAP.md#non-goals) before proposing a feature, and report security
125
+ issues privately per **[SECURITY.md](SECURITY.md)** (Coxpit exposes shells).
118
126
 
119
127
  ## License
120
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "4.5.0",
3
+ "version": "4.6.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
@@ -152,6 +152,56 @@ export const BOARD_HTML = /* html */ `<!doctype html>
152
152
  .gband.folded .gband-grid{display:none}
153
153
  .gband-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
154
154
  .attempt{color:var(--brand);opacity:.8}
155
+
156
+ /* ── goal workroom (v4.6) — 한 goal 을 여는 단일 방 ── */
157
+ .gband-open{margin-left:2px}
158
+ .room{width:min(920px,96vw);max-height:90vh;background:var(--surface);border:1px solid var(--line);
159
+ border-radius:16px;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--shadow)}
160
+ .rh{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line);
161
+ background:linear-gradient(180deg,var(--brand-dim),transparent)}
162
+ .rh .rh-glyph{color:var(--brand);font-family:var(--mono);font-size:15px}
163
+ .rh .rh-t{font-weight:600;font-size:14px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
164
+ .rh .rh-n{font-family:var(--mono);font-size:11px;color:var(--faint)}
165
+ .rh .x{background:transparent;border:none;color:var(--muted);font-size:19px;cursor:pointer;
166
+ padding:2px 9px;border-radius:6px;line-height:1}
167
+ .rh .x:hover{color:var(--ink);background:var(--surface2)}
168
+ .chips{display:flex;flex-wrap:wrap;gap:7px;padding:12px 18px;border-bottom:1px solid var(--line)}
169
+ .room .chip{cursor:pointer;transition:border-color .15s,filter .15s}
170
+ .room .chip:hover{filter:brightness(1.15)}
171
+ .room .chip .cid{font-weight:600}
172
+ .room .chip .cmeta{opacity:.7;text-transform:none;letter-spacing:0}
173
+ .room .chip.new{border-style:dashed}
174
+ .body{overflow:auto;flex:1;min-height:120px}
175
+ .feed{display:flex;flex-direction:column;gap:6px;padding:12px 18px;font-family:var(--mono);font-size:11.5px}
176
+ .feed .fl{display:flex;gap:9px;align-items:baseline;min-width:0}
177
+ .feed .fl .who{min-width:52px;flex:none;opacity:.9}
178
+ .feed .fl .ft{color:var(--muted);word-break:break-word;flex:1}
179
+ .feed .fl.mark .who{color:var(--brand)}
180
+ .feed .empty-note{color:var(--faint)}
181
+ .conv{display:flex;flex-direction:column;gap:12px;padding:12px 18px;font-size:12.5px}
182
+ .conv .empty-note{color:var(--faint);font-family:var(--mono);font-size:11.5px}
183
+ .conv .turn{display:flex;flex-direction:column;gap:3px}
184
+ .conv .turn .role{font-family:var(--mono);font-size:9.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em}
185
+ .conv .turn.you .role{color:var(--muted)}
186
+ .conv .turn.coord .role{color:var(--brand)}
187
+ .conv .turn .msg{color:var(--ink);white-space:pre-wrap;word-break:break-word;line-height:1.55}
188
+ .conv .turn.you .msg{color:var(--muted)}
189
+ .conv .turn .ro-note{font-family:var(--mono);font-size:9.5px;color:var(--faint);letter-spacing:.02em}
190
+ .send-only{display:flex;gap:8px;align-items:center}
191
+ .send-only .ro-hint{flex:1;font-family:var(--mono);font-size:10px;color:var(--faint);letter-spacing:.02em}
192
+ .composer{border-top:1px solid var(--line);padding:12px 18px;display:flex;flex-direction:column;gap:8px}
193
+ .composer .comp-seg{flex:0 0 auto}
194
+ .composer .comp-hint{font-family:var(--mono);font-size:10.5px;color:var(--faint);letter-spacing:.02em}
195
+ .composer textarea{min-height:52px}
196
+ .verbs{display:flex;gap:8px;align-items:center}
197
+ .verbs .grow{flex:1}
198
+ .conv-menu{position:relative}
199
+ .conv-pop{position:absolute;bottom:calc(100% + 6px);right:0;z-index:5;background:var(--surface2);
200
+ border:1px solid var(--line-hi);border-radius:9px;box-shadow:var(--shadow);padding:4px;min-width:200px;display:none}
201
+ .conv-menu.open .conv-pop{display:block}
202
+ .conv-pop button{display:block;width:100%;text-align:left;background:transparent;border:none;
203
+ color:var(--muted);font-size:12.5px;padding:8px 10px;border-radius:6px;cursor:pointer}
204
+ .conv-pop button:hover{background:var(--surface);color:var(--ink)}
155
205
  /* ── active/archive view seg + archive list ── */
156
206
  .view-seg{padding:2px;gap:2px}
157
207
  .view-seg .seg-opt{padding:4px 12px;font-size:11.5px}
@@ -589,6 +639,46 @@ export const BOARD_HTML = /* html */ `<!doctype html>
589
639
  </div>
590
640
  </div>
591
641
 
642
+ <div class="overlay" id="groupRoomOverlay">
643
+ <div class="room">
644
+ <div class="rh">
645
+ <span class="rh-glyph">⌒</span>
646
+ <span class="rh-t" id="roomTitle">Goal</span>
647
+ <span class="rh-n" id="roomCount"></span>
648
+ <button class="x" id="roomClose" aria-label="close">×</button>
649
+ </div>
650
+ <div class="chips" id="roomChips"></div>
651
+ <div class="body">
652
+ <div class="feed" id="roomFeed"></div>
653
+ <div class="conv" id="roomConv" hidden></div>
654
+ </div>
655
+ <div class="composer">
656
+ <div class="seg comp-seg" id="roomSeg" role="group" aria-label="workroom mode">
657
+ <button type="button" class="seg-opt on" data-rmode="work">✎ Work</button>
658
+ <button type="button" class="seg-opt" data-rmode="ask">? Ask</button>
659
+ </div>
660
+ <div class="comp-hint" id="roomHint"></div>
661
+ <textarea id="roomInput" placeholder="New attempt prompt, or a broadcast to the settled runs…"></textarea>
662
+ <div class="verbs" id="roomVerbs">
663
+ <button type="button" class="btn-ghost sm" id="roomNew">+ New attempt</button>
664
+ <button type="button" class="btn-ghost sm" id="roomBroadcast">→ Broadcast</button>
665
+ <span class="grow"></span>
666
+ <div class="conv-menu" id="roomConvMenu">
667
+ <button type="button" class="btn sm" id="roomConverge">Converge ▾</button>
668
+ <div class="conv-pop">
669
+ <button type="button" id="roomReview">Review group</button>
670
+ <button type="button" id="roomIntegrate">Select runs to integrate</button>
671
+ </div>
672
+ </div>
673
+ </div>
674
+ <div class="send-only" id="roomSend" hidden>
675
+ <span class="ro-hint">read-only — switch to Work to act</span>
676
+ <button type="button" class="btn sm" id="roomAsk">Ask</button>
677
+ </div>
678
+ </div>
679
+ </div>
680
+ </div>
681
+
592
682
  <div class="overlay" id="cmpOverlay">
593
683
  <div class="modal wide">
594
684
  <div class="modal-h">
@@ -636,9 +726,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
636
726
  </div>
637
727
  <div class="brw-list" id="brwList"></div>
638
728
  <div class="brw-f">
639
- <span class="hint">folders with a <span style="color:var(--brand)">git</span> badge are repositories hit Register</span>
729
+ <span class="hint"><span style="color:var(--brand)">git</span> badge = repo (Register) · empty folder = Start here</span>
730
+ <button class="btn-ghost sm" id="brwNewFolder">+ New folder here</button>
640
731
  <button class="btn sm" id="brwRegHere" style="display:none">Register this folder</button>
641
732
  </div>
733
+ <form class="brw-f" id="brwNewForm" hidden>
734
+ <input id="brwNewName" placeholder="new-project-folder-name" style="flex:1" />
735
+ <button class="btn sm" type="submit" id="brwNewGo">Create &amp; start</button>
736
+ </form>
642
737
  </div>
643
738
  </div>
644
739
 
@@ -809,7 +904,8 @@ async function brwGo(p){
809
904
  (d.error ? '<div class="brw-row"><span class="nm" style="color:var(--s-failed)">'+esc(d.error)+'</span></div>' : '')
810
905
  + (d.dirs.map(x =>
811
906
  '<div class="brw-row" data-n="'+esc(x.name)+'"><span class="ico">▸</span><span class="nm">'+esc(x.name)+'</span>'
812
- + (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>' : '')
907
+ + (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>'
908
+ : x.isEmpty ? '<button type="button" class="btn-ghost sm" data-start="'+esc(x.name)+'">Start here</button>' : '')
813
909
  + '</div>').join('')
814
910
  || '<div class="brw-row"><span class="nm" style="color:var(--faint)">no folders here</span></div>');
815
911
  }
@@ -834,18 +930,42 @@ async function brwRegister(fullPath){
834
930
  $('repoBrowse').addEventListener('click', ()=>{
835
931
  const m = machines.find(x=>x.slug===$('repoMachine').value);
836
932
  if (m && m.address){ toast('remote machine — type the path manually for now', 'error'); return; }
933
+ $('brwNewForm').hidden = true; $('brwNewName').value = '';
837
934
  $('brwOverlay').classList.add('open');
838
935
  brwGo(brwCur || '');
839
936
  });
840
937
  $('brwList').addEventListener('click',(e)=>{
841
938
  const reg = e.target.closest('button[data-reg]');
842
939
  if (reg){ brwRegister(brwCur.replace(/\\/$/,'')+'/'+reg.dataset.reg); return; }
940
+ const start = e.target.closest('button[data-start]');
941
+ if (start){ brwStartHere(brwCur.replace(/\\/$/,'')+'/'+start.dataset.start); return; }
843
942
  const row = e.target.closest('.brw-row[data-n]');
844
943
  if (row) brwGo(brwCur.replace(/\\/$/,'')+'/'+row.dataset.n);
845
944
  });
846
945
  $('brwUp').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
847
946
  $('brwHome').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
848
947
  $('brwRegHere').addEventListener('click',()=>brwRegister(brwCur));
948
+ /* 빈 폴더 행의 Start here — 클릭한 폴더에 greenfield 씨앗 심고 등록 */
949
+ async function brwStartHere(fullPath){
950
+ if (await createNewProject(fullPath)) $('brwOverlay').classList.remove('open');
951
+ }
952
+ /* + New folder here — 현재 탐색 위치에 새 폴더명만 받아 생성+시작 (절대경로 타이핑 불필요) */
953
+ $('brwNewFolder').addEventListener('click', ()=>{
954
+ const f = $('brwNewForm'); f.hidden = !f.hidden; if(!f.hidden) $('brwNewName').focus();
955
+ });
956
+ $('brwNewForm').addEventListener('submit', async (e)=>{
957
+ e.preventDefault();
958
+ const name = $('brwNewName').value.trim();
959
+ if (!name){ toast('enter a folder name', 'error'); return; }
960
+ if (name.includes('/')){ toast('folder name cannot contain /', 'error'); return; }
961
+ const go = $('brwNewGo'); go.disabled = true; go.textContent = 'Creating…';
962
+ try{
963
+ if (await createNewProject(brwCur.replace(/\\/$/,'')+'/'+name)){
964
+ $('brwNewName').value=''; $('brwNewForm').hidden=true;
965
+ $('brwOverlay').classList.remove('open');
966
+ }
967
+ } finally { go.disabled=false; go.textContent='Create & start'; }
968
+ });
849
969
  $('brwClose').addEventListener('click',()=>$('brwOverlay').classList.remove('open'));
850
970
  $('brwOverlay').addEventListener('click',(e)=>{ if(e.target===$('brwOverlay')) $('brwOverlay').classList.remove('open'); });
851
971
 
@@ -985,6 +1105,7 @@ function bandHTML(g, grpRuns){
985
1105
  + '<span class="gband-t">'+title+'</span>'
986
1106
  + '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
987
1107
  + '<span class="gband-sp"></span>'
1108
+ + '<button class="btn-ghost sm gband-open" data-groom="'+g.id+'">⌒ Open workroom</button>'
988
1109
  + '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
989
1110
  + '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
990
1111
  + '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
@@ -1285,6 +1406,7 @@ function connectWS(){
1285
1406
  let ev; try{ ev = JSON.parse(m.data); }catch{ return; }
1286
1407
  // 아카이브 뷰는 정적 스냅샷 — 라이브 갱신으로 뒤엎지 않는다.
1287
1408
  if (boardView==='archive' && (ev.type==='run'||ev.type==='event'||ev.type==='task')) return;
1409
+ roomOnWS(ev); // 워크룸이 열려있으면 그룹 소속 run/event 를 방에 반영
1288
1410
  if (ev.type==='run'){
1289
1411
  const rid = ev.runId ?? ev.id;
1290
1412
  const known = runs.has(rid);
@@ -1436,6 +1558,8 @@ $('selGo').addEventListener('click', async ()=>{
1436
1558
 
1437
1559
  /* ── 그룹 밴드 액션 (fold / Select runs / Close group) ── */
1438
1560
  $('grid').addEventListener('click', async (e)=>{
1561
+ const groom = e.target.closest('[data-groom]');
1562
+ if (groom){ openRoom(Number(groom.dataset.groom)); return; }
1439
1563
  const fold = e.target.closest('[data-gfold]');
1440
1564
  if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
1441
1565
  const gsel = e.target.closest('[data-gsel]');
@@ -1496,7 +1620,7 @@ $('grid').addEventListener('click',(e)=>{
1496
1620
  });
1497
1621
  $('mClose').addEventListener('click', closeModal);
1498
1622
  $('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
1499
- document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('npOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); $('remoteOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
1623
+ document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('npOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); $('remoteOverlay').classList.remove('open'); closeRoom(); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
1500
1624
  $('mRefreshDiff').addEventListener('click', loadDiff);
1501
1625
  $('mExport').addEventListener('click', ()=>{
1502
1626
  if (openRunId==null) return;
@@ -1698,6 +1822,254 @@ $('mCompare').addEventListener('click', ()=>{
1698
1822
  closeModal(); openCompare(r.taskId);
1699
1823
  });
1700
1824
 
1825
+ /* ── goal workroom (v4.6 L1) — 한 goal 을 여는 단일 방 ── */
1826
+ let roomGroupId = null; // 열려있는 그룹 id (null = 닫힘)
1827
+ const roomRunSet = new Set(); // 이 그룹에 속한 runId — WS 필터용
1828
+ let roomRuns = []; // 마지막 aggregate 의 runs (chips/scope hint)
1829
+ const roomExtra = []; // goal-level 마커(spawn/broadcast) — 피드에 섞음
1830
+ const roomTurns = []; // Ask 대화 턴 [{role:'you'|'coord', text}] — 읽기 전용
1831
+ let roomMode = 'work'; // 'work' | 'ask' — 방 composer 모드(스틸모드와 무관, 방 전용)
1832
+ /* run → 상태색 (chip / who) */
1833
+ function roomStatusColor(s){ return statusColor(s); }
1834
+ function roomChipHTML(r){
1835
+ const s = r.status||'pending';
1836
+ const meta = ['done','failed','stopped','merged'].includes(s)
1837
+ ? (r.filesChanged? '+'+r.filesChanged+'f' : 'no changes')
1838
+ : (r.agent||'agent')+(s==='running'?' · editing…':'');
1839
+ return '<span class="chip'+(s==='running'?' running':'')+(r.isNew?' new':'')+'" data-roomrun="'+r.runId+'"'
1840
+ + ' style="color:'+roomStatusColor(s)+';border-color:'+roomStatusColor(s)+'">'
1841
+ + '<i></i><span class="cid">r'+r.runId+'</span> <span class="cmeta">'+esc(meta)+'</span></span>';
1842
+ }
1843
+ function roomRenderChips(){
1844
+ $('roomChips').innerHTML = roomRuns.length
1845
+ ? roomRuns.map(roomChipHTML).join('')
1846
+ : '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">no runs yet</span>';
1847
+ }
1848
+ function roomFeedLine(who, whoStatus, text, mark){
1849
+ return '<div class="fl'+(mark?' mark':'')+'"><span class="who" style="color:'
1850
+ + (mark?'var(--brand)':roomStatusColor(whoStatus))+'">'+esc(who)+'</span>'
1851
+ + '<span class="ft">'+esc(text)+'</span></div>';
1852
+ }
1853
+ function roomRenderFeed(events){
1854
+ // run 이벤트 + goal-level 마커를 시간(id/순서)대로. 간단히: 이벤트 먼저, 마커는 append.
1855
+ const statusOf = (rid)=>{ const r = roomRuns.find(x=>x.runId===rid); return r? r.status : 'pending'; };
1856
+ const rows = [];
1857
+ for (const e of (events||[])){
1858
+ const h = humanize({ kind:e.kind, payload:e.payload });
1859
+ if (!h) continue;
1860
+ const list = Array.isArray(h) ? h : [h];
1861
+ for (const x of list) rows.push(roomFeedLine('r'+e.runId, statusOf(e.runId), x.k+' · '+x.t, false));
1862
+ }
1863
+ for (const m of roomExtra){
1864
+ rows.push(roomFeedLine(m.who, 'pending', m.text, true));
1865
+ }
1866
+ $('roomFeed').innerHTML = rows.length ? rows.join('')
1867
+ : '<div class="empty-note">no activity yet — spawn an attempt or wait for runs to report</div>';
1868
+ $('roomFeed').scrollTop = $('roomFeed').scrollHeight;
1869
+ }
1870
+ /* Ask 대화(읽기 전용) — you / coordinator 턴. 코디네이터 답 아래 read-only 노트. */
1871
+ function roomRenderConv(){
1872
+ if (!roomTurns.length){
1873
+ $('roomConv').innerHTML = '<div class="empty-note">read-only coordinator — ask what these attempts are doing. it never acts.</div>';
1874
+ return;
1875
+ }
1876
+ $('roomConv').innerHTML = roomTurns.map(t=>{
1877
+ const role = t.role==='you' ? 'you' : 'coordinator';
1878
+ const note = t.role==='coord'
1879
+ ? '<span class="ro-note">read-only — switch to Work to act</span>' : '';
1880
+ return '<div class="turn '+t.role+'"><span class="role">'+esc(role)+'</span>'
1881
+ + '<span class="msg">'+esc(t.text)+'</span>'+note+'</div>';
1882
+ }).join('');
1883
+ $('roomConv').scrollTop = $('roomConv').scrollHeight;
1884
+ }
1885
+ function roomRenderHeaderAndHint(){
1886
+ const g = groups.get(roomGroupId) || {};
1887
+ const done = roomRuns.filter(r=>['done','failed','stopped','merged'].includes(r.status)).length;
1888
+ const running = roomRuns.filter(r=>r.live).length;
1889
+ $('roomTitle').textContent = 'Goal: '+(g.title||'');
1890
+ $('roomCount').textContent = '· '+roomRuns.length+' run'+(roomRuns.length===1?'':'s')
1891
+ +' · '+done+' done · '+running+' running';
1892
+ roomRenderHint();
1893
+ }
1894
+ /* Work=정직한 steer 스코프, Ask=읽기 전용 안내. 모드에 따라 힌트/뷰/composer 전환. */
1895
+ function roomRenderHint(){
1896
+ if (roomMode==='ask'){
1897
+ $('roomHint').textContent = 'read-only coordinator · reads run diffs, never acts';
1898
+ return;
1899
+ }
1900
+ const running = roomRuns.filter(r=>r.live).length;
1901
+ const steerable = roomRuns.filter(r=>r.steerable).length;
1902
+ $('roomHint').textContent = steerable+' steerable now · '+running+' still running';
1903
+ }
1904
+ /* feed↔conv, verbs↔send-only 를 roomMode 로 스왑. */
1905
+ function roomApplyMode(){
1906
+ const ask = roomMode==='ask';
1907
+ $('roomFeed').hidden = ask;
1908
+ $('roomConv').hidden = !ask;
1909
+ $('roomVerbs').hidden = ask;
1910
+ $('roomSend').hidden = !ask;
1911
+ $('roomInput').placeholder = ask
1912
+ ? 'Ask the coordinator about these attempts (read-only)…'
1913
+ : 'New attempt prompt, or a broadcast to the settled runs…';
1914
+ document.querySelectorAll('#roomSeg .seg-opt').forEach(x=>x.classList.toggle('on', x.dataset.rmode===roomMode));
1915
+ roomRenderHint();
1916
+ if (ask) roomRenderConv();
1917
+ }
1918
+ async function openRoom(groupId){
1919
+ roomGroupId = groupId;
1920
+ roomExtra.length = 0; roomRunSet.clear(); roomRuns = []; roomTurns.length = 0;
1921
+ roomMode = 'work';
1922
+ $('roomInput').value = '';
1923
+ $('roomChips').innerHTML = '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">loading…</span>';
1924
+ $('roomFeed').innerHTML = '';
1925
+ $('roomConv').innerHTML = '';
1926
+ $('roomTitle').textContent = 'Goal';
1927
+ $('roomCount').textContent = '';
1928
+ $('roomHint').textContent = '';
1929
+ roomApplyMode();
1930
+ $('groupRoomOverlay').classList.add('open');
1931
+ await roomLoad();
1932
+ }
1933
+ async function roomLoad(){
1934
+ if (roomGroupId==null) return;
1935
+ let j;
1936
+ try{ j = await fetch('/api/groups/'+roomGroupId).then(x=>x.json()); }
1937
+ catch{ toast('workroom load failed', 'error'); return; }
1938
+ if (!j || !j.group){ toast('group not found', 'error'); return; }
1939
+ if (!groups.has(j.group.id)) groups.set(j.group.id, j.group);
1940
+ roomRuns = j.runs||[];
1941
+ roomRunSet.clear();
1942
+ roomRuns.forEach(r=>roomRunSet.add(r.runId));
1943
+ roomRenderChips();
1944
+ roomRenderHeaderAndHint();
1945
+ roomRenderFeed(j.events||[]);
1946
+ }
1947
+ function closeRoom(){ roomGroupId = null; $('groupRoomOverlay').classList.remove('open'); }
1948
+ /* chip 클릭 → 그 run 을 기존 run 모달로 */
1949
+ $('roomChips').addEventListener('click',(e)=>{
1950
+ const c = e.target.closest('[data-roomrun]'); if(!c) return;
1951
+ const rid = Number(c.dataset.roomrun);
1952
+ closeRoom();
1953
+ if (runs.has(rid)) openModal(rid);
1954
+ else { // 아카이브 등 맵에 없는 run — 태스크로 하이드레이트 후 연다
1955
+ fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run){ runs.set(rid, {...d.run, events:d.events||[]}); openModal(rid); } }).catch(()=>{});
1956
+ }
1957
+ });
1958
+ $('roomClose').addEventListener('click', closeRoom);
1959
+ $('groupRoomOverlay').addEventListener('click',(e)=>{ if(e.target===$('groupRoomOverlay')) closeRoom(); });
1960
+ /* + New attempt — 같은 그룹에 새 시도 발사 (현재 dry/real 토글 반영) */
1961
+ $('roomNew').addEventListener('click', async ()=>{
1962
+ if (roomGroupId==null) return;
1963
+ const prompt = $('roomInput').value.trim();
1964
+ if (!prompt){ toast('write a prompt for the new attempt', 'error'); return; }
1965
+ const real = $('taskReal').checked;
1966
+ const btn = $('roomNew'); btn.disabled = true;
1967
+ try{
1968
+ const res = await fetch('/api/groups/'+roomGroupId+'/spawn',{method:'POST',
1969
+ headers:{'content-type':'application/json'}, body:JSON.stringify({prompt, real})});
1970
+ const j = await res.json().catch(()=>({}));
1971
+ if (res.ok){
1972
+ $('roomInput').value='';
1973
+ (j.tasks||[]).forEach(t=>{ roomExtra.push({ who:'goal +', text:'new attempt r'+t.runId+' — '+prompt.slice(0,60) }); roomRunSet.add(t.runId); });
1974
+ toast((j.tasks||[]).length+' attempt(s) launched'+(real?'':' (dry)'), 'ok');
1975
+ roomRenderFeed([]); await roomLoad(); hydrate();
1976
+ } else toast('spawn: '+(j.error||res.status), 'error');
1977
+ } finally { btn.disabled = false; }
1978
+ });
1979
+ /* → Broadcast — 그룹의 정착 steerable run 전부에 후속 지시(라이브/드라이는 정직하게 skip) */
1980
+ $('roomBroadcast').addEventListener('click', async ()=>{
1981
+ if (roomGroupId==null) return;
1982
+ const message = $('roomInput').value.trim();
1983
+ if (!message){ toast('write a broadcast message', 'error'); return; }
1984
+ const btn = $('roomBroadcast'); btn.disabled = true;
1985
+ try{
1986
+ const res = await fetch('/api/groups/'+roomGroupId+'/steer',{method:'POST',
1987
+ headers:{'content-type':'application/json'}, body:JSON.stringify({message, mode:roomMode})});
1988
+ const j = await res.json().catch(()=>({}));
1989
+ if (res.ok){
1990
+ $('roomInput').value='';
1991
+ roomExtra.push({ who:'goal →', text:message.slice(0,80)+' ('+j.detail+')' });
1992
+ toast('broadcast: '+j.detail, (j.skipped&&j.skipped.length)?undefined:'ok');
1993
+ roomRenderFeed([]); await roomLoad();
1994
+ } else toast('broadcast: '+(j.error||res.status), 'error');
1995
+ } finally { btn.disabled = false; }
1996
+ });
1997
+ /* Converge ▾ — 기존 review/integrate 를 노출(재구현 아님) */
1998
+ $('roomConverge').addEventListener('click',(e)=>{ e.stopPropagation(); $('roomConvMenu').classList.toggle('open'); });
1999
+ document.addEventListener('click',()=>$('roomConvMenu').classList.remove('open'));
2000
+ $('roomReview').addEventListener('click', ()=>{
2001
+ $('roomConvMenu').classList.remove('open');
2002
+ // 그룹 태스크가 여럿이면 각 태스크가 자체 비교/리뷰를 가짐 — 첫 태스크의 Compare 로 진입.
2003
+ const first = roomRuns[0];
2004
+ if (!first){ toast('no runs to review', 'error'); return; }
2005
+ closeRoom(); openCompare(first.taskId);
2006
+ });
2007
+ $('roomIntegrate').addEventListener('click', ()=>{
2008
+ $('roomConvMenu').classList.remove('open');
2009
+ const g = roomGroupId; closeRoom();
2010
+ if (!selectMode) setSelectMode(true);
2011
+ // 그룹의 정착·변경 run 전부 사전 선택(기존 밴드 Select runs 와 동일 규칙)
2012
+ const tids = new Set([...tasks.values()].filter(t=>t.groupId===g).map(t=>t.id));
2013
+ for (const r of [...runs.values()].sort((a,b)=>a.id-b.id)){
2014
+ if (!tids.has(r.taskId)) continue;
2015
+ const ok = ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
2016
+ if (ok && !selected.has(r.id)){ selected.add(r.id); selOrder.push(r.id); }
2017
+ }
2018
+ $('selCnt').textContent = selected.size + ' selected';
2019
+ render();
2020
+ if (!selected.size) toast('no settled runs with changes in this group yet', 'error');
2021
+ });
2022
+ /* Work | Ask 세그 — Work=행동(spawn/broadcast/converge), Ask=읽기 전용 코디네이터. 방 전용 모드. */
2023
+ document.querySelectorAll('#roomSeg .seg-opt').forEach(b=>{
2024
+ b.addEventListener('click', ()=>{
2025
+ roomMode = b.dataset.rmode;
2026
+ roomApplyMode();
2027
+ });
2028
+ });
2029
+ /* ? Ask — 그룹 스코프 읽기 전용 코디네이터. 절대 발사·steer·쓰기 없음 (POST /ask). */
2030
+ $('roomAsk').addEventListener('click', async ()=>{
2031
+ if (roomGroupId==null) return;
2032
+ const message = $('roomInput').value.trim();
2033
+ if (!message){ toast('write a question for the coordinator', 'error'); return; }
2034
+ const real = $('taskReal').checked;
2035
+ const btn = $('roomAsk'); btn.disabled = true;
2036
+ roomTurns.push({ role:'you', text:message });
2037
+ roomRenderConv();
2038
+ $('roomInput').value='';
2039
+ try{
2040
+ const res = await fetch('/api/groups/'+roomGroupId+'/ask',{method:'POST',
2041
+ headers:{'content-type':'application/json'}, body:JSON.stringify({message, real})});
2042
+ const j = await res.json().catch(()=>({}));
2043
+ if (res.ok && j.answer){
2044
+ roomTurns.push({ role:'coord', text:j.answer });
2045
+ } else {
2046
+ roomTurns.push({ role:'coord', text:'(ask failed — '+(j.error||res.status)+')' });
2047
+ toast('ask: '+(j.error||res.status), 'error');
2048
+ }
2049
+ roomRenderConv();
2050
+ } catch { toast('ask request failed', 'error'); }
2051
+ finally { btn.disabled = false; }
2052
+ });
2053
+ /* WS 라이브 — 그룹 소속 run/event 만 방에 반영 (connectWS 가 호출) */
2054
+ function roomOnWS(ev){
2055
+ if (roomGroupId==null) return;
2056
+ if (ev.type==='run'){
2057
+ const rid = ev.runId ?? ev.id;
2058
+ if (!roomRunSet.has(rid)) return; // 이 방 소속만
2059
+ roomLoad(); // 상태·steerable 재계산은 aggregate 로 (정확)
2060
+ } else if (ev.type==='event'){
2061
+ if (!roomRunSet.has(ev.runId)) return;
2062
+ const h = humanize({ kind:ev.kind, payload:ev.payload });
2063
+ if (h){
2064
+ const r = roomRuns.find(x=>x.runId===ev.runId);
2065
+ const list = Array.isArray(h) ? h : [h];
2066
+ const frag = list.map(x=>roomFeedLine('r'+ev.runId, r?r.status:'pending', x.k+' · '+x.t, false)).join('');
2067
+ $('roomFeed').insertAdjacentHTML('beforeend', frag);
2068
+ $('roomFeed').scrollTop = $('roomFeed').scrollHeight;
2069
+ }
2070
+ }
2071
+ }
2072
+
1701
2073
  /* ── terminal — 초기크기 접속·자동 재연결(백오프)·unicode11·CJK 폰트 ── */
1702
2074
  let termWS = null, termObj = null, fitAddon = null, termResizeObs = null;
1703
2075
  let termRunId = null, termClosing = false, termRetry = 0, termRetryTimer = null;
package/src/db/index.ts CHANGED
@@ -98,4 +98,5 @@ export async function ensureSchema(): Promise<void> {
98
98
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN model TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
99
99
  try { await client.execute('ALTER TABLE tasks ADD COLUMN group_id INTEGER'); } catch { /* exists */ }
100
100
  try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
101
+ try { await client.execute("ALTER TABLE task_groups ADD COLUMN coord_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
101
102
  }
package/src/db/schema.ts CHANGED
@@ -37,6 +37,7 @@ export const taskGroups = sqliteTable('task_groups', {
37
37
  id: integer('id').primaryKey({ autoIncrement: true }),
38
38
  kind: text('kind').notNull().default('goal'), // 'goal' | 'swarm'
39
39
  title: text('title').notNull(),
40
+ coordSessionId: text('coord_session_id').notNull().default(''), // L2 — 읽기전용 Ask 코디네이터의 재개 세션(--resume 키)
40
41
  createdAt: integer('created_at', { mode: 'timestamp' }),
41
42
  });
42
43
 
@@ -59,6 +59,11 @@ export function resolveAgentToken(token: string): number | null {
59
59
  return agentTokens.get(token) ?? null;
60
60
  }
61
61
 
62
+ /** run 이 지금 살아 있는가(자식 프로세스 보유). aggregate 뷰의 live/steerable 판정용. */
63
+ export function isRunLive(runId: number): boolean {
64
+ return liveChildren.has(runId);
65
+ }
66
+
62
67
  /** 에이전트 프롬프트에 붙는 능력 고지 — 독립 하위작업을 병렬 서브런으로 뺄 수 있다.
63
68
  * 파일 기반: 기본 권한(claude acceptEdits · codex workspace-write)이 네트워크를 막아도
64
69
  * 파일 쓰기는 되므로, spawn 요청을 워크트리의 .coxpit/spawn.json 으로 받는다. */
@@ -668,6 +673,25 @@ export async function openWorkbench(repoId: number, title: string): Promise<{
668
673
  return { ok: true, detail: 'workbench open', taskId: task.id, runId };
669
674
  }
670
675
 
676
+ /**
677
+ * 그룹에 속한 태스크 1개를 만들고 run 1개를 발사한다(공용 helper).
678
+ * planFanout(plan 형제) 과 /api/groups/:id/spawn(+New attempt) 이 공유하는
679
+ * "태스크 생성(groupId 각인) → run 생성 → 브로드캐스트 → launchRun" 몸통.
680
+ */
681
+ export async function launchGroupTask(
682
+ groupId: number, repoId: number, title: string, prompt: string, real: boolean,
683
+ ): Promise<{ id: number; title: string; runId: number }> {
684
+ const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
685
+ const machineId = rp[0]!.machineId;
686
+ const tIns = await db.insert(tasks).values({ repoId, title: title.slice(0, 140), prompt, groupId }).returning();
687
+ const task = tIns[0]!;
688
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId, agent: 'claude-code', status: 'pending' }).returning();
689
+ const run = rIns[0]!;
690
+ broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
691
+ void launchRun(run.id, real);
692
+ return { id: task.id, title: task.title, runId: run.id };
693
+ }
694
+
671
695
  /**
672
696
  * Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
673
697
  * 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
@@ -728,13 +752,7 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
728
752
 
729
753
  const created: Array<{ id: number; title: string; runId: number }> = [];
730
754
  for (const t of plan) {
731
- const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt, groupId }).returning();
732
- const task = tIns[0]!;
733
- const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
734
- const run = rIns[0]!;
735
- broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
736
- void launchRun(run.id, real);
737
- created.push({ id: task.id, title: t.title, runId: run.id });
755
+ created.push(await launchGroupTask(groupId, repoId, t.title, t.prompt, real));
738
756
  }
739
757
  return { ok: true, detail: `${created.length} task(s) launched`, tasks: created };
740
758
  }
@@ -795,6 +813,117 @@ export async function reviewTask(taskId: number, real: boolean): Promise<{ ok: b
795
813
  }
796
814
  }
797
815
 
816
+ /**
817
+ * Ask 코디네이터 — 읽기 전용, 재개 가능한 그룹 스코프 Q&A.
818
+ * 그룹의 형제 run 들에서 {title,status,agent,filesChanged} + 정착 run 의 bounded diff 요약을
819
+ * 모아 컨텍스트로 주고, 질문에 답만 한다. worktree 를 열지도, run 을 발사하지도, 파일을 쓰지도,
820
+ * steer 하지도 않는다 — getRunDiff(읽기)와 텍스트 반환뿐. (reviewTask 를 대화형·재개형으로 변형)
821
+ *
822
+ * 세션: 첫 호출은 1회용(`bin -p <prompt> --output-format json`)으로 session_id 를 캡처해
823
+ * task_groups.coord_session_id 에 저장. 이후 호출은 provider.resumeCmd 로 진짜 대화를 잇는다.
824
+ * 드라이(real=false / COXPIT_AGENT_REAL off)는 결정적 mock + 합성 세션 id 반환(크레딧 0, e2e 안전).
825
+ */
826
+ export async function askGroupCoordinator(
827
+ groupId: number, message: string, real: boolean,
828
+ ): Promise<{ ok: boolean; detail: string; answer?: string }> {
829
+ const gr = await db.select().from(taskGroups).where(eq(taskGroups.id, groupId)).limit(1);
830
+ const group = gr[0];
831
+ if (!group) return { ok: false, detail: 'group not found' };
832
+ const msg = message.trim();
833
+ if (!msg) return { ok: false, detail: 'empty message' };
834
+
835
+ // 그룹의 형제 run 들(태스크 조인) — bounded 컨텍스트만.
836
+ const gts = await db.select().from(tasks).where(eq(tasks.groupId, groupId));
837
+ const rows: Array<{ run: typeof agentRuns.$inferSelect; task: typeof tasks.$inferSelect }> = [];
838
+ for (const t of gts) {
839
+ const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
840
+ for (const run of trs) rows.push({ run, task: t });
841
+ }
842
+ rows.sort((a, b) => a.run.id - b.run.id);
843
+
844
+ // repo/machine 은 그룹의 아무 태스크에서 상속(형제는 같은 repo 공유). read-only 는 repo 본체에서.
845
+ const anyTask = rows[0]?.task ?? gts[0];
846
+ let machine: MachineTarget | null = null;
847
+ let repoPath = '';
848
+ if (anyTask) {
849
+ const rp = await db.select().from(repos).where(eq(repos.id, anyTask.repoId)).limit(1);
850
+ const repo = rp[0];
851
+ if (repo) {
852
+ repoPath = repo.path;
853
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
854
+ const m = mr[0];
855
+ if (m) machine = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
856
+ }
857
+ }
858
+
859
+ // bounded 컨텍스트: run 요약 + 정착 run 의 diff 요약(각 ~1500자, 합 ~12k 상한).
860
+ const SETTLED = ['done', 'failed', 'stopped', 'merged'];
861
+ const sections: string[] = [];
862
+ let budget = 12000;
863
+ for (const { run, task } of rows) {
864
+ let sec = `### run r${run.id} — ${task.title.slice(0, 80)}\n`
865
+ + `status: ${run.status} · agent: ${run.agent} · files changed: ${run.filesChanged}`;
866
+ if (SETTLED.includes(run.status) && budget > 0) {
867
+ const d = await getRunDiff(run.id).catch(() => ({ ok: false, diff: '', stat: '' }));
868
+ const raw = (d.ok ? (d.diff || d.stat || '(no changes)') : '(worktree gone — diff unavailable)');
869
+ const cap = Math.min(1500, Math.max(0, budget));
870
+ const clip = raw.slice(0, cap);
871
+ budget -= clip.length;
872
+ sec += `\nDiff summary:\n\`\`\`diff\n${clip}\n\`\`\``;
873
+ }
874
+ sections.push(sec);
875
+ }
876
+
877
+ const preamble =
878
+ `You are a READ-ONLY coordinator for a goal with these parallel attempts. `
879
+ + `Answer the question about their state and diffs. Do NOT propose running commands, `
880
+ + `do NOT modify files, do NOT suggest editing anything — you can only observe and explain.`;
881
+ const context = `Goal: ${group.title}\n\n${sections.join('\n\n') || '(no runs yet)'}`;
882
+
883
+ // 드라이: 결정적 mock 답변 + 합성 세션 id(첫 호출 시 저장, 이후 재사용). e2e 크레딧 0.
884
+ if (!real) {
885
+ const done = rows.filter((r) => SETTLED.includes(r.run.status)).length;
886
+ const running = rows.filter((r) => r.run.status === 'running').length;
887
+ const answer = `[dry coordinator] ${rows.length} attempt(s) · ${done} settled · ${running} running.\n`
888
+ + `Q: ${msg.slice(0, 120)}\n`
889
+ + `(rehearsal answer — read-only; run with Real agent for a substantive reply.)`;
890
+ if (!group.coordSessionId) {
891
+ const synth = 'dry-coord-' + randomBytes(6).toString('hex');
892
+ await db.update(taskGroups).set({ coordSessionId: synth }).where(eq(taskGroups.id, groupId));
893
+ }
894
+ return { ok: true, detail: 'rehearsal answer', answer };
895
+ }
896
+
897
+ if (!machine || !repoPath) return { ok: false, detail: 'group has no repo to run the coordinator from' };
898
+
899
+ // 첫 호출 = 1회용(session_id 캡처), 이후 = resume(대화 이어가기). 둘 다 파일 미변경 read-only.
900
+ const provider = getProvider('claude-code');
901
+ let cmd: string;
902
+ const resuming = !!group.coordSessionId;
903
+ if (resuming) {
904
+ // resume 은 stream-json 을 내지만 여기선 마지막 result 만 필요 — json 으로 강제 재래핑 불가하므로
905
+ // 세션 id 는 이미 있으니 resumeCmd(대화)로 잇고, 최종 텍스트는 result 라인에서 추출한다.
906
+ cmd = `cd ${shq(repoPath)} && ${provider.resumeCmd(group.coordSessionId, `${preamble}\n\n${context}\n\nQuestion: ${msg}`)} --output-format json`;
907
+ } else {
908
+ const oneShot = `${preamble}\n\n${context}\n\nQuestion: ${msg}`;
909
+ cmd = `cd ${shq(repoPath)} && ${config.agent.bin} -p ${shq(oneShot)} --output-format json`;
910
+ }
911
+ const r = await runShellOn(machine, cmd, 300000);
912
+ if (!r.ok) return { ok: false, detail: 'coordinator failed: ' + (r.stderr || r.stdout).trim().slice(0, 300) };
913
+ try {
914
+ const envelope = JSON.parse(r.stdout.trim()) as { result?: string; session_id?: string };
915
+ const answer = (envelope.result ?? '').trim();
916
+ if (!answer) throw new Error('empty answer');
917
+ // 첫 호출에서만 세션 각인(이후엔 유지). resume 응답도 같은 세션이라 덮어써도 무해.
918
+ if (typeof envelope.session_id === 'string' && envelope.session_id && envelope.session_id !== group.coordSessionId) {
919
+ await db.update(taskGroups).set({ coordSessionId: envelope.session_id }).where(eq(taskGroups.id, groupId));
920
+ }
921
+ return { ok: true, detail: resuming ? 'resumed' : 'answered', answer };
922
+ } catch (e) {
923
+ return { ok: false, detail: 'could not parse coordinator answer: ' + String(e).slice(0, 200) };
924
+ }
925
+ }
926
+
798
927
  export interface IntegrateResult {
799
928
  runId: number;
800
929
  status: 'merged' | 'conflict' | 'skipped';
package/src/server.ts CHANGED
@@ -13,7 +13,7 @@ import { db } from './db';
13
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, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
16
+ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator } from './orchestrator';
17
17
  import { openTerm } from './term';
18
18
  import { addSink, removeSink, broadcast } from './hub';
19
19
  import { getProvider, listProviders } from './providers';
@@ -481,13 +481,19 @@ export async function buildServer(): Promise<FastifyInstance> {
481
481
  const q = (req.query ?? {}) as { path?: string };
482
482
  const start = q.path && q.path.startsWith('/') ? q.path : homedir();
483
483
  const p = presolve(start);
484
- let dirs: Array<{ name: string; isRepo: boolean }> = [];
484
+ let dirs: Array<{ name: string; isRepo: boolean; isEmpty: boolean }> = [];
485
485
  let error: string | undefined;
486
486
  try {
487
487
  const entries = await readdir(p, { withFileTypes: true });
488
488
  for (const e of entries) {
489
489
  if (!e.isDirectory() || e.name.startsWith('.')) continue;
490
- dirs.push({ name: e.name, isRepo: existsSync(pjoin(p, e.name, '.git')) });
490
+ const full = pjoin(p, e.name);
491
+ const isRepo = existsSync(pjoin(full, '.git'));
492
+ // 빈 폴더면 greenfield "Start here" 대상 — 서버 EMPTYDIR 판정(ls -A)과 동일하게
493
+ // 모든 엔트리(닷파일 포함) 0개일 때만. repo 폴더는 Register 로 다루므로 계산 생략.
494
+ let isEmpty = false;
495
+ if (!isRepo) { try { isEmpty = (await readdir(full)).length === 0; } catch { isEmpty = false; } }
496
+ dirs.push({ name: e.name, isRepo, isEmpty });
491
497
  if (dirs.length >= 300) break;
492
498
  }
493
499
  dirs.sort((a, b) => (b.isRepo ? 1 : 0) - (a.isRepo ? 1 : 0) || a.name.localeCompare(b.name));
@@ -755,6 +761,113 @@ export async function buildServer(): Promise<FastifyInstance> {
755
761
  return reply.code(202).send(res);
756
762
  });
757
763
 
764
+ // ─── Goal workroom (v4.6 L1) — 한 그룹(goal/swarm)을 한 방에서 몰기 ────────
765
+ // steerable = 정착(done/failed/stopped) + sessionId 보유 + worktree 살아있음.
766
+ // (steerRun 전제 그대로 — 라이브 run 은 steer 불가, 드라이런은 세션 없음.)
767
+ const groupRuns = async (groupId: number): Promise<{
768
+ group: typeof taskGroups.$inferSelect;
769
+ rows: Array<{ run: typeof agentRuns.$inferSelect; task: typeof tasks.$inferSelect }>;
770
+ } | null> => {
771
+ const gr = await db.select().from(taskGroups).where(eq(taskGroups.id, groupId)).limit(1);
772
+ if (!gr[0]) return null;
773
+ const gts = await db.select().from(tasks).where(eq(tasks.groupId, groupId));
774
+ const rows: Array<{ run: typeof agentRuns.$inferSelect; task: typeof tasks.$inferSelect }> = [];
775
+ for (const t of gts) {
776
+ const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
777
+ for (const run of trs) rows.push({ run, task: t });
778
+ }
779
+ rows.sort((a, b) => a.run.id - b.run.id);
780
+ return { group: gr[0], rows };
781
+ };
782
+ const isSteerable = (r: typeof agentRuns.$inferSelect): boolean =>
783
+ !isRunLive(r.id) && ['done', 'failed', 'stopped'].includes(r.status) && !!r.sessionId && !!r.worktreePath;
784
+
785
+ // B1 — 애그리게이트 뷰(방의 chips + 최근 타임라인). 페이로드 다이어트: 최근 200 이벤트만.
786
+ app.get('/api/groups/:id', async (req, reply) => {
787
+ const id = Number((req.params as { id: string }).id);
788
+ const g = await groupRuns(id);
789
+ if (!g) return reply.code(404).send({ error: 'group not found' });
790
+ const runs = g.rows.map(({ run, task }) => ({
791
+ runId: run.id, taskId: task.id, title: task.title, status: run.status,
792
+ agent: run.agent, model: run.model, branch: run.branch, filesChanged: run.filesChanged,
793
+ live: isRunLive(run.id), steerable: isSteerable(run),
794
+ }));
795
+ const runIds = g.rows.map((x) => x.run.id);
796
+ // 이벤트: 그룹 run 전체에서 최근 200개(id 순, 오래된 것 먼저 — 방 피드는 append-only).
797
+ const evs = runIds.length
798
+ ? (await db.select().from(agentEvents).where(inArray(agentEvents.runId, runIds)))
799
+ .sort((a, b) => a.id - b.id).slice(-200)
800
+ : [];
801
+ return {
802
+ group: { id: g.group.id, kind: g.group.kind, title: g.group.title, coordSessionId: g.group.coordSessionId },
803
+ runs,
804
+ events: evs.map((e) => ({ runId: e.runId, kind: e.kind, payload: e.payload, ts: e.ts })),
805
+ };
806
+ });
807
+
808
+ // B2 — "+ New attempt": 그룹에 새 시도(들)를 발사. repo 는 그룹의 기존 태스크에서 상속.
809
+ app.post('/api/groups/:id/spawn', async (req, reply) => {
810
+ const id = Number((req.params as { id: string }).id);
811
+ const b = (req.body ?? {}) as { title?: string; prompt?: string; count?: number; real?: boolean };
812
+ const prompt = (b.prompt ?? '').trim();
813
+ if (!prompt) return reply.code(400).send({ error: 'prompt required' });
814
+ const g = await groupRuns(id);
815
+ if (!g) return reply.code(404).send({ error: 'group not found' });
816
+ if (!g.rows[0]) return reply.code(409).send({ error: 'group has no tasks to inherit a repo from' });
817
+ const repoId = g.rows[0].task.repoId; // 형제는 같은 repo 를 공유
818
+ const title = (b.title ?? '').trim() || prompt.slice(0, 40);
819
+ const count = Math.max(1, Math.min(5, Number(b.count) || 1));
820
+ const created: Array<{ id: number; title: string; runId: number }> = [];
821
+ for (let i = 0; i < count; i++) {
822
+ created.push(await launchGroupTask(id, repoId, title, prompt, b.real === true));
823
+ }
824
+ return reply.code(201).send({ ok: true, tasks: created });
825
+ });
826
+
827
+ // B3 — "→ Broadcast": 그룹의 정착·steerable run 전부에 후속 지시. 라이브/드라이는 정직하게 skip.
828
+ app.post('/api/groups/:id/steer', async (req, reply) => {
829
+ const id = Number((req.params as { id: string }).id);
830
+ const b = (req.body ?? {}) as { message?: string; mode?: string };
831
+ const message = (b.message ?? '').trim();
832
+ if (!message) return reply.code(400).send({ error: 'message required' });
833
+ const g = await groupRuns(id);
834
+ if (!g) return reply.code(404).send({ error: 'group not found' });
835
+ const mode = b.mode === 'ask' ? 'ask' : 'work';
836
+ let steered = 0;
837
+ const skipped: Array<{ runId: number; reason: string }> = [];
838
+ let running = 0, noSession = 0;
839
+ // 그룹 규모가 작아 순차 for 루프로 충분(폭주 fan-out 없음).
840
+ for (const { run } of g.rows) {
841
+ const res = await steerRun(run.id, message, mode);
842
+ if (res.ok) { steered++; continue; }
843
+ skipped.push({ runId: run.id, reason: res.detail });
844
+ if (/still running/.test(res.detail)) running++;
845
+ else if (/no agent session/.test(res.detail)) noSession++;
846
+ }
847
+ const parts = [`${steered} steered`];
848
+ if (running) parts.push(`${running} still running (steer after they settle)`);
849
+ if (noSession) parts.push(`${noSession} no session`);
850
+ const otherSkips = skipped.length - running - noSession;
851
+ if (otherSkips > 0) parts.push(`${otherSkips} skipped`);
852
+ return { ok: true, steered, skipped, detail: parts.join(' · ') };
853
+ });
854
+ // NOTE(v4.6): queuing a broadcast to apply to running runs once they settle is
855
+ // explicitly out of scope for L1 — running runs are reported as skipped, not queued.
856
+
857
+ // B4 (L2) — "? Ask": 그룹 스코프 읽기 전용 코디네이터. run 발사·steer·파일 쓰기 절대 없음.
858
+ // askGroupCoordinator 는 getRunDiff(읽기)와 텍스트 반환뿐 — launch/steer/write 경로를 부르지 않는다.
859
+ app.post('/api/groups/:id/ask', async (req, reply) => {
860
+ const id = Number((req.params as { id: string }).id);
861
+ const b = (req.body ?? {}) as { message?: string; real?: boolean };
862
+ const message = (b.message ?? '').trim();
863
+ if (!message) return reply.code(400).send({ error: 'message required' });
864
+ const g = await groupRuns(id);
865
+ if (!g) return reply.code(404).send({ error: 'group not found' });
866
+ const res = await askGroupCoordinator(id, message, b.real === true);
867
+ if (!res.ok) return reply.code(422).send({ error: res.detail });
868
+ return { ok: true, answer: res.answer };
869
+ });
870
+
758
871
  // 통합 — 여러 run(태스크 무관)을 base 에 순차 머지, 충돌은 통합 에이전트 자동 발사.
759
872
  app.post('/api/integrate', async (req, reply) => {
760
873
  const b = (req.body ?? {}) as { runIds?: number[]; real?: boolean };