coxpit 2.5.0 → 2.7.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.5.0",
3
+ "version": "2.7.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
@@ -159,6 +159,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
159
159
  .ev{display:flex;gap:9px;align-items:baseline;min-width:0}
160
160
  .ev .k{color:var(--brand);min-width:78px;flex:none;opacity:.85}
161
161
  .ev .t{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
162
+ /* ── select mode (integrate) ── */
163
+ .toolbar{display:flex;justify-content:flex-end;gap:8px;margin-bottom:12px}
164
+ .card.selmode{cursor:copy}
165
+ .card .selbox{display:none;width:20px;height:20px;border-radius:50%;border:1px solid var(--line-hi);
166
+ align-items:center;justify-content:center;font-size:11px;color:transparent;flex:none}
167
+ .card.selmode .selbox{display:flex}
168
+ .card.selected{border-color:var(--brand)}
169
+ .card.selected .selbox{background:var(--brand);border-color:var(--brand);color:var(--brand-ink)}
170
+ .selbar{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:45;display:none;
171
+ align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line-hi);border-radius:12px;
172
+ padding:10px 16px;box-shadow:var(--shadow)}
173
+ .selbar.on{display:flex}
174
+ .selbar .cnt{font-family:var(--mono);font-size:12px;color:var(--brand)}
175
+ .selbar .note{font-family:var(--mono);font-size:11px;color:var(--faint)}
176
+
162
177
  .empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:64px 24px;text-align:center;
163
178
  display:flex;flex-direction:column;gap:10px;align-items:center}
164
179
  .empty .glyph{font-size:22px;color:#2c3444;letter-spacing:4px}
@@ -300,7 +315,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
300
315
  </div>
301
316
  <input type="checkbox" id="taskReal" hidden />
302
317
  <div class="row">
303
- <input id="taskCount" class="narrow" type="number" min="1" max="8" value="2" title="number of agents" />
318
+ <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="number of agents — 1 for a job, N to explore variants" />
304
319
  <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
305
320
  </div>
306
321
  </form>
@@ -315,6 +330,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
315
330
  </div>
316
331
  </aside>
317
332
  <main>
333
+ <div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
318
334
  <div class="grid" id="grid"></div>
319
335
  <div class="empty" id="empty">
320
336
  <span class="glyph">▚▞▚</span>
@@ -350,6 +366,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
350
366
  <button class="btn-ghost sm" id="mTerm">Terminal</button>
351
367
  <button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
352
368
  <button class="btn-ghost sm" id="mCompare">Compare runs</button>
369
+ <button class="btn-ghost sm" id="mExport">Export files…</button>
353
370
  <span class="spacer"></span>
354
371
  <button class="btn-danger sm" id="mStop">Stop</button>
355
372
  <button class="btn-ghost sm" id="mCleanup">Cleanup</button>
@@ -399,6 +416,28 @@ export const BOARD_HTML = /* html */ `<!doctype html>
399
416
 
400
417
  <div class="toasts" id="toasts"></div>
401
418
 
419
+ <div class="selbar" id="selbar">
420
+ <span class="cnt" id="selCnt">0 selected</span>
421
+ <span class="note">merges in selection order · conflicts spawn an integration agent</span>
422
+ <button class="btn sm" id="selGo">Integrate → base</button>
423
+ <button class="btn-ghost sm" id="selCancel">Cancel</button>
424
+ </div>
425
+
426
+ <div class="overlay" id="expOverlay">
427
+ <div class="cfm">
428
+ <div class="cfm-b">
429
+ <div class="m">Export this run's changed files</div>
430
+ <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>
431
+ <p class="flabel" style="margin-top:12px">destination folder</p>
432
+ <input id="expDest" placeholder="empty = ~/coxpit-exports/r<id>" />
433
+ </div>
434
+ <div class="cfm-f">
435
+ <button class="btn-ghost sm" id="expCancel">Cancel</button>
436
+ <button class="btn sm" id="expOk">Export</button>
437
+ </div>
438
+ </div>
439
+ </div>
440
+
402
441
  <div class="overlay" id="cfmOverlay">
403
442
  <div class="cfm">
404
443
  <div class="cfm-b"><div class="m" id="cfmMsg"></div><div class="s" id="cfmSub"></div></div>
@@ -620,11 +659,15 @@ function cardHTML(r){
620
659
  const evs = (r.events||[]).slice(-8).map(e =>
621
660
  '<div class="ev"><span class="k">'+esc(e.kind)+'</span><span class="t">'+esc(summarize(e.kind,e.payload)).slice(0,140)+'</span></div>'
622
661
  ).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
623
- return '<div class="card" id="card-'+r.id+'">'
624
- + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'+chipHTML(r.status)+'</div>'
662
+ const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
663
+ return '<div class="card'+selCls+'" id="card-'+r.id+'">'
664
+ + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
665
+ + '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
625
666
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
626
667
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
627
- + '<span>'+esc(r.agent||'')+'</span></div>'
668
+ + '<span>'+esc(r.agent||'')+'</span>'
669
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR ↗</a>' : '')
670
+ + '</div>'
628
671
  + '<div class="log">'+evs+'</div></div>';
629
672
  }
630
673
  function flash(id){ const el=$('card-'+id); if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); } }
@@ -650,6 +693,7 @@ function paintSidebar(){
650
693
  '<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
651
694
  $('repos').innerHTML = repos.map(r =>
652
695
  '<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
696
+ + '<button class="x" data-delrepo="'+r.id+'" title="remove repo" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
653
697
  + '<div class="path">'+esc(r.path)+'</div></div>').join('')
654
698
  || '<div class="repo" style="color:var(--faint)">none registered</div>';
655
699
  $('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
@@ -676,6 +720,16 @@ $('captures').addEventListener('click', async (e)=>{
676
720
  await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
677
721
  hydrate();
678
722
  });
723
+ $('repos').addEventListener('click', async (e)=>{
724
+ const b = e.target.closest('button[data-delrepo]'); if(!b) return;
725
+ const yes = await confirmUI('Remove this repository from coxpit?',
726
+ { sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
727
+ if (!yes) return;
728
+ const res = await fetch('/api/repos/'+b.dataset.delrepo,{method:'DELETE'});
729
+ const j = await res.json().catch(()=>({}));
730
+ if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
731
+ else toast('remove: '+(j.detail||res.status), 'error');
732
+ });
679
733
 
680
734
  function connectWS(){
681
735
  const proto = location.protocol==='https:'?'wss':'ws';
@@ -688,6 +742,7 @@ function connectWS(){
688
742
  const known = runs.has(ev.runId ?? ev.id);
689
743
  upsertRun(ev);
690
744
  if (!known && ev.taskId==null){ hydrate(); return; }
745
+ if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
691
746
  render(); flash(ev.runId ?? ev.id); paintModal();
692
747
  if (openRunId===(ev.runId??ev.id) && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
693
748
  if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
@@ -737,14 +792,72 @@ async function loadDiff(){
737
792
  }
738
793
  function openModal(id){ openRunId = id; paintModal(); $('overlay').classList.add('open'); loadDiff(); }
739
794
  function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
795
+ /* ── select mode (integrate) ── */
796
+ let selectMode = false;
797
+ const selected = new Set();
798
+ const selOrder = [];
799
+ function setSelectMode(on){
800
+ selectMode = on;
801
+ if (!on){ selected.clear(); selOrder.length = 0; }
802
+ $('selToggle').textContent = on ? 'Exit select' : 'Select runs';
803
+ $('selbar').classList.toggle('on', on);
804
+ $('selCnt').textContent = selected.size + ' selected';
805
+ render();
806
+ }
807
+ $('selToggle').addEventListener('click', ()=>setSelectMode(!selectMode));
808
+ $('selCancel').addEventListener('click', ()=>setSelectMode(false));
809
+ $('selGo').addEventListener('click', async ()=>{
810
+ if (!selOrder.length){ toast('select settled runs with changes first', 'error'); return; }
811
+ const yes = await confirmUI('Integrate '+selOrder.length+' run(s) into the base branch?',
812
+ { sub: 'Merged one by one in selection order. A run that conflicts spawns an integration agent (real, spends credits) to resolve it.', okLabel: 'Integrate' });
813
+ if (!yes) return;
814
+ const res = await fetch('/api/integrate',{method:'POST',headers:{'content-type':'application/json'},
815
+ body:JSON.stringify({runIds:selOrder})});
816
+ const j = await res.json().catch(()=>({}));
817
+ if (res.ok){
818
+ toast('integrate: '+j.merged+' merged · '+j.conflicts+' conflict→agent · '+j.skipped+' skipped', j.conflicts||j.skipped ? undefined : 'ok');
819
+ setSelectMode(false); hydrate();
820
+ } else toast('integrate: '+(j.error||res.status), 'error');
821
+ });
822
+
740
823
  $('grid').addEventListener('click',(e)=>{
741
824
  const card = e.target.closest('.card'); if(!card) return;
742
- openModal(Number(card.id.replace('card-','')));
825
+ const id = Number(card.id.replace('card-',''));
826
+ if (selectMode){
827
+ const r = runs.get(id);
828
+ const eligible = r && ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0) > 0 && r.status!=='merged';
829
+ if (!eligible){ toast('r'+id+': only settled runs with changes can be integrated', 'error'); return; }
830
+ if (selected.has(id)){ selected.delete(id); selOrder.splice(selOrder.indexOf(id),1); }
831
+ else { selected.add(id); selOrder.push(id); }
832
+ $('selCnt').textContent = selected.size + ' selected';
833
+ render();
834
+ return;
835
+ }
836
+ openModal(id);
743
837
  });
744
838
  $('mClose').addEventListener('click', closeModal);
745
839
  $('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
746
- document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
840
+ 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'); } });
747
841
  $('mRefreshDiff').addEventListener('click', loadDiff);
842
+ $('mExport').addEventListener('click', ()=>{
843
+ if (openRunId==null) return;
844
+ $('expDest').value='';
845
+ $('expDest').placeholder='empty = ~/coxpit-exports/r'+openRunId;
846
+ $('expOverlay').classList.add('open');
847
+ });
848
+ async function doExport(){
849
+ if (openRunId==null) return;
850
+ const res = await fetch('/api/runs/'+openRunId+'/export',{method:'POST',
851
+ headers:{'content-type':'application/json'}, body:JSON.stringify({dest:$('expDest').value.trim()})});
852
+ const j = await res.json().catch(()=>({}));
853
+ $('expOverlay').classList.remove('open');
854
+ if (res.ok) toast(j.copied+' file(s) → '+j.dest, 'ok');
855
+ else toast('export: '+(j.detail||res.status), 'error');
856
+ }
857
+ $('expOk').addEventListener('click', doExport);
858
+ $('expDest').addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); doExport(); } });
859
+ $('expCancel').addEventListener('click', ()=>$('expOverlay').classList.remove('open'));
860
+ $('expOverlay').addEventListener('click',(e)=>{ if(e.target===$('expOverlay')) $('expOverlay').classList.remove('open'); });
748
861
  async function sendSteer(){
749
862
  if (openRunId==null) return;
750
863
  const msg = $('steerInput').value.trim(); if(!msg) return;
@@ -804,14 +917,30 @@ async function paintCompare(){
804
917
  + '<span class="files">'+files+' file'+(files===1?'':'s')+'</span></div>'
805
918
  + '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
806
919
  + '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
807
- + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'"></span>'
920
+ + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'">'
921
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR ↗ '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
922
+ + '</span>'
808
923
  + (merged
809
924
  ? chipHTML('merged')
810
- : '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
925
+ : (r.prUrl ? '' : '<button class="btn-ghost sm" data-pr="'+r.id+'"'+(mergeable?'':' disabled')+'>Open PR</button>')
926
+ + '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
811
927
  + '</div></div>';
812
928
  }).join('');
813
929
  }
814
930
  $('cmpBody').addEventListener('click', async (e)=>{
931
+ const prBtn = e.target.closest('button[data-pr]');
932
+ if (prBtn){
933
+ const rid = Number(prBtn.dataset.pr);
934
+ const yes = await confirmUI('Open a pull request from r'+rid+'?',
935
+ { 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' });
936
+ if (!yes) return;
937
+ prBtn.disabled = true;
938
+ const res = await fetch('/api/runs/'+rid+'/pr',{method:'POST'});
939
+ const j = await res.json().catch(()=>({}));
940
+ if (res.ok){ toast('PR opened: '+j.url, 'ok'); await paintCompare(); hydrate(); }
941
+ else { toast('PR: '+(j.detail||res.status), 'error'); prBtn.disabled = false; }
942
+ return;
943
+ }
815
944
  const btn = e.target.closest('button[data-merge]'); if(!btn) return;
816
945
  const rid = Number(btn.dataset.merge);
817
946
  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';
@@ -42,6 +45,8 @@ const stoppedRuns = new Set<number>();
42
45
  interface RunContext {
43
46
  runId: number;
44
47
  machine: MachineTarget;
48
+ machineId: number;
49
+ repoId: number;
45
50
  repoPath: string;
46
51
  baseBranch: string;
47
52
  prompt: string;
@@ -78,6 +83,8 @@ async function loadContext(runId: number): Promise<RunContext | null> {
78
83
  return {
79
84
  runId,
80
85
  machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
86
+ machineId: m.id,
87
+ repoId: repo.id,
81
88
  repoPath: repo.path,
82
89
  baseBranch: repo.defaultBranch,
83
90
  prompt,
@@ -282,7 +289,7 @@ export async function getRunDiff(runId: number): Promise<{ ok: boolean; diff: st
282
289
  * 승자 run 머지 — worktree 미커밋 변경을 자동 커밋 후 run 브랜치를
283
290
  * repo 기본 브랜치에 merge. 본 repo 가 기본 브랜치+클린일 때만, 충돌 시 abort.
284
291
  */
285
- export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: string }> {
292
+ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: string; conflict?: boolean }> {
286
293
  const ctx = await loadContext(runId);
287
294
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
288
295
  const run = rr[0];
@@ -320,12 +327,144 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
320
327
  30000,
321
328
  );
322
329
  if (mg.stdout.includes('COXPIT_MERGE_FAILED')) {
323
- return { ok: false, detail: 'merge conflict — aborted: ' + mg.stdout.replace('COXPIT_MERGE_FAILED', '').trim().slice(0, 300) };
330
+ return { ok: false, conflict: true, detail: 'merge conflict — aborted: ' + mg.stdout.replace('COXPIT_MERGE_FAILED', '').trim().slice(0, 300) };
324
331
  }
325
332
  await setRun(runId, { status: 'merged' });
326
333
  return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
327
334
  }
328
335
 
336
+ export interface IntegrateResult {
337
+ runId: number;
338
+ status: 'merged' | 'conflict' | 'skipped';
339
+ detail?: string;
340
+ integrationTaskId?: number;
341
+ integrationRunId?: number;
342
+ }
343
+
344
+ /**
345
+ * 통합(Integrate) — 여러 run(태스크 무관)을 base 에 순차 머지한다.
346
+ * 충돌 나는 run 은 멈추지 않고 "통합 태스크"를 자동 발사 — 에이전트가
347
+ * base 에서 분기한 새 worktree 에서 해당 브랜치를 머지·충돌 해소한다.
348
+ * (스웜의 수렴 단계: 분업한 결과를 다시 하나로.)
349
+ */
350
+ export async function integrateRuns(runIds: number[], real?: boolean): Promise<IntegrateResult[]> {
351
+ const results: IntegrateResult[] = [];
352
+ for (const id of runIds) {
353
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
354
+ const run = rr[0];
355
+ if (!run) { results.push({ runId: id, status: 'skipped', detail: 'not found' }); continue; }
356
+ if (run.status === 'merged') { results.push({ runId: id, status: 'skipped', detail: 'already merged' }); continue; }
357
+
358
+ const m = await mergeRun(id);
359
+ if (m.ok) { results.push({ runId: id, status: 'merged' }); continue; }
360
+ if (!m.conflict) { results.push({ runId: id, status: 'skipped', detail: m.detail }); continue; }
361
+
362
+ // 충돌 → 통합 태스크 자동 발사 (에이전트가 머지를 대신 푼다)
363
+ const ctx = await loadContext(id);
364
+ if (!ctx) { results.push({ runId: id, status: 'skipped', detail: 'context missing for integration' }); continue; }
365
+ const title = `Integrate r${id} into ${ctx.baseBranch}`;
366
+ const prompt =
367
+ `This worktree is branched from '${ctx.baseBranch}'. Merge the branch '${run.branch}' into it:\n` +
368
+ `1. Run: git merge ${run.branch}\n` +
369
+ `2. Resolve every conflict by preserving the intent of BOTH sides — do not simply pick one side.\n` +
370
+ `3. Make sure the result is consistent (typecheck/tests if available), then commit the merge.\n` +
371
+ `Do not modify files unrelated to the conflicts.`;
372
+ const tIns = await db.insert(tasks).values({ repoId: ctx.repoId, title, prompt }).returning();
373
+ const newTask = tIns[0]!;
374
+ const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending' }).returning();
375
+ const newRun = rIns[0]!;
376
+ broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, branch: '', filesChanged: 0 });
377
+ void launchRun(newRun.id, real ?? true);
378
+ results.push({ runId: id, status: 'conflict', detail: m.detail, integrationTaskId: newTask.id, integrationRunId: newRun.id });
379
+ }
380
+ return results;
381
+ }
382
+
383
+ /**
384
+ * 결과 파일 회수(export) — 조회성 태스크용: worktree 의 변경·신규 파일을
385
+ * 지정 폴더로 복사한다. 머지 없이 산출물만 가져오는 길. (v1: 로컬 머신 전용)
386
+ */
387
+ export async function exportRun(runId: number, destIn?: string): Promise<{ ok: boolean; detail: string; dest?: string; copied?: number; skipped?: number }> {
388
+ const ctx = await loadContext(runId);
389
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
390
+ const run = rr[0];
391
+ if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
392
+ if (liveChildren.has(runId)) return { ok: false, detail: 'still running — wait for it to settle' };
393
+ if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
394
+ return { ok: false, detail: 'remote export not yet supported — files are on the remote machine' };
395
+ }
396
+ const wt = run.worktreePath;
397
+ if (!existsSync(wt)) return { ok: false, detail: 'worktree missing (cleaned up)' };
398
+
399
+ // 회수 대상 = 분기점 이후 커밋된 변경(머지 시도가 auto-commit 했을 수 있음) ∪ 미커밋·신규
400
+ const list = await runShellOn(
401
+ ctx.machine,
402
+ `cd ${shq(wt)} && { git diff --name-only ${shq(ctx.baseBranch)}...HEAD -z 2>/dev/null; git ls-files -mo --exclude-standard -z; }`,
403
+ 15000,
404
+ );
405
+ if (!list.ok) return { ok: false, detail: 'could not list changed files' };
406
+ const files = [...new Set(list.stdout.split('\0').filter(Boolean))];
407
+ if (!files.length) return { ok: false, detail: 'no changed files in this run' };
408
+
409
+ const dest = (destIn ?? '').trim() || ppath.join(homedir(), 'coxpit-exports', `r${runId}`);
410
+ if (!dest.startsWith('/')) return { ok: false, detail: 'destination must be an absolute path' };
411
+
412
+ let copied = 0, skipped = 0;
413
+ for (const f of files) {
414
+ const src = ppath.join(wt, f);
415
+ if (!existsSync(src)) { skipped++; continue; } // 삭제된 파일 등
416
+ const out = ppath.join(dest, f);
417
+ await mkdir(ppath.dirname(out), { recursive: true });
418
+ await copyFile(src, out);
419
+ copied++;
420
+ }
421
+ await recordEvent(runId, 'export', JSON.stringify({ dest, copied, skipped }));
422
+ return { ok: true, detail: `${copied} file(s) exported`, dest, copied, skipped };
423
+ }
424
+
425
+ /**
426
+ * PR 모드 — run 브랜치를 origin 에 push 하고 gh 로 pull request 를 연다.
427
+ * 팀 repo·리뷰 흐름용: 로컬 merge 대신 PR 로 결과를 보낸다.
428
+ */
429
+ export async function prRun(runId: number): Promise<{ ok: boolean; detail: string; url?: string }> {
430
+ const ctx = await loadContext(runId);
431
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
432
+ const run = rr[0];
433
+ if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
434
+ if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
435
+ if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
436
+ const wt = shq(run.worktreePath);
437
+
438
+ // 0) 사전 조건: origin 리모트 + gh CLI
439
+ const pre = await runShellOn(ctx.machine,
440
+ `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);
441
+ if (!pre.stdout.includes('R1')) return { ok: false, detail: 'no origin remote on this repo' };
442
+ if (!pre.stdout.includes('G1')) return { ok: false, detail: 'GitHub CLI (gh) not found on the machine' };
443
+
444
+ // 1) worktree 미커밋 변경 자동 커밋
445
+ const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
446
+ const c1 = await runShellOn(ctx.machine,
447
+ `cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: agent changes`)})`, 20000);
448
+ if (!c1.ok) return { ok: false, detail: 'worktree commit failed: ' + (c1.stderr || c1.stdout).trim().slice(0, 300) };
449
+
450
+ // 2) push
451
+ const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(run.branch)} 2>&1`, 60000);
452
+ if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
453
+
454
+ // 3) PR 생성 (동일 브랜치 PR 이 이미 있으면 그 URL 재사용)
455
+ const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
456
+ const title = `${tr[0]?.title ?? 'coxpit run'} (r${runId})`;
457
+ const body = (run.exitSummary ? run.exitSummary + '\n\n' : '') + '🤖 Opened from a coxpit agent run';
458
+ const pr = await runShellOn(ctx.machine,
459
+ `cd ${wt} && gh pr create -B ${shq(ctx.baseBranch)} -H ${shq(run.branch)} -t ${shq(title)} -b ${shq(body)} 2>&1 || true`, 60000);
460
+ const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
461
+ if (!m) return { ok: false, detail: 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 300) };
462
+
463
+ await setRun(runId, { prUrl: m[0] });
464
+ await recordEvent(runId, 'pr', m[0]);
465
+ return { ok: true, detail: 'pull request opened', url: m[0] };
466
+ }
467
+
329
468
  /**
330
469
  * worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
331
470
  */
package/src/server.ts CHANGED
@@ -12,7 +12,7 @@ import { db } from './db';
12
12
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
13
13
  import { BOOKMARKLET_JS } from './design';
14
14
  import { runShellOn, shq } from './exec';
15
- import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun } from './orchestrator';
15
+ import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns } from './orchestrator';
16
16
  import { openTerm } from './term';
17
17
  import { addSink, removeSink, broadcast } from './hub';
18
18
  import { BOARD_HTML } from './board';
@@ -32,7 +32,7 @@ export async function buildServer(): Promise<FastifyInstance> {
32
32
  app.addHook('onRequest', authGate);
33
33
 
34
34
  // 무인증 헬스(외부 감시용)
35
- app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.5.0' }));
35
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.7.0' }));
36
36
 
37
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -155,10 +155,17 @@ export async function buildServer(): Promise<FastifyInstance> {
155
155
  const m = mr[0];
156
156
  if (!m) return reply.code(404).send({ error: 'machine not found' });
157
157
 
158
+ // 기본 브랜치는 "지금 체크아웃된 브랜치"가 아니라 repo 의 진짜 기본값:
159
+ // origin/HEAD → 로컬 main/master → 현재 HEAD 순으로 감지.
160
+ const g = `git -C ${shq(path)}`;
158
161
  const cmd =
159
- `git -C ${shq(path)} rev-parse --is-inside-work-tree 2>&1` +
162
+ `${g} rev-parse --is-inside-work-tree 2>&1` +
160
163
  ` && echo '---B---'` +
161
- ` && 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`;
162
169
  const r = await runShellOn(m, cmd);
163
170
  const isRepo = r.ok && /(^|\n)true(\n|$)/.test(r.stdout);
164
171
  if (!isRepo) {
@@ -167,7 +174,12 @@ export async function buildServer(): Promise<FastifyInstance> {
167
174
  detail: (r.stdout || r.stderr).trim().slice(0, 400),
168
175
  });
169
176
  }
170
- 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';
171
183
  const name = (b.name ?? '').trim() || path.split('/').filter(Boolean).pop() || path;
172
184
 
173
185
  const ins = await db.insert(repos).values({
@@ -177,6 +189,17 @@ export async function buildServer(): Promise<FastifyInstance> {
177
189
  return reply.code(201).send({ ok: true, repo: ins[0] });
178
190
  });
179
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
+
180
203
  // 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
181
204
  app.get('/api/browse', async (req) => {
182
205
  const q = (req.query ?? {}) as { path?: string };
@@ -378,6 +401,43 @@ export async function buildServer(): Promise<FastifyInstance> {
378
401
  return reply.code(202).send(res);
379
402
  });
380
403
 
404
+ // 통합 — 여러 run(태스크 무관)을 base 에 순차 머지, 충돌은 통합 에이전트 자동 발사.
405
+ app.post('/api/integrate', async (req, reply) => {
406
+ const b = (req.body ?? {}) as { runIds?: number[]; real?: boolean };
407
+ const ids = (b.runIds ?? []).map(Number).filter((n) => Number.isInteger(n) && n > 0);
408
+ if (!ids.length) return reply.code(400).send({ error: 'runIds required' });
409
+ if (ids.length > 20) return reply.code(400).send({ error: 'too many runs (max 20)' });
410
+ const results = await integrateRuns(ids, b.real);
411
+ return {
412
+ ok: true,
413
+ merged: results.filter((r) => r.status === 'merged').length,
414
+ conflicts: results.filter((r) => r.status === 'conflict').length,
415
+ skipped: results.filter((r) => r.status === 'skipped').length,
416
+ results,
417
+ };
418
+ });
419
+
420
+ // 결과 파일 회수 — 머지 없이 worktree 산출물만 지정 폴더로 복사(조회성 태스크).
421
+ app.post('/api/runs/:id/export', async (req, reply) => {
422
+ const id = Number((req.params as { id: string }).id);
423
+ const b = (req.body ?? {}) as { dest?: string };
424
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
425
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
426
+ const res = await exportRun(id, b.dest);
427
+ if (!res.ok) return reply.code(409).send(res);
428
+ return res;
429
+ });
430
+
431
+ // PR 모드 — run 브랜치 push + gh pr create.
432
+ app.post('/api/runs/:id/pr', async (req, reply) => {
433
+ const id = Number((req.params as { id: string }).id);
434
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
435
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
436
+ const res = await prRun(id);
437
+ if (!res.ok) return reply.code(409).send(res);
438
+ return res;
439
+ });
440
+
381
441
  // 실행 중 run 중지(SIGTERM) — close 핸들러가 stopped 로 봉인.
382
442
  app.post('/api/runs/:id/stop', async (req, reply) => {
383
443
  const id = Number((req.params as { id: string }).id);
@@ -397,7 +457,7 @@ export async function buildServer(): Promise<FastifyInstance> {
397
457
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
398
458
  app.get('/ws', { websocket: true }, (socket) => {
399
459
  addSink(socket);
400
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.5.0' }));
460
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.7.0' }));
401
461
  socket.on('close', () => removeSink(socket));
402
462
  });
403
463