coxpit 2.4.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.4.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
@@ -72,6 +72,28 @@ export const BOARD_HTML = /* html */ `<!doctype html>
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
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
+
75
97
  /* ── custom dropdown (native select 대체) ── */
76
98
  .dd{position:relative}
77
99
  .dd-btn{width:100%;display:flex;align-items:center;gap:8px;background:#0e1118;color:var(--ink);
@@ -251,20 +273,27 @@ export const BOARD_HTML = /* html */ `<!doctype html>
251
273
  <p class="sect-label">Repositories</p>
252
274
  <div id="repos" style="display:flex;flex-direction:column;gap:6px"></div>
253
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>
254
280
  <div class="row">
255
- <select id="repoMachine" class="narrow" style="flex:0 0 96px"></select>
256
- <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>
257
283
  </div>
258
- <button class="btn-ghost sm" type="submit">Register repo</button>
259
284
  </form>
260
285
  </div>
261
286
  <div class="sect">
262
287
  <p class="sect-label">Launch agents</p>
263
288
  <form id="taskForm">
289
+ <p class="flabel">repository</p>
264
290
  <select id="taskRepo"></select>
291
+ <p class="flabel">task</p>
265
292
  <input id="taskTitle" placeholder="Task title" />
266
- <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>
267
295
  <select id="taskCapture"><option value="">no design capture</option></select>
296
+ <p class="flabel">mode</p>
268
297
  <div class="seg" id="modeSeg" role="group" aria-label="agent mode">
269
298
  <button type="button" class="seg-opt" data-real="0">Dry run</button>
270
299
  <button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
@@ -272,7 +301,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
272
301
  <input type="checkbox" id="taskReal" hidden />
273
302
  <div class="row">
274
303
  <input id="taskCount" class="narrow" type="number" min="1" max="8" value="2" title="number of agents" />
275
- <button class="btn" type="submit">Run fleet</button>
304
+ <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
276
305
  </div>
277
306
  </form>
278
307
  </div>
@@ -352,6 +381,22 @@ export const BOARD_HTML = /* html */ `<!doctype html>
352
381
  </div>
353
382
  </div>
354
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
+
355
400
  <div class="toasts" id="toasts"></div>
356
401
 
357
402
  <div class="overlay" id="cfmOverlay">
@@ -417,6 +462,55 @@ function syncSelect(id){
417
462
  function closeDropdowns(){ document.querySelectorAll('.dd.open').forEach(d=>d.classList.remove('open')); }
418
463
  document.addEventListener('click', closeDropdowns);
419
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
+
420
514
  let cfmResolve = null;
421
515
  function confirmUI(message, opts){
422
516
  opts = opts || {};
@@ -559,7 +653,10 @@ function paintSidebar(){
559
653
  + '<div class="path">'+esc(r.path)+'</div></div>').join('')
560
654
  || '<div class="repo" style="color:var(--faint)">none registered</div>';
561
655
  $('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
562
- $('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;
563
660
  const capSel = $('taskCapture');
564
661
  const cur = capSel.value;
565
662
  capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
@@ -646,7 +743,7 @@ $('grid').addEventListener('click',(e)=>{
646
743
  });
647
744
  $('mClose').addEventListener('click', closeModal);
648
745
  $('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
649
- document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); 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'); } });
650
747
  $('mRefreshDiff').addEventListener('click', loadDiff);
651
748
  async function sendSteer(){
652
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.4.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.4.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