coxpit 2.2.1 → 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 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 "real agent" toggle per launch, or set `COXPIT_AGENT_REAL=1` to default to real.
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.2.1",
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
@@ -120,6 +120,48 @@ export const BOARD_HTML = /* html */ `<!doctype html>
120
120
  .empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:64px 24px;text-align:center;
121
121
  display:flex;flex-direction:column;gap:10px;align-items:center}
122
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)}
123
165
  .flash{animation:flash .5s ease}
124
166
  @keyframes flash{from{border-color:rgba(78,201,176,.6)}to{border-color:var(--line)}}
125
167
 
@@ -290,6 +332,18 @@ export const BOARD_HTML = /* html */ `<!doctype html>
290
332
  </div>
291
333
  </div>
292
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
+
293
347
  <script src="/vendor/xterm.js"></script>
294
348
  <script src="/vendor/addon-fit.js"></script>
295
349
  <script>
@@ -301,6 +355,31 @@ const $ = (id) => document.getElementById(id);
301
355
  const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
302
356
  const statusColor = (s) => 'var(--s-' + (s||'pending') + ', var(--muted))';
303
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
+
304
383
  function summarize(kind, payload){
305
384
  try{
306
385
  const o = JSON.parse(payload);
@@ -336,8 +415,55 @@ function chipHTML(status){
336
415
  function render(){
337
416
  const list = [...runs.values()].sort((a,b)=>b.id-a.id);
338
417
  $('empty').style.display = list.length ? 'none' : 'flex';
418
+ if (!list.length) paintOnboarding();
339
419
  $('grid').innerHTML = list.map(cardHTML).join('');
340
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
+ }
341
467
  function cardHTML(r){
342
468
  const task = tasks.get(r.taskId);
343
469
  const closed = task && task.status==='closed';
@@ -465,15 +591,15 @@ $('grid').addEventListener('click',(e)=>{
465
591
  });
466
592
  $('mClose').addEventListener('click', closeModal);
467
593
  $('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'); } });
594
+ document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ cfmClose(false); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
469
595
  $('mRefreshDiff').addEventListener('click', loadDiff);
470
596
  async function sendSteer(){
471
597
  if (openRunId==null) return;
472
598
  const msg = $('steerInput').value.trim(); if(!msg) return;
473
599
  const res = await fetch('/api/runs/'+openRunId+'/steer',{method:'POST',
474
600
  headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg})});
475
- if (res.ok){ $('steerInput').value=''; }
476
- else { const j = await res.json().catch(()=>({})); alert('steer: '+(j.detail||res.status)); }
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'); }
477
603
  }
478
604
  $('steerSend').addEventListener('click', sendSteer);
479
605
  $('steerInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') sendSteer(); });
@@ -483,15 +609,21 @@ $('mStop').addEventListener('click', async ()=>{
483
609
  });
484
610
  $('mCleanup').addEventListener('click', async ()=>{
485
611
  if (openRunId==null) return;
486
- if (!confirm('Remove worktree + branch for r'+openRunId+'?')) return;
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;
487
615
  await fetch('/api/runs/'+openRunId+'/cleanup',{method:'POST'});
616
+ toast('r'+openRunId+' cleaned up', 'ok');
488
617
  closeModal(); hydrate();
489
618
  });
490
619
  $('mCloseTask').addEventListener('click', async ()=>{
491
620
  if (openRunId==null) return;
492
621
  const r = runs.get(openRunId); if(!r) return;
493
- if (!confirm('Close task — stop + cleanup ALL its runs?')) return;
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;
494
625
  await fetch('/api/tasks/'+r.taskId+'/close',{method:'POST'});
626
+ toast('task closed — all runs cleaned', 'ok');
495
627
  closeModal(); hydrate();
496
628
  });
497
629
 
@@ -530,13 +662,16 @@ async function paintCompare(){
530
662
  $('cmpBody').addEventListener('click', async (e)=>{
531
663
  const btn = e.target.closest('button[data-merge]'); if(!btn) return;
532
664
  const rid = Number(btn.dataset.merge);
533
- if (!confirm('Merge r'+rid+' into the base branch?')) return;
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;
534
668
  btn.disabled = true;
535
669
  const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
536
670
  const j = await res.json().catch(()=>({detail:'merge failed'}));
537
671
  const msg = $('cmpMsg-'+rid);
538
672
  if (msg) msg.textContent = j.detail || (res.ok?'merged':'failed');
539
- if (res.ok){ await paintCompare(); hydrate(); } else { btn.disabled = false; }
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; }
540
675
  });
541
676
  $('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
542
677
  $('cmpOverlay').addEventListener('click',(e)=>{ if(e.target===$('cmpOverlay')){ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
@@ -607,7 +742,8 @@ $('repoForm').addEventListener('submit', async (e)=>{
607
742
  const body = { machineSlug: $('repoMachine').value, path: $('repoPath').value.trim() };
608
743
  if (!body.path) return;
609
744
  const res = await fetch('/api/repos',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
610
- if (res.ok){ $('repoPath').value=''; await hydrate(); } else { alert('repo: '+(await res.text())); }
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'); }
611
747
  });
612
748
  $('taskForm').addEventListener('submit', async (e)=>{
613
749
  e.preventDefault();
@@ -617,7 +753,7 @@ $('taskForm').addEventListener('submit', async (e)=>{
617
753
  const capId = Number($('taskCapture').value) || undefined;
618
754
  const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
619
755
  body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
620
- if (!t.ok){ alert('task failed'); return; }
756
+ if (!t.ok){ toast('task create failed', 'error'); return; }
621
757
  tasks.set(t.task.id, t.task);
622
758
  await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
623
759
  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.2.1' }));
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
  };
@@ -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.2.1' }));
375
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.3.0' }));
371
376
  socket.on('close', () => removeSink(socket));
372
377
  });
373
378