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 +1 -1
- package/src/board.ts +36 -1
- package/src/orchestrator.ts +67 -0
- package/src/server.ts +16 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "2.
|
|
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 & 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
|
-
|
|
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() };
|
package/src/orchestrator.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|