coxpit 4.1.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
@@ -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.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.
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.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}
@@ -625,6 +636,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
625
636
  <script>
626
637
  const runs = new Map(); // runId -> run object
627
638
  const tasks = new Map(); // taskId -> task
639
+ const groups = new Map(); // groupId -> {id, kind, title}
628
640
  let repos = [], machines = [], captures = [];
629
641
 
630
642
  const $ = (id) => document.getElementById(id);
@@ -828,12 +840,56 @@ function chipHTML(status){
828
840
  return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
829
841
  + '<i></i>'+esc(s)+'</span>';
830
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
+ }
831
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
+ }
832
877
  function render(){
833
878
  const list = [...runs.values()].sort((a,b)=>b.id-a.id);
834
879
  $('empty').style.display = list.length ? 'none' : 'flex';
835
- if (!list.length) paintOnboarding();
836
- $('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;
837
893
  if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
838
894
  }
839
895
 
@@ -898,6 +954,7 @@ function cardHTML(r){
898
954
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
899
955
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
900
956
  + '<span>'+esc(r.agent||'')+'</span>'
957
+ + attemptHTML(r)
901
958
  + (r.model ? '<span title="model">⚙ '+esc(r.model)+'</span>' : '')
902
959
  + (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
903
960
  + (r.sessionId && ['done','failed','stopped'].includes(r.status)
@@ -920,6 +977,8 @@ async function hydrate(){
920
977
  machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
921
978
  tasks.clear();
922
979
  (r.tasks||[]).forEach(t => tasks.set(t.id, t));
980
+ groups.clear();
981
+ (r.groups||[]).forEach(g => groups.set(g.id, g));
923
982
  runs.clear();
924
983
  (r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
925
984
  if (r.daemon) {
@@ -1048,7 +1107,7 @@ function connectWS(){
1048
1107
  render(); flash(ev.runId); paintModal();
1049
1108
  } else if (ev.type==='task'){
1050
1109
  const t = tasks.get(ev.taskId);
1051
- 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(); }
1052
1111
  } else if (ev.type==='capture'){
1053
1112
  captures.push(ev.capture); paintSidebar();
1054
1113
  }
@@ -1174,6 +1233,51 @@ $('selGo').addEventListener('click', async ()=>{
1174
1233
  } else toast('integrate: '+(j.error||res.status), 'error');
1175
1234
  });
1176
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
+ }
1177
1281
  $('grid').addEventListener('click',(e)=>{
1178
1282
  const card = e.target.closest('.card'); if(!card) return;
1179
1283
  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,5 @@ 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 */ }
93
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,7 @@ 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)
44
53
  createdAt: integer('created_at', { mode: 'timestamp' }),
45
54
  });
46
55
 
@@ -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
@@ -10,7 +10,7 @@ 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
16
  import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
@@ -171,13 +171,14 @@ export async function buildServer(): Promise<FastifyInstance> {
171
171
 
172
172
  // 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
173
173
  app.get('/api/fleet', async () => {
174
- const [ms, rs, ts, rns, evs, dcs] = await Promise.all([
174
+ const [ms, rs, ts, rns, evs, dcs, gs] = await Promise.all([
175
175
  db.select().from(machines),
176
176
  db.select().from(repos),
177
177
  db.select().from(tasks),
178
178
  db.select().from(agentRuns),
179
179
  db.select().from(agentEvents),
180
180
  db.select().from(designCaptures),
181
+ db.select().from(taskGroups),
181
182
  ]);
182
183
  const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
183
184
  for (const e of evs) {
@@ -186,7 +187,7 @@ export async function buildServer(): Promise<FastifyInstance> {
186
187
  byRun.set(e.runId, arr);
187
188
  }
188
189
  return {
189
- machines: ms, repos: rs, tasks: ts, captures: dcs,
190
+ machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
190
191
  runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
191
192
  // 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
192
193
  daemon: { version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath },