coxpit 2.8.0 → 2.9.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.8.0",
3
+ "version": "2.9.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
@@ -263,6 +263,18 @@ export const BOARD_HTML = /* html */ `<!doctype html>
263
263
  .cmp-f .msg{font-family:var(--mono);font-size:11px;color:var(--muted);flex:1;
264
264
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
265
265
 
266
+ /* ── AI review panel (compare) ── */
267
+ .cmp-review{border-bottom:1px solid var(--line);background:var(--surface2);padding:14px 20px;
268
+ max-height:42vh;overflow:auto;font-size:13px;line-height:1.65;color:var(--muted)}
269
+ .cmp-review[hidden]{display:none}
270
+ .cmp-review h2{font-size:13px;color:var(--brand);margin:14px 0 6px;letter-spacing:.02em}
271
+ .cmp-review h3{font-size:12.5px;color:var(--ink);margin:12px 0 4px}
272
+ .cmp-review ul{margin:4px 0 8px;padding-left:18px}
273
+ .cmp-review li{margin-bottom:3px}
274
+ .cmp-review strong{color:var(--ink)}
275
+ .cmp-review code{font-family:var(--mono);font-size:.9em;background:#0e1118;padding:1px 5px;border-radius:4px;color:var(--brand)}
276
+ .cmp-review p{margin:0 0 8px}
277
+
266
278
  /* ── terminal ───────────────────────────── */
267
279
  .term-body{flex:1;min-height:0;background:#0b0d12;padding:8px 4px 4px 10px}
268
280
  #xterm{width:100%;height:100%}
@@ -390,9 +402,11 @@ export const BOARD_HTML = /* html */ `<!doctype html>
390
402
  <div class="modal wide">
391
403
  <div class="modal-h">
392
404
  <span class="title" id="cmpTitle">Compare</span>
405
+ <button class="btn sm" id="cmpAI">AI review</button>
393
406
  <button class="btn-ghost sm" id="cmpRefresh">Refresh</button>
394
407
  <button class="x" id="cmpClose" aria-label="close">×</button>
395
408
  </div>
409
+ <div class="cmp-review" id="cmpReview" hidden></div>
396
410
  <div class="cmp" id="cmpBody"></div>
397
411
  </div>
398
412
  </div>
@@ -578,20 +592,49 @@ $('cfmOk').addEventListener('click', ()=>cfmClose(true));
578
592
  $('cfmCancel').addEventListener('click', ()=>cfmClose(false));
579
593
  $('cfmOverlay').addEventListener('click',(e)=>{ if(e.target===$('cfmOverlay')) cfmClose(false); });
580
594
 
581
- function summarize(kind, payload){
595
+ /* 이벤트 인간화 — JSON 원문 대신 사람이 읽는 한 줄로. null = 표시 생략(노이즈). */
596
+ function humanize(e){
597
+ const kind = e.kind, payload = e.payload;
598
+ if (kind === 'rate_limit_event') return null;
599
+ if (kind === 'steer') return { k:'steer', t:'→ '+payload };
600
+ if (kind === 'export'){ try{ const o=JSON.parse(payload); return { k:'export', t:o.copied+' file(s) → '+o.dest }; }catch{ return { k:'export', t:payload }; } }
601
+ if (kind === 'pr') return { k:'pr', t:payload };
602
+ if (kind === 'stderr') return { k:'stderr', t:payload };
582
603
  try{
583
604
  const o = JSON.parse(payload);
605
+ if (o.type === 'system') return { k:'session', t:'started · '+(o.model||o.subtype||'') };
606
+ if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
584
607
  if (o.type === 'assistant' && o.message){
585
- const c = (o.message.content||[]).map(x => x.type==='text' ? x.text : (x.type==='tool_use' ? '['+x.name+']' : '')).join(' ');
586
- return c || '(assistant)';
608
+ const parts = [];
609
+ for (const x of (o.message.content||[])){
610
+ if (x.type === 'text' && x.text) parts.push({ k:'said', t:x.text });
611
+ else if (x.type === 'tool_use'){
612
+ const i = x.input || {};
613
+ const arg = i.file_path || i.command || i.path || i.pattern || '';
614
+ parts.push({ k:'tool', t:'▸ '+x.name+(arg?' — '+String(arg).split('/').slice(-2).join('/').slice(0,60):'') });
615
+ }
616
+ }
617
+ return parts.length ? parts : null;
587
618
  }
588
- if (o.type === 'assistant' && o.text) return o.text;
589
- if (o.type === 'result') return o.result || '(result)';
590
- if (o.type === 'user') return '(tool result)';
591
- if (o.type === 'system') return o.subtype || 'system';
592
- if (kind === 'meta') return 'worktree ' + (o.worktree||'');
593
- return kind;
594
- }catch{ return payload; }
619
+ if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
620
+ if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
621
+ if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
622
+ return { k:kind, t:payload.slice(0,140) };
623
+ }catch{ return { k:kind, t:payload }; }
624
+ }
625
+ function humanLines(events){
626
+ const out = [];
627
+ for (const e of (events||[])){
628
+ const h = humanize(e);
629
+ if (!h) continue;
630
+ if (Array.isArray(h)) out.push(...h); else out.push(h);
631
+ }
632
+ return out;
633
+ }
634
+ function summarize(kind, payload){
635
+ const h = humanize({kind, payload});
636
+ if (!h) return '';
637
+ return Array.isArray(h) ? h.map(x=>x.t).join(' · ') : h.t;
595
638
  }
596
639
  function diffHTML(text){
597
640
  if (!text.trim()) return '<span style="color:var(--faint)">no changes</span>';
@@ -667,8 +710,8 @@ function cardHTML(r){
667
710
  const closed = task && task.status==='closed';
668
711
  const title = (task ? esc(task.title) : ('task ' + (r.taskId ?? '?')))
669
712
  + (closed ? ' <span class="closed">· closed</span>' : '');
670
- const evs = (r.events||[]).slice(-8).map(e =>
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>'
713
+ const evs = humanLines(r.events).slice(-8).map(h =>
714
+ '<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t).slice(0,140)+'</span></div>'
672
715
  ).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
673
716
  const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
674
717
  return '<div class="card'+selCls+'" id="card-'+r.id+'">'
@@ -787,8 +830,8 @@ function paintModal(){
787
830
  $('mStop').style.display = (r.status==='running'||r.status==='preparing'||r.status==='pending') ? '' : 'none';
788
831
  // steer 는 정착한 real run 에서만 의미(드라이런은 세션 없음 — 서버가 사유와 함께 거절)
789
832
  $('steerRow').style.display = ['done','failed','stopped'].includes(r.status) ? '' : 'none';
790
- $('mTimeline').innerHTML = (r.events||[]).map(e =>
791
- '<div class="ev"><span class="k">'+esc(e.kind)+'</span><span class="t">'+esc(summarize(e.kind,e.payload))+'</span></div>'
833
+ $('mTimeline').innerHTML = humanLines(r.events).map(h =>
834
+ '<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t)+'</span></div>'
792
835
  ).join('') || '<span style="color:var(--faint)">no events yet</span>';
793
836
  }
794
837
  async function loadDiff(){
@@ -908,6 +951,7 @@ $('mCloseTask').addEventListener('click', async ()=>{
908
951
  let cmpTaskId = null;
909
952
  async function openCompare(taskId){
910
953
  cmpTaskId = taskId;
954
+ $('cmpReview').hidden = true; $('cmpReview').innerHTML = '';
911
955
  $('cmpOverlay').classList.add('open');
912
956
  $('cmpBody').innerHTML = '<div class="empty" style="flex:1">loading…</div>';
913
957
  await paintCompare();
@@ -969,6 +1013,34 @@ $('cmpBody').addEventListener('click', async (e)=>{
969
1013
  $('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
970
1014
  $('cmpOverlay').addEventListener('click',(e)=>{ if(e.target===$('cmpOverlay')){ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
971
1015
  $('cmpRefresh').addEventListener('click', paintCompare);
1016
+ /* 초경량 md 렌더 (리뷰 표시용) */
1017
+ function mdLite(src){
1018
+ let s = esc(src);
1019
+ s = s.replace(/\`\`\`[a-z]*\\n([\\s\\S]*?)\`\`\`/g, (m,c)=>'<pre style="background:#0e1118;border:1px solid var(--line);border-radius:7px;padding:8px 10px;overflow-x:auto">'+c+'</pre>');
1020
+ s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
1021
+ s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
1022
+ s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
1023
+ s = s.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
1024
+ s = s.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
1025
+ s = s.replace(/(<li>[\\s\\S]*?<\\/li>)(?!\\s*<li>)/g, '<ul>$1</ul>');
1026
+ s = s.split(/\\n{2,}/).map(b => /^<(h2|h3|ul|pre)/.test(b.trim()) ? b : (b.trim()? '<p>'+b.replace(/\\n/g,'<br>')+'</p>':'' )).join('');
1027
+ return s;
1028
+ }
1029
+ $('cmpAI').addEventListener('click', async ()=>{
1030
+ if (cmpTaskId==null) return;
1031
+ const yes = await confirmUI('Run an AI review of these implementations?',
1032
+ { sub: 'A reviewer agent reads every diff and summarizes each approach, pros/cons, and a recommendation — so you judge instead of reading all the code. Real agent, spends credits (~1–2 min).', okLabel: 'Review' });
1033
+ if (!yes) return;
1034
+ const btn = $('cmpAI');
1035
+ btn.disabled = true; btn.textContent = 'Reviewing…';
1036
+ try{
1037
+ const res = await fetch('/api/tasks/'+cmpTaskId+'/review',{method:'POST',
1038
+ headers:{'content-type':'application/json'}, body:JSON.stringify({real:true})});
1039
+ const j = await res.json().catch(()=>({}));
1040
+ if (res.ok){ $('cmpReview').innerHTML = mdLite(j.review||''); $('cmpReview').hidden = false; }
1041
+ else toast('review: '+(j.detail||res.status), 'error');
1042
+ } finally { btn.disabled = false; btn.textContent = 'AI review'; }
1043
+ });
972
1044
  $('mCompare').addEventListener('click', ()=>{
973
1045
  if (openRunId==null) return;
974
1046
  const r = runs.get(openRunId); if(!r) return;
@@ -400,6 +400,62 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
400
400
  return { ok: true, detail: `${created.length} task(s) launched`, tasks: created };
401
401
  }
402
402
 
403
+ /**
404
+ * AI 리뷰(심판) — 태스크의 정착 run diff 들을 리뷰 에이전트가 읽고
405
+ * 접근 방식·장단점·리스크·추천을 요약한다. 사람은 코드 전수가 아니라
406
+ * 판단만 하면 되도록. (read-only, 워크트리 불필요)
407
+ */
408
+ export async function reviewTask(taskId: number, real: boolean): Promise<{ ok: boolean; detail: string; review?: string }> {
409
+ const tr = await db.select().from(tasks).where(eq(tasks.id, taskId)).limit(1);
410
+ const task = tr[0];
411
+ if (!task) return { ok: false, detail: 'task not found' };
412
+ const rp = await db.select().from(repos).where(eq(repos.id, task.repoId)).limit(1);
413
+ const repo = rp[0];
414
+ if (!repo) return { ok: false, detail: 'repo not found' };
415
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
416
+ const m = mr[0];
417
+ if (!m) return { ok: false, detail: 'machine not found' };
418
+ const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
419
+
420
+ const trs = (await db.select().from(agentRuns).where(eq(agentRuns.taskId, taskId)))
421
+ .filter((r) => ['done', 'failed', 'stopped', 'merged'].includes(r.status));
422
+ if (trs.length < 2) return { ok: false, detail: 'need at least 2 settled runs to review' };
423
+
424
+ const sections: string[] = [];
425
+ for (const r of trs) {
426
+ const d = await getRunDiff(r.id);
427
+ const diff = (d.ok ? d.diff : '(worktree gone — diff unavailable)').slice(0, 15000);
428
+ sections.push(`### run r${r.id} (status: ${r.status})\nAgent's own summary: ${(r.exitSummary || '-').slice(0, 300)}\n\nDiff:\n\`\`\`diff\n${diff}\n\`\`\``);
429
+ }
430
+
431
+ if (!real) {
432
+ return {
433
+ ok: true, detail: 'rehearsal review',
434
+ review: `## AI Review (rehearsal)\n\n${trs.map((r) => `**r${r.id}** — approach: (dry-run placeholder)\n- pros: n/a\n- cons: n/a`).join('\n\n')}\n\n**Recommendation**: run with Real agent for an actual review.`,
435
+ };
436
+ }
437
+
438
+ const prompt =
439
+ `You are reviewing ${trs.length} competing implementations of the same task.\n` +
440
+ `Task: ${task.title}\nOriginal prompt: ${task.prompt.slice(0, 800)}\n\n` +
441
+ sections.join('\n\n') +
442
+ `\n\nWrite a review in markdown, in the language of the task prompt (Korean if the prompt is Korean):\n` +
443
+ `1. For EACH run: one-line approach summary, then pros (max 3) and cons (max 3) as bullets.\n` +
444
+ `2. '## 추천' section: which run to merge and WHY, in 2-3 sentences. If combining both is better, say exactly what to steer.\n` +
445
+ `Judge correctness, simplicity, consistency with the existing codebase, and risk. Be decisive. Respond with ONLY the markdown.`;
446
+ const cmd = `cd ${shq(repo.path)} && ${config.agent.bin} -p ${shq(prompt)} --output-format json`;
447
+ const r = await runShellOn(machine, cmd, 300000);
448
+ if (!r.ok) return { ok: false, detail: 'reviewer failed: ' + (r.stderr || r.stdout).trim().slice(0, 300) };
449
+ try {
450
+ const envelope = JSON.parse(r.stdout.trim()) as { result?: string };
451
+ const review = (envelope.result ?? '').trim();
452
+ if (!review) throw new Error('empty review');
453
+ return { ok: true, detail: 'reviewed', review };
454
+ } catch (e) {
455
+ return { ok: false, detail: 'could not parse review: ' + String(e).slice(0, 200) };
456
+ }
457
+ }
458
+
403
459
  export interface IntegrateResult {
404
460
  runId: number;
405
461
  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, planFanout } from './orchestrator';
15
+ import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask } 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.8.0' }));
35
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.9.0' }));
36
36
 
37
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -325,6 +325,17 @@ export async function buildServer(): Promise<FastifyInstance> {
325
325
  return reply.code(202).send({ ok: true, runs: created.map((r) => ({ id: r.id, status: r.status })) });
326
326
  });
327
327
 
328
+ // AI 리뷰 — 심판 에이전트가 run diff 들을 읽고 접근/장단점/추천을 요약.
329
+ app.post('/api/tasks/:id/review', async (req, reply) => {
330
+ const id = Number((req.params as { id: string }).id);
331
+ const b = (req.body ?? {}) as { real?: boolean };
332
+ const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
333
+ if (!tr[0]) return reply.code(404).send({ error: 'task not found' });
334
+ const res = await reviewTask(id, b.real === true);
335
+ if (!res.ok) return reply.code(422).send(res);
336
+ return res;
337
+ });
338
+
328
339
  // 비교 뷰 — 태스크의 모든 run + 각 diff 를 한 방에 (승자 고르기용).
329
340
  app.get('/api/tasks/:id/compare', async (req, reply) => {
330
341
  const id = Number((req.params as { id: string }).id);
@@ -470,7 +481,7 @@ export async function buildServer(): Promise<FastifyInstance> {
470
481
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
471
482
  app.get('/ws', { websocket: true }, (socket) => {
472
483
  addSink(socket);
473
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.8.0' }));
484
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.9.0' }));
474
485
  socket.on('close', () => removeSink(socket));
475
486
  });
476
487