coxpit 3.0.0 → 3.1.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 +112 -64
- package/src/orchestrator.ts +81 -4
- package/src/server.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.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
|
@@ -13,12 +13,13 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
13
13
|
--ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
|
|
14
14
|
--brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
|
|
15
15
|
--s-pending:#8792a2; --s-preparing:#d6a249; --s-running:#55a7e0;
|
|
16
|
-
--s-done:#58b368; --s-failed:#e25b67; --s-error:#e25b67; --s-stopped:#b58be0; --s-merged:#4ec9b0;
|
|
16
|
+
--s-done:#58b368; --s-failed:#e25b67; --s-error:#e25b67; --s-stopped:#b58be0; --s-merged:#4ec9b0; --s-open:#7f9cf5;
|
|
17
17
|
--mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
|
|
18
18
|
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
|
|
19
19
|
--r-card:10px; --r-ctl:8px; --shadow:0 8px 28px rgba(0,0,0,.35);
|
|
20
20
|
}
|
|
21
21
|
*{box-sizing:border-box}
|
|
22
|
+
[hidden]{display:none !important}
|
|
22
23
|
html,body{height:100%}
|
|
23
24
|
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
|
|
24
25
|
-webkit-font-smoothing:antialiased}
|
|
@@ -298,60 +299,65 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
298
299
|
<div class="layout">
|
|
299
300
|
<aside>
|
|
300
301
|
<div class="sect">
|
|
301
|
-
<p class="sect-label">
|
|
302
|
-
<
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
<button class="btn sm"
|
|
302
|
+
<p class="sect-label">Context</p>
|
|
303
|
+
<p class="flabel">machine</p>
|
|
304
|
+
<select id="repoMachine"></select>
|
|
305
|
+
<p class="flabel">repository</p>
|
|
306
|
+
<select id="taskRepo"></select>
|
|
307
|
+
<div class="row">
|
|
308
|
+
<button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
|
|
309
|
+
<button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
|
|
310
|
+
<button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
|
|
311
|
+
</div>
|
|
312
|
+
<form id="repoForm" hidden>
|
|
308
313
|
<div class="row">
|
|
309
|
-
<input id="repoPath" placeholder="
|
|
314
|
+
<input id="repoPath" placeholder="/abs/path/to/repo" />
|
|
310
315
|
<button class="btn-ghost sm" type="submit" style="flex:0 0 auto">Register</button>
|
|
311
316
|
</div>
|
|
312
317
|
</form>
|
|
313
318
|
</div>
|
|
319
|
+
|
|
314
320
|
<div class="sect">
|
|
315
|
-
<p class="sect-label">
|
|
321
|
+
<p class="sect-label">Start</p>
|
|
322
|
+
<div class="seg" id="launchTabs">
|
|
323
|
+
<button type="button" class="seg-opt on" data-tab="task">Task</button>
|
|
324
|
+
<button type="button" class="seg-opt" data-tab="goal">Goal</button>
|
|
325
|
+
<button type="button" class="seg-opt" data-tab="bench">Workbench</button>
|
|
326
|
+
</div>
|
|
316
327
|
<form id="taskForm">
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
<
|
|
324
|
-
|
|
328
|
+
<div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
|
|
329
|
+
<input id="taskTitle" placeholder="Task title" />
|
|
330
|
+
<textarea id="taskPrompt" placeholder="Prompt — target files, constraints, how to verify"></textarea>
|
|
331
|
+
<p class="flabel">design capture · optional</p>
|
|
332
|
+
<select id="taskCapture"><option value="">no design capture</option></select>
|
|
333
|
+
</div>
|
|
334
|
+
<div id="panelGoal" hidden style="flex-direction:column;gap:8px">
|
|
335
|
+
<textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge with Select runs → Integrate."></textarea>
|
|
336
|
+
</div>
|
|
337
|
+
<div id="panelBench" hidden style="flex-direction:column;gap:8px">
|
|
338
|
+
<input id="benchTitle" placeholder="Workbench name · optional" />
|
|
339
|
+
<p style="font-size:11.5px;color:var(--faint);margin:0;line-height:1.55">Isolated worktree + terminal. Work interactively — run <span style="color:var(--brand);font-family:var(--mono)">claude</span> inside, take hours — then decide the merge from the card.</p>
|
|
340
|
+
</div>
|
|
325
341
|
<div class="seg" id="modeSeg" role="group" aria-label="agent mode">
|
|
326
342
|
<button type="button" class="seg-opt" data-real="0">Dry run</button>
|
|
327
343
|
<button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
|
|
328
344
|
</div>
|
|
329
345
|
<input type="checkbox" id="taskReal" hidden />
|
|
330
346
|
<div class="row">
|
|
331
|
-
<input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="
|
|
347
|
+
<input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="agents — 1 for a job, N to explore variants" />
|
|
332
348
|
<button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
|
|
333
349
|
</div>
|
|
334
350
|
</form>
|
|
335
351
|
</div>
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
<p class="flabel">goal</p>
|
|
342
|
-
<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>
|
|
343
|
-
<button class="btn" type="submit" id="planGo">Plan & fan out</button>
|
|
344
|
-
<span style="font-size:11px;color:var(--faint);font-family:var(--mono)">follows the Dry/Real mode above · planner reads only</span>
|
|
345
|
-
</form>
|
|
346
|
-
</div>
|
|
347
|
-
<div class="sect">
|
|
348
|
-
<p class="sect-label">Design captures</p>
|
|
349
|
-
<div id="captures" style="display:flex;flex-direction:column;gap:6px"></div>
|
|
350
|
-
<a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px"
|
|
352
|
+
|
|
353
|
+
<details class="sect" id="libraryBox">
|
|
354
|
+
<summary class="sect-label" style="cursor:pointer;list-style:none">Library · design captures ▾</summary>
|
|
355
|
+
<div id="captures" style="display:flex;flex-direction:column;gap:6px;margin-top:10px"></div>
|
|
356
|
+
<a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px;margin-top:6px"
|
|
351
357
|
title="drag me to your bookmarks bar, then click it on your running app">⌖ coxpit inspect</a>
|
|
352
358
|
<span style="font-size:11px;color:var(--faint)">Drag to bookmarks. Click it on your app, then click an element.
|
|
353
359
|
With auth on, append ?k=<pass> to the script URL.</span>
|
|
354
|
-
</
|
|
360
|
+
</details>
|
|
355
361
|
</aside>
|
|
356
362
|
<main>
|
|
357
363
|
<div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
|
|
@@ -610,7 +616,11 @@ function humanize(e){
|
|
|
610
616
|
if (kind === 'stderr') return { k:'stderr', t:payload };
|
|
611
617
|
try{
|
|
612
618
|
const o = JSON.parse(payload);
|
|
613
|
-
if (o.type === 'system')
|
|
619
|
+
if (o.type === 'system'){
|
|
620
|
+
if (o.subtype === 'init' || !o.subtype) return { k:'session', t:'started'+(o.model?' · '+o.model:'') };
|
|
621
|
+
if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
|
|
622
|
+
return null; // thinking_tokens 등 스트림 잡음
|
|
623
|
+
}
|
|
614
624
|
if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
|
|
615
625
|
if (o.type === 'assistant' && o.message){
|
|
616
626
|
const parts = [];
|
|
@@ -628,7 +638,17 @@ function humanize(e){
|
|
|
628
638
|
if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
|
|
629
639
|
if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
|
|
630
640
|
return { k:kind, t:payload.slice(0,140) };
|
|
631
|
-
}catch{
|
|
641
|
+
}catch{
|
|
642
|
+
// 파싱 실패(과거에 잘려 저장된 이벤트 등) — JSON 잔해를 그대로 보여주지 않는다:
|
|
643
|
+
// text 조각만 구제하고, 없으면 생략.
|
|
644
|
+
if (payload.trim().startsWith('{')){
|
|
645
|
+
const texts = [];
|
|
646
|
+
const re = /"text":"((?:[^"\\\\]|\\\\.)*)"/g; let m;
|
|
647
|
+
while ((m = re.exec(payload)) && texts.length < 2) texts.push(m[1].replace(/\\\\n/g,' ').slice(0,140));
|
|
648
|
+
return texts.length ? { k:'said', t:texts.join(' · ') } : null;
|
|
649
|
+
}
|
|
650
|
+
return { k:kind, t:payload };
|
|
651
|
+
}
|
|
632
652
|
}
|
|
633
653
|
function humanLines(events){
|
|
634
654
|
const out = [];
|
|
@@ -753,23 +773,19 @@ async function hydrate(){
|
|
|
753
773
|
function paintSidebar(){
|
|
754
774
|
$('machines').innerHTML = machines.map(m =>
|
|
755
775
|
'<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
|
|
756
|
-
$('repos').innerHTML = repos.map(r =>
|
|
757
|
-
'<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
|
|
758
|
-
+ '<button class="x" data-delrepo="'+r.id+'" title="remove repo" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
|
|
759
|
-
+ '<div class="path">'+esc(r.path)+'</div></div>').join('')
|
|
760
|
-
|| '<div class="repo" style="color:var(--faint)">none registered</div>';
|
|
761
776
|
$('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
|
|
777
|
+
const prevRepo = $('taskRepo').value;
|
|
762
778
|
$('taskRepo').innerHTML = repos.length
|
|
763
|
-
? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('')
|
|
764
|
-
: '<option value="">register a repo
|
|
779
|
+
? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+' · '+esc(r.defaultBranch)+'</option>').join('')
|
|
780
|
+
: '<option value="">register a repo — Browse… ↓</option>';
|
|
781
|
+
if (prevRepo && repos.some(r=>String(r.id)===prevRepo)) $('taskRepo').value = prevRepo;
|
|
765
782
|
$('runFleetBtn').disabled = !repos.length;
|
|
766
783
|
const capSel = $('taskCapture');
|
|
767
784
|
const cur = capSel.value;
|
|
768
785
|
capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
|
|
769
786
|
'<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
|
|
770
787
|
capSel.value = cur;
|
|
771
|
-
|
|
772
|
-
['repoMachine','taskRepo','taskCapture','planRepo'].forEach(id => { dressSelect(id); syncSelect(id); });
|
|
788
|
+
['repoMachine','taskRepo','taskCapture'].forEach(id => { dressSelect(id); syncSelect(id); });
|
|
773
789
|
$('captures').innerHTML = captures.map(c=>
|
|
774
790
|
'<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
|
|
775
791
|
+ '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
|
|
@@ -783,16 +799,21 @@ $('captures').addEventListener('click', async (e)=>{
|
|
|
783
799
|
await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
|
|
784
800
|
hydrate();
|
|
785
801
|
});
|
|
786
|
-
$('
|
|
787
|
-
const
|
|
788
|
-
|
|
802
|
+
$('repoRemove').addEventListener('click', async ()=>{
|
|
803
|
+
const rid = $('taskRepo').value;
|
|
804
|
+
if (!rid){ toast('no repository selected', 'error'); return; }
|
|
805
|
+
const yes = await confirmUI('Remove the selected repository from coxpit?',
|
|
789
806
|
{ sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
|
|
790
807
|
if (!yes) return;
|
|
791
|
-
const res = await fetch('/api/repos/'+
|
|
808
|
+
const res = await fetch('/api/repos/'+rid,{method:'DELETE'});
|
|
792
809
|
const j = await res.json().catch(()=>({}));
|
|
793
810
|
if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
|
|
794
811
|
else toast('remove: '+(j.detail||res.status), 'error');
|
|
795
812
|
});
|
|
813
|
+
$('repoManual').addEventListener('click', ()=>{
|
|
814
|
+
const f = $('repoForm'); f.hidden = !f.hidden;
|
|
815
|
+
if (!f.hidden) $('repoPath').focus();
|
|
816
|
+
});
|
|
796
817
|
|
|
797
818
|
/* ── 완료 알림(브라우저) — 벨 토글, run 정착 시 통지 ── */
|
|
798
819
|
let notifyOn = false;
|
|
@@ -1158,28 +1179,53 @@ $('mTerm').addEventListener('click', ()=>{
|
|
|
1158
1179
|
});
|
|
1159
1180
|
|
|
1160
1181
|
/* ── forms ── */
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1182
|
+
/* ── 런처 탭 — 하나의 시작점: Task | Goal | Workbench ── */
|
|
1183
|
+
let lTab = 'task';
|
|
1184
|
+
const L_LABEL = { task: 'Run fleet', goal: 'Plan & fan out', bench: 'Open workbench' };
|
|
1185
|
+
document.querySelectorAll('#launchTabs .seg-opt').forEach(b=>{
|
|
1186
|
+
b.addEventListener('click', ()=>{
|
|
1187
|
+
lTab = b.dataset.tab;
|
|
1188
|
+
document.querySelectorAll('#launchTabs .seg-opt').forEach(x=>x.classList.toggle('on', x===b));
|
|
1189
|
+
$('panelTask').style.display = lTab==='task' ? 'flex' : 'none';
|
|
1190
|
+
$('panelGoal').hidden = lTab!=='goal';
|
|
1191
|
+
$('panelGoal').style.display = lTab==='goal' ? 'flex' : 'none';
|
|
1192
|
+
$('panelBench').hidden = lTab!=='bench';
|
|
1193
|
+
$('panelBench').style.display = lTab==='bench' ? 'flex' : 'none';
|
|
1194
|
+
$('modeSeg').style.display = lTab==='bench' ? 'none' : 'flex'; // workbench 는 에이전트 없음
|
|
1195
|
+
$('taskCount').style.display = lTab==='task' ? '' : 'none';
|
|
1196
|
+
$('runFleetBtn').textContent = L_LABEL[lTab];
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
async function submitGoal(){
|
|
1201
|
+
const repoId = Number($('taskRepo').value);
|
|
1164
1202
|
const goal = $('planGoal').value.trim();
|
|
1165
1203
|
if (!repoId){ toast('register a repo first', 'error'); return; }
|
|
1166
1204
|
if (!goal){ toast('write a goal first', 'error'); return; }
|
|
1167
1205
|
const real = $('taskReal').checked;
|
|
1168
|
-
const btn = $('
|
|
1206
|
+
const btn = $('runFleetBtn');
|
|
1169
1207
|
btn.disabled = true; btn.textContent = real ? 'Planning… (1–3 min)' : 'Planning…';
|
|
1170
1208
|
try{
|
|
1171
1209
|
const res = await fetch('/api/plan',{method:'POST',headers:{'content-type':'application/json'},
|
|
1172
1210
|
body:JSON.stringify({repoId, goal, real})});
|
|
1173
1211
|
const j = await res.json().catch(()=>({}));
|
|
1174
|
-
if (res.ok){
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1212
|
+
if (res.ok){ toast(j.tasks.length+' task(s) planned & launched', 'ok'); $('planGoal').value=''; hydrate(); }
|
|
1213
|
+
else toast('plan: '+(j.detail||j.error||res.status), 'error');
|
|
1214
|
+
} finally { btn.disabled = false; btn.textContent = L_LABEL[lTab]; }
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
async function submitBench(){
|
|
1218
|
+
const repoId = Number($('taskRepo').value);
|
|
1219
|
+
if (!repoId){ toast('register a repo first', 'error'); return; }
|
|
1220
|
+
const res = await fetch('/api/workbench',{method:'POST',headers:{'content-type':'application/json'},
|
|
1221
|
+
body:JSON.stringify({repoId, title: $('benchTitle').value.trim()})});
|
|
1222
|
+
const j = await res.json().catch(()=>({}));
|
|
1223
|
+
if (!res.ok){ toast('workbench: '+(j.detail||j.error||res.status), 'error'); return; }
|
|
1224
|
+
toast('workbench open — terminal attached', 'ok');
|
|
1225
|
+
$('benchTitle').value='';
|
|
1226
|
+
await hydrate();
|
|
1227
|
+
openTerm(j.runId); // 바로 터미널로
|
|
1228
|
+
}
|
|
1183
1229
|
|
|
1184
1230
|
$('repoForm').addEventListener('submit', async (e)=>{
|
|
1185
1231
|
e.preventDefault();
|
|
@@ -1191,9 +1237,11 @@ $('repoForm').addEventListener('submit', async (e)=>{
|
|
|
1191
1237
|
});
|
|
1192
1238
|
$('taskForm').addEventListener('submit', async (e)=>{
|
|
1193
1239
|
e.preventDefault();
|
|
1240
|
+
if (lTab === 'goal') return submitGoal();
|
|
1241
|
+
if (lTab === 'bench') return submitBench();
|
|
1194
1242
|
const repoId = Number($('taskRepo').value);
|
|
1195
1243
|
const title = $('taskTitle').value.trim();
|
|
1196
|
-
if (!repoId || !title) return;
|
|
1244
|
+
if (!repoId || !title){ toast(!repoId?'register a repo first':'task title required', 'error'); return; }
|
|
1197
1245
|
const capId = Number($('taskCapture').value) || undefined;
|
|
1198
1246
|
const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
|
|
1199
1247
|
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
|
package/src/orchestrator.ts
CHANGED
|
@@ -155,8 +155,12 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
155
155
|
const s = line.trim();
|
|
156
156
|
if (!s) return;
|
|
157
157
|
let kind = 'log';
|
|
158
|
+
let stored = s;
|
|
158
159
|
try {
|
|
159
|
-
const obj = JSON.parse(s) as {
|
|
160
|
+
const obj = JSON.parse(s) as {
|
|
161
|
+
type?: string; subtype?: string; model?: string; result?: string; session_id?: string;
|
|
162
|
+
message?: { content?: Array<{ type?: string; text?: string; name?: string; input?: Record<string, unknown> }> };
|
|
163
|
+
};
|
|
160
164
|
if (obj.type) kind = obj.type;
|
|
161
165
|
// steer(--resume) 용 세션 키 캡처
|
|
162
166
|
if (obj.type === 'system' && typeof obj.session_id === 'string') {
|
|
@@ -164,8 +168,28 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
164
168
|
}
|
|
165
169
|
// result 이벤트의 사람이 읽는 요약만 뽑아 둔다(없으면 원본 라인).
|
|
166
170
|
if (obj.type === 'result') lastResult = typeof obj.result === 'string' ? obj.result : s;
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
// 2000자 초과 이벤트는 자르면 JSON 이 깨져 잔해가 화면에 노출된다 —
|
|
172
|
+
// 저장 전에 "요지만 남긴" 유효 JSON 으로 압축한다.
|
|
173
|
+
if (s.length > 2000) {
|
|
174
|
+
if (obj.type === 'assistant' && obj.message) {
|
|
175
|
+
const content = (obj.message.content ?? [])
|
|
176
|
+
.filter((c) => c.type === 'text' || c.type === 'tool_use')
|
|
177
|
+
.map((c) => c.type === 'text'
|
|
178
|
+
? { type: 'text', text: (c.text ?? '').slice(0, 600) }
|
|
179
|
+
: { type: 'tool_use', name: c.name, input: compactInput(c.input) });
|
|
180
|
+
stored = JSON.stringify({ type: 'assistant', message: { content } }).slice(0, 2000);
|
|
181
|
+
} else if (obj.type === 'user') {
|
|
182
|
+
stored = JSON.stringify({ type: 'user' }); // tool 결과 회신 — 표시 안 함
|
|
183
|
+
} else if (obj.type === 'system') {
|
|
184
|
+
stored = JSON.stringify({ type: 'system', subtype: obj.subtype, model: obj.model });
|
|
185
|
+
} else if (obj.type === 'result') {
|
|
186
|
+
stored = JSON.stringify({ type: 'result', result: (obj.result ?? '').slice(0, 1500) });
|
|
187
|
+
} else {
|
|
188
|
+
stored = s.slice(0, 2000);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch { stored = s.slice(0, 2000); /* 비-JSON 로그 라인 */ }
|
|
192
|
+
void recordEvent(runId, kind, stored.slice(0, 2000));
|
|
169
193
|
});
|
|
170
194
|
}
|
|
171
195
|
if (child.stderr) {
|
|
@@ -192,6 +216,16 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
192
216
|
void notifySettle(runId, status, filesChanged, exitSummary);
|
|
193
217
|
}
|
|
194
218
|
|
|
219
|
+
/** tool_use input 을 표시용 핵심 필드만 남긴다(이벤트 압축용). */
|
|
220
|
+
function compactInput(input?: Record<string, unknown>): Record<string, string> {
|
|
221
|
+
const out: Record<string, string> = {};
|
|
222
|
+
if (!input) return out;
|
|
223
|
+
for (const k of ['file_path', 'command', 'path', 'pattern', 'url']) {
|
|
224
|
+
if (typeof input[k] === 'string') out[k] = (input[k] as string).slice(0, 200);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
195
229
|
/** run 정착 웹훅(선택) — COXPIT_WEBHOOK_URL 로 JSON POST. 실패는 무해. */
|
|
196
230
|
async function notifySettle(runId: number, status: string, filesChanged: number, exitSummary: string): Promise<void> {
|
|
197
231
|
if (!config.webhookUrl) return;
|
|
@@ -379,6 +413,49 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
|
|
|
379
413
|
return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
|
|
380
414
|
}
|
|
381
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Workbench — 인터랙티브 작업방: 격리 worktree + tmux 만 만들고 에이전트는
|
|
418
|
+
* 띄우지 않는다. 사람이 터미널로 들어가(원하면 claude TUI 로) 오래 작업하고,
|
|
419
|
+
* coxpit 은 diff·merge·PR·export 레일만 제공한다. status='open'.
|
|
420
|
+
*/
|
|
421
|
+
export async function openWorkbench(repoId: number, title: string): Promise<{
|
|
422
|
+
ok: boolean; detail: string; taskId?: number; runId?: number;
|
|
423
|
+
}> {
|
|
424
|
+
const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
|
|
425
|
+
const repo = rp[0];
|
|
426
|
+
if (!repo) return { ok: false, detail: 'repo not found' };
|
|
427
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
428
|
+
const m = mr[0];
|
|
429
|
+
if (!m) return { ok: false, detail: 'machine not found' };
|
|
430
|
+
const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
|
|
431
|
+
|
|
432
|
+
const tIns = await db.insert(tasks).values({ repoId, title: title || 'Workbench', prompt: '(interactive workbench)' }).returning();
|
|
433
|
+
const task = tIns[0]!;
|
|
434
|
+
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'workbench', status: 'pending' }).returning();
|
|
435
|
+
const run = rIns[0]!;
|
|
436
|
+
const runId = run.id;
|
|
437
|
+
broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'workbench', branch: '', filesChanged: 0 });
|
|
438
|
+
|
|
439
|
+
const branch = `coxpit/r${runId}`;
|
|
440
|
+
const wtParent = ppath.join(ppath.dirname(repo.path), '.coxpit-worktrees');
|
|
441
|
+
const wtPath = ppath.join(wtParent, `r${runId}`);
|
|
442
|
+
const session = `coxpit-r${runId}`;
|
|
443
|
+
|
|
444
|
+
const prep = await runShellOn(
|
|
445
|
+
machine,
|
|
446
|
+
`mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
|
|
447
|
+
` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
|
|
448
|
+
20000,
|
|
449
|
+
);
|
|
450
|
+
if (!prep.ok) {
|
|
451
|
+
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'workbench prep failed' });
|
|
452
|
+
return { ok: false, detail: (prep.stderr || prep.stdout).trim().slice(0, 300) };
|
|
453
|
+
}
|
|
454
|
+
await setRun(runId, { status: 'open', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
|
|
455
|
+
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, workbench: true }));
|
|
456
|
+
return { ok: true, detail: 'workbench open', taskId: task.id, runId };
|
|
457
|
+
}
|
|
458
|
+
|
|
382
459
|
/**
|
|
383
460
|
* Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
|
|
384
461
|
* 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
|
|
@@ -601,7 +678,7 @@ export async function prRun(runId: number): Promise<{ ok: boolean; detail: strin
|
|
|
601
678
|
const run = rr[0];
|
|
602
679
|
if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
|
|
603
680
|
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
|
|
604
|
-
if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
|
|
681
|
+
if (!['done', 'failed', 'stopped', 'open'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
|
|
605
682
|
const wt = shq(run.worktreePath);
|
|
606
683
|
|
|
607
684
|
// 0) 사전 조건: origin 리모트 + gh CLI
|
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, reviewTask, syncRun } from './orchestrator';
|
|
15
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench } 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: '3.
|
|
35
|
+
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.1.0' }));
|
|
36
36
|
|
|
37
37
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨.
|
|
38
38
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
@@ -422,6 +422,16 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
422
422
|
return res;
|
|
423
423
|
});
|
|
424
424
|
|
|
425
|
+
// Workbench — 인터랙티브 작업방(worktree+tmux, 에이전트 없음).
|
|
426
|
+
app.post('/api/workbench', async (req, reply) => {
|
|
427
|
+
const b = (req.body ?? {}) as { repoId?: number; title?: string };
|
|
428
|
+
const repoId = Number(b.repoId);
|
|
429
|
+
if (!repoId) return reply.code(400).send({ error: 'repoId required' });
|
|
430
|
+
const res = await openWorkbench(repoId, (b.title ?? '').trim());
|
|
431
|
+
if (!res.ok) return reply.code(422).send(res);
|
|
432
|
+
return reply.code(201).send(res);
|
|
433
|
+
});
|
|
434
|
+
|
|
425
435
|
// Plan fan-out — 목표 하나 → 플래너가 태스크 분해 → 전부 자동 발사.
|
|
426
436
|
// (real 플래너는 repo 를 읽고 계획하느라 1~3분 걸릴 수 있음 — 클라이언트는 대기)
|
|
427
437
|
app.post('/api/plan', async (req, reply) => {
|
|
@@ -491,7 +501,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
491
501
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
492
502
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
493
503
|
addSink(socket);
|
|
494
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.
|
|
504
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.1.0' }));
|
|
495
505
|
socket.on('close', () => removeSink(socket));
|
|
496
506
|
});
|
|
497
507
|
|