coxpit 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "2.6.0",
3
+ "version": "2.8.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
@@ -159,6 +159,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
159
159
  .ev{display:flex;gap:9px;align-items:baseline;min-width:0}
160
160
  .ev .k{color:var(--brand);min-width:78px;flex:none;opacity:.85}
161
161
  .ev .t{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
162
+ /* ── select mode (integrate) ── */
163
+ .toolbar{display:flex;justify-content:flex-end;gap:8px;margin-bottom:12px}
164
+ .card.selmode{cursor:copy}
165
+ .card .selbox{display:none;width:20px;height:20px;border-radius:50%;border:1px solid var(--line-hi);
166
+ align-items:center;justify-content:center;font-size:11px;color:transparent;flex:none}
167
+ .card.selmode .selbox{display:flex}
168
+ .card.selected{border-color:var(--brand)}
169
+ .card.selected .selbox{background:var(--brand);border-color:var(--brand);color:var(--brand-ink)}
170
+ .selbar{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:45;display:none;
171
+ align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line-hi);border-radius:12px;
172
+ padding:10px 16px;box-shadow:var(--shadow)}
173
+ .selbar.on{display:flex}
174
+ .selbar .cnt{font-family:var(--mono);font-size:12px;color:var(--brand)}
175
+ .selbar .note{font-family:var(--mono);font-size:11px;color:var(--faint)}
176
+
162
177
  .empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:64px 24px;text-align:center;
163
178
  display:flex;flex-direction:column;gap:10px;align-items:center}
164
179
  .empty .glyph{font-size:22px;color:#2c3444;letter-spacing:4px}
@@ -300,11 +315,22 @@ export const BOARD_HTML = /* html */ `<!doctype html>
300
315
  </div>
301
316
  <input type="checkbox" id="taskReal" hidden />
302
317
  <div class="row">
303
- <input id="taskCount" class="narrow" type="number" min="1" max="8" value="2" title="number of agents" />
318
+ <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="number of agents — 1 for a job, N to explore variants" />
304
319
  <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
305
320
  </div>
306
321
  </form>
307
322
  </div>
323
+ <div class="sect">
324
+ <p class="sect-label">Plan a goal · swarm</p>
325
+ <form id="planForm">
326
+ <p class="flabel">repository</p>
327
+ <select id="planRepo"></select>
328
+ <p class="flabel">goal</p>
329
+ <textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge later with Select runs → Integrate."></textarea>
330
+ <button class="btn" type="submit" id="planGo">Plan &amp; fan out</button>
331
+ <span style="font-size:11px;color:var(--faint);font-family:var(--mono)">follows the Dry/Real mode above · planner reads only</span>
332
+ </form>
333
+ </div>
308
334
  <div class="sect">
309
335
  <p class="sect-label">Design captures</p>
310
336
  <div id="captures" style="display:flex;flex-direction:column;gap:6px"></div>
@@ -315,6 +341,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
315
341
  </div>
316
342
  </aside>
317
343
  <main>
344
+ <div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
318
345
  <div class="grid" id="grid"></div>
319
346
  <div class="empty" id="empty">
320
347
  <span class="glyph">▚▞▚</span>
@@ -400,6 +427,13 @@ export const BOARD_HTML = /* html */ `<!doctype html>
400
427
 
401
428
  <div class="toasts" id="toasts"></div>
402
429
 
430
+ <div class="selbar" id="selbar">
431
+ <span class="cnt" id="selCnt">0 selected</span>
432
+ <span class="note">merges in selection order · conflicts spawn an integration agent</span>
433
+ <button class="btn sm" id="selGo">Integrate → base</button>
434
+ <button class="btn-ghost sm" id="selCancel">Cancel</button>
435
+ </div>
436
+
403
437
  <div class="overlay" id="expOverlay">
404
438
  <div class="cfm">
405
439
  <div class="cfm-b">
@@ -636,8 +670,10 @@ function cardHTML(r){
636
670
  const evs = (r.events||[]).slice(-8).map(e =>
637
671
  '<div class="ev"><span class="k">'+esc(e.kind)+'</span><span class="t">'+esc(summarize(e.kind,e.payload)).slice(0,140)+'</span></div>'
638
672
  ).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
639
- return '<div class="card" id="card-'+r.id+'">'
640
- + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'+chipHTML(r.status)+'</div>'
673
+ const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
674
+ return '<div class="card'+selCls+'" id="card-'+r.id+'">'
675
+ + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
676
+ + '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
641
677
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
642
678
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
643
679
  + '<span>'+esc(r.agent||'')+'</span>'
@@ -681,7 +717,8 @@ function paintSidebar(){
681
717
  capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
682
718
  '<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
683
719
  capSel.value = cur;
684
- ['repoMachine','taskRepo','taskCapture'].forEach(id => { dressSelect(id); syncSelect(id); });
720
+ $('planRepo').innerHTML = $('taskRepo').innerHTML;
721
+ ['repoMachine','taskRepo','taskCapture','planRepo'].forEach(id => { dressSelect(id); syncSelect(id); });
685
722
  $('captures').innerHTML = captures.map(c=>
686
723
  '<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
687
724
  + '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
@@ -717,6 +754,7 @@ function connectWS(){
717
754
  const known = runs.has(ev.runId ?? ev.id);
718
755
  upsertRun(ev);
719
756
  if (!known && ev.taskId==null){ hydrate(); return; }
757
+ if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
720
758
  render(); flash(ev.runId ?? ev.id); paintModal();
721
759
  if (openRunId===(ev.runId??ev.id) && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
722
760
  if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
@@ -766,9 +804,48 @@ async function loadDiff(){
766
804
  }
767
805
  function openModal(id){ openRunId = id; paintModal(); $('overlay').classList.add('open'); loadDiff(); }
768
806
  function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
807
+ /* ── select mode (integrate) ── */
808
+ let selectMode = false;
809
+ const selected = new Set();
810
+ const selOrder = [];
811
+ function setSelectMode(on){
812
+ selectMode = on;
813
+ if (!on){ selected.clear(); selOrder.length = 0; }
814
+ $('selToggle').textContent = on ? 'Exit select' : 'Select runs';
815
+ $('selbar').classList.toggle('on', on);
816
+ $('selCnt').textContent = selected.size + ' selected';
817
+ render();
818
+ }
819
+ $('selToggle').addEventListener('click', ()=>setSelectMode(!selectMode));
820
+ $('selCancel').addEventListener('click', ()=>setSelectMode(false));
821
+ $('selGo').addEventListener('click', async ()=>{
822
+ if (!selOrder.length){ toast('select settled runs with changes first', 'error'); return; }
823
+ const yes = await confirmUI('Integrate '+selOrder.length+' run(s) into the base branch?',
824
+ { sub: 'Merged one by one in selection order. A run that conflicts spawns an integration agent (real, spends credits) to resolve it.', okLabel: 'Integrate' });
825
+ if (!yes) return;
826
+ const res = await fetch('/api/integrate',{method:'POST',headers:{'content-type':'application/json'},
827
+ body:JSON.stringify({runIds:selOrder})});
828
+ const j = await res.json().catch(()=>({}));
829
+ if (res.ok){
830
+ toast('integrate: '+j.merged+' merged · '+j.conflicts+' conflict→agent · '+j.skipped+' skipped', j.conflicts||j.skipped ? undefined : 'ok');
831
+ setSelectMode(false); hydrate();
832
+ } else toast('integrate: '+(j.error||res.status), 'error');
833
+ });
834
+
769
835
  $('grid').addEventListener('click',(e)=>{
770
836
  const card = e.target.closest('.card'); if(!card) return;
771
- openModal(Number(card.id.replace('card-','')));
837
+ const id = Number(card.id.replace('card-',''));
838
+ if (selectMode){
839
+ const r = runs.get(id);
840
+ const eligible = r && ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0) > 0 && r.status!=='merged';
841
+ if (!eligible){ toast('r'+id+': only settled runs with changes can be integrated', 'error'); return; }
842
+ if (selected.has(id)){ selected.delete(id); selOrder.splice(selOrder.indexOf(id),1); }
843
+ else { selected.add(id); selOrder.push(id); }
844
+ $('selCnt').textContent = selected.size + ' selected';
845
+ render();
846
+ return;
847
+ }
848
+ openModal(id);
772
849
  });
773
850
  $('mClose').addEventListener('click', closeModal);
774
851
  $('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
@@ -953,6 +1030,29 @@ $('mTerm').addEventListener('click', ()=>{
953
1030
  });
954
1031
 
955
1032
  /* ── forms ── */
1033
+ $('planForm').addEventListener('submit', async (e)=>{
1034
+ e.preventDefault();
1035
+ const repoId = Number($('planRepo').value);
1036
+ const goal = $('planGoal').value.trim();
1037
+ if (!repoId){ toast('register a repo first', 'error'); return; }
1038
+ if (!goal){ toast('write a goal first', 'error'); return; }
1039
+ const real = $('taskReal').checked;
1040
+ const btn = $('planGo');
1041
+ btn.disabled = true; btn.textContent = real ? 'Planning… (1–3 min)' : 'Planning…';
1042
+ try{
1043
+ const res = await fetch('/api/plan',{method:'POST',headers:{'content-type':'application/json'},
1044
+ body:JSON.stringify({repoId, goal, real})});
1045
+ const j = await res.json().catch(()=>({}));
1046
+ if (res.ok){
1047
+ toast(j.tasks.length+' task(s) planned & launched', 'ok');
1048
+ $('planGoal').value='';
1049
+ hydrate();
1050
+ } else toast('plan: '+(j.detail||j.error||res.status), 'error');
1051
+ } finally {
1052
+ btn.disabled = false; btn.textContent = 'Plan & fan out';
1053
+ }
1054
+ });
1055
+
956
1056
  $('repoForm').addEventListener('submit', async (e)=>{
957
1057
  e.preventDefault();
958
1058
  const body = { machineSlug: $('repoMachine').value, path: $('repoPath').value.trim() };
@@ -45,6 +45,8 @@ const stoppedRuns = new Set<number>();
45
45
  interface RunContext {
46
46
  runId: number;
47
47
  machine: MachineTarget;
48
+ machineId: number;
49
+ repoId: number;
48
50
  repoPath: string;
49
51
  baseBranch: string;
50
52
  prompt: string;
@@ -81,6 +83,8 @@ async function loadContext(runId: number): Promise<RunContext | null> {
81
83
  return {
82
84
  runId,
83
85
  machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
86
+ machineId: m.id,
87
+ repoId: repo.id,
84
88
  repoPath: repo.path,
85
89
  baseBranch: repo.defaultBranch,
86
90
  prompt,
@@ -285,7 +289,7 @@ export async function getRunDiff(runId: number): Promise<{ ok: boolean; diff: st
285
289
  * 승자 run 머지 — worktree 미커밋 변경을 자동 커밋 후 run 브랜치를
286
290
  * repo 기본 브랜치에 merge. 본 repo 가 기본 브랜치+클린일 때만, 충돌 시 abort.
287
291
  */
288
- export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: string }> {
292
+ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: string; conflict?: boolean }> {
289
293
  const ctx = await loadContext(runId);
290
294
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
291
295
  const run = rr[0];
@@ -323,12 +327,126 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
323
327
  30000,
324
328
  );
325
329
  if (mg.stdout.includes('COXPIT_MERGE_FAILED')) {
326
- return { ok: false, detail: 'merge conflict — aborted: ' + mg.stdout.replace('COXPIT_MERGE_FAILED', '').trim().slice(0, 300) };
330
+ return { ok: false, conflict: true, detail: 'merge conflict — aborted: ' + mg.stdout.replace('COXPIT_MERGE_FAILED', '').trim().slice(0, 300) };
327
331
  }
328
332
  await setRun(runId, { status: 'merged' });
329
333
  return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
330
334
  }
331
335
 
336
+ /**
337
+ * Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
338
+ * 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
339
+ * (수렴은 Integrate 가 담당. real=false 는 배관 리허설용 모의 2분할.)
340
+ */
341
+ export async function planFanout(repoId: number, goal: string, real: boolean): Promise<{
342
+ ok: boolean; detail: string; tasks?: Array<{ id: number; title: string; runId: number }>;
343
+ }> {
344
+ const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
345
+ const repo = rp[0];
346
+ if (!repo) return { ok: false, detail: 'repo not found' };
347
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
348
+ const m = mr[0];
349
+ if (!m) return { ok: false, detail: 'machine not found' };
350
+ const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
351
+
352
+ let plan: Array<{ title: string; prompt: string }>;
353
+ if (!real) {
354
+ // 드라이런: 파이프라인 리허설용 고정 2분할
355
+ plan = [
356
+ { title: `[plan] ${goal.slice(0, 40)} — part 1`, prompt: `${goal}\n(rehearsal plan, part 1)` },
357
+ { title: `[plan] ${goal.slice(0, 40)} — part 2`, prompt: `${goal}\n(rehearsal plan, part 2)` },
358
+ ];
359
+ } else {
360
+ const plannerPrompt =
361
+ `You are planning work for this repository. Goal:\n${goal}\n\n` +
362
+ `Read the repository as needed, then respond with ONLY a JSON object (no prose, no code fences):\n` +
363
+ `{"tasks":[{"title":"short imperative title","prompt":"full agent prompt"}]}\n` +
364
+ `Rules: 2-6 tasks. Each must be independently executable in an isolated git worktree by a coding agent ` +
365
+ `that knows nothing about the other tasks. Each prompt must name the target files, the constraints, and how to verify. ` +
366
+ `Minimize file overlap between tasks to reduce merge conflicts. Do not include setup/integration tasks.`;
367
+ // 플래너는 읽기만 하면 되므로 repo 본체에서 default 권한(편집 자동거부)으로 실행
368
+ const cmd = `cd ${shq(repo.path)} && ${config.agent.bin} -p ${shq(plannerPrompt)} --output-format json`;
369
+ const r = await runShellOn(machine, cmd, 300000);
370
+ if (!r.ok) return { ok: false, detail: 'planner failed: ' + (r.stderr || r.stdout).trim().slice(0, 300) };
371
+ try {
372
+ const envelope = JSON.parse(r.stdout.trim()) as { result?: string };
373
+ let body = (envelope.result ?? '').trim();
374
+ const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/);
375
+ if (fence?.[1] != null) body = fence[1].trim();
376
+ const first = body.indexOf('{');
377
+ const last = body.lastIndexOf('}');
378
+ if (first === -1 || last === -1) throw new Error('no JSON in planner output');
379
+ const parsed = JSON.parse(body.slice(first, last + 1)) as { tasks?: Array<{ title?: string; prompt?: string }> };
380
+ plan = (parsed.tasks ?? [])
381
+ .filter((t) => typeof t.title === 'string' && typeof t.prompt === 'string' && t.title && t.prompt)
382
+ .slice(0, 8)
383
+ .map((t) => ({ title: t.title as string, prompt: t.prompt as string }));
384
+ } catch (e) {
385
+ return { ok: false, detail: 'could not parse the plan: ' + String(e).slice(0, 200) };
386
+ }
387
+ if (plan.length < 1) return { ok: false, detail: 'planner returned no tasks' };
388
+ }
389
+
390
+ const created: Array<{ id: number; title: string; runId: number }> = [];
391
+ for (const t of plan) {
392
+ const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt }).returning();
393
+ const task = tIns[0]!;
394
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
395
+ const run = rIns[0]!;
396
+ broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
397
+ void launchRun(run.id, real);
398
+ created.push({ id: task.id, title: t.title, runId: run.id });
399
+ }
400
+ return { ok: true, detail: `${created.length} task(s) launched`, tasks: created };
401
+ }
402
+
403
+ export interface IntegrateResult {
404
+ runId: number;
405
+ status: 'merged' | 'conflict' | 'skipped';
406
+ detail?: string;
407
+ integrationTaskId?: number;
408
+ integrationRunId?: number;
409
+ }
410
+
411
+ /**
412
+ * 통합(Integrate) — 여러 run(태스크 무관)을 base 에 순차 머지한다.
413
+ * 충돌 나는 run 은 멈추지 않고 "통합 태스크"를 자동 발사 — 에이전트가
414
+ * base 에서 분기한 새 worktree 에서 해당 브랜치를 머지·충돌 해소한다.
415
+ * (스웜의 수렴 단계: 분업한 결과를 다시 하나로.)
416
+ */
417
+ export async function integrateRuns(runIds: number[], real?: boolean): Promise<IntegrateResult[]> {
418
+ const results: IntegrateResult[] = [];
419
+ for (const id of runIds) {
420
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
421
+ const run = rr[0];
422
+ if (!run) { results.push({ runId: id, status: 'skipped', detail: 'not found' }); continue; }
423
+ if (run.status === 'merged') { results.push({ runId: id, status: 'skipped', detail: 'already merged' }); continue; }
424
+
425
+ const m = await mergeRun(id);
426
+ if (m.ok) { results.push({ runId: id, status: 'merged' }); continue; }
427
+ if (!m.conflict) { results.push({ runId: id, status: 'skipped', detail: m.detail }); continue; }
428
+
429
+ // 충돌 → 통합 태스크 자동 발사 (에이전트가 머지를 대신 푼다)
430
+ const ctx = await loadContext(id);
431
+ if (!ctx) { results.push({ runId: id, status: 'skipped', detail: 'context missing for integration' }); continue; }
432
+ const title = `Integrate r${id} into ${ctx.baseBranch}`;
433
+ const prompt =
434
+ `This worktree is branched from '${ctx.baseBranch}'. Merge the branch '${run.branch}' into it:\n` +
435
+ `1. Run: git merge ${run.branch}\n` +
436
+ `2. Resolve every conflict by preserving the intent of BOTH sides — do not simply pick one side.\n` +
437
+ `3. Make sure the result is consistent (typecheck/tests if available), then commit the merge.\n` +
438
+ `Do not modify files unrelated to the conflicts.`;
439
+ const tIns = await db.insert(tasks).values({ repoId: ctx.repoId, title, prompt }).returning();
440
+ const newTask = tIns[0]!;
441
+ const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending' }).returning();
442
+ const newRun = rIns[0]!;
443
+ broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, branch: '', filesChanged: 0 });
444
+ void launchRun(newRun.id, real ?? true);
445
+ results.push({ runId: id, status: 'conflict', detail: m.detail, integrationTaskId: newTask.id, integrationRunId: newRun.id });
446
+ }
447
+ return results;
448
+ }
449
+
332
450
  /**
333
451
  * 결과 파일 회수(export) — 조회성 태스크용: worktree 의 변경·신규 파일을
334
452
  * 지정 폴더로 복사한다. 머지 없이 산출물만 가져오는 길. (v1: 로컬 머신 전용)
package/src/server.ts CHANGED
@@ -12,7 +12,7 @@ import { db } from './db';
12
12
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
13
13
  import { BOOKMARKLET_JS } from './design';
14
14
  import { runShellOn, shq } from './exec';
15
- import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun } from './orchestrator';
15
+ import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout } from './orchestrator';
16
16
  import { openTerm } from './term';
17
17
  import { addSink, removeSink, broadcast } from './hub';
18
18
  import { BOARD_HTML } from './board';
@@ -32,7 +32,7 @@ export async function buildServer(): Promise<FastifyInstance> {
32
32
  app.addHook('onRequest', authGate);
33
33
 
34
34
  // 무인증 헬스(외부 감시용)
35
- app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.6.0' }));
35
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.8.0' }));
36
36
 
37
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -401,6 +401,35 @@ export async function buildServer(): Promise<FastifyInstance> {
401
401
  return reply.code(202).send(res);
402
402
  });
403
403
 
404
+ // Plan fan-out — 목표 하나 → 플래너가 태스크 분해 → 전부 자동 발사.
405
+ // (real 플래너는 repo 를 읽고 계획하느라 1~3분 걸릴 수 있음 — 클라이언트는 대기)
406
+ app.post('/api/plan', async (req, reply) => {
407
+ const b = (req.body ?? {}) as { repoId?: number; goal?: string; real?: boolean };
408
+ const repoId = Number(b.repoId);
409
+ const goal = (b.goal ?? '').trim();
410
+ if (!repoId || !goal) return reply.code(400).send({ error: 'repoId and goal required' });
411
+ if (goal.length > 4000) return reply.code(400).send({ error: 'goal too long' });
412
+ const res = await planFanout(repoId, goal, b.real === true);
413
+ if (!res.ok) return reply.code(422).send(res);
414
+ return reply.code(202).send(res);
415
+ });
416
+
417
+ // 통합 — 여러 run(태스크 무관)을 base 에 순차 머지, 충돌은 통합 에이전트 자동 발사.
418
+ app.post('/api/integrate', async (req, reply) => {
419
+ const b = (req.body ?? {}) as { runIds?: number[]; real?: boolean };
420
+ const ids = (b.runIds ?? []).map(Number).filter((n) => Number.isInteger(n) && n > 0);
421
+ if (!ids.length) return reply.code(400).send({ error: 'runIds required' });
422
+ if (ids.length > 20) return reply.code(400).send({ error: 'too many runs (max 20)' });
423
+ const results = await integrateRuns(ids, b.real);
424
+ return {
425
+ ok: true,
426
+ merged: results.filter((r) => r.status === 'merged').length,
427
+ conflicts: results.filter((r) => r.status === 'conflict').length,
428
+ skipped: results.filter((r) => r.status === 'skipped').length,
429
+ results,
430
+ };
431
+ });
432
+
404
433
  // 결과 파일 회수 — 머지 없이 worktree 산출물만 지정 폴더로 복사(조회성 태스크).
405
434
  app.post('/api/runs/:id/export', async (req, reply) => {
406
435
  const id = Number((req.params as { id: string }).id);
@@ -441,7 +470,7 @@ export async function buildServer(): Promise<FastifyInstance> {
441
470
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
442
471
  app.get('/ws', { websocket: true }, (socket) => {
443
472
  addSink(socket);
444
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.6.0' }));
473
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.8.0' }));
445
474
  socket.on('close', () => removeSink(socket));
446
475
  });
447
476