coxpit 2.8.0 → 3.0.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 +2 -1
- package/package.json +1 -1
- package/src/board.ts +146 -18
- package/src/config.ts +2 -0
- package/src/orchestrator.ts +111 -9
- package/src/server.ts +26 -5
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ Your keys and login never touch coxpit's config or database.
|
|
|
71
71
|
| `COXPIT_AGENT_REAL` | — | `1` = real agent CLI by default (credits!) |
|
|
72
72
|
| `COXPIT_AGENT_BIN` | `claude` | agent command |
|
|
73
73
|
| `COXPIT_AGENT_PERM` | `acceptEdits` | headless permission mode passed to the agent |
|
|
74
|
+
| `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
|
|
74
75
|
|
|
75
76
|
Running on the open internet? Put it behind your own front door (Tailscale, Cloudflare Access, a reverse proxy with TLS) and keep basic auth on — it exposes shells.
|
|
76
77
|
|
|
@@ -88,7 +89,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
|
|
|
88
89
|
|
|
89
90
|
## Status
|
|
90
91
|
|
|
91
|
-
`
|
|
92
|
+
`v3.0` — fleet, compare/merge, terminal, Design Mode, run destinations (merge · export · PR), swarm (plan fan-out + integrate with conflict-resolving agents), AI review, and sessions (work/ask + notifications) — all shipped and e2e-tested. Roadmap: ROADMAP.md.
|
|
92
93
|
|
|
93
94
|
## License
|
|
94
95
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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%}
|
|
@@ -280,6 +292,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
280
292
|
<header>
|
|
281
293
|
<div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
|
|
282
294
|
<div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
|
|
295
|
+
<button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
|
|
283
296
|
<div class="machines" id="machines"></div>
|
|
284
297
|
</header>
|
|
285
298
|
<div class="layout">
|
|
@@ -370,14 +383,19 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
370
383
|
</div>
|
|
371
384
|
</div>
|
|
372
385
|
<div class="modal-f" id="steerRow" style="border-top:1px solid var(--line)">
|
|
373
|
-
<
|
|
374
|
-
|
|
386
|
+
<div class="seg" style="flex:0 0 132px" id="steerModeSeg">
|
|
387
|
+
<button type="button" class="seg-opt on" data-mode="work">Work</button>
|
|
388
|
+
<button type="button" class="seg-opt" data-mode="ask">Ask</button>
|
|
389
|
+
</div>
|
|
390
|
+
<input id="steerInput" placeholder="Next instruction — same session & worktree…" style="flex:1" />
|
|
391
|
+
<button class="btn sm" id="steerSend">Send</button>
|
|
375
392
|
</div>
|
|
376
393
|
<div class="modal-f">
|
|
377
394
|
<button class="btn-ghost sm" id="mTerm">Terminal</button>
|
|
378
395
|
<button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
|
|
379
396
|
<button class="btn-ghost sm" id="mCompare">Compare runs</button>
|
|
380
397
|
<button class="btn-ghost sm" id="mExport">Export files…</button>
|
|
398
|
+
<button class="btn-ghost sm" id="mSync">Sync base</button>
|
|
381
399
|
<span class="spacer"></span>
|
|
382
400
|
<button class="btn-danger sm" id="mStop">Stop</button>
|
|
383
401
|
<button class="btn-ghost sm" id="mCleanup">Cleanup</button>
|
|
@@ -390,9 +408,11 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
390
408
|
<div class="modal wide">
|
|
391
409
|
<div class="modal-h">
|
|
392
410
|
<span class="title" id="cmpTitle">Compare</span>
|
|
411
|
+
<button class="btn sm" id="cmpAI">AI review</button>
|
|
393
412
|
<button class="btn-ghost sm" id="cmpRefresh">Refresh</button>
|
|
394
413
|
<button class="x" id="cmpClose" aria-label="close">×</button>
|
|
395
414
|
</div>
|
|
415
|
+
<div class="cmp-review" id="cmpReview" hidden></div>
|
|
396
416
|
<div class="cmp" id="cmpBody"></div>
|
|
397
417
|
</div>
|
|
398
418
|
</div>
|
|
@@ -578,20 +598,51 @@ $('cfmOk').addEventListener('click', ()=>cfmClose(true));
|
|
|
578
598
|
$('cfmCancel').addEventListener('click', ()=>cfmClose(false));
|
|
579
599
|
$('cfmOverlay').addEventListener('click',(e)=>{ if(e.target===$('cfmOverlay')) cfmClose(false); });
|
|
580
600
|
|
|
581
|
-
|
|
601
|
+
/* 이벤트 인간화 — JSON 원문 대신 사람이 읽는 한 줄로. null = 표시 생략(노이즈). */
|
|
602
|
+
function humanize(e){
|
|
603
|
+
const kind = e.kind, payload = e.payload;
|
|
604
|
+
if (kind === 'rate_limit_event') return null;
|
|
605
|
+
if (kind === 'steer') return { k:'steer', t:'→ '+payload };
|
|
606
|
+
if (kind === 'ask') return { k:'ask', t:'? '+payload };
|
|
607
|
+
if (kind === 'sync') return { k:'sync', t:payload };
|
|
608
|
+
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 }; } }
|
|
609
|
+
if (kind === 'pr') return { k:'pr', t:payload };
|
|
610
|
+
if (kind === 'stderr') return { k:'stderr', t:payload };
|
|
582
611
|
try{
|
|
583
612
|
const o = JSON.parse(payload);
|
|
613
|
+
if (o.type === 'system') return { k:'session', t:'started · '+(o.model||o.subtype||'') };
|
|
614
|
+
if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
|
|
584
615
|
if (o.type === 'assistant' && o.message){
|
|
585
|
-
const
|
|
586
|
-
|
|
616
|
+
const parts = [];
|
|
617
|
+
for (const x of (o.message.content||[])){
|
|
618
|
+
if (x.type === 'text' && x.text) parts.push({ k:'said', t:x.text });
|
|
619
|
+
else if (x.type === 'tool_use'){
|
|
620
|
+
const i = x.input || {};
|
|
621
|
+
const arg = i.file_path || i.command || i.path || i.pattern || '';
|
|
622
|
+
parts.push({ k:'tool', t:'▸ '+x.name+(arg?' — '+String(arg).split('/').slice(-2).join('/').slice(0,60):'') });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return parts.length ? parts : null;
|
|
587
626
|
}
|
|
588
|
-
if (o.type === 'assistant' && o.text) return o.text;
|
|
589
|
-
if (o.type === 'result') return o.result || '
|
|
590
|
-
if (
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
627
|
+
if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
|
|
628
|
+
if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
|
|
629
|
+
if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
|
|
630
|
+
return { k:kind, t:payload.slice(0,140) };
|
|
631
|
+
}catch{ return { k:kind, t:payload }; }
|
|
632
|
+
}
|
|
633
|
+
function humanLines(events){
|
|
634
|
+
const out = [];
|
|
635
|
+
for (const e of (events||[])){
|
|
636
|
+
const h = humanize(e);
|
|
637
|
+
if (!h) continue;
|
|
638
|
+
if (Array.isArray(h)) out.push(...h); else out.push(h);
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
function summarize(kind, payload){
|
|
643
|
+
const h = humanize({kind, payload});
|
|
644
|
+
if (!h) return '';
|
|
645
|
+
return Array.isArray(h) ? h.map(x=>x.t).join(' · ') : h.t;
|
|
595
646
|
}
|
|
596
647
|
function diffHTML(text){
|
|
597
648
|
if (!text.trim()) return '<span style="color:var(--faint)">no changes</span>';
|
|
@@ -667,8 +718,8 @@ function cardHTML(r){
|
|
|
667
718
|
const closed = task && task.status==='closed';
|
|
668
719
|
const title = (task ? esc(task.title) : ('task ' + (r.taskId ?? '?')))
|
|
669
720
|
+ (closed ? ' <span class="closed">· closed</span>' : '');
|
|
670
|
-
const evs = (r.events
|
|
671
|
-
'<div class="ev"><span class="k">'+esc(
|
|
721
|
+
const evs = humanLines(r.events).slice(-8).map(h =>
|
|
722
|
+
'<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t).slice(0,140)+'</span></div>'
|
|
672
723
|
).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
|
|
673
724
|
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
|
|
674
725
|
return '<div class="card'+selCls+'" id="card-'+r.id+'">'
|
|
@@ -743,6 +794,33 @@ $('repos').addEventListener('click', async (e)=>{
|
|
|
743
794
|
else toast('remove: '+(j.detail||res.status), 'error');
|
|
744
795
|
});
|
|
745
796
|
|
|
797
|
+
/* ── 완료 알림(브라우저) — 벨 토글, run 정착 시 통지 ── */
|
|
798
|
+
let notifyOn = false;
|
|
799
|
+
try { notifyOn = localStorage.getItem('coxpit.notify') === '1' && Notification.permission === 'granted'; } catch {}
|
|
800
|
+
function paintBell(){ $('bell').textContent = notifyOn ? '🔔' : '🔕'; }
|
|
801
|
+
$('bell').addEventListener('click', async ()=>{
|
|
802
|
+
if (!('Notification' in window)){ toast('this browser has no notification support', 'error'); return; }
|
|
803
|
+
if (!notifyOn){
|
|
804
|
+
const perm = await Notification.requestPermission();
|
|
805
|
+
if (perm !== 'granted'){ toast('notification permission denied', 'error'); return; }
|
|
806
|
+
notifyOn = true;
|
|
807
|
+
} else notifyOn = false;
|
|
808
|
+
try { localStorage.setItem('coxpit.notify', notifyOn ? '1' : '0'); } catch {}
|
|
809
|
+
paintBell();
|
|
810
|
+
toast(notifyOn ? 'will notify when runs settle' : 'notifications off', 'ok');
|
|
811
|
+
});
|
|
812
|
+
paintBell();
|
|
813
|
+
function notifySettleUI(ev){
|
|
814
|
+
if (!notifyOn) return;
|
|
815
|
+
const t = tasks.get(ev.taskId ?? (runs.get(ev.runId)||{}).taskId);
|
|
816
|
+
try {
|
|
817
|
+
new Notification('coxpit · r'+ev.runId+' '+ev.status, {
|
|
818
|
+
body: (t ? t.title+' — ' : '') + (ev.filesChanged??0)+' file(s) changed',
|
|
819
|
+
tag: 'coxpit-r'+ev.runId,
|
|
820
|
+
});
|
|
821
|
+
} catch {}
|
|
822
|
+
}
|
|
823
|
+
|
|
746
824
|
function connectWS(){
|
|
747
825
|
const proto = location.protocol==='https:'?'wss':'ws';
|
|
748
826
|
const ws = new WebSocket(proto+'://'+location.host+'/ws');
|
|
@@ -758,6 +836,7 @@ function connectWS(){
|
|
|
758
836
|
render(); flash(ev.runId ?? ev.id); paintModal();
|
|
759
837
|
if (openRunId===(ev.runId??ev.id) && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
|
|
760
838
|
if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
|
|
839
|
+
if (['done','failed','error','stopped'].includes(ev.status)) notifySettleUI(ev);
|
|
761
840
|
} else if (ev.type==='event'){
|
|
762
841
|
const r = runs.get(ev.runId); if(!r){ hydrate(); return; }
|
|
763
842
|
r.events = r.events||[]; r.events.push({ kind:ev.kind, payload:ev.payload });
|
|
@@ -787,8 +866,8 @@ function paintModal(){
|
|
|
787
866
|
$('mStop').style.display = (r.status==='running'||r.status==='preparing'||r.status==='pending') ? '' : 'none';
|
|
788
867
|
// steer 는 정착한 real run 에서만 의미(드라이런은 세션 없음 — 서버가 사유와 함께 거절)
|
|
789
868
|
$('steerRow').style.display = ['done','failed','stopped'].includes(r.status) ? '' : 'none';
|
|
790
|
-
$('mTimeline').innerHTML = (r.events
|
|
791
|
-
'<div class="ev"><span class="k">'+esc(
|
|
869
|
+
$('mTimeline').innerHTML = humanLines(r.events).map(h =>
|
|
870
|
+
'<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t)+'</span></div>'
|
|
792
871
|
).join('') || '<span style="color:var(--faint)">no events yet</span>';
|
|
793
872
|
}
|
|
794
873
|
async function loadDiff(){
|
|
@@ -870,14 +949,34 @@ $('expOk').addEventListener('click', doExport);
|
|
|
870
949
|
$('expDest').addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); doExport(); } });
|
|
871
950
|
$('expCancel').addEventListener('click', ()=>$('expOverlay').classList.remove('open'));
|
|
872
951
|
$('expOverlay').addEventListener('click',(e)=>{ if(e.target===$('expOverlay')) $('expOverlay').classList.remove('open'); });
|
|
952
|
+
let steerMode = 'work';
|
|
953
|
+
document.querySelectorAll('#steerModeSeg .seg-opt').forEach(b=>{
|
|
954
|
+
b.addEventListener('click', ()=>{
|
|
955
|
+
steerMode = b.dataset.mode;
|
|
956
|
+
document.querySelectorAll('#steerModeSeg .seg-opt').forEach(x=>x.classList.toggle('on', x===b));
|
|
957
|
+
$('steerInput').placeholder = steerMode==='ask'
|
|
958
|
+
? 'Ask the session — status, decisions, anything (no file changes)…'
|
|
959
|
+
: 'Next instruction — same session & worktree…';
|
|
960
|
+
});
|
|
961
|
+
});
|
|
873
962
|
async function sendSteer(){
|
|
874
963
|
if (openRunId==null) return;
|
|
875
964
|
const msg = $('steerInput').value.trim(); if(!msg) return;
|
|
876
965
|
const res = await fetch('/api/runs/'+openRunId+'/steer',{method:'POST',
|
|
877
|
-
headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg})});
|
|
878
|
-
if (res.ok){
|
|
966
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg, mode:steerMode})});
|
|
967
|
+
if (res.ok){
|
|
968
|
+
$('steerInput').value='';
|
|
969
|
+
toast(steerMode==='ask' ? 'asking — answer lands in the timeline' : 'working — same session & worktree', 'ok');
|
|
970
|
+
}
|
|
879
971
|
else { const j = await res.json().catch(()=>({})); toast('steer: '+(j.detail||res.status), 'error'); }
|
|
880
972
|
}
|
|
973
|
+
$('mSync').addEventListener('click', async ()=>{
|
|
974
|
+
if (openRunId==null) return;
|
|
975
|
+
const res = await fetch('/api/runs/'+openRunId+'/sync',{method:'POST'});
|
|
976
|
+
const j = await res.json().catch(()=>({}));
|
|
977
|
+
if (res.ok){ toast('base merged into session worktree', 'ok'); loadDiff(); }
|
|
978
|
+
else toast('sync: '+(j.detail||res.status), 'error');
|
|
979
|
+
});
|
|
881
980
|
$('steerSend').addEventListener('click', sendSteer);
|
|
882
981
|
$('steerInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') sendSteer(); });
|
|
883
982
|
$('mStop').addEventListener('click', async ()=>{
|
|
@@ -908,6 +1007,7 @@ $('mCloseTask').addEventListener('click', async ()=>{
|
|
|
908
1007
|
let cmpTaskId = null;
|
|
909
1008
|
async function openCompare(taskId){
|
|
910
1009
|
cmpTaskId = taskId;
|
|
1010
|
+
$('cmpReview').hidden = true; $('cmpReview').innerHTML = '';
|
|
911
1011
|
$('cmpOverlay').classList.add('open');
|
|
912
1012
|
$('cmpBody').innerHTML = '<div class="empty" style="flex:1">loading…</div>';
|
|
913
1013
|
await paintCompare();
|
|
@@ -969,6 +1069,34 @@ $('cmpBody').addEventListener('click', async (e)=>{
|
|
|
969
1069
|
$('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
|
|
970
1070
|
$('cmpOverlay').addEventListener('click',(e)=>{ if(e.target===$('cmpOverlay')){ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
971
1071
|
$('cmpRefresh').addEventListener('click', paintCompare);
|
|
1072
|
+
/* 초경량 md 렌더 (리뷰 표시용) */
|
|
1073
|
+
function mdLite(src){
|
|
1074
|
+
let s = esc(src);
|
|
1075
|
+
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>');
|
|
1076
|
+
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
1077
|
+
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
1078
|
+
s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
1079
|
+
s = s.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
|
|
1080
|
+
s = s.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
|
|
1081
|
+
s = s.replace(/(<li>[\\s\\S]*?<\\/li>)(?!\\s*<li>)/g, '<ul>$1</ul>');
|
|
1082
|
+
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('');
|
|
1083
|
+
return s;
|
|
1084
|
+
}
|
|
1085
|
+
$('cmpAI').addEventListener('click', async ()=>{
|
|
1086
|
+
if (cmpTaskId==null) return;
|
|
1087
|
+
const yes = await confirmUI('Run an AI review of these implementations?',
|
|
1088
|
+
{ 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' });
|
|
1089
|
+
if (!yes) return;
|
|
1090
|
+
const btn = $('cmpAI');
|
|
1091
|
+
btn.disabled = true; btn.textContent = 'Reviewing…';
|
|
1092
|
+
try{
|
|
1093
|
+
const res = await fetch('/api/tasks/'+cmpTaskId+'/review',{method:'POST',
|
|
1094
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({real:true})});
|
|
1095
|
+
const j = await res.json().catch(()=>({}));
|
|
1096
|
+
if (res.ok){ $('cmpReview').innerHTML = mdLite(j.review||''); $('cmpReview').hidden = false; }
|
|
1097
|
+
else toast('review: '+(j.detail||res.status), 'error');
|
|
1098
|
+
} finally { btn.disabled = false; btn.textContent = 'AI review'; }
|
|
1099
|
+
});
|
|
972
1100
|
$('mCompare').addEventListener('click', ()=>{
|
|
973
1101
|
if (openRunId==null) return;
|
|
974
1102
|
const r = runs.get(openRunId); if(!r) return;
|
package/src/config.ts
CHANGED
|
@@ -21,6 +21,8 @@ export const config = {
|
|
|
21
21
|
dbPath: process.env.COXPIT_DB ?? './coxpit.db',
|
|
22
22
|
// 원격 머신 SSH 개인키 경로(선택). 없으면 ssh 기본 키/에이전트 사용.
|
|
23
23
|
sshKey: process.env.COXPIT_SSH_KEY ?? '',
|
|
24
|
+
// run 정착 시 POST 할 웹훅(선택) — 텔레그램 브릿지 등 사용자 연결용.
|
|
25
|
+
webhookUrl: process.env.COXPIT_WEBHOOK_URL ?? '',
|
|
24
26
|
agent: {
|
|
25
27
|
// 기본 드라이런(모의 에이전트). 실제 CLI 실행은 켤 때만(크레딧 소모).
|
|
26
28
|
real: process.env.COXPIT_AGENT_REAL === '1',
|
package/src/orchestrator.ts
CHANGED
|
@@ -186,19 +186,35 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
186
186
|
const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
|
|
187
187
|
|
|
188
188
|
const wasStopped = stoppedRuns.delete(runId);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
189
|
+
const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
|
|
190
|
+
const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
|
|
191
|
+
await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary });
|
|
192
|
+
void notifySettle(runId, status, filesChanged, exitSummary);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** run 정착 웹훅(선택) — COXPIT_WEBHOOK_URL 로 JSON POST. 실패는 무해. */
|
|
196
|
+
async function notifySettle(runId: number, status: string, filesChanged: number, exitSummary: string): Promise<void> {
|
|
197
|
+
if (!config.webhookUrl) return;
|
|
198
|
+
try {
|
|
199
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
200
|
+
const tr = rr[0] ? await db.select().from(tasks).where(eq(tasks.id, rr[0].taskId)).limit(1) : [];
|
|
201
|
+
await fetch(config.webhookUrl, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { 'content-type': 'application/json' },
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
event: 'run.settled',
|
|
206
|
+
run: { id: runId, status, filesChanged, exitSummary: exitSummary.slice(0, 300), task: tr[0]?.title ?? '' },
|
|
207
|
+
}),
|
|
208
|
+
signal: AbortSignal.timeout(8000),
|
|
209
|
+
});
|
|
210
|
+
} catch { /* 웹훅 실패는 조용히 */ }
|
|
195
211
|
}
|
|
196
212
|
|
|
197
213
|
/**
|
|
198
214
|
* 후속 지시(steer) — 정착한 run 의 세션을 --resume 으로 이어 같은 worktree 에서 계속.
|
|
199
215
|
* fire-and-forget. 진행 중 run 은 거부(개입은 터미널로).
|
|
200
216
|
*/
|
|
201
|
-
export async function steerRun(runId: number, message: string): Promise<{ ok: boolean; detail: string }> {
|
|
217
|
+
export async function steerRun(runId: number, message: string, mode: 'work' | 'ask' = 'work'): Promise<{ ok: boolean; detail: string }> {
|
|
202
218
|
const ctx = await loadContext(runId);
|
|
203
219
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
204
220
|
const run = rr[0];
|
|
@@ -213,11 +229,16 @@ export async function steerRun(runId: number, message: string): Promise<{ ok: bo
|
|
|
213
229
|
if (!exists.stdout.includes('yes')) return { ok: false, detail: 'worktree missing on machine' };
|
|
214
230
|
|
|
215
231
|
await setRun(runId, { status: 'running', endedAt: null });
|
|
216
|
-
await recordEvent(runId, 'steer', message.slice(0, 2000));
|
|
232
|
+
await recordEvent(runId, mode === 'ask' ? 'ask' : 'steer', message.slice(0, 2000));
|
|
233
|
+
|
|
234
|
+
// Ask 모드 — 세션에 질문만: 파일 수정 없이 답변만 하도록 래핑
|
|
235
|
+
const finalMessage = mode === 'ask'
|
|
236
|
+
? `Question about your work in this session (do NOT modify any files, do NOT run write commands — answer concisely):\n${message}`
|
|
237
|
+
: message;
|
|
217
238
|
|
|
218
239
|
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
219
240
|
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
220
|
-
const resume = `${config.agent.bin} -p --resume ${shq(run.sessionId)} ${shq(
|
|
241
|
+
const resume = `${config.agent.bin} -p --resume ${shq(run.sessionId)} ${shq(finalMessage)}` +
|
|
221
242
|
` --output-format stream-json --verbose --permission-mode ${config.agent.perm}`;
|
|
222
243
|
const cmd = `cd ${shq(wt)} && ${pidPrefix}{ ${resume}; }`;
|
|
223
244
|
void runAgentChild(runId, ctx.machine, wt, cmd);
|
|
@@ -285,6 +306,31 @@ export async function getRunDiff(runId: number): Promise<{ ok: boolean; diff: st
|
|
|
285
306
|
return { ok: true, stat: stat.trim(), diff: diff.slice(0, 200_000) };
|
|
286
307
|
}
|
|
287
308
|
|
|
309
|
+
/**
|
|
310
|
+
* base 동기화 — 오래 사는 세션의 worktree 에 base 브랜치 최신을 머지한다.
|
|
311
|
+
* 충돌 시 자동 abort — 그땐 steer 로 에이전트에게 머지를 맡기라고 안내.
|
|
312
|
+
*/
|
|
313
|
+
export async function syncRun(runId: number): Promise<{ ok: boolean; detail: string; conflict?: boolean }> {
|
|
314
|
+
const ctx = await loadContext(runId);
|
|
315
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
316
|
+
const run = rr[0];
|
|
317
|
+
if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
|
|
318
|
+
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — wait for it to settle' };
|
|
319
|
+
const wt = shq(run.worktreePath);
|
|
320
|
+
const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
|
|
321
|
+
// 미커밋 변경 먼저 커밋(머지 가능 상태로)
|
|
322
|
+
const c1 = await runShellOn(ctx.machine,
|
|
323
|
+
`cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: checkpoint before base sync`)})`, 20000);
|
|
324
|
+
if (!c1.ok) return { ok: false, detail: 'checkpoint commit failed' };
|
|
325
|
+
const mg = await runShellOn(ctx.machine,
|
|
326
|
+
`cd ${wt} && git ${ident} -c commit.gpgsign=false merge --no-edit ${shq(ctx.baseBranch)} 2>&1 || (git merge --abort 2>/dev/null; echo COXPIT_SYNC_CONFLICT)`, 30000);
|
|
327
|
+
if (mg.stdout.includes('COXPIT_SYNC_CONFLICT')) {
|
|
328
|
+
return { ok: false, conflict: true, detail: `conflict with ${ctx.baseBranch} — steer the agent: "merge ${ctx.baseBranch} and resolve conflicts"` };
|
|
329
|
+
}
|
|
330
|
+
await recordEvent(runId, 'sync', `merged ${ctx.baseBranch} into session worktree`);
|
|
331
|
+
return { ok: true, detail: mg.stdout.trim().split('\n').slice(-1)[0] ?? 'synced' };
|
|
332
|
+
}
|
|
333
|
+
|
|
288
334
|
/**
|
|
289
335
|
* 승자 run 머지 — worktree 미커밋 변경을 자동 커밋 후 run 브랜치를
|
|
290
336
|
* repo 기본 브랜치에 merge. 본 repo 가 기본 브랜치+클린일 때만, 충돌 시 abort.
|
|
@@ -400,6 +446,62 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
|
|
|
400
446
|
return { ok: true, detail: `${created.length} task(s) launched`, tasks: created };
|
|
401
447
|
}
|
|
402
448
|
|
|
449
|
+
/**
|
|
450
|
+
* AI 리뷰(심판) — 태스크의 정착 run diff 들을 리뷰 에이전트가 읽고
|
|
451
|
+
* 접근 방식·장단점·리스크·추천을 요약한다. 사람은 코드 전수가 아니라
|
|
452
|
+
* 판단만 하면 되도록. (read-only, 워크트리 불필요)
|
|
453
|
+
*/
|
|
454
|
+
export async function reviewTask(taskId: number, real: boolean): Promise<{ ok: boolean; detail: string; review?: string }> {
|
|
455
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, taskId)).limit(1);
|
|
456
|
+
const task = tr[0];
|
|
457
|
+
if (!task) return { ok: false, detail: 'task not found' };
|
|
458
|
+
const rp = await db.select().from(repos).where(eq(repos.id, task.repoId)).limit(1);
|
|
459
|
+
const repo = rp[0];
|
|
460
|
+
if (!repo) return { ok: false, detail: 'repo not found' };
|
|
461
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
462
|
+
const m = mr[0];
|
|
463
|
+
if (!m) return { ok: false, detail: 'machine not found' };
|
|
464
|
+
const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
|
|
465
|
+
|
|
466
|
+
const trs = (await db.select().from(agentRuns).where(eq(agentRuns.taskId, taskId)))
|
|
467
|
+
.filter((r) => ['done', 'failed', 'stopped', 'merged'].includes(r.status));
|
|
468
|
+
if (trs.length < 2) return { ok: false, detail: 'need at least 2 settled runs to review' };
|
|
469
|
+
|
|
470
|
+
const sections: string[] = [];
|
|
471
|
+
for (const r of trs) {
|
|
472
|
+
const d = await getRunDiff(r.id);
|
|
473
|
+
const diff = (d.ok ? d.diff : '(worktree gone — diff unavailable)').slice(0, 15000);
|
|
474
|
+
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\`\`\``);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (!real) {
|
|
478
|
+
return {
|
|
479
|
+
ok: true, detail: 'rehearsal review',
|
|
480
|
+
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.`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const prompt =
|
|
485
|
+
`You are reviewing ${trs.length} competing implementations of the same task.\n` +
|
|
486
|
+
`Task: ${task.title}\nOriginal prompt: ${task.prompt.slice(0, 800)}\n\n` +
|
|
487
|
+
sections.join('\n\n') +
|
|
488
|
+
`\n\nWrite a review in markdown, in the language of the task prompt (Korean if the prompt is Korean):\n` +
|
|
489
|
+
`1. For EACH run: one-line approach summary, then pros (max 3) and cons (max 3) as bullets.\n` +
|
|
490
|
+
`2. '## 추천' section: which run to merge and WHY, in 2-3 sentences. If combining both is better, say exactly what to steer.\n` +
|
|
491
|
+
`Judge correctness, simplicity, consistency with the existing codebase, and risk. Be decisive. Respond with ONLY the markdown.`;
|
|
492
|
+
const cmd = `cd ${shq(repo.path)} && ${config.agent.bin} -p ${shq(prompt)} --output-format json`;
|
|
493
|
+
const r = await runShellOn(machine, cmd, 300000);
|
|
494
|
+
if (!r.ok) return { ok: false, detail: 'reviewer failed: ' + (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
495
|
+
try {
|
|
496
|
+
const envelope = JSON.parse(r.stdout.trim()) as { result?: string };
|
|
497
|
+
const review = (envelope.result ?? '').trim();
|
|
498
|
+
if (!review) throw new Error('empty review');
|
|
499
|
+
return { ok: true, detail: 'reviewed', review };
|
|
500
|
+
} catch (e) {
|
|
501
|
+
return { ok: false, detail: 'could not parse review: ' + String(e).slice(0, 200) };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
403
505
|
export interface IntegrateResult {
|
|
404
506
|
runId: number;
|
|
405
507
|
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, syncRun } 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: '
|
|
35
|
+
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.0.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);
|
|
@@ -391,16 +402,26 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
391
402
|
// 후속 지시(steer) — 정착한 run 을 같은 세션(--resume)·같은 worktree 로 계속.
|
|
392
403
|
app.post('/api/runs/:id/steer', async (req, reply) => {
|
|
393
404
|
const id = Number((req.params as { id: string }).id);
|
|
394
|
-
const b = (req.body ?? {}) as { message?: string };
|
|
405
|
+
const b = (req.body ?? {}) as { message?: string; mode?: string };
|
|
395
406
|
const message = (b.message ?? '').trim();
|
|
396
407
|
if (!message) return reply.code(400).send({ error: 'message required' });
|
|
397
408
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
398
409
|
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
399
|
-
const res = await steerRun(id, message);
|
|
410
|
+
const res = await steerRun(id, message, b.mode === 'ask' ? 'ask' : 'work');
|
|
400
411
|
if (!res.ok) return reply.code(409).send(res);
|
|
401
412
|
return reply.code(202).send(res);
|
|
402
413
|
});
|
|
403
414
|
|
|
415
|
+
// base 동기화 — 오래 사는 세션 worktree 에 base 최신 머지.
|
|
416
|
+
app.post('/api/runs/:id/sync', async (req, reply) => {
|
|
417
|
+
const id = Number((req.params as { id: string }).id);
|
|
418
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
419
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
420
|
+
const res = await syncRun(id);
|
|
421
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
422
|
+
return res;
|
|
423
|
+
});
|
|
424
|
+
|
|
404
425
|
// Plan fan-out — 목표 하나 → 플래너가 태스크 분해 → 전부 자동 발사.
|
|
405
426
|
// (real 플래너는 repo 를 읽고 계획하느라 1~3분 걸릴 수 있음 — 클라이언트는 대기)
|
|
406
427
|
app.post('/api/plan', async (req, reply) => {
|
|
@@ -470,7 +491,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
470
491
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
471
492
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
472
493
|
addSink(socket);
|
|
473
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '
|
|
494
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.0.0' }));
|
|
474
495
|
socket.on('close', () => removeSink(socket));
|
|
475
496
|
});
|
|
476
497
|
|