coxpit 2.4.0 → 2.6.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.6.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>
@@ -321,6 +350,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
321
350
  <button class="btn-ghost sm" id="mTerm">Terminal</button>
322
351
  <button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
323
352
  <button class="btn-ghost sm" id="mCompare">Compare runs</button>
353
+ <button class="btn-ghost sm" id="mExport">Export files…</button>
324
354
  <span class="spacer"></span>
325
355
  <button class="btn-danger sm" id="mStop">Stop</button>
326
356
  <button class="btn-ghost sm" id="mCleanup">Cleanup</button>
@@ -352,8 +382,39 @@ export const BOARD_HTML = /* html */ `<!doctype html>
352
382
  </div>
353
383
  </div>
354
384
 
385
+ <div class="overlay" id="brwOverlay">
386
+ <div class="brw">
387
+ <div class="brw-h">
388
+ <button class="btn-ghost sm" id="brwUp">↑ Up</button>
389
+ <button class="btn-ghost sm" id="brwHome">Home</button>
390
+ <span class="brw-path" id="brwPath"></span>
391
+ <button class="x" id="brwClose" aria-label="close">×</button>
392
+ </div>
393
+ <div class="brw-list" id="brwList"></div>
394
+ <div class="brw-f">
395
+ <span class="hint">folders with a <span style="color:var(--brand)">git</span> badge are repositories — hit Register</span>
396
+ <button class="btn sm" id="brwRegHere" style="display:none">Register this folder</button>
397
+ </div>
398
+ </div>
399
+ </div>
400
+
355
401
  <div class="toasts" id="toasts"></div>
356
402
 
403
+ <div class="overlay" id="expOverlay">
404
+ <div class="cfm">
405
+ <div class="cfm-b">
406
+ <div class="m">Export this run's changed files</div>
407
+ <div class="s">Copies changed &amp; new files (with their folder structure) out of the worktree — no merge. Good for reports and one-off artifacts.</div>
408
+ <p class="flabel" style="margin-top:12px">destination folder</p>
409
+ <input id="expDest" placeholder="empty = ~/coxpit-exports/r<id>" />
410
+ </div>
411
+ <div class="cfm-f">
412
+ <button class="btn-ghost sm" id="expCancel">Cancel</button>
413
+ <button class="btn sm" id="expOk">Export</button>
414
+ </div>
415
+ </div>
416
+ </div>
417
+
357
418
  <div class="overlay" id="cfmOverlay">
358
419
  <div class="cfm">
359
420
  <div class="cfm-b"><div class="m" id="cfmMsg"></div><div class="s" id="cfmSub"></div></div>
@@ -417,6 +478,55 @@ function syncSelect(id){
417
478
  function closeDropdowns(){ document.querySelectorAll('.dd.open').forEach(d=>d.classList.remove('open')); }
418
479
  document.addEventListener('click', closeDropdowns);
419
480
 
481
+ /* ── repo browser — 경로 타이핑 없이 클릭으로 등록 ── */
482
+ let brwCur = '';
483
+ async function brwGo(p){
484
+ let d;
485
+ try{ d = await fetch('/api/browse'+(p?('?path='+encodeURIComponent(p)):'')).then(x=>x.json()); }
486
+ catch{ toast('browse failed', 'error'); return; }
487
+ brwCur = d.path;
488
+ $('brwPath').textContent = d.path;
489
+ $('brwUp').dataset.p = d.parent;
490
+ $('brwHome').dataset.p = d.home;
491
+ $('brwRegHere').style.display = d.isRepo ? '' : 'none';
492
+ $('brwList').innerHTML =
493
+ (d.error ? '<div class="brw-row"><span class="nm" style="color:var(--s-failed)">'+esc(d.error)+'</span></div>' : '')
494
+ + (d.dirs.map(x =>
495
+ '<div class="brw-row" data-n="'+esc(x.name)+'"><span class="ico">▸</span><span class="nm">'+esc(x.name)+'</span>'
496
+ + (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>' : '')
497
+ + '</div>').join('')
498
+ || '<div class="brw-row"><span class="nm" style="color:var(--faint)">no folders here</span></div>');
499
+ }
500
+ async function brwRegister(fullPath){
501
+ const res = await fetch('/api/repos',{method:'POST',headers:{'content-type':'application/json'},
502
+ body:JSON.stringify({machineSlug:$('repoMachine').value, path:fullPath})});
503
+ if (res.ok){
504
+ toast('repo registered — pick it under Launch agents', 'ok');
505
+ $('brwOverlay').classList.remove('open');
506
+ await hydrate();
507
+ } else {
508
+ const j = await res.json().catch(()=>({}));
509
+ toast('register: '+(j.detail||j.error||res.status), 'error');
510
+ }
511
+ }
512
+ $('repoBrowse').addEventListener('click', ()=>{
513
+ const m = machines.find(x=>x.slug===$('repoMachine').value);
514
+ if (m && m.address){ toast('remote machine — type the path manually for now', 'error'); return; }
515
+ $('brwOverlay').classList.add('open');
516
+ brwGo(brwCur || '');
517
+ });
518
+ $('brwList').addEventListener('click',(e)=>{
519
+ const reg = e.target.closest('button[data-reg]');
520
+ if (reg){ brwRegister(brwCur.replace(/\\/$/,'')+'/'+reg.dataset.reg); return; }
521
+ const row = e.target.closest('.brw-row[data-n]');
522
+ if (row) brwGo(brwCur.replace(/\\/$/,'')+'/'+row.dataset.n);
523
+ });
524
+ $('brwUp').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
525
+ $('brwHome').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
526
+ $('brwRegHere').addEventListener('click',()=>brwRegister(brwCur));
527
+ $('brwClose').addEventListener('click',()=>$('brwOverlay').classList.remove('open'));
528
+ $('brwOverlay').addEventListener('click',(e)=>{ if(e.target===$('brwOverlay')) $('brwOverlay').classList.remove('open'); });
529
+
420
530
  let cfmResolve = null;
421
531
  function confirmUI(message, opts){
422
532
  opts = opts || {};
@@ -530,7 +640,9 @@ function cardHTML(r){
530
640
  + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'+chipHTML(r.status)+'</div>'
531
641
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
532
642
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
533
- + '<span>'+esc(r.agent||'')+'</span></div>'
643
+ + '<span>'+esc(r.agent||'')+'</span>'
644
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR ↗</a>' : '')
645
+ + '</div>'
534
646
  + '<div class="log">'+evs+'</div></div>';
535
647
  }
536
648
  function flash(id){ const el=$('card-'+id); if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); } }
@@ -556,10 +668,14 @@ function paintSidebar(){
556
668
  '<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
557
669
  $('repos').innerHTML = repos.map(r =>
558
670
  '<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
671
+ + '<button class="x" data-delrepo="'+r.id+'" title="remove repo" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
559
672
  + '<div class="path">'+esc(r.path)+'</div></div>').join('')
560
673
  || '<div class="repo" style="color:var(--faint)">none registered</div>';
561
674
  $('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('');
675
+ $('taskRepo').innerHTML = repos.length
676
+ ? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('')
677
+ : '<option value="">register a repo first ↑</option>';
678
+ $('runFleetBtn').disabled = !repos.length;
563
679
  const capSel = $('taskCapture');
564
680
  const cur = capSel.value;
565
681
  capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
@@ -579,6 +695,16 @@ $('captures').addEventListener('click', async (e)=>{
579
695
  await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
580
696
  hydrate();
581
697
  });
698
+ $('repos').addEventListener('click', async (e)=>{
699
+ const b = e.target.closest('button[data-delrepo]'); if(!b) return;
700
+ const yes = await confirmUI('Remove this repository from coxpit?',
701
+ { sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
702
+ if (!yes) return;
703
+ const res = await fetch('/api/repos/'+b.dataset.delrepo,{method:'DELETE'});
704
+ const j = await res.json().catch(()=>({}));
705
+ if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
706
+ else toast('remove: '+(j.detail||res.status), 'error');
707
+ });
582
708
 
583
709
  function connectWS(){
584
710
  const proto = location.protocol==='https:'?'wss':'ws';
@@ -646,8 +772,27 @@ $('grid').addEventListener('click',(e)=>{
646
772
  });
647
773
  $('mClose').addEventListener('click', closeModal);
648
774
  $('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'); } });
775
+ document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
650
776
  $('mRefreshDiff').addEventListener('click', loadDiff);
777
+ $('mExport').addEventListener('click', ()=>{
778
+ if (openRunId==null) return;
779
+ $('expDest').value='';
780
+ $('expDest').placeholder='empty = ~/coxpit-exports/r'+openRunId;
781
+ $('expOverlay').classList.add('open');
782
+ });
783
+ async function doExport(){
784
+ if (openRunId==null) return;
785
+ const res = await fetch('/api/runs/'+openRunId+'/export',{method:'POST',
786
+ headers:{'content-type':'application/json'}, body:JSON.stringify({dest:$('expDest').value.trim()})});
787
+ const j = await res.json().catch(()=>({}));
788
+ $('expOverlay').classList.remove('open');
789
+ if (res.ok) toast(j.copied+' file(s) → '+j.dest, 'ok');
790
+ else toast('export: '+(j.detail||res.status), 'error');
791
+ }
792
+ $('expOk').addEventListener('click', doExport);
793
+ $('expDest').addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); doExport(); } });
794
+ $('expCancel').addEventListener('click', ()=>$('expOverlay').classList.remove('open'));
795
+ $('expOverlay').addEventListener('click',(e)=>{ if(e.target===$('expOverlay')) $('expOverlay').classList.remove('open'); });
651
796
  async function sendSteer(){
652
797
  if (openRunId==null) return;
653
798
  const msg = $('steerInput').value.trim(); if(!msg) return;
@@ -707,14 +852,30 @@ async function paintCompare(){
707
852
  + '<span class="files">'+files+' file'+(files===1?'':'s')+'</span></div>'
708
853
  + '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
709
854
  + '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
710
- + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'"></span>'
855
+ + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'">'
856
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR ↗ '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
857
+ + '</span>'
711
858
  + (merged
712
859
  ? chipHTML('merged')
713
- : '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
860
+ : (r.prUrl ? '' : '<button class="btn-ghost sm" data-pr="'+r.id+'"'+(mergeable?'':' disabled')+'>Open PR</button>')
861
+ + '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
714
862
  + '</div></div>';
715
863
  }).join('');
716
864
  }
717
865
  $('cmpBody').addEventListener('click', async (e)=>{
866
+ const prBtn = e.target.closest('button[data-pr]');
867
+ if (prBtn){
868
+ const rid = Number(prBtn.dataset.pr);
869
+ const yes = await confirmUI('Open a pull request from r'+rid+'?',
870
+ { sub: 'Commits the worktree, pushes the branch to origin, and opens a PR against the base branch (needs gh CLI signed in).', okLabel: 'Open PR' });
871
+ if (!yes) return;
872
+ prBtn.disabled = true;
873
+ const res = await fetch('/api/runs/'+rid+'/pr',{method:'POST'});
874
+ const j = await res.json().catch(()=>({}));
875
+ if (res.ok){ toast('PR opened: '+j.url, 'ok'); await paintCompare(); hydrate(); }
876
+ else { toast('PR: '+(j.detail||res.status), 'error'); prBtn.disabled = false; }
877
+ return;
878
+ }
718
879
  const btn = e.target.closest('button[data-merge]'); if(!btn) return;
719
880
  const rid = Number(btn.dataset.merge);
720
881
  const yes = await confirmUI('Merge r'+rid+' into the base branch?',
package/src/db/index.ts CHANGED
@@ -56,6 +56,7 @@ export async function ensureSchema(): Promise<void> {
56
56
  tmux_window TEXT NOT NULL DEFAULT '',
57
57
  status TEXT NOT NULL DEFAULT 'pending',
58
58
  session_id TEXT NOT NULL DEFAULT '',
59
+ pr_url TEXT NOT NULL DEFAULT '',
59
60
  files_changed INTEGER NOT NULL DEFAULT 0,
60
61
  started_at INTEGER,
61
62
  ended_at INTEGER,
@@ -72,4 +73,5 @@ export async function ensureSchema(): Promise<void> {
72
73
  // 기존 DB 마이그레이션(멱등)
73
74
  try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
74
75
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
76
+ try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
75
77
  }
package/src/db/schema.ts CHANGED
@@ -54,6 +54,7 @@ export const agentRuns = sqliteTable('agent_runs', {
54
54
  tmuxWindow: text('tmux_window').notNull().default(''),
55
55
  status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
56
56
  sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
57
+ prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
57
58
  filesChanged: integer('files_changed').notNull().default(0),
58
59
  startedAt: integer('started_at', { mode: 'timestamp' }),
59
60
  endedAt: integer('ended_at', { mode: 'timestamp' }),
@@ -1,5 +1,8 @@
1
1
  import { posix as ppath } from 'node:path';
2
2
  import { createInterface } from 'node:readline';
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdir, copyFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
3
6
  import type { ChildProcess } from 'node:child_process';
4
7
  import { eq } from 'drizzle-orm';
5
8
  import { config } from './config';
@@ -326,6 +329,91 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
326
329
  return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
327
330
  }
328
331
 
332
+ /**
333
+ * 결과 파일 회수(export) — 조회성 태스크용: worktree 의 변경·신규 파일을
334
+ * 지정 폴더로 복사한다. 머지 없이 산출물만 가져오는 길. (v1: 로컬 머신 전용)
335
+ */
336
+ export async function exportRun(runId: number, destIn?: string): Promise<{ ok: boolean; detail: string; dest?: string; copied?: number; skipped?: number }> {
337
+ const ctx = await loadContext(runId);
338
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
339
+ const run = rr[0];
340
+ if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
341
+ if (liveChildren.has(runId)) return { ok: false, detail: 'still running — wait for it to settle' };
342
+ if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
343
+ return { ok: false, detail: 'remote export not yet supported — files are on the remote machine' };
344
+ }
345
+ const wt = run.worktreePath;
346
+ if (!existsSync(wt)) return { ok: false, detail: 'worktree missing (cleaned up)' };
347
+
348
+ // 회수 대상 = 분기점 이후 커밋된 변경(머지 시도가 auto-commit 했을 수 있음) ∪ 미커밋·신규
349
+ const list = await runShellOn(
350
+ ctx.machine,
351
+ `cd ${shq(wt)} && { git diff --name-only ${shq(ctx.baseBranch)}...HEAD -z 2>/dev/null; git ls-files -mo --exclude-standard -z; }`,
352
+ 15000,
353
+ );
354
+ if (!list.ok) return { ok: false, detail: 'could not list changed files' };
355
+ const files = [...new Set(list.stdout.split('\0').filter(Boolean))];
356
+ if (!files.length) return { ok: false, detail: 'no changed files in this run' };
357
+
358
+ const dest = (destIn ?? '').trim() || ppath.join(homedir(), 'coxpit-exports', `r${runId}`);
359
+ if (!dest.startsWith('/')) return { ok: false, detail: 'destination must be an absolute path' };
360
+
361
+ let copied = 0, skipped = 0;
362
+ for (const f of files) {
363
+ const src = ppath.join(wt, f);
364
+ if (!existsSync(src)) { skipped++; continue; } // 삭제된 파일 등
365
+ const out = ppath.join(dest, f);
366
+ await mkdir(ppath.dirname(out), { recursive: true });
367
+ await copyFile(src, out);
368
+ copied++;
369
+ }
370
+ await recordEvent(runId, 'export', JSON.stringify({ dest, copied, skipped }));
371
+ return { ok: true, detail: `${copied} file(s) exported`, dest, copied, skipped };
372
+ }
373
+
374
+ /**
375
+ * PR 모드 — run 브랜치를 origin 에 push 하고 gh 로 pull request 를 연다.
376
+ * 팀 repo·리뷰 흐름용: 로컬 merge 대신 PR 로 결과를 보낸다.
377
+ */
378
+ export async function prRun(runId: number): Promise<{ ok: boolean; detail: string; url?: string }> {
379
+ const ctx = await loadContext(runId);
380
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
381
+ const run = rr[0];
382
+ if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
383
+ if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
384
+ if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
385
+ const wt = shq(run.worktreePath);
386
+
387
+ // 0) 사전 조건: origin 리모트 + gh CLI
388
+ const pre = await runShellOn(ctx.machine,
389
+ `cd ${wt} && { git remote get-url origin >/dev/null 2>&1 && echo R1 || echo R0; } && { command -v gh >/dev/null 2>&1 && echo G1 || echo G0; }`, 10000);
390
+ if (!pre.stdout.includes('R1')) return { ok: false, detail: 'no origin remote on this repo' };
391
+ if (!pre.stdout.includes('G1')) return { ok: false, detail: 'GitHub CLI (gh) not found on the machine' };
392
+
393
+ // 1) worktree 미커밋 변경 자동 커밋
394
+ const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
395
+ const c1 = await runShellOn(ctx.machine,
396
+ `cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: agent changes`)})`, 20000);
397
+ if (!c1.ok) return { ok: false, detail: 'worktree commit failed: ' + (c1.stderr || c1.stdout).trim().slice(0, 300) };
398
+
399
+ // 2) push
400
+ const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(run.branch)} 2>&1`, 60000);
401
+ if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
402
+
403
+ // 3) PR 생성 (동일 브랜치 PR 이 이미 있으면 그 URL 재사용)
404
+ const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
405
+ const title = `${tr[0]?.title ?? 'coxpit run'} (r${runId})`;
406
+ const body = (run.exitSummary ? run.exitSummary + '\n\n' : '') + '🤖 Opened from a coxpit agent run';
407
+ const pr = await runShellOn(ctx.machine,
408
+ `cd ${wt} && gh pr create -B ${shq(ctx.baseBranch)} -H ${shq(run.branch)} -t ${shq(title)} -b ${shq(body)} 2>&1 || true`, 60000);
409
+ const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
410
+ if (!m) return { ok: false, detail: 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 300) };
411
+
412
+ await setRun(runId, { prUrl: m[0] });
413
+ await recordEvent(runId, 'pr', m[0]);
414
+ return { ok: true, detail: 'pull request opened', url: m[0] };
415
+ }
416
+
329
417
  /**
330
418
  * worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
331
419
  */
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';
@@ -9,7 +12,7 @@ import { db } from './db';
9
12
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
10
13
  import { BOOKMARKLET_JS } from './design';
11
14
  import { runShellOn, shq } from './exec';
12
- import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun } from './orchestrator';
15
+ import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun } from './orchestrator';
13
16
  import { openTerm } from './term';
14
17
  import { addSink, removeSink, broadcast } from './hub';
15
18
  import { BOARD_HTML } from './board';
@@ -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.6.0' }));
33
36
 
34
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
35
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -152,10 +155,17 @@ export async function buildServer(): Promise<FastifyInstance> {
152
155
  const m = mr[0];
153
156
  if (!m) return reply.code(404).send({ error: 'machine not found' });
154
157
 
158
+ // 기본 브랜치는 "지금 체크아웃된 브랜치"가 아니라 repo 의 진짜 기본값:
159
+ // origin/HEAD → 로컬 main/master → 현재 HEAD 순으로 감지.
160
+ const g = `git -C ${shq(path)}`;
155
161
  const cmd =
156
- `git -C ${shq(path)} rev-parse --is-inside-work-tree 2>&1` +
162
+ `${g} rev-parse --is-inside-work-tree 2>&1` +
157
163
  ` && echo '---B---'` +
158
- ` && git -C ${shq(path)} rev-parse --abbrev-ref HEAD 2>&1`;
164
+ ` && { ${g} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || true; }` +
165
+ ` && echo '---C---'` +
166
+ ` && { ${g} show-ref --verify -q refs/heads/main && echo main || { ${g} show-ref --verify -q refs/heads/master && echo master; } || true; }` +
167
+ ` && echo '---D---'` +
168
+ ` && ${g} rev-parse --abbrev-ref HEAD 2>&1`;
159
169
  const r = await runShellOn(m, cmd);
160
170
  const isRepo = r.ok && /(^|\n)true(\n|$)/.test(r.stdout);
161
171
  if (!isRepo) {
@@ -164,7 +174,12 @@ export async function buildServer(): Promise<FastifyInstance> {
164
174
  detail: (r.stdout || r.stderr).trim().slice(0, 400),
165
175
  });
166
176
  }
167
- const branch = (r.stdout.split('---B---')[1] ?? '').trim() || 'main';
177
+ const seg = (a: string, b: string): string =>
178
+ ((r.stdout.split(a)[1] ?? '').split(b)[0] ?? '').trim();
179
+ const originHead = seg('---B---', '---C---').replace(/^origin\//, '');
180
+ const localMain = seg('---C---', '---D---');
181
+ const headNow = (r.stdout.split('---D---')[1] ?? '').trim();
182
+ const branch = originHead || localMain || headNow || 'main';
168
183
  const name = (b.name ?? '').trim() || path.split('/').filter(Boolean).pop() || path;
169
184
 
170
185
  const ins = await db.insert(repos).values({
@@ -174,6 +189,39 @@ export async function buildServer(): Promise<FastifyInstance> {
174
189
  return reply.code(201).send({ ok: true, repo: ins[0] });
175
190
  });
176
191
 
192
+ // repo 삭제 — 열린 태스크가 있으면 거부(이력 보호).
193
+ app.delete('/api/repos/:id', async (req, reply) => {
194
+ const id = Number((req.params as { id: string }).id);
195
+ const rp = await db.select().from(repos).where(eq(repos.id, id)).limit(1);
196
+ if (!rp[0]) return reply.code(404).send({ error: 'not found' });
197
+ const open = (await db.select().from(tasks).where(eq(tasks.repoId, id))).filter((t) => t.status !== 'closed');
198
+ if (open.length) return reply.code(409).send({ ok: false, detail: `close ${open.length} open task(s) on this repo first` });
199
+ await db.delete(repos).where(eq(repos.id, id));
200
+ return { ok: true };
201
+ });
202
+
203
+ // 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
204
+ app.get('/api/browse', async (req) => {
205
+ const q = (req.query ?? {}) as { path?: string };
206
+ const start = q.path && q.path.startsWith('/') ? q.path : homedir();
207
+ const p = presolve(start);
208
+ let dirs: Array<{ name: string; isRepo: boolean }> = [];
209
+ let error: string | undefined;
210
+ try {
211
+ const entries = await readdir(p, { withFileTypes: true });
212
+ for (const e of entries) {
213
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
214
+ dirs.push({ name: e.name, isRepo: existsSync(pjoin(p, e.name, '.git')) });
215
+ if (dirs.length >= 300) break;
216
+ }
217
+ dirs.sort((a, b) => (b.isRepo ? 1 : 0) - (a.isRepo ? 1 : 0) || a.name.localeCompare(b.name));
218
+ } catch {
219
+ error = 'cannot read directory';
220
+ dirs = [];
221
+ }
222
+ return { path: p, parent: pdirname(p), home: homedir(), isRepo: existsSync(pjoin(p, '.git')), dirs, error };
223
+ });
224
+
177
225
  // ─── Design Mode ───────────────────────────────────────────────
178
226
  // 캡처 키: 인증 off 면 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
179
227
  const captureKeyOk = (req: { query?: unknown }): boolean => {
@@ -353,6 +401,27 @@ export async function buildServer(): Promise<FastifyInstance> {
353
401
  return reply.code(202).send(res);
354
402
  });
355
403
 
404
+ // 결과 파일 회수 — 머지 없이 worktree 산출물만 지정 폴더로 복사(조회성 태스크).
405
+ app.post('/api/runs/:id/export', async (req, reply) => {
406
+ const id = Number((req.params as { id: string }).id);
407
+ const b = (req.body ?? {}) as { dest?: string };
408
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
409
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
410
+ const res = await exportRun(id, b.dest);
411
+ if (!res.ok) return reply.code(409).send(res);
412
+ return res;
413
+ });
414
+
415
+ // PR 모드 — run 브랜치 push + gh pr create.
416
+ app.post('/api/runs/:id/pr', async (req, reply) => {
417
+ const id = Number((req.params as { id: string }).id);
418
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
419
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
420
+ const res = await prRun(id);
421
+ if (!res.ok) return reply.code(409).send(res);
422
+ return res;
423
+ });
424
+
356
425
  // 실행 중 run 중지(SIGTERM) — close 핸들러가 stopped 로 봉인.
357
426
  app.post('/api/runs/:id/stop', async (req, reply) => {
358
427
  const id = Number((req.params as { id: string }).id);
@@ -372,7 +441,7 @@ export async function buildServer(): Promise<FastifyInstance> {
372
441
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
373
442
  app.get('/ws', { websocket: true }, (socket) => {
374
443
  addSink(socket);
375
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.4.0' }));
444
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.6.0' }));
376
445
  socket.on('close', () => removeSink(socket));
377
446
  });
378
447