coxpit 2.3.0 → 2.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "2.3.0",
3
+ "version": "2.5.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,48 @@ 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
+ .flabel{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em;
76
+ color:var(--faint);margin:2px 0 -2px}
77
+ .btn[disabled]{opacity:.4;cursor:not-allowed;filter:none}
78
+
79
+ /* ── repo browser ── */
80
+ .brw{width:min(560px,94vw);max-height:78vh;background:var(--surface);border:1px solid var(--line);
81
+ border-radius:14px;box-shadow:var(--shadow);display:flex;flex-direction:column;overflow:hidden}
82
+ .brw-h{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line)}
83
+ .brw-path{flex:1;font-family:var(--mono);font-size:11.5px;color:var(--muted);overflow:hidden;
84
+ white-space:nowrap;text-overflow:ellipsis;direction:rtl;text-align:left}
85
+ .brw-list{overflow:auto;flex:1;min-height:200px;padding:6px}
86
+ .brw-row{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:7px;cursor:pointer;
87
+ font-family:var(--mono);font-size:12.5px;color:var(--muted)}
88
+ .brw-row:hover{background:var(--surface2);color:var(--ink)}
89
+ .brw-row .ico{color:var(--faint);flex:none}
90
+ .brw-row .nm{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
91
+ .brw-row .gitchip{font-size:9.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--brand);
92
+ border:1px solid rgba(78,201,176,.4);border-radius:999px;padding:1px 7px;flex:none}
93
+ .brw-row .btn{flex:none}
94
+ .brw-f{display:flex;gap:8px;padding:11px 16px;border-top:1px solid var(--line);align-items:center}
95
+ .brw-f .hint{font-family:var(--mono);font-size:11px;color:var(--faint);flex:1}
96
+
97
+ /* ── custom dropdown (native select 대체) ── */
98
+ .dd{position:relative}
99
+ .dd-btn{width:100%;display:flex;align-items:center;gap:8px;background:#0e1118;color:var(--ink);
100
+ border:1px solid var(--line);border-radius:var(--r-ctl);padding:8px 10px;font-family:var(--mono);
101
+ font-size:12px;cursor:pointer;transition:border-color .15s;text-align:left}
102
+ .dd-btn:hover{border-color:var(--line-hi)}
103
+ .dd.open .dd-btn{border-color:rgba(78,201,176,.55)}
104
+ .dd-lbl{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
105
+ .dd-car{color:var(--faint);font-size:10px;transition:transform .15s}
106
+ .dd.open .dd-car{transform:rotate(180deg)}
107
+ .dd-panel{position:absolute;top:calc(100% + 5px);left:0;right:0;z-index:40;background:var(--surface2);
108
+ border:1px solid var(--line-hi);border-radius:9px;box-shadow:var(--shadow);max-height:230px;
109
+ overflow:auto;padding:4px;display:none}
110
+ .dd.open .dd-panel{display:block}
111
+ .dd-opt{padding:7px 10px;border-radius:6px;font-family:var(--mono);font-size:12px;color:var(--muted);
112
+ cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
113
+ .dd-opt:hover{background:var(--surface);color:var(--ink)}
114
+ .dd-opt.on{color:var(--brand)}
115
+ .dd-opt.on::before{content:'✓ ';font-size:10px}
74
116
  .seg{display:flex;gap:3px;padding:3px;border:1px solid var(--line);border-radius:var(--r-ctl);background:#0e1118}
75
117
  .seg-opt{flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;padding:6px 8px;border:none;
76
118
  background:transparent;color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;
@@ -231,20 +273,27 @@ export const BOARD_HTML = /* html */ `<!doctype html>
231
273
  <p class="sect-label">Repositories</p>
232
274
  <div id="repos" style="display:flex;flex-direction:column;gap:6px"></div>
233
275
  <form id="repoForm">
276
+ <p class="flabel">machine</p>
277
+ <select id="repoMachine"></select>
278
+ <p class="flabel">add a repository</p>
279
+ <button class="btn sm" type="button" id="repoBrowse">Browse this machine…</button>
234
280
  <div class="row">
235
- <select id="repoMachine" class="narrow" style="flex:0 0 96px"></select>
236
- <input id="repoPath" placeholder="/abs/path/to/repo" />
281
+ <input id="repoPath" placeholder="or type an absolute path" />
282
+ <button class="btn-ghost sm" type="submit" style="flex:0 0 auto">Register</button>
237
283
  </div>
238
- <button class="btn-ghost sm" type="submit">Register repo</button>
239
284
  </form>
240
285
  </div>
241
286
  <div class="sect">
242
287
  <p class="sect-label">Launch agents</p>
243
288
  <form id="taskForm">
289
+ <p class="flabel">repository</p>
244
290
  <select id="taskRepo"></select>
291
+ <p class="flabel">task</p>
245
292
  <input id="taskTitle" placeholder="Task title" />
246
- <textarea id="taskPrompt" placeholder="Prompt for the agents…"></textarea>
293
+ <textarea id="taskPrompt" placeholder="Prompt name the target files, constraints, and how to verify"></textarea>
294
+ <p class="flabel">design capture · optional</p>
247
295
  <select id="taskCapture"><option value="">no design capture</option></select>
296
+ <p class="flabel">mode</p>
248
297
  <div class="seg" id="modeSeg" role="group" aria-label="agent mode">
249
298
  <button type="button" class="seg-opt" data-real="0">Dry run</button>
250
299
  <button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
@@ -252,7 +301,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
252
301
  <input type="checkbox" id="taskReal" hidden />
253
302
  <div class="row">
254
303
  <input id="taskCount" class="narrow" type="number" min="1" max="8" value="2" title="number of agents" />
255
- <button class="btn" type="submit">Run fleet</button>
304
+ <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
256
305
  </div>
257
306
  </form>
258
307
  </div>
@@ -332,6 +381,22 @@ export const BOARD_HTML = /* html */ `<!doctype html>
332
381
  </div>
333
382
  </div>
334
383
 
384
+ <div class="overlay" id="brwOverlay">
385
+ <div class="brw">
386
+ <div class="brw-h">
387
+ <button class="btn-ghost sm" id="brwUp">↑ Up</button>
388
+ <button class="btn-ghost sm" id="brwHome">Home</button>
389
+ <span class="brw-path" id="brwPath"></span>
390
+ <button class="x" id="brwClose" aria-label="close">×</button>
391
+ </div>
392
+ <div class="brw-list" id="brwList"></div>
393
+ <div class="brw-f">
394
+ <span class="hint">folders with a <span style="color:var(--brand)">git</span> badge are repositories — hit Register</span>
395
+ <button class="btn sm" id="brwRegHere" style="display:none">Register this folder</button>
396
+ </div>
397
+ </div>
398
+ </div>
399
+
335
400
  <div class="toasts" id="toasts"></div>
336
401
 
337
402
  <div class="overlay" id="cfmOverlay">
@@ -363,6 +428,89 @@ function toast(msg, kind){
363
428
  $('toasts').appendChild(el);
364
429
  setTimeout(()=>{ el.style.opacity='0'; el.style.transition='opacity .25s'; setTimeout(()=>el.remove(),260); }, 4200);
365
430
  }
431
+ /* ── custom dropdown — 숨긴 native select 를 상태 보관용으로 감싼다 ── */
432
+ function dressSelect(id){
433
+ const sel = $(id); if(!sel || sel.dataset.dd) return;
434
+ sel.dataset.dd = '1';
435
+ const dd = document.createElement('div'); dd.className = 'dd';
436
+ dd.innerHTML = '<button type="button" class="dd-btn"><span class="dd-lbl"></span><span class="dd-car">▾</span></button><div class="dd-panel"></div>';
437
+ sel.parentNode.insertBefore(dd, sel);
438
+ sel.style.display = 'none';
439
+ dd.querySelector('.dd-btn').addEventListener('click', (e)=>{
440
+ e.stopPropagation();
441
+ const wasOpen = dd.classList.contains('open');
442
+ closeDropdowns();
443
+ if (!wasOpen) dd.classList.add('open');
444
+ });
445
+ dd.querySelector('.dd-panel').addEventListener('click', (e)=>{
446
+ const o = e.target.closest('.dd-opt'); if(!o) return;
447
+ sel.value = o.dataset.v;
448
+ dd.classList.remove('open');
449
+ syncSelect(id);
450
+ });
451
+ syncSelect(id);
452
+ }
453
+ function syncSelect(id){
454
+ const sel = $(id); if(!sel) return;
455
+ const dd = sel.previousElementSibling;
456
+ if (!dd || !dd.classList || !dd.classList.contains('dd')) return;
457
+ const cur = sel.options[sel.selectedIndex];
458
+ dd.querySelector('.dd-lbl').textContent = cur ? cur.textContent : '—';
459
+ dd.querySelector('.dd-panel').innerHTML = [...sel.options].map(o =>
460
+ '<div class="dd-opt'+(o.value===sel.value?' on':'')+'" data-v="'+esc(o.value)+'">'+esc(o.textContent)+'</div>').join('');
461
+ }
462
+ function closeDropdowns(){ document.querySelectorAll('.dd.open').forEach(d=>d.classList.remove('open')); }
463
+ document.addEventListener('click', closeDropdowns);
464
+
465
+ /* ── repo browser — 경로 타이핑 없이 클릭으로 등록 ── */
466
+ let brwCur = '';
467
+ async function brwGo(p){
468
+ let d;
469
+ try{ d = await fetch('/api/browse'+(p?('?path='+encodeURIComponent(p)):'')).then(x=>x.json()); }
470
+ catch{ toast('browse failed', 'error'); return; }
471
+ brwCur = d.path;
472
+ $('brwPath').textContent = d.path;
473
+ $('brwUp').dataset.p = d.parent;
474
+ $('brwHome').dataset.p = d.home;
475
+ $('brwRegHere').style.display = d.isRepo ? '' : 'none';
476
+ $('brwList').innerHTML =
477
+ (d.error ? '<div class="brw-row"><span class="nm" style="color:var(--s-failed)">'+esc(d.error)+'</span></div>' : '')
478
+ + (d.dirs.map(x =>
479
+ '<div class="brw-row" data-n="'+esc(x.name)+'"><span class="ico">▸</span><span class="nm">'+esc(x.name)+'</span>'
480
+ + (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>' : '')
481
+ + '</div>').join('')
482
+ || '<div class="brw-row"><span class="nm" style="color:var(--faint)">no folders here</span></div>');
483
+ }
484
+ async function brwRegister(fullPath){
485
+ const res = await fetch('/api/repos',{method:'POST',headers:{'content-type':'application/json'},
486
+ body:JSON.stringify({machineSlug:$('repoMachine').value, path:fullPath})});
487
+ if (res.ok){
488
+ toast('repo registered — pick it under Launch agents', 'ok');
489
+ $('brwOverlay').classList.remove('open');
490
+ await hydrate();
491
+ } else {
492
+ const j = await res.json().catch(()=>({}));
493
+ toast('register: '+(j.detail||j.error||res.status), 'error');
494
+ }
495
+ }
496
+ $('repoBrowse').addEventListener('click', ()=>{
497
+ const m = machines.find(x=>x.slug===$('repoMachine').value);
498
+ if (m && m.address){ toast('remote machine — type the path manually for now', 'error'); return; }
499
+ $('brwOverlay').classList.add('open');
500
+ brwGo(brwCur || '');
501
+ });
502
+ $('brwList').addEventListener('click',(e)=>{
503
+ const reg = e.target.closest('button[data-reg]');
504
+ if (reg){ brwRegister(brwCur.replace(/\\/$/,'')+'/'+reg.dataset.reg); return; }
505
+ const row = e.target.closest('.brw-row[data-n]');
506
+ if (row) brwGo(brwCur.replace(/\\/$/,'')+'/'+row.dataset.n);
507
+ });
508
+ $('brwUp').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
509
+ $('brwHome').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
510
+ $('brwRegHere').addEventListener('click',()=>brwRegister(brwCur));
511
+ $('brwClose').addEventListener('click',()=>$('brwOverlay').classList.remove('open'));
512
+ $('brwOverlay').addEventListener('click',(e)=>{ if(e.target===$('brwOverlay')) $('brwOverlay').classList.remove('open'); });
513
+
366
514
  let cfmResolve = null;
367
515
  function confirmUI(message, opts){
368
516
  opts = opts || {};
@@ -505,12 +653,16 @@ function paintSidebar(){
505
653
  + '<div class="path">'+esc(r.path)+'</div></div>').join('')
506
654
  || '<div class="repo" style="color:var(--faint)">none registered</div>';
507
655
  $('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
508
- $('taskRepo').innerHTML = repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('');
656
+ $('taskRepo').innerHTML = repos.length
657
+ ? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('')
658
+ : '<option value="">register a repo first ↑</option>';
659
+ $('runFleetBtn').disabled = !repos.length;
509
660
  const capSel = $('taskCapture');
510
661
  const cur = capSel.value;
511
662
  capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
512
663
  '<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
513
664
  capSel.value = cur;
665
+ ['repoMachine','taskRepo','taskCapture'].forEach(id => { dressSelect(id); syncSelect(id); });
514
666
  $('captures').innerHTML = captures.map(c=>
515
667
  '<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
516
668
  + '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
@@ -591,7 +743,7 @@ $('grid').addEventListener('click',(e)=>{
591
743
  });
592
744
  $('mClose').addEventListener('click', closeModal);
593
745
  $('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
594
- document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ cfmClose(false); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
746
+ document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
595
747
  $('mRefreshDiff').addEventListener('click', loadDiff);
596
748
  async function sendSteer(){
597
749
  if (openRunId==null) return;
package/src/server.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { resolve as presolve, dirname as pdirname, join as pjoin } from 'node:path';
2
5
  import { createRequire } from 'node:module';
3
6
  import Fastify, { type FastifyInstance } from 'fastify';
4
7
  import websocket from '@fastify/websocket';
@@ -29,7 +32,7 @@ export async function buildServer(): Promise<FastifyInstance> {
29
32
  app.addHook('onRequest', authGate);
30
33
 
31
34
  // 무인증 헬스(외부 감시용)
32
- app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.3.0' }));
35
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.5.0' }));
33
36
 
34
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
35
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -174,6 +177,28 @@ export async function buildServer(): Promise<FastifyInstance> {
174
177
  return reply.code(201).send({ ok: true, repo: ins[0] });
175
178
  });
176
179
 
180
+ // 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
181
+ app.get('/api/browse', async (req) => {
182
+ const q = (req.query ?? {}) as { path?: string };
183
+ const start = q.path && q.path.startsWith('/') ? q.path : homedir();
184
+ const p = presolve(start);
185
+ let dirs: Array<{ name: string; isRepo: boolean }> = [];
186
+ let error: string | undefined;
187
+ try {
188
+ const entries = await readdir(p, { withFileTypes: true });
189
+ for (const e of entries) {
190
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
191
+ dirs.push({ name: e.name, isRepo: existsSync(pjoin(p, e.name, '.git')) });
192
+ if (dirs.length >= 300) break;
193
+ }
194
+ dirs.sort((a, b) => (b.isRepo ? 1 : 0) - (a.isRepo ? 1 : 0) || a.name.localeCompare(b.name));
195
+ } catch {
196
+ error = 'cannot read directory';
197
+ dirs = [];
198
+ }
199
+ return { path: p, parent: pdirname(p), home: homedir(), isRepo: existsSync(pjoin(p, '.git')), dirs, error };
200
+ });
201
+
177
202
  // ─── Design Mode ───────────────────────────────────────────────
178
203
  // 캡처 키: 인증 off 면 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
179
204
  const captureKeyOk = (req: { query?: unknown }): boolean => {
@@ -372,7 +397,7 @@ export async function buildServer(): Promise<FastifyInstance> {
372
397
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
373
398
  app.get('/ws', { websocket: true }, (socket) => {
374
399
  addSink(socket);
375
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.3.0' }));
400
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.5.0' }));
376
401
  socket.on('close', () => removeSink(socket));
377
402
  });
378
403