coxpit 2.1.2 → 2.3.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 +16 -1
- package/package.json +1 -1
- package/src/board.ts +188 -8
- package/src/config.ts +14 -0
- package/src/db/index.ts +3 -1
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +85 -44
- package/src/server.ts +23 -5
package/README.md
CHANGED
|
@@ -42,7 +42,22 @@ npm run dev
|
|
|
42
42
|
|
|
43
43
|
Open the board, register a repo (absolute path), write a task, hit **Run fleet**.
|
|
44
44
|
|
|
45
|
-
By default agents run in **dry-run mode** (a mock that exercises the whole pipeline without spending credits). Flip the
|
|
45
|
+
By default agents run in **dry-run mode** (a mock that exercises the whole pipeline without spending credits). Flip the Dry/Real toggle per launch, or set `COXPIT_AGENT_REAL=1` to default to real.
|
|
46
|
+
|
|
47
|
+
## First run
|
|
48
|
+
|
|
49
|
+
Coxpit has no accounts of its own — it drives the agent CLI already on your machine, with that CLI's own login:
|
|
50
|
+
|
|
51
|
+
1. **Install & sign in to the agent CLI once** (Claude Code by default):
|
|
52
|
+
```bash
|
|
53
|
+
npm i -g @anthropic-ai/claude-code
|
|
54
|
+
claude # first run opens browser login
|
|
55
|
+
```
|
|
56
|
+
Prefer another CLI? Set `COXPIT_AGENT_BIN`.
|
|
57
|
+
2. **Open the board** — the first-run panel checks this machine (git · tmux · agent CLI) and tells you what's missing.
|
|
58
|
+
3. **Rehearse with Dry run**, then flip to Real agent. Real runs spend your CLI account's credits — nothing is billed through coxpit.
|
|
59
|
+
|
|
60
|
+
Your keys and login never touch coxpit's config or database.
|
|
46
61
|
|
|
47
62
|
## Configuration
|
|
48
63
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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
|
@@ -71,6 +71,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
71
71
|
.row .narrow{flex:0 0 64px}
|
|
72
72
|
.check{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);cursor:pointer;user-select:none}
|
|
73
73
|
.check input{width:auto;accent-color:var(--brand)}
|
|
74
|
+
.seg{display:flex;gap:3px;padding:3px;border:1px solid var(--line);border-radius:var(--r-ctl);background:#0e1118}
|
|
75
|
+
.seg-opt{flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;padding:6px 8px;border:none;
|
|
76
|
+
background:transparent;color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;
|
|
77
|
+
border-radius:calc(var(--r-ctl) - 3px);transition:background .15s,color .15s}
|
|
78
|
+
.seg-opt:hover{color:var(--ink)}
|
|
79
|
+
.seg-opt.on{background:var(--surface2);color:var(--ink)}
|
|
80
|
+
.seg-opt[data-real="1"].on{background:var(--brand);color:var(--brand-ink)}
|
|
81
|
+
.seg-hint{font-family:var(--mono);font-size:9.5px;font-weight:500;text-transform:uppercase;letter-spacing:.06em;opacity:.7}
|
|
74
82
|
|
|
75
83
|
/* ── buttons ────────────────────────────── */
|
|
76
84
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:none;cursor:pointer;
|
|
@@ -112,6 +120,48 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
112
120
|
.empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:64px 24px;text-align:center;
|
|
113
121
|
display:flex;flex-direction:column;gap:10px;align-items:center}
|
|
114
122
|
.empty .glyph{font-size:22px;color:#2c3444;letter-spacing:4px}
|
|
123
|
+
|
|
124
|
+
/* ── onboarding (first run) ─────────────── */
|
|
125
|
+
.setup{max-width:560px;margin:40px auto;border:1px solid var(--line);border-radius:14px;
|
|
126
|
+
background:var(--surface);overflow:hidden;text-align:left}
|
|
127
|
+
.setup-h{padding:18px 22px 14px;border-bottom:1px solid var(--line)}
|
|
128
|
+
.setup-h .t{font-weight:700;font-size:16px}
|
|
129
|
+
.setup-h .d{color:var(--muted);font-size:13px;margin-top:3px}
|
|
130
|
+
.setup-sec{padding:14px 22px;border-bottom:1px solid var(--line)}
|
|
131
|
+
.setup-sec:last-child{border-bottom:none}
|
|
132
|
+
.setup-label{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.14em;
|
|
133
|
+
color:var(--faint);margin:0 0 9px}
|
|
134
|
+
.chk{display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px}
|
|
135
|
+
.chk .st{font-family:var(--mono);font-size:12px;width:18px;text-align:center;flex:none}
|
|
136
|
+
.chk.ok .st{color:var(--s-done)} .chk.bad .st{color:var(--s-failed)} .chk.wait .st{color:var(--faint)}
|
|
137
|
+
.chk .nm{color:var(--ink);min-width:88px;font-weight:500}
|
|
138
|
+
.chk .v{color:var(--faint);font-family:var(--mono);font-size:11.5px;overflow:hidden;
|
|
139
|
+
white-space:nowrap;text-overflow:ellipsis}
|
|
140
|
+
.setup-fix{margin:8px 0 2px;padding:10px 13px;background:#0e1118;border:1px solid var(--line);
|
|
141
|
+
border-radius:8px;font-family:var(--mono);font-size:11.5px;color:var(--muted);overflow-x:auto;white-space:pre}
|
|
142
|
+
.setup-steps{margin:0;padding-left:20px;color:var(--muted);font-size:13px}
|
|
143
|
+
.setup-steps li{margin-bottom:7px}
|
|
144
|
+
.setup-steps b{color:var(--ink)}
|
|
145
|
+
|
|
146
|
+
/* ── toasts ─────────────────────────────── */
|
|
147
|
+
.toasts{position:fixed;top:66px;right:18px;z-index:60;display:flex;flex-direction:column;gap:8px;
|
|
148
|
+
max-width:380px}
|
|
149
|
+
.toast{border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);
|
|
150
|
+
padding:10px 14px;font-size:13px;box-shadow:var(--shadow);display:flex;gap:9px;align-items:baseline;
|
|
151
|
+
animation:tin .18s ease}
|
|
152
|
+
.toast .tk{font-family:var(--mono);font-size:11px;flex:none}
|
|
153
|
+
.toast.err{border-color:rgba(226,91,103,.5)} .toast.err .tk{color:var(--s-failed)}
|
|
154
|
+
.toast.ok{border-color:rgba(78,201,176,.5)} .toast.ok .tk{color:var(--brand)}
|
|
155
|
+
@keyframes tin{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
|
|
156
|
+
@media (prefers-reduced-motion:reduce){.toast{animation:none}}
|
|
157
|
+
|
|
158
|
+
/* ── confirm dialog ─────────────────────── */
|
|
159
|
+
.cfm{width:min(440px,92vw);background:var(--surface);border:1px solid var(--line);border-radius:14px;
|
|
160
|
+
box-shadow:var(--shadow);overflow:hidden}
|
|
161
|
+
.cfm-b{padding:20px 22px 14px}
|
|
162
|
+
.cfm-b .m{font-size:14px;color:var(--ink);line-height:1.6}
|
|
163
|
+
.cfm-b .s{font-size:12.5px;color:var(--faint);margin-top:6px}
|
|
164
|
+
.cfm-f{display:flex;gap:8px;justify-content:flex-end;padding:12px 18px;border-top:1px solid var(--line)}
|
|
115
165
|
.flash{animation:flash .5s ease}
|
|
116
166
|
@keyframes flash{from{border-color:rgba(78,201,176,.6)}to{border-color:var(--line)}}
|
|
117
167
|
|
|
@@ -195,11 +245,15 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
195
245
|
<input id="taskTitle" placeholder="Task title" />
|
|
196
246
|
<textarea id="taskPrompt" placeholder="Prompt for the agents…"></textarea>
|
|
197
247
|
<select id="taskCapture"><option value="">no design capture</option></select>
|
|
248
|
+
<div class="seg" id="modeSeg" role="group" aria-label="agent mode">
|
|
249
|
+
<button type="button" class="seg-opt" data-real="0">Dry run</button>
|
|
250
|
+
<button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
|
|
251
|
+
</div>
|
|
252
|
+
<input type="checkbox" id="taskReal" hidden />
|
|
198
253
|
<div class="row">
|
|
199
254
|
<input id="taskCount" class="narrow" type="number" min="1" max="8" value="2" title="number of agents" />
|
|
200
255
|
<button class="btn" type="submit">Run fleet</button>
|
|
201
256
|
</div>
|
|
202
|
-
<label class="check"><input type="checkbox" id="taskReal" /> real agent · spends credits</label>
|
|
203
257
|
</form>
|
|
204
258
|
</div>
|
|
205
259
|
<div class="sect">
|
|
@@ -239,6 +293,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
239
293
|
<div class="pane-c"><pre class="diff" id="mDiff">loading…</pre></div>
|
|
240
294
|
</div>
|
|
241
295
|
</div>
|
|
296
|
+
<div class="modal-f" id="steerRow" style="border-top:1px solid var(--line)">
|
|
297
|
+
<input id="steerInput" placeholder="Send follow-up instructions — continues in the same session & worktree…" style="flex:1" />
|
|
298
|
+
<button class="btn sm" id="steerSend">Steer</button>
|
|
299
|
+
</div>
|
|
242
300
|
<div class="modal-f">
|
|
243
301
|
<button class="btn-ghost sm" id="mTerm">Terminal</button>
|
|
244
302
|
<button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
|
|
@@ -274,6 +332,18 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
274
332
|
</div>
|
|
275
333
|
</div>
|
|
276
334
|
|
|
335
|
+
<div class="toasts" id="toasts"></div>
|
|
336
|
+
|
|
337
|
+
<div class="overlay" id="cfmOverlay">
|
|
338
|
+
<div class="cfm">
|
|
339
|
+
<div class="cfm-b"><div class="m" id="cfmMsg"></div><div class="s" id="cfmSub"></div></div>
|
|
340
|
+
<div class="cfm-f">
|
|
341
|
+
<button class="btn-ghost sm" id="cfmCancel">Cancel</button>
|
|
342
|
+
<button class="btn sm" id="cfmOk">Confirm</button>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
|
|
277
347
|
<script src="/vendor/xterm.js"></script>
|
|
278
348
|
<script src="/vendor/addon-fit.js"></script>
|
|
279
349
|
<script>
|
|
@@ -285,6 +355,31 @@ const $ = (id) => document.getElementById(id);
|
|
|
285
355
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
|
286
356
|
const statusColor = (s) => 'var(--s-' + (s||'pending') + ', var(--muted))';
|
|
287
357
|
|
|
358
|
+
/* ── custom toast / confirm (시스템 alert·confirm 대체) ── */
|
|
359
|
+
function toast(msg, kind){
|
|
360
|
+
const el = document.createElement('div');
|
|
361
|
+
el.className = 'toast ' + (kind==='error'?'err':kind==='ok'?'ok':'');
|
|
362
|
+
el.innerHTML = '<span class="tk">'+(kind==='error'?'✕':kind==='ok'?'✓':'·')+'</span><span>'+esc(msg)+'</span>';
|
|
363
|
+
$('toasts').appendChild(el);
|
|
364
|
+
setTimeout(()=>{ el.style.opacity='0'; el.style.transition='opacity .25s'; setTimeout(()=>el.remove(),260); }, 4200);
|
|
365
|
+
}
|
|
366
|
+
let cfmResolve = null;
|
|
367
|
+
function confirmUI(message, opts){
|
|
368
|
+
opts = opts || {};
|
|
369
|
+
$('cfmMsg').textContent = message;
|
|
370
|
+
$('cfmSub').textContent = opts.sub || '';
|
|
371
|
+
$('cfmSub').style.display = opts.sub ? '' : 'none';
|
|
372
|
+
const ok = $('cfmOk');
|
|
373
|
+
ok.textContent = opts.okLabel || 'Confirm';
|
|
374
|
+
ok.className = (opts.danger ? 'btn-danger sm' : 'btn sm');
|
|
375
|
+
$('cfmOverlay').classList.add('open');
|
|
376
|
+
return new Promise((resolve)=>{ cfmResolve = resolve; });
|
|
377
|
+
}
|
|
378
|
+
function cfmClose(v){ $('cfmOverlay').classList.remove('open'); if(cfmResolve){ cfmResolve(v); cfmResolve=null; } }
|
|
379
|
+
$('cfmOk').addEventListener('click', ()=>cfmClose(true));
|
|
380
|
+
$('cfmCancel').addEventListener('click', ()=>cfmClose(false));
|
|
381
|
+
$('cfmOverlay').addEventListener('click',(e)=>{ if(e.target===$('cfmOverlay')) cfmClose(false); });
|
|
382
|
+
|
|
288
383
|
function summarize(kind, payload){
|
|
289
384
|
try{
|
|
290
385
|
const o = JSON.parse(payload);
|
|
@@ -320,8 +415,55 @@ function chipHTML(status){
|
|
|
320
415
|
function render(){
|
|
321
416
|
const list = [...runs.values()].sort((a,b)=>b.id-a.id);
|
|
322
417
|
$('empty').style.display = list.length ? 'none' : 'flex';
|
|
418
|
+
if (!list.length) paintOnboarding();
|
|
323
419
|
$('grid').innerHTML = list.map(cardHTML).join('');
|
|
324
420
|
}
|
|
421
|
+
|
|
422
|
+
/* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
|
|
423
|
+
let readiness = null, probing = false;
|
|
424
|
+
async function probeFirstMachine(){
|
|
425
|
+
if (probing || !machines.length) return;
|
|
426
|
+
probing = true;
|
|
427
|
+
try{
|
|
428
|
+
readiness = await fetch('/api/machines/'+encodeURIComponent(machines[0].slug)+'/probe',{method:'POST'}).then(x=>x.json());
|
|
429
|
+
}catch{ readiness = { reachable:false }; }
|
|
430
|
+
probing = false;
|
|
431
|
+
if (![...runs.values()].length) paintOnboarding();
|
|
432
|
+
}
|
|
433
|
+
function chkRow(name, ok, val){
|
|
434
|
+
const cls = ok===null ? 'wait' : ok ? 'ok' : 'bad';
|
|
435
|
+
const st = ok===null ? '…' : ok ? '✓' : '✕';
|
|
436
|
+
return '<div class="chk '+cls+'"><span class="st">'+st+'</span><span class="nm">'+esc(name)+'</span>'
|
|
437
|
+
+ '<span class="v">'+esc(val||'')+'</span></div>';
|
|
438
|
+
}
|
|
439
|
+
function paintOnboarding(){
|
|
440
|
+
const r = readiness;
|
|
441
|
+
const agentBin = r && r.agent ? r.agent.bin : 'claude';
|
|
442
|
+
let checks;
|
|
443
|
+
if (!r){
|
|
444
|
+
checks = chkRow('machine', null, 'checking…') ;
|
|
445
|
+
probeFirstMachine();
|
|
446
|
+
} else {
|
|
447
|
+
checks = chkRow('connection', !!r.reachable, machines.length ? machines[0].slug : '')
|
|
448
|
+
+ chkRow('git', r.git ? r.git.ok : false, r.git ? r.git.version : '')
|
|
449
|
+
+ chkRow('tmux', r.tmux ? r.tmux.ok : false, r.tmux ? r.tmux.version : '')
|
|
450
|
+
+ chkRow('agent', r.agent ? r.agent.ok : false, r.agent ? (r.agent.ok ? agentBin+' '+r.agent.version : 'not found on PATH') : '');
|
|
451
|
+
}
|
|
452
|
+
const agentMissing = r && r.agent && !r.agent.ok;
|
|
453
|
+
$('empty').innerHTML = '<div class="setup">'
|
|
454
|
+
+ '<div class="setup-h"><div class="t">Welcome to coxpit</div>'
|
|
455
|
+
+ '<div class="d">Run a fleet of coding agents on this machine — each in its own git worktree.</div></div>'
|
|
456
|
+
+ '<div class="setup-sec"><p class="setup-label">This machine</p>' + checks
|
|
457
|
+
+ (agentMissing
|
|
458
|
+
? '<div class="setup-fix"># install the agent CLI, then sign in once:\\nnpm i -g @anthropic-ai/claude-code\\n'+esc(agentBin)+' # first run opens browser login</div>'
|
|
459
|
+
: '<div class="setup-fix" style="white-space:normal">Agent CLI found. If real runs fail with an auth error, run <b>'+esc(agentBin)+'</b> once in a terminal to sign in.</div>')
|
|
460
|
+
+ '</div>'
|
|
461
|
+
+ '<div class="setup-sec"><p class="setup-label">Get started</p><ol class="setup-steps">'
|
|
462
|
+
+ '<li><b>Register a repo</b> — absolute path, in the left sidebar</li>'
|
|
463
|
+
+ '<li><b>Write a task</b> — title + a prompt that names the target files</li>'
|
|
464
|
+
+ '<li><b>Run fleet</b> — try <b>Dry run</b> first (free rehearsal), then <b>Real agent</b></li>'
|
|
465
|
+
+ '</ol></div></div>';
|
|
466
|
+
}
|
|
325
467
|
function cardHTML(r){
|
|
326
468
|
const task = tasks.get(r.taskId);
|
|
327
469
|
const closed = task && task.status==='closed';
|
|
@@ -424,6 +566,8 @@ function paintModal(){
|
|
|
424
566
|
chip.style.borderColor = statusColor(r.status);
|
|
425
567
|
$('mChipTxt').textContent = r.status||'pending';
|
|
426
568
|
$('mStop').style.display = (r.status==='running'||r.status==='preparing'||r.status==='pending') ? '' : 'none';
|
|
569
|
+
// steer 는 정착한 real run 에서만 의미(드라이런은 세션 없음 — 서버가 사유와 함께 거절)
|
|
570
|
+
$('steerRow').style.display = ['done','failed','stopped'].includes(r.status) ? '' : 'none';
|
|
427
571
|
$('mTimeline').innerHTML = (r.events||[]).map(e =>
|
|
428
572
|
'<div class="ev"><span class="k">'+esc(e.kind)+'</span><span class="t">'+esc(summarize(e.kind,e.payload))+'</span></div>'
|
|
429
573
|
).join('') || '<span style="color:var(--faint)">no events yet</span>';
|
|
@@ -447,23 +591,39 @@ $('grid').addEventListener('click',(e)=>{
|
|
|
447
591
|
});
|
|
448
592
|
$('mClose').addEventListener('click', closeModal);
|
|
449
593
|
$('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
|
|
450
|
-
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
594
|
+
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ cfmClose(false); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
451
595
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
596
|
+
async function sendSteer(){
|
|
597
|
+
if (openRunId==null) return;
|
|
598
|
+
const msg = $('steerInput').value.trim(); if(!msg) return;
|
|
599
|
+
const res = await fetch('/api/runs/'+openRunId+'/steer',{method:'POST',
|
|
600
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg})});
|
|
601
|
+
if (res.ok){ $('steerInput').value=''; toast('steering — the agent resumes in its worktree', 'ok'); }
|
|
602
|
+
else { const j = await res.json().catch(()=>({})); toast('steer: '+(j.detail||res.status), 'error'); }
|
|
603
|
+
}
|
|
604
|
+
$('steerSend').addEventListener('click', sendSteer);
|
|
605
|
+
$('steerInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') sendSteer(); });
|
|
452
606
|
$('mStop').addEventListener('click', async ()=>{
|
|
453
607
|
if (openRunId==null) return;
|
|
454
608
|
await fetch('/api/runs/'+openRunId+'/stop',{method:'POST'});
|
|
455
609
|
});
|
|
456
610
|
$('mCleanup').addEventListener('click', async ()=>{
|
|
457
611
|
if (openRunId==null) return;
|
|
458
|
-
|
|
612
|
+
const yes = await confirmUI('Remove the worktree and branch for r'+openRunId+'?',
|
|
613
|
+
{ sub: 'Unmerged changes in this run will be lost. This cannot be undone.', danger: true, okLabel: 'Cleanup' });
|
|
614
|
+
if (!yes) return;
|
|
459
615
|
await fetch('/api/runs/'+openRunId+'/cleanup',{method:'POST'});
|
|
616
|
+
toast('r'+openRunId+' cleaned up', 'ok');
|
|
460
617
|
closeModal(); hydrate();
|
|
461
618
|
});
|
|
462
619
|
$('mCloseTask').addEventListener('click', async ()=>{
|
|
463
620
|
if (openRunId==null) return;
|
|
464
621
|
const r = runs.get(openRunId); if(!r) return;
|
|
465
|
-
|
|
622
|
+
const yes = await confirmUI('Close this task?',
|
|
623
|
+
{ sub: 'Stops any live runs and removes every worktree and branch of the task.', danger: true, okLabel: 'Close task' });
|
|
624
|
+
if (!yes) return;
|
|
466
625
|
await fetch('/api/tasks/'+r.taskId+'/close',{method:'POST'});
|
|
626
|
+
toast('task closed — all runs cleaned', 'ok');
|
|
467
627
|
closeModal(); hydrate();
|
|
468
628
|
});
|
|
469
629
|
|
|
@@ -502,13 +662,16 @@ async function paintCompare(){
|
|
|
502
662
|
$('cmpBody').addEventListener('click', async (e)=>{
|
|
503
663
|
const btn = e.target.closest('button[data-merge]'); if(!btn) return;
|
|
504
664
|
const rid = Number(btn.dataset.merge);
|
|
505
|
-
|
|
665
|
+
const yes = await confirmUI('Merge r'+rid+' into the base branch?',
|
|
666
|
+
{ sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.', okLabel: 'Merge' });
|
|
667
|
+
if (!yes) return;
|
|
506
668
|
btn.disabled = true;
|
|
507
669
|
const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
|
|
508
670
|
const j = await res.json().catch(()=>({detail:'merge failed'}));
|
|
509
671
|
const msg = $('cmpMsg-'+rid);
|
|
510
672
|
if (msg) msg.textContent = j.detail || (res.ok?'merged':'failed');
|
|
511
|
-
if (res.ok){ await paintCompare(); hydrate(); }
|
|
673
|
+
if (res.ok){ toast('r'+rid+' merged to base', 'ok'); await paintCompare(); hydrate(); }
|
|
674
|
+
else { toast('merge: '+(j.detail||res.status), 'error'); btn.disabled = false; }
|
|
512
675
|
});
|
|
513
676
|
$('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
|
|
514
677
|
$('cmpOverlay').addEventListener('click',(e)=>{ if(e.target===$('cmpOverlay')){ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
@@ -579,7 +742,8 @@ $('repoForm').addEventListener('submit', async (e)=>{
|
|
|
579
742
|
const body = { machineSlug: $('repoMachine').value, path: $('repoPath').value.trim() };
|
|
580
743
|
if (!body.path) return;
|
|
581
744
|
const res = await fetch('/api/repos',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
|
582
|
-
if (res.ok){ $('repoPath').value='';
|
|
745
|
+
if (res.ok){ $('repoPath').value=''; toast('repo registered', 'ok'); await hydrate(); }
|
|
746
|
+
else { const j = await res.json().catch(()=>({})); toast('repo: '+(j.detail||j.error||res.status), 'error'); }
|
|
583
747
|
});
|
|
584
748
|
$('taskForm').addEventListener('submit', async (e)=>{
|
|
585
749
|
e.preventDefault();
|
|
@@ -589,13 +753,29 @@ $('taskForm').addEventListener('submit', async (e)=>{
|
|
|
589
753
|
const capId = Number($('taskCapture').value) || undefined;
|
|
590
754
|
const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
|
|
591
755
|
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
|
|
592
|
-
if (!t.ok){
|
|
756
|
+
if (!t.ok){ toast('task create failed', 'error'); return; }
|
|
593
757
|
tasks.set(t.task.id, t.task);
|
|
594
758
|
await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
595
759
|
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked})});
|
|
596
760
|
$('taskTitle').value=''; $('taskPrompt').value='';
|
|
597
761
|
});
|
|
598
762
|
|
|
763
|
+
/* ── agent mode segmented control (mirrors hidden #taskReal) ── */
|
|
764
|
+
const segOpts = Array.from(document.querySelectorAll('#modeSeg .seg-opt'));
|
|
765
|
+
function setMode(real, persist){
|
|
766
|
+
$('taskReal').checked = real;
|
|
767
|
+
for (const b of segOpts){
|
|
768
|
+
const on = (b.dataset.real === '1') === real;
|
|
769
|
+
b.classList.toggle('on', on);
|
|
770
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
771
|
+
}
|
|
772
|
+
if (persist) try { localStorage.setItem('coxpit.real', real ? '1' : '0'); } catch {}
|
|
773
|
+
}
|
|
774
|
+
for (const b of segOpts) b.addEventListener('click', ()=>setMode(b.dataset.real === '1', true));
|
|
775
|
+
let savedMode = null;
|
|
776
|
+
try { savedMode = localStorage.getItem('coxpit.real'); } catch {}
|
|
777
|
+
setMode(savedMode === '1', false);
|
|
778
|
+
|
|
599
779
|
hydrate().then(connectWS);
|
|
600
780
|
</script>
|
|
601
781
|
</body>
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import 'dotenv/config';
|
|
2
2
|
|
|
3
|
+
// GUI 앱(Finder/데스크톱)에서 뜨면 PATH 가 최소(/usr/bin:/bin...)라 brew 로 설치한
|
|
4
|
+
// 도구(claude·tmux·git)를 못 찾는다 — 표준 설치 경로를 부팅 시 1회 보강한다.
|
|
5
|
+
{
|
|
6
|
+
const extra = [
|
|
7
|
+
'/opt/homebrew/bin',
|
|
8
|
+
'/usr/local/bin',
|
|
9
|
+
`${process.env.HOME ?? ''}/.local/bin`,
|
|
10
|
+
`${process.env.HOME ?? ''}/.npm-global/bin`,
|
|
11
|
+
];
|
|
12
|
+
const cur = (process.env.PATH ?? '').split(':').filter(Boolean);
|
|
13
|
+
for (const p of extra) if (p && !p.startsWith('/.') && !cur.includes(p)) cur.push(p);
|
|
14
|
+
process.env.PATH = cur.join(':');
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
/** 런타임 설정. 시크릿은 전부 env 주입(번들 0). */
|
|
4
18
|
export const config = {
|
|
5
19
|
host: process.env.COXPIT_HOST ?? '127.0.0.1',
|
package/src/db/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ export async function ensureSchema(): Promise<void> {
|
|
|
55
55
|
branch TEXT NOT NULL DEFAULT '',
|
|
56
56
|
tmux_window TEXT NOT NULL DEFAULT '',
|
|
57
57
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
58
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
58
59
|
files_changed INTEGER NOT NULL DEFAULT 0,
|
|
59
60
|
started_at INTEGER,
|
|
60
61
|
ended_at INTEGER,
|
|
@@ -68,6 +69,7 @@ export async function ensureSchema(): Promise<void> {
|
|
|
68
69
|
ts INTEGER DEFAULT (unixepoch())
|
|
69
70
|
);
|
|
70
71
|
`);
|
|
71
|
-
// 기존 DB 마이그레이션(멱등)
|
|
72
|
+
// 기존 DB 마이그레이션(멱등)
|
|
72
73
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
|
74
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
73
75
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -53,6 +53,7 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
53
53
|
branch: text('branch').notNull().default(''),
|
|
54
54
|
tmuxWindow: text('tmux_window').notNull().default(''),
|
|
55
55
|
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
56
|
+
sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
|
|
56
57
|
filesChanged: integer('files_changed').notNull().default(0),
|
|
57
58
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
58
59
|
endedAt: integer('ended_at', { mode: 'timestamp' }),
|
package/src/orchestrator.ts
CHANGED
|
@@ -126,56 +126,97 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
126
126
|
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
127
127
|
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
128
128
|
const cmd = `cd ${shq(wtPath)} && ${pidPrefix}{ ${agentCommand(ctx.prompt, useReal)}; }`;
|
|
129
|
-
|
|
130
|
-
liveChildren.set(runId, child);
|
|
131
|
-
|
|
132
|
-
let lastResult = '';
|
|
133
|
-
if (child.stdout) {
|
|
134
|
-
const rl = createInterface({ input: child.stdout });
|
|
135
|
-
rl.on('line', (line: string) => {
|
|
136
|
-
const s = line.trim();
|
|
137
|
-
if (!s) return;
|
|
138
|
-
let kind = 'log';
|
|
139
|
-
try {
|
|
140
|
-
const obj = JSON.parse(s) as { type?: string; result?: string };
|
|
141
|
-
if (obj.type) kind = obj.type;
|
|
142
|
-
// result 이벤트의 사람이 읽는 요약만 뽑아 둔다(없으면 원본 라인).
|
|
143
|
-
if (obj.type === 'result') lastResult = typeof obj.result === 'string' ? obj.result : s;
|
|
144
|
-
} catch { /* 비-JSON 로그 라인 */ }
|
|
145
|
-
void recordEvent(runId, kind, s.slice(0, 2000));
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
if (child.stderr) {
|
|
149
|
-
const rle = createInterface({ input: child.stderr });
|
|
150
|
-
rle.on('line', (line: string) => {
|
|
151
|
-
const s = line.trim();
|
|
152
|
-
if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000));
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const code: number = await new Promise((resolve) => {
|
|
157
|
-
child.on('close', (c) => resolve(c ?? 0));
|
|
158
|
-
child.on('error', () => resolve(-1));
|
|
159
|
-
});
|
|
160
|
-
liveChildren.delete(runId);
|
|
161
|
-
|
|
162
|
-
// 4) 변경 파일 수 집계
|
|
163
|
-
const stat = await runShellOn(ctx.machine, `git -C ${shq(wtPath)} status --porcelain | wc -l`, 10000);
|
|
164
|
-
const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
|
|
165
|
-
|
|
166
|
-
const wasStopped = stoppedRuns.delete(runId);
|
|
167
|
-
await setRun(runId, {
|
|
168
|
-
status: wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed',
|
|
169
|
-
endedAt: new Date(),
|
|
170
|
-
filesChanged,
|
|
171
|
-
exitSummary: wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`,
|
|
172
|
-
});
|
|
129
|
+
await runAgentChild(runId, ctx.machine, wtPath, cmd);
|
|
173
130
|
} catch (e) {
|
|
174
131
|
await recordEvent(runId, 'error', String(e).slice(0, 500));
|
|
175
132
|
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'orchestrator error' });
|
|
176
133
|
}
|
|
177
134
|
}
|
|
178
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 에이전트 자식 프로세스 배선(공용) — stream-json 파싱→이벤트, session 캡처,
|
|
138
|
+
* 종료 시 files_changed 집계 + 상태 전이. launchRun/steerRun 이 공유.
|
|
139
|
+
*/
|
|
140
|
+
async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string): Promise<void> {
|
|
141
|
+
const child = spawnShellOn(machine, cmd);
|
|
142
|
+
liveChildren.set(runId, child);
|
|
143
|
+
|
|
144
|
+
let lastResult = '';
|
|
145
|
+
if (child.stdout) {
|
|
146
|
+
const rl = createInterface({ input: child.stdout });
|
|
147
|
+
rl.on('line', (line: string) => {
|
|
148
|
+
const s = line.trim();
|
|
149
|
+
if (!s) return;
|
|
150
|
+
let kind = 'log';
|
|
151
|
+
try {
|
|
152
|
+
const obj = JSON.parse(s) as { type?: string; result?: string; session_id?: string };
|
|
153
|
+
if (obj.type) kind = obj.type;
|
|
154
|
+
// steer(--resume) 용 세션 키 캡처
|
|
155
|
+
if (obj.type === 'system' && typeof obj.session_id === 'string') {
|
|
156
|
+
void setRun(runId, { sessionId: obj.session_id });
|
|
157
|
+
}
|
|
158
|
+
// result 이벤트의 사람이 읽는 요약만 뽑아 둔다(없으면 원본 라인).
|
|
159
|
+
if (obj.type === 'result') lastResult = typeof obj.result === 'string' ? obj.result : s;
|
|
160
|
+
} catch { /* 비-JSON 로그 라인 */ }
|
|
161
|
+
void recordEvent(runId, kind, s.slice(0, 2000));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (child.stderr) {
|
|
165
|
+
const rle = createInterface({ input: child.stderr });
|
|
166
|
+
rle.on('line', (line: string) => {
|
|
167
|
+
const s = line.trim();
|
|
168
|
+
if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const code: number = await new Promise((resolve) => {
|
|
173
|
+
child.on('close', (c) => resolve(c ?? 0));
|
|
174
|
+
child.on('error', () => resolve(-1));
|
|
175
|
+
});
|
|
176
|
+
liveChildren.delete(runId);
|
|
177
|
+
|
|
178
|
+
const stat = await runShellOn(machine, `git -C ${shq(wtPath)} status --porcelain | wc -l`, 10000);
|
|
179
|
+
const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
|
|
180
|
+
|
|
181
|
+
const wasStopped = stoppedRuns.delete(runId);
|
|
182
|
+
await setRun(runId, {
|
|
183
|
+
status: wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed',
|
|
184
|
+
endedAt: new Date(),
|
|
185
|
+
filesChanged,
|
|
186
|
+
exitSummary: wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 후속 지시(steer) — 정착한 run 의 세션을 --resume 으로 이어 같은 worktree 에서 계속.
|
|
192
|
+
* fire-and-forget. 진행 중 run 은 거부(개입은 터미널로).
|
|
193
|
+
*/
|
|
194
|
+
export async function steerRun(runId: number, message: string): Promise<{ ok: boolean; detail: string }> {
|
|
195
|
+
const ctx = await loadContext(runId);
|
|
196
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
197
|
+
const run = rr[0];
|
|
198
|
+
if (!ctx || !run) return { ok: false, detail: 'run not found' };
|
|
199
|
+
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — attach the terminal to intervene' };
|
|
200
|
+
if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot steer a '${run.status}' run` };
|
|
201
|
+
if (!run.worktreePath) return { ok: false, detail: 'worktree gone (cleaned up)' };
|
|
202
|
+
if (!run.sessionId) return { ok: false, detail: 'no agent session on this run (dry-run runs cannot be steered)' };
|
|
203
|
+
|
|
204
|
+
const wt = run.worktreePath;
|
|
205
|
+
const exists = await runShellOn(ctx.machine, `test -d ${shq(wt)} && echo yes`, 8000);
|
|
206
|
+
if (!exists.stdout.includes('yes')) return { ok: false, detail: 'worktree missing on machine' };
|
|
207
|
+
|
|
208
|
+
await setRun(runId, { status: 'running', endedAt: null });
|
|
209
|
+
await recordEvent(runId, 'steer', message.slice(0, 2000));
|
|
210
|
+
|
|
211
|
+
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
212
|
+
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
213
|
+
const resume = `${config.agent.bin} -p --resume ${shq(run.sessionId)} ${shq(message)}` +
|
|
214
|
+
` --output-format stream-json --verbose --permission-mode ${config.agent.perm}`;
|
|
215
|
+
const cmd = `cd ${shq(wt)} && ${pidPrefix}{ ${resume}; }`;
|
|
216
|
+
void runAgentChild(runId, ctx.machine, wt, cmd);
|
|
217
|
+
return { ok: true, detail: 'steering' };
|
|
218
|
+
}
|
|
219
|
+
|
|
179
220
|
/** 터미널 attach 용 — run 의 머신 타깃 + tmux 세션명. */
|
|
180
221
|
export async function getRunTermInfo(runId: number): Promise<{ machine: MachineTarget; session: string } | null> {
|
|
181
222
|
const ctx = await loadContext(runId);
|
package/src/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { db } from './db';
|
|
|
9
9
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
|
|
10
10
|
import { BOOKMARKLET_JS } from './design';
|
|
11
11
|
import { runShellOn, shq } from './exec';
|
|
12
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo } from './orchestrator';
|
|
12
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun } from './orchestrator';
|
|
13
13
|
import { openTerm } from './term';
|
|
14
14
|
import { addSink, removeSink, broadcast } from './hub';
|
|
15
15
|
import { BOARD_HTML } from './board';
|
|
@@ -29,7 +29,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
29
29
|
app.addHook('onRequest', authGate);
|
|
30
30
|
|
|
31
31
|
// 무인증 헬스(외부 감시용)
|
|
32
|
-
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.
|
|
32
|
+
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.3.0' }));
|
|
33
33
|
|
|
34
34
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨.
|
|
35
35
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
@@ -96,12 +96,14 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
96
96
|
const m = rows[0];
|
|
97
97
|
if (!m) return reply.code(404).send({ error: 'not found' });
|
|
98
98
|
|
|
99
|
+
const agentBin = config.agent.bin;
|
|
99
100
|
const cmd = [
|
|
100
101
|
'echo GIT:$(git --version 2>&1)',
|
|
101
102
|
'echo TMUX:$(tmux -V 2>&1)',
|
|
103
|
+
`echo AGENT:$(command -v ${shq(agentBin)} >/dev/null 2>&1 && ${shq(agentBin)} --version 2>/dev/null | head -1 || echo missing)`,
|
|
102
104
|
'echo OS:$(uname -sr 2>&1)',
|
|
103
105
|
].join('; ');
|
|
104
|
-
const r = await runShellOn(m, cmd);
|
|
106
|
+
const r = await runShellOn(m, cmd, 20000);
|
|
105
107
|
|
|
106
108
|
const pick = (key: string): string => {
|
|
107
109
|
const line = r.stdout.split('\n').find((l) => l.startsWith(`${key}:`));
|
|
@@ -109,9 +111,12 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
109
111
|
};
|
|
110
112
|
const gitStr = pick('GIT');
|
|
111
113
|
const tmuxStr = pick('TMUX');
|
|
114
|
+
const agentStr = pick('AGENT');
|
|
112
115
|
const reachable = r.ok;
|
|
113
116
|
const git = { ok: /git version/i.test(gitStr), version: gitStr };
|
|
114
117
|
const tmux = { ok: /tmux \d/i.test(tmuxStr), version: tmuxStr };
|
|
118
|
+
// 에이전트 CLI 존재 여부(인증까지는 여기서 알 수 없음 — 첫 real run 이 판정)
|
|
119
|
+
const agent = { ok: agentStr !== '' && agentStr !== 'missing', version: agentStr, bin: agentBin };
|
|
115
120
|
|
|
116
121
|
await db.update(machines)
|
|
117
122
|
.set({ online: reachable, lastSeen: new Date() })
|
|
@@ -119,7 +124,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
119
124
|
|
|
120
125
|
return {
|
|
121
126
|
slug, reachable,
|
|
122
|
-
git, tmux, os: pick('OS'),
|
|
127
|
+
git, tmux, agent, os: pick('OS'),
|
|
123
128
|
ready: reachable && git.ok && tmux.ok,
|
|
124
129
|
error: reachable ? undefined : (r.stderr.trim() || `ssh exit ${r.code}`),
|
|
125
130
|
};
|
|
@@ -335,6 +340,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
335
340
|
return res;
|
|
336
341
|
});
|
|
337
342
|
|
|
343
|
+
// 후속 지시(steer) — 정착한 run 을 같은 세션(--resume)·같은 worktree 로 계속.
|
|
344
|
+
app.post('/api/runs/:id/steer', async (req, reply) => {
|
|
345
|
+
const id = Number((req.params as { id: string }).id);
|
|
346
|
+
const b = (req.body ?? {}) as { message?: string };
|
|
347
|
+
const message = (b.message ?? '').trim();
|
|
348
|
+
if (!message) return reply.code(400).send({ error: 'message required' });
|
|
349
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
350
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
351
|
+
const res = await steerRun(id, message);
|
|
352
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
353
|
+
return reply.code(202).send(res);
|
|
354
|
+
});
|
|
355
|
+
|
|
338
356
|
// 실행 중 run 중지(SIGTERM) — close 핸들러가 stopped 로 봉인.
|
|
339
357
|
app.post('/api/runs/:id/stop', async (req, reply) => {
|
|
340
358
|
const id = Number((req.params as { id: string }).id);
|
|
@@ -354,7 +372,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
354
372
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
355
373
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
356
374
|
addSink(socket);
|
|
357
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.
|
|
375
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.3.0' }));
|
|
358
376
|
socket.on('close', () => removeSink(socket));
|
|
359
377
|
});
|
|
360
378
|
|