coxpit 2.2.1 → 2.4.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 +200 -9
- package/src/config.ts +14 -0
- package/src/server.ts +9 -4
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.4.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,26 @@ 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
|
+
|
|
75
|
+
/* ── custom dropdown (native select 대체) ── */
|
|
76
|
+
.dd{position:relative}
|
|
77
|
+
.dd-btn{width:100%;display:flex;align-items:center;gap:8px;background:#0e1118;color:var(--ink);
|
|
78
|
+
border:1px solid var(--line);border-radius:var(--r-ctl);padding:8px 10px;font-family:var(--mono);
|
|
79
|
+
font-size:12px;cursor:pointer;transition:border-color .15s;text-align:left}
|
|
80
|
+
.dd-btn:hover{border-color:var(--line-hi)}
|
|
81
|
+
.dd.open .dd-btn{border-color:rgba(78,201,176,.55)}
|
|
82
|
+
.dd-lbl{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
83
|
+
.dd-car{color:var(--faint);font-size:10px;transition:transform .15s}
|
|
84
|
+
.dd.open .dd-car{transform:rotate(180deg)}
|
|
85
|
+
.dd-panel{position:absolute;top:calc(100% + 5px);left:0;right:0;z-index:40;background:var(--surface2);
|
|
86
|
+
border:1px solid var(--line-hi);border-radius:9px;box-shadow:var(--shadow);max-height:230px;
|
|
87
|
+
overflow:auto;padding:4px;display:none}
|
|
88
|
+
.dd.open .dd-panel{display:block}
|
|
89
|
+
.dd-opt{padding:7px 10px;border-radius:6px;font-family:var(--mono);font-size:12px;color:var(--muted);
|
|
90
|
+
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
91
|
+
.dd-opt:hover{background:var(--surface);color:var(--ink)}
|
|
92
|
+
.dd-opt.on{color:var(--brand)}
|
|
93
|
+
.dd-opt.on::before{content:'✓ ';font-size:10px}
|
|
74
94
|
.seg{display:flex;gap:3px;padding:3px;border:1px solid var(--line);border-radius:var(--r-ctl);background:#0e1118}
|
|
75
95
|
.seg-opt{flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;padding:6px 8px;border:none;
|
|
76
96
|
background:transparent;color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;
|
|
@@ -120,6 +140,48 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
120
140
|
.empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:64px 24px;text-align:center;
|
|
121
141
|
display:flex;flex-direction:column;gap:10px;align-items:center}
|
|
122
142
|
.empty .glyph{font-size:22px;color:#2c3444;letter-spacing:4px}
|
|
143
|
+
|
|
144
|
+
/* ── onboarding (first run) ─────────────── */
|
|
145
|
+
.setup{max-width:560px;margin:40px auto;border:1px solid var(--line);border-radius:14px;
|
|
146
|
+
background:var(--surface);overflow:hidden;text-align:left}
|
|
147
|
+
.setup-h{padding:18px 22px 14px;border-bottom:1px solid var(--line)}
|
|
148
|
+
.setup-h .t{font-weight:700;font-size:16px}
|
|
149
|
+
.setup-h .d{color:var(--muted);font-size:13px;margin-top:3px}
|
|
150
|
+
.setup-sec{padding:14px 22px;border-bottom:1px solid var(--line)}
|
|
151
|
+
.setup-sec:last-child{border-bottom:none}
|
|
152
|
+
.setup-label{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.14em;
|
|
153
|
+
color:var(--faint);margin:0 0 9px}
|
|
154
|
+
.chk{display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px}
|
|
155
|
+
.chk .st{font-family:var(--mono);font-size:12px;width:18px;text-align:center;flex:none}
|
|
156
|
+
.chk.ok .st{color:var(--s-done)} .chk.bad .st{color:var(--s-failed)} .chk.wait .st{color:var(--faint)}
|
|
157
|
+
.chk .nm{color:var(--ink);min-width:88px;font-weight:500}
|
|
158
|
+
.chk .v{color:var(--faint);font-family:var(--mono);font-size:11.5px;overflow:hidden;
|
|
159
|
+
white-space:nowrap;text-overflow:ellipsis}
|
|
160
|
+
.setup-fix{margin:8px 0 2px;padding:10px 13px;background:#0e1118;border:1px solid var(--line);
|
|
161
|
+
border-radius:8px;font-family:var(--mono);font-size:11.5px;color:var(--muted);overflow-x:auto;white-space:pre}
|
|
162
|
+
.setup-steps{margin:0;padding-left:20px;color:var(--muted);font-size:13px}
|
|
163
|
+
.setup-steps li{margin-bottom:7px}
|
|
164
|
+
.setup-steps b{color:var(--ink)}
|
|
165
|
+
|
|
166
|
+
/* ── toasts ─────────────────────────────── */
|
|
167
|
+
.toasts{position:fixed;top:66px;right:18px;z-index:60;display:flex;flex-direction:column;gap:8px;
|
|
168
|
+
max-width:380px}
|
|
169
|
+
.toast{border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);
|
|
170
|
+
padding:10px 14px;font-size:13px;box-shadow:var(--shadow);display:flex;gap:9px;align-items:baseline;
|
|
171
|
+
animation:tin .18s ease}
|
|
172
|
+
.toast .tk{font-family:var(--mono);font-size:11px;flex:none}
|
|
173
|
+
.toast.err{border-color:rgba(226,91,103,.5)} .toast.err .tk{color:var(--s-failed)}
|
|
174
|
+
.toast.ok{border-color:rgba(78,201,176,.5)} .toast.ok .tk{color:var(--brand)}
|
|
175
|
+
@keyframes tin{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
|
|
176
|
+
@media (prefers-reduced-motion:reduce){.toast{animation:none}}
|
|
177
|
+
|
|
178
|
+
/* ── confirm dialog ─────────────────────── */
|
|
179
|
+
.cfm{width:min(440px,92vw);background:var(--surface);border:1px solid var(--line);border-radius:14px;
|
|
180
|
+
box-shadow:var(--shadow);overflow:hidden}
|
|
181
|
+
.cfm-b{padding:20px 22px 14px}
|
|
182
|
+
.cfm-b .m{font-size:14px;color:var(--ink);line-height:1.6}
|
|
183
|
+
.cfm-b .s{font-size:12.5px;color:var(--faint);margin-top:6px}
|
|
184
|
+
.cfm-f{display:flex;gap:8px;justify-content:flex-end;padding:12px 18px;border-top:1px solid var(--line)}
|
|
123
185
|
.flash{animation:flash .5s ease}
|
|
124
186
|
@keyframes flash{from{border-color:rgba(78,201,176,.6)}to{border-color:var(--line)}}
|
|
125
187
|
|
|
@@ -290,6 +352,18 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
290
352
|
</div>
|
|
291
353
|
</div>
|
|
292
354
|
|
|
355
|
+
<div class="toasts" id="toasts"></div>
|
|
356
|
+
|
|
357
|
+
<div class="overlay" id="cfmOverlay">
|
|
358
|
+
<div class="cfm">
|
|
359
|
+
<div class="cfm-b"><div class="m" id="cfmMsg"></div><div class="s" id="cfmSub"></div></div>
|
|
360
|
+
<div class="cfm-f">
|
|
361
|
+
<button class="btn-ghost sm" id="cfmCancel">Cancel</button>
|
|
362
|
+
<button class="btn sm" id="cfmOk">Confirm</button>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
293
367
|
<script src="/vendor/xterm.js"></script>
|
|
294
368
|
<script src="/vendor/addon-fit.js"></script>
|
|
295
369
|
<script>
|
|
@@ -301,6 +375,65 @@ const $ = (id) => document.getElementById(id);
|
|
|
301
375
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
|
302
376
|
const statusColor = (s) => 'var(--s-' + (s||'pending') + ', var(--muted))';
|
|
303
377
|
|
|
378
|
+
/* ── custom toast / confirm (시스템 alert·confirm 대체) ── */
|
|
379
|
+
function toast(msg, kind){
|
|
380
|
+
const el = document.createElement('div');
|
|
381
|
+
el.className = 'toast ' + (kind==='error'?'err':kind==='ok'?'ok':'');
|
|
382
|
+
el.innerHTML = '<span class="tk">'+(kind==='error'?'✕':kind==='ok'?'✓':'·')+'</span><span>'+esc(msg)+'</span>';
|
|
383
|
+
$('toasts').appendChild(el);
|
|
384
|
+
setTimeout(()=>{ el.style.opacity='0'; el.style.transition='opacity .25s'; setTimeout(()=>el.remove(),260); }, 4200);
|
|
385
|
+
}
|
|
386
|
+
/* ── custom dropdown — 숨긴 native select 를 상태 보관용으로 감싼다 ── */
|
|
387
|
+
function dressSelect(id){
|
|
388
|
+
const sel = $(id); if(!sel || sel.dataset.dd) return;
|
|
389
|
+
sel.dataset.dd = '1';
|
|
390
|
+
const dd = document.createElement('div'); dd.className = 'dd';
|
|
391
|
+
dd.innerHTML = '<button type="button" class="dd-btn"><span class="dd-lbl"></span><span class="dd-car">▾</span></button><div class="dd-panel"></div>';
|
|
392
|
+
sel.parentNode.insertBefore(dd, sel);
|
|
393
|
+
sel.style.display = 'none';
|
|
394
|
+
dd.querySelector('.dd-btn').addEventListener('click', (e)=>{
|
|
395
|
+
e.stopPropagation();
|
|
396
|
+
const wasOpen = dd.classList.contains('open');
|
|
397
|
+
closeDropdowns();
|
|
398
|
+
if (!wasOpen) dd.classList.add('open');
|
|
399
|
+
});
|
|
400
|
+
dd.querySelector('.dd-panel').addEventListener('click', (e)=>{
|
|
401
|
+
const o = e.target.closest('.dd-opt'); if(!o) return;
|
|
402
|
+
sel.value = o.dataset.v;
|
|
403
|
+
dd.classList.remove('open');
|
|
404
|
+
syncSelect(id);
|
|
405
|
+
});
|
|
406
|
+
syncSelect(id);
|
|
407
|
+
}
|
|
408
|
+
function syncSelect(id){
|
|
409
|
+
const sel = $(id); if(!sel) return;
|
|
410
|
+
const dd = sel.previousElementSibling;
|
|
411
|
+
if (!dd || !dd.classList || !dd.classList.contains('dd')) return;
|
|
412
|
+
const cur = sel.options[sel.selectedIndex];
|
|
413
|
+
dd.querySelector('.dd-lbl').textContent = cur ? cur.textContent : '—';
|
|
414
|
+
dd.querySelector('.dd-panel').innerHTML = [...sel.options].map(o =>
|
|
415
|
+
'<div class="dd-opt'+(o.value===sel.value?' on':'')+'" data-v="'+esc(o.value)+'">'+esc(o.textContent)+'</div>').join('');
|
|
416
|
+
}
|
|
417
|
+
function closeDropdowns(){ document.querySelectorAll('.dd.open').forEach(d=>d.classList.remove('open')); }
|
|
418
|
+
document.addEventListener('click', closeDropdowns);
|
|
419
|
+
|
|
420
|
+
let cfmResolve = null;
|
|
421
|
+
function confirmUI(message, opts){
|
|
422
|
+
opts = opts || {};
|
|
423
|
+
$('cfmMsg').textContent = message;
|
|
424
|
+
$('cfmSub').textContent = opts.sub || '';
|
|
425
|
+
$('cfmSub').style.display = opts.sub ? '' : 'none';
|
|
426
|
+
const ok = $('cfmOk');
|
|
427
|
+
ok.textContent = opts.okLabel || 'Confirm';
|
|
428
|
+
ok.className = (opts.danger ? 'btn-danger sm' : 'btn sm');
|
|
429
|
+
$('cfmOverlay').classList.add('open');
|
|
430
|
+
return new Promise((resolve)=>{ cfmResolve = resolve; });
|
|
431
|
+
}
|
|
432
|
+
function cfmClose(v){ $('cfmOverlay').classList.remove('open'); if(cfmResolve){ cfmResolve(v); cfmResolve=null; } }
|
|
433
|
+
$('cfmOk').addEventListener('click', ()=>cfmClose(true));
|
|
434
|
+
$('cfmCancel').addEventListener('click', ()=>cfmClose(false));
|
|
435
|
+
$('cfmOverlay').addEventListener('click',(e)=>{ if(e.target===$('cfmOverlay')) cfmClose(false); });
|
|
436
|
+
|
|
304
437
|
function summarize(kind, payload){
|
|
305
438
|
try{
|
|
306
439
|
const o = JSON.parse(payload);
|
|
@@ -336,8 +469,55 @@ function chipHTML(status){
|
|
|
336
469
|
function render(){
|
|
337
470
|
const list = [...runs.values()].sort((a,b)=>b.id-a.id);
|
|
338
471
|
$('empty').style.display = list.length ? 'none' : 'flex';
|
|
472
|
+
if (!list.length) paintOnboarding();
|
|
339
473
|
$('grid').innerHTML = list.map(cardHTML).join('');
|
|
340
474
|
}
|
|
475
|
+
|
|
476
|
+
/* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
|
|
477
|
+
let readiness = null, probing = false;
|
|
478
|
+
async function probeFirstMachine(){
|
|
479
|
+
if (probing || !machines.length) return;
|
|
480
|
+
probing = true;
|
|
481
|
+
try{
|
|
482
|
+
readiness = await fetch('/api/machines/'+encodeURIComponent(machines[0].slug)+'/probe',{method:'POST'}).then(x=>x.json());
|
|
483
|
+
}catch{ readiness = { reachable:false }; }
|
|
484
|
+
probing = false;
|
|
485
|
+
if (![...runs.values()].length) paintOnboarding();
|
|
486
|
+
}
|
|
487
|
+
function chkRow(name, ok, val){
|
|
488
|
+
const cls = ok===null ? 'wait' : ok ? 'ok' : 'bad';
|
|
489
|
+
const st = ok===null ? '…' : ok ? '✓' : '✕';
|
|
490
|
+
return '<div class="chk '+cls+'"><span class="st">'+st+'</span><span class="nm">'+esc(name)+'</span>'
|
|
491
|
+
+ '<span class="v">'+esc(val||'')+'</span></div>';
|
|
492
|
+
}
|
|
493
|
+
function paintOnboarding(){
|
|
494
|
+
const r = readiness;
|
|
495
|
+
const agentBin = r && r.agent ? r.agent.bin : 'claude';
|
|
496
|
+
let checks;
|
|
497
|
+
if (!r){
|
|
498
|
+
checks = chkRow('machine', null, 'checking…') ;
|
|
499
|
+
probeFirstMachine();
|
|
500
|
+
} else {
|
|
501
|
+
checks = chkRow('connection', !!r.reachable, machines.length ? machines[0].slug : '')
|
|
502
|
+
+ chkRow('git', r.git ? r.git.ok : false, r.git ? r.git.version : '')
|
|
503
|
+
+ chkRow('tmux', r.tmux ? r.tmux.ok : false, r.tmux ? r.tmux.version : '')
|
|
504
|
+
+ chkRow('agent', r.agent ? r.agent.ok : false, r.agent ? (r.agent.ok ? agentBin+' '+r.agent.version : 'not found on PATH') : '');
|
|
505
|
+
}
|
|
506
|
+
const agentMissing = r && r.agent && !r.agent.ok;
|
|
507
|
+
$('empty').innerHTML = '<div class="setup">'
|
|
508
|
+
+ '<div class="setup-h"><div class="t">Welcome to coxpit</div>'
|
|
509
|
+
+ '<div class="d">Run a fleet of coding agents on this machine — each in its own git worktree.</div></div>'
|
|
510
|
+
+ '<div class="setup-sec"><p class="setup-label">This machine</p>' + checks
|
|
511
|
+
+ (agentMissing
|
|
512
|
+
? '<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>'
|
|
513
|
+
: '<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>')
|
|
514
|
+
+ '</div>'
|
|
515
|
+
+ '<div class="setup-sec"><p class="setup-label">Get started</p><ol class="setup-steps">'
|
|
516
|
+
+ '<li><b>Register a repo</b> — absolute path, in the left sidebar</li>'
|
|
517
|
+
+ '<li><b>Write a task</b> — title + a prompt that names the target files</li>'
|
|
518
|
+
+ '<li><b>Run fleet</b> — try <b>Dry run</b> first (free rehearsal), then <b>Real agent</b></li>'
|
|
519
|
+
+ '</ol></div></div>';
|
|
520
|
+
}
|
|
341
521
|
function cardHTML(r){
|
|
342
522
|
const task = tasks.get(r.taskId);
|
|
343
523
|
const closed = task && task.status==='closed';
|
|
@@ -385,6 +565,7 @@ function paintSidebar(){
|
|
|
385
565
|
capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
|
|
386
566
|
'<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
|
|
387
567
|
capSel.value = cur;
|
|
568
|
+
['repoMachine','taskRepo','taskCapture'].forEach(id => { dressSelect(id); syncSelect(id); });
|
|
388
569
|
$('captures').innerHTML = captures.map(c=>
|
|
389
570
|
'<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
|
|
390
571
|
+ '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
|
|
@@ -465,15 +646,15 @@ $('grid').addEventListener('click',(e)=>{
|
|
|
465
646
|
});
|
|
466
647
|
$('mClose').addEventListener('click', closeModal);
|
|
467
648
|
$('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
|
|
468
|
-
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
649
|
+
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
469
650
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
470
651
|
async function sendSteer(){
|
|
471
652
|
if (openRunId==null) return;
|
|
472
653
|
const msg = $('steerInput').value.trim(); if(!msg) return;
|
|
473
654
|
const res = await fetch('/api/runs/'+openRunId+'/steer',{method:'POST',
|
|
474
655
|
headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg})});
|
|
475
|
-
if (res.ok){ $('steerInput').value=''; }
|
|
476
|
-
else { const j = await res.json().catch(()=>({}));
|
|
656
|
+
if (res.ok){ $('steerInput').value=''; toast('steering — the agent resumes in its worktree', 'ok'); }
|
|
657
|
+
else { const j = await res.json().catch(()=>({})); toast('steer: '+(j.detail||res.status), 'error'); }
|
|
477
658
|
}
|
|
478
659
|
$('steerSend').addEventListener('click', sendSteer);
|
|
479
660
|
$('steerInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') sendSteer(); });
|
|
@@ -483,15 +664,21 @@ $('mStop').addEventListener('click', async ()=>{
|
|
|
483
664
|
});
|
|
484
665
|
$('mCleanup').addEventListener('click', async ()=>{
|
|
485
666
|
if (openRunId==null) return;
|
|
486
|
-
|
|
667
|
+
const yes = await confirmUI('Remove the worktree and branch for r'+openRunId+'?',
|
|
668
|
+
{ sub: 'Unmerged changes in this run will be lost. This cannot be undone.', danger: true, okLabel: 'Cleanup' });
|
|
669
|
+
if (!yes) return;
|
|
487
670
|
await fetch('/api/runs/'+openRunId+'/cleanup',{method:'POST'});
|
|
671
|
+
toast('r'+openRunId+' cleaned up', 'ok');
|
|
488
672
|
closeModal(); hydrate();
|
|
489
673
|
});
|
|
490
674
|
$('mCloseTask').addEventListener('click', async ()=>{
|
|
491
675
|
if (openRunId==null) return;
|
|
492
676
|
const r = runs.get(openRunId); if(!r) return;
|
|
493
|
-
|
|
677
|
+
const yes = await confirmUI('Close this task?',
|
|
678
|
+
{ sub: 'Stops any live runs and removes every worktree and branch of the task.', danger: true, okLabel: 'Close task' });
|
|
679
|
+
if (!yes) return;
|
|
494
680
|
await fetch('/api/tasks/'+r.taskId+'/close',{method:'POST'});
|
|
681
|
+
toast('task closed — all runs cleaned', 'ok');
|
|
495
682
|
closeModal(); hydrate();
|
|
496
683
|
});
|
|
497
684
|
|
|
@@ -530,13 +717,16 @@ async function paintCompare(){
|
|
|
530
717
|
$('cmpBody').addEventListener('click', async (e)=>{
|
|
531
718
|
const btn = e.target.closest('button[data-merge]'); if(!btn) return;
|
|
532
719
|
const rid = Number(btn.dataset.merge);
|
|
533
|
-
|
|
720
|
+
const yes = await confirmUI('Merge r'+rid+' into the base branch?',
|
|
721
|
+
{ sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.', okLabel: 'Merge' });
|
|
722
|
+
if (!yes) return;
|
|
534
723
|
btn.disabled = true;
|
|
535
724
|
const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
|
|
536
725
|
const j = await res.json().catch(()=>({detail:'merge failed'}));
|
|
537
726
|
const msg = $('cmpMsg-'+rid);
|
|
538
727
|
if (msg) msg.textContent = j.detail || (res.ok?'merged':'failed');
|
|
539
|
-
if (res.ok){ await paintCompare(); hydrate(); }
|
|
728
|
+
if (res.ok){ toast('r'+rid+' merged to base', 'ok'); await paintCompare(); hydrate(); }
|
|
729
|
+
else { toast('merge: '+(j.detail||res.status), 'error'); btn.disabled = false; }
|
|
540
730
|
});
|
|
541
731
|
$('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
|
|
542
732
|
$('cmpOverlay').addEventListener('click',(e)=>{ if(e.target===$('cmpOverlay')){ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
@@ -607,7 +797,8 @@ $('repoForm').addEventListener('submit', async (e)=>{
|
|
|
607
797
|
const body = { machineSlug: $('repoMachine').value, path: $('repoPath').value.trim() };
|
|
608
798
|
if (!body.path) return;
|
|
609
799
|
const res = await fetch('/api/repos',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
|
610
|
-
if (res.ok){ $('repoPath').value='';
|
|
800
|
+
if (res.ok){ $('repoPath').value=''; toast('repo registered', 'ok'); await hydrate(); }
|
|
801
|
+
else { const j = await res.json().catch(()=>({})); toast('repo: '+(j.detail||j.error||res.status), 'error'); }
|
|
611
802
|
});
|
|
612
803
|
$('taskForm').addEventListener('submit', async (e)=>{
|
|
613
804
|
e.preventDefault();
|
|
@@ -617,7 +808,7 @@ $('taskForm').addEventListener('submit', async (e)=>{
|
|
|
617
808
|
const capId = Number($('taskCapture').value) || undefined;
|
|
618
809
|
const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
|
|
619
810
|
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
|
|
620
|
-
if (!t.ok){
|
|
811
|
+
if (!t.ok){ toast('task create failed', 'error'); return; }
|
|
621
812
|
tasks.set(t.task.id, t.task);
|
|
622
813
|
await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
623
814
|
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked})});
|
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/server.ts
CHANGED
|
@@ -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.4.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
|
};
|
|
@@ -367,7 +372,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
367
372
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
368
373
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
369
374
|
addSink(socket);
|
|
370
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.
|
|
375
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.4.0' }));
|
|
371
376
|
socket.on('close', () => removeSink(socket));
|
|
372
377
|
});
|
|
373
378
|
|