coxpit 2.7.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.7.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
@@ -320,6 +320,17 @@ export const BOARD_HTML = /* html */ `<!doctype html>
320
320
  </div>
321
321
  </form>
322
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>
323
334
  <div class="sect">
324
335
  <p class="sect-label">Design captures</p>
325
336
  <div id="captures" style="display:flex;flex-direction:column;gap:6px"></div>
@@ -706,7 +717,8 @@ function paintSidebar(){
706
717
  capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
707
718
  '<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
708
719
  capSel.value = cur;
709
- ['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); });
710
722
  $('captures').innerHTML = captures.map(c=>
711
723
  '<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
712
724
  + '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
@@ -1018,6 +1030,29 @@ $('mTerm').addEventListener('click', ()=>{
1018
1030
  });
1019
1031
 
1020
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
+
1021
1056
  $('repoForm').addEventListener('submit', async (e)=>{
1022
1057
  e.preventDefault();
1023
1058
  const body = { machineSlug: $('repoMachine').value, path: $('repoPath').value.trim() };
@@ -333,6 +333,73 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
333
333
  return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
334
334
  }
335
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
+
336
403
  export interface IntegrateResult {
337
404
  runId: number;
338
405
  status: 'merged' | 'conflict' | 'skipped';
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, integrateRuns } 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.7.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,19 @@ 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
+
404
417
  // 통합 — 여러 run(태스크 무관)을 base 에 순차 머지, 충돌은 통합 에이전트 자동 발사.
405
418
  app.post('/api/integrate', async (req, reply) => {
406
419
  const b = (req.body ?? {}) as { runIds?: number[]; real?: boolean };
@@ -457,7 +470,7 @@ export async function buildServer(): Promise<FastifyInstance> {
457
470
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
458
471
  app.get('/ws', { websocket: true }, (socket) => {
459
472
  addSink(socket);
460
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.7.0' }));
473
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.8.0' }));
461
474
  socket.on('close', () => removeSink(socket));
462
475
  });
463
476