coxpit 5.0.3 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <p align="center"><img src="docs/brand/og-lockup.png" alt="coxpit — the cockpit for your agent fleet" width="640"></p>
2
+
1
3
  # Coxpit
2
4
 
3
5
  **Own your agent fleet. Run parallel AI coding agents across your own machines — steer them from any browser.**
@@ -141,3 +143,5 @@ issues privately per **[SECURITY.md](SECURITY.md)** (Coxpit exposes shells).
141
143
  MIT
142
144
 
143
145
  Icons — [Lucide](https://lucide.dev) (ISC). Paths are inlined as an SVG `<symbol>` sprite (no runtime dependency); the ISC notice is kept in [`licenses/lucide.txt`](licenses/lucide.txt).
146
+
147
+ Wordmark — [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans) (SIL Open Font License 1.1), self-hosted; the licence is kept in [`licenses/pixelify-sans-OFL.txt`](licenses/pixelify-sans-OFL.txt). The pilot mascot and logo mark are original artwork for this project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.0.3",
3
+ "version": "5.2.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/auth.ts CHANGED
@@ -12,8 +12,11 @@ import { loginPageHTML } from './login';
12
12
  const EXEMPT = new Set([
13
13
  '/api/health', '/api/design/capture', '/design/bookmarklet.js', '/api/agent/subtasks',
14
14
  '/api/auth/setup', '/api/auth/unlock', '/api/auth/logout',
15
+ '/favicon.ico',
15
16
  ]);
16
- const EXEMPT_PREFIX = ['/share/'];
17
+ // /brand/* 공개 브랜드 에셋(로고·마스코트·워드마크 폰트·favicon). 미인증 로그인 페이지가
18
+ // 이걸 불러와야 하므로 게이트 앞. 시크릿 아님(패키지 동봉 정적물).
19
+ const EXEMPT_PREFIX = ['/share/', '/brand/'];
17
20
 
18
21
  /** 이 요청이 HTML 문서를 원하는 GET 인가(→ 팝업 대신 login/setup 페이지 서빙). */
19
22
  function wantsHtml(req: FastifyRequest): boolean {
package/src/board.ts CHANGED
@@ -8,8 +8,12 @@ export const BOARD_HTML = /* html */ `<!doctype html>
8
8
  <meta charset="utf-8" />
9
9
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
10
10
  <title>coxpit · fleet</title>
11
+ <link rel="icon" href="/brand/favicon.ico" sizes="any" />
12
+ <link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
13
+ <link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
11
14
  <link rel="stylesheet" href="/vendor/xterm.css" />
12
15
  <style>
16
+ @font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
13
17
  :root{
14
18
  --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --line:#222835; --line-hi:#2f3648;
15
19
  --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
@@ -40,8 +44,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
40
44
  header{display:flex;align-items:center;gap:14px;height:54px;padding:0 20px;
41
45
  border-bottom:1px solid var(--line);background:rgba(18,21,28,.92);backdrop-filter:blur(8px);
42
46
  position:sticky;top:0;z-index:10}
43
- .brand{display:flex;align-items:baseline;gap:9px;font-family:var(--mono)}
44
- .brand .mark{color:var(--brand);font-size:15px;font-weight:700;letter-spacing:.01em}
47
+ .brand{display:flex;align-items:center;gap:9px;font-family:var(--mono)}
48
+ .brand .mark{display:inline-flex;align-items:center;gap:6px;line-height:1}
49
+ .brand .mark img{height:24px;width:auto;display:block}
50
+ .brand .mark .wm{font-family:'Pixelify';font-size:21px;font-weight:600;color:var(--ink);letter-spacing:.01em;margin-left:-2px}
45
51
  .brand .sub{color:var(--faint);font-size:11px;text-transform:uppercase;letter-spacing:.14em}
46
52
  .daemon-badge{font-family:var(--mono);font-size:10.5px;color:var(--faint);padding:3px 9px;
47
53
  border:1px solid var(--line);border-radius:999px;background:var(--surface);cursor:default}
@@ -312,6 +318,68 @@ export const BOARD_HTML = /* html */ `<!doctype html>
312
318
  .gband-fold{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px}
313
319
  .gband.folded .gband-grid{display:none}
314
320
  .gband-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
321
+ /* v5.1 A3 — sibling overlap panel */
322
+ .gband-overlap{margin:0 0 10px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;
323
+ background:#0e1118;font-family:var(--mono);font-size:11.5px;color:var(--muted)}
324
+ .gov-load{color:var(--faint)}
325
+ .gov-clean{color:var(--brand);display:flex;align-items:center;gap:6px}
326
+ .gov-clean .ic{width:13px;height:13px}
327
+ .gov-t{color:var(--ink);margin-bottom:6px}
328
+ .gov-list{list-style:none;margin:0 0 6px;padding:0;display:flex;flex-direction:column;gap:3px}
329
+ .gov-list li{display:flex;align-items:center;gap:8px}
330
+ .gov-list code{background:var(--surface2);border:1px solid var(--line);border-radius:4px;padding:0 5px;color:var(--ink)}
331
+ .gov-r{color:#c9922e}
332
+ .gov-order{color:var(--muted);border-top:1px solid var(--line);padding-top:6px;margin-top:2px}
333
+ .gov-order b{color:var(--brand)}
334
+ /* v5.1 Documents (문서함) — grouped by run, list only, no cards */
335
+ #docbox .db-sub{color:var(--muted);font-size:13px;margin:0 0 14px}
336
+ .db-tools{display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
337
+ .db-tools select,.db-tools input{background:var(--surface);border:1px solid var(--line);border-radius:7px;
338
+ color:var(--muted);font-family:var(--mono);font-size:12px;padding:6px 10px}
339
+ .db-tools input{flex:1;min-width:160px;color:var(--ink)}
340
+ .db-count{font-family:var(--mono);font-size:11px;color:var(--faint);margin:0 0 8px}
341
+ .db-count b{color:var(--brand)}
342
+ .db-load{padding:24px 14px;font-family:var(--mono);font-size:13px;color:var(--faint);text-align:center;
343
+ border:1px solid var(--line);border-radius:10px}
344
+ .db-inner{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--surface)}
345
+ .db-run{display:grid;grid-template-columns:16px 52px 1fr auto auto auto auto;gap:12px;align-items:center;
346
+ padding:11px 14px;background:var(--surface2);border-top:1px solid var(--line);cursor:pointer}
347
+ .db-run:first-child{border-top:none}
348
+ .db-run:hover{background:#1b212c}
349
+ .db-run .car{color:var(--faint);font-size:11px;transition:transform .12s}
350
+ .db-run.fold .car{transform:rotate(-90deg)}
351
+ .db-run .rid{font-family:var(--mono);font-size:13px;font-weight:700;color:var(--brand)}
352
+ .db-run .ttl{font-size:13.5px;color:var(--ink);font-weight:600;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
353
+ .db-run .repo{font-family:var(--mono);font-size:11.5px;color:var(--muted)}
354
+ .db-run .st{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.05em;
355
+ display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border:1px solid;border-radius:999px}
356
+ .db-run .st::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}
357
+ .db-run .st.done{color:var(--s-done);border-color:rgba(88,179,104,.4)}
358
+ .db-run .st.merged{color:var(--s-merged);border-color:rgba(78,201,176,.4)}
359
+ .db-run .st.stopped{color:var(--s-stopped);border-color:rgba(181,139,224,.4)}
360
+ .db-run .st.failed{color:var(--s-failed);border-color:rgba(226,91,103,.4)}
361
+ .db-run .st.running{color:var(--s-running);border-color:rgba(85,167,224,.4)}
362
+ .db-run .dt{font-family:var(--mono);font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums}
363
+ .db-run .cnt{font-family:var(--mono);font-size:11px;color:var(--faint);min-width:26px;text-align:right}
364
+ .db-run .cnt b{color:var(--muted)}
365
+ .db-items{border-top:1px solid var(--line)}
366
+ .db-run.fold + .db-items{display:none}
367
+ .db-item{display:flex;align-items:center;gap:12px;padding:8px 14px 8px 38px;
368
+ border-top:1px solid #1a2029;position:relative;cursor:pointer}
369
+ .db-item:first-child{border-top:none}
370
+ .db-item:hover{background:#141922}
371
+ .db-item .tree{position:absolute;left:24px;color:#2a323f;font-family:var(--mono);font-size:12px}
372
+ .db-item .badge{flex:0 0 46px;width:46px;font-family:var(--mono);font-size:9.5px;font-weight:700;
373
+ text-transform:uppercase;letter-spacing:.05em;text-align:center;padding:2px 0;border:1px solid;border-radius:5px}
374
+ .db-item .badge.answer{color:var(--s-running);border-color:rgba(85,167,224,.35);background:rgba(85,167,224,.08)}
375
+ .db-item .badge.code{color:var(--muted);border-color:var(--line-hi)}
376
+ .db-item .badge.doc,.db-item .badge.page{color:var(--brand);border-color:rgba(78,201,176,.35);background:var(--brand-dim)}
377
+ .db-item .badge.file{color:var(--s-stopped);border-color:rgba(181,139,224,.35);background:rgba(181,139,224,.08)}
378
+ .db-item .nm{flex:1;min-width:0;font-family:var(--mono);font-size:12.5px;color:var(--ink);
379
+ overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
380
+ .db-item .nm .meta{color:var(--faint);margin-left:8px;font-size:11px}
381
+ .db-item .act{flex:0 0 auto;margin-left:auto;font-family:var(--mono);font-size:11.5px;color:var(--brand);opacity:.8}
382
+ .db-item:hover .act{opacity:1;text-decoration:underline}
315
383
  .attempt{color:var(--brand);opacity:.8}
316
384
 
317
385
  /* ── goal workroom (v4.6) — 한 goal 을 여는 단일 방 ── */
@@ -452,6 +520,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
452
520
  .chip.running i{animation:pulse 1.2s ease-in-out infinite}
453
521
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
454
522
  @media (prefers-reduced-motion:reduce){.chip.running i{animation:none}.card:hover{transform:none}}
523
+ /* v5.1 A2 — a settled run that changed nothing (intent-gated); 'blocked' = likely stalled on approval */
524
+ .chip.noop{color:#c9922e;border-color:rgba(201,146,46,.45)}
525
+ .chip.noop.blk{color:#e0955a;border-color:rgba(224,149,90,.6)}
526
+ .card-h .chip.noop::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:1px}
455
527
  .meta{display:flex;gap:16px;padding:0 14px 10px;font-family:var(--mono);font-size:11px;color:var(--faint)}
456
528
  .meta b{color:var(--muted);font-weight:500;font-variant-numeric:tabular-nums}
457
529
  .meta .resumable{color:var(--brand);opacity:.75}
@@ -484,14 +556,16 @@ export const BOARD_HTML = /* html */ `<!doctype html>
484
556
  .selbar .cnt{font-family:var(--mono);font-size:12px;color:var(--brand)}
485
557
  .selbar .note{font-family:var(--mono);font-size:11px;color:var(--faint)}
486
558
 
487
- .empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:64px 24px;text-align:center;
559
+ .empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:56px 24px;text-align:center;
488
560
  display:flex;flex-direction:column;gap:10px;align-items:center}
489
561
  .empty .glyph{font-size:22px;color:#2c3444;letter-spacing:4px}
562
+ .empty .mascot{width:88px;height:auto;margin-bottom:2px;opacity:.96;user-select:none;-webkit-user-drag:none}
490
563
 
491
564
  /* ── onboarding (first run) ─────────────── */
492
565
  .setup{max-width:560px;margin:40px auto;border:1px solid var(--line);border-radius:14px;
493
566
  background:var(--surface);overflow:hidden;text-align:left}
494
- .setup-h{padding:18px 22px 14px;border-bottom:1px solid var(--line)}
567
+ .setup-h{padding:18px 22px 14px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:15px}
568
+ .setup-h .setup-mascot{width:62px;height:auto;flex:none;-webkit-user-drag:none;user-select:none}
495
569
  .setup-h .t{font-weight:700;font-size:16px}
496
570
  .setup-h .d{color:var(--muted);font-size:13px;margin-top:3px}
497
571
  .setup-sec{padding:14px 22px;border-bottom:1px solid var(--line)}
@@ -768,7 +842,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
768
842
  ${ICON_SPRITE}
769
843
  <header>
770
844
  <button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
771
- <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
845
+ <div class="brand"><span class="mark"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></span><span class="sub">fleet console</span></div>
772
846
  <span class="daemon-badge" id="daemonBadge" style="display:none"></span>
773
847
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
774
848
  <button class="btn-ghost sm" id="bell" title="notify when a run settles"><svg class="ic"><use href="#i-bell-off"/></svg></button>
@@ -801,6 +875,9 @@ ${ICON_SPRITE}
801
875
  <button type="button" class="navi" data-view="goals">
802
876
  <svg class="ic"><use href="#i-target"/></svg><span>Goals</span><span class="nsp"></span><span class="n" id="navGoalsN"></span>
803
877
  </button>
878
+ <button type="button" class="navi" data-view="documents">
879
+ <svg class="ic"><use href="#i-file"/></svg><span>Documents</span><span class="nsp"></span><span class="n" id="navDocsN"></span>
880
+ </button>
804
881
  <button type="button" class="navi" data-view="archive">
805
882
  <svg class="ic"><use href="#i-archive"/></svg><span>Archive</span><span class="nsp"></span><span class="n" id="navArchiveN"></span>
806
883
  </button>
@@ -922,7 +999,7 @@ ${ICON_SPRITE}
922
999
  <button class="btn-ghost sm" id="selToggle">Select runs</button></div>
923
1000
  <div class="grid" id="grid"></div>
924
1001
  <div class="empty" id="empty">
925
- <span class="glyph">▚▞▚</span>
1002
+ <img class="mascot" src="/brand/sleep.png" alt="" />
926
1003
  <span>No runs yet</span>
927
1004
  <span style="color:#3d4657">register a repo, write a task, hit Run fleet</span>
928
1005
  </div>
@@ -936,6 +1013,15 @@ ${ICON_SPRITE}
936
1013
  <div id="archList"></div>
937
1014
  <div style="text-align:center;margin-top:14px"><button class="btn-ghost sm" id="archMore" hidden>load 50 more</button></div>
938
1015
  </div>
1016
+ <div id="docbox" hidden>
1017
+ <p class="db-sub">이 워크스페이스가 만든 모든 산출물 — 머지·종료 후에도 DB 스냅샷으로 보존. run 기준으로 묶어 봅니다.</p>
1018
+ <div class="db-tools">
1019
+ <select id="dbRepo"><option value="">all repos</option></select>
1020
+ <select id="dbType"><option value="">all types</option><option value="answer">answer</option><option value="code">code</option><option value="doc">doc</option><option value="page">page</option><option value="file">file</option></select>
1021
+ <input id="dbQ" placeholder="search path or title…" autocomplete="off" />
1022
+ </div>
1023
+ <div class="db-list" id="dbList"></div>
1024
+ </div>
939
1025
  </main>
940
1026
  </div>
941
1027
 
@@ -1465,11 +1551,38 @@ function bandHTML(g, grpRuns){
1465
1551
  + '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
1466
1552
  + '<span class="gband-sp"></span>'
1467
1553
  + '<button class="btn-ghost sm gband-open" data-groom="'+g.id+'">⌒ Open workroom</button>'
1554
+ + '<button class="btn-ghost sm" data-goverlap="'+g.id+'" title="which files sibling runs both touch — plan the land order">⧉ Overlap</button>'
1468
1555
  + '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
1469
1556
  + '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
1470
1557
  + '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
1558
+ + '<div class="gband-overlap" id="gov-'+g.id+'" hidden></div>'
1471
1559
  + '<div class="gband-grid">'+cards+'</div></div>';
1472
1560
  }
1561
+ // v5.1 A3 — sibling overlap: on-demand fetch, render contended files + suggested land order.
1562
+ async function toggleOverlap(gid){
1563
+ const box = $('gov-'+gid); if(!box) return;
1564
+ if (!box.hidden){ box.hidden = true; box.innerHTML=''; return; }
1565
+ box.hidden = false; box.innerHTML = '<span class="gov-load">checking overlap…</span>';
1566
+ try {
1567
+ const res = await fetch('/api/groups/'+gid+'/overlap');
1568
+ box.innerHTML = overlapHTML(await res.json());
1569
+ } catch(e){ box.innerHTML = '<span class="gov-load">overlap check failed</span>'; }
1570
+ }
1571
+ function overlapHTML(j){
1572
+ const runs = j.runs||[];
1573
+ if (!runs.length) return '<span class="gov-load">no run diffs yet — land order needs settled runs</span>';
1574
+ const cont = j.contended||[];
1575
+ const order = (j.order||[]).map(id=>'r'+id).join(' → ');
1576
+ let h = '';
1577
+ if (!cont.length){
1578
+ h += '<div class="gov-clean">'+ic('check')+' no file overlap — siblings touch disjoint files, land in any order</div>';
1579
+ } else {
1580
+ h += '<div class="gov-t">'+cont.length+' contended file'+(cont.length>1?'s':'')+' — plan the sequence</div>';
1581
+ h += '<ul class="gov-list">'+cont.map(c=>'<li><code>'+esc(c.path)+'</code><span class="gov-r">'+c.runIds.map(i=>'r'+i).join(' · ')+'</span></li>').join('')+'</ul>';
1582
+ }
1583
+ if (order) h += '<div class="gov-order">suggested land order · <b>'+esc(order)+'</b></div>';
1584
+ return h;
1585
+ }
1473
1586
  // v5.0 — 레일에서 고른 repo 로 run 을 스코프(client-side). selectedRepo=null → 전체.
1474
1587
  function runInScope(r){
1475
1588
  if (selectedRepo==null) return true;
@@ -1519,14 +1632,80 @@ function setView(v){
1519
1632
  b.classList.toggle('on', on); b.setAttribute('aria-pressed', on?'true':'false');
1520
1633
  });
1521
1634
  const archive = v==='archive';
1635
+ const docs = v==='documents';
1522
1636
  $('archive').hidden = !archive;
1523
- $('grid').style.display = archive ? 'none' : '';
1524
- $('empty').style.display = archive ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
1525
- document.querySelector('.toolbar').style.display = archive ? 'none' : 'flex';
1637
+ $('docbox').hidden = !docs;
1638
+ $('grid').style.display = (archive||docs) ? 'none' : '';
1639
+ $('empty').style.display = (archive||docs) ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
1640
+ document.querySelector('.toolbar').style.display = (archive||docs) ? 'none' : 'flex';
1526
1641
  if (archive){ paintArchRepos(); $('archRepo').value = selectedRepo!=null ? String(selectedRepo) : ''; archFetch(true); reclaimRefresh(); }
1642
+ else if (docs){ renderDocbox(); }
1527
1643
  else render();
1528
1644
  }
1529
1645
  document.querySelectorAll('#viewNav .navi').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
1646
+ // v5.1 Documents (문서함) — every output grouped by run, list only.
1647
+ let dbData = null; const dbFold = new Set();
1648
+ async function renderDocbox(){
1649
+ const box = $('dbList');
1650
+ box.innerHTML = '<div class="db-load">loading…</div>';
1651
+ try { dbData = (await (await fetch('/api/documents')).json()).runs || []; }
1652
+ catch(e){ box.innerHTML = '<div class="db-load">failed to load documents</div>'; return; }
1653
+ $('navDocsN').textContent = dbData.length || '';
1654
+ const reposList = [...new Set(dbData.map(r=>r.repo))].sort();
1655
+ const cur = $('dbRepo').value;
1656
+ $('dbRepo').innerHTML = '<option value="">all repos</option>' + reposList.map(r=>'<option'+(r===cur?' selected':'')+'>'+esc(r)+'</option>').join('');
1657
+ paintDocbox();
1658
+ }
1659
+ function fmtDbDate(ts){ const d=new Date(ts*1000); const p=n=>String(n).padStart(2,'0'); return p(d.getMonth()+1)+'·'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes()); }
1660
+ function docRunHTML(run){
1661
+ const stCls = ['done','merged','stopped','failed','running'].includes(run.status) ? run.status : 'done';
1662
+ const items = run.outputs.map((o,i,arr)=>{
1663
+ const tree = i===arr.length-1 ? '└' : '├';
1664
+ const act = ({answer:'view',code:'diff',doc:'rendered',page:'open',file:'download'})[o.type] || 'view';
1665
+ return '<div class="db-item" data-run="'+run.runId+'"><span class="tree">'+tree+'</span>'
1666
+ + '<span class="badge '+o.type+'">'+esc(o.type)+'</span>'
1667
+ + '<span class="nm">'+esc(o.name)+(o.meta?'<span class="meta">'+esc(o.meta)+'</span>':'')+'</span>'
1668
+ + '<a class="act">'+act+' →</a></div>';
1669
+ }).join('');
1670
+ return '<div class="db-run'+(dbFold.has(run.runId)?' fold':'')+'" data-run="'+run.runId+'"><span class="car">▾</span>'
1671
+ + '<span class="rid">r'+run.runId+'</span><span class="ttl">'+esc(run.title)+'</span>'
1672
+ + '<span class="repo">'+esc(run.repo)+'</span>'
1673
+ + '<span class="st '+stCls+'">'+esc(run.status)+'</span>'
1674
+ + '<span class="dt">'+(run.ts?fmtDbDate(run.ts):'')+'</span><span class="cnt"><b>'+run.outputs.length+'</b></span></div>'
1675
+ + '<div class="db-items">'+items+'</div>';
1676
+ }
1677
+ function paintDocbox(){
1678
+ if (!dbData) return;
1679
+ const repo = $('dbRepo').value, type = $('dbType').value, q = ($('dbQ').value||'').toLowerCase().trim();
1680
+ const runs = dbData.map(run=>({ ...run, outputs: type ? run.outputs.filter(o=>o.type===type) : run.outputs }))
1681
+ .filter(run=> run.outputs.length
1682
+ && (!repo || run.repo===repo)
1683
+ && (!q || run.title.toLowerCase().includes(q) || run.outputs.some(o=>o.name.toLowerCase().includes(q))));
1684
+ const box = $('dbList');
1685
+ if (!runs.length){ box.innerHTML = '<div class="db-load">no documents'+((q||repo||type)?' match the filter':' yet')+'</div>'; return; }
1686
+ const total = runs.reduce((n,r)=>n+r.outputs.length,0);
1687
+ box.innerHTML = '<div class="db-count"><b>'+total+'</b> output'+(total>1?'s':'')+' · '+runs.length+' run'+(runs.length>1?'s':'')+'</div>'
1688
+ + '<div class="db-inner">'+runs.map(docRunHTML).join('')+'</div>';
1689
+ }
1690
+ $('dbList').addEventListener('click', (e)=>{
1691
+ const run = e.target.closest('.db-run');
1692
+ if (run){ const id=Number(run.dataset.run); if(dbFold.has(id))dbFold.delete(id); else dbFold.add(id); paintDocbox(); return; }
1693
+ const item = e.target.closest('.db-item');
1694
+ if (!item) return;
1695
+ const id = Number(item.dataset.run);
1696
+ // documents include closed/archived runs not in the live map — seed a minimal run+task so the modal renders
1697
+ if (!runs.has(id)){
1698
+ const dr = (dbData||[]).find(r=>r.runId===id);
1699
+ if (dr){
1700
+ runs.set(id, { id, taskId: dr.taskId, status: dr.status, branch:'', filesChanged:0, events:[], prUrl: dr.prUrl });
1701
+ if (!tasks.has(dr.taskId)) tasks.set(dr.taskId, { id: dr.taskId, title: dr.title, status:'closed', repoId:0 });
1702
+ }
1703
+ }
1704
+ openModal(id);
1705
+ });
1706
+ $('dbRepo').addEventListener('change', paintDocbox);
1707
+ $('dbType').addEventListener('change', paintDocbox);
1708
+ $('dbQ').addEventListener('input', paintDocbox);
1530
1709
  // 뷰 nav 카운트 — Active=스코프 run 수, Goals=스코프 그룹 수, Archive=닫힌 태스크 수(hydrate)
1531
1710
  function paintNavCounts(){
1532
1711
  const scoped = [...runs.values()].filter(runInScope);
@@ -1650,8 +1829,9 @@ function paintOnboarding(goalsOnly){
1650
1829
  }
1651
1830
  const agentMissing = r && r.agent && !r.agent.ok;
1652
1831
  $('empty').innerHTML = '<div class="setup">'
1653
- + '<div class="setup-h"><div class="t">Welcome to coxpit</div>'
1654
- + '<div class="d">Run a fleet of coding agents on this machine — each in its own git worktree.</div></div>'
1832
+ + '<div class="setup-h"><img class="setup-mascot" src="/brand/sleep.png" alt="" />'
1833
+ + '<div><div class="t">Welcome to coxpit</div>'
1834
+ + '<div class="d">Run a fleet of coding agents on this machine — each in its own git worktree.</div></div></div>'
1655
1835
  + '<div class="setup-sec"><p class="setup-label">This machine</p>' + checks
1656
1836
  + (agentMissing
1657
1837
  ? '<div class="setup-fix"># install the agent CLI, then sign in once:\\nnpm i -g @anthropic-ai/claude-code\\n'+esc(agentBin)+' # first run opens browser login</div>'
@@ -1678,7 +1858,11 @@ function cardHTML(r){
1678
1858
  const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
1679
1859
  return '<div class="card'+selCls+'" id="card-'+r.id+'">'
1680
1860
  + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
1681
- + '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)+'</div>'
1861
+ + '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)
1862
+ + (r.noop ? '<span class="chip noop'+(r.noopReason==='blocked'?' blk':'')+'" title="'
1863
+ +(r.noopReason==='blocked'?'settled without making the change it was asked for — likely stalled on approval':'this run changed no files — did it actually do the work?')
1864
+ +'">'+(r.noopReason==='blocked'?'blocked':'no changes')+'</span>' : '')
1865
+ + '</div>'
1682
1866
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
1683
1867
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
1684
1868
  + '<span>'+esc(r.agent||'')+'</span>'
@@ -2178,6 +2362,8 @@ $('selGo').addEventListener('click', async ()=>{
2178
2362
  $('grid').addEventListener('click', async (e)=>{
2179
2363
  const groom = e.target.closest('[data-groom]');
2180
2364
  if (groom){ openRoom(Number(groom.dataset.groom)); return; }
2365
+ const gov = e.target.closest('[data-goverlap]');
2366
+ if (gov){ toggleOverlap(Number(gov.dataset.goverlap)); return; }
2181
2367
  const fold = e.target.closest('[data-gfold]');
2182
2368
  if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
2183
2369
  const gsel = e.target.closest('[data-gsel]');
@@ -2377,24 +2563,52 @@ async function paintCompare(){
2377
2563
  }
2378
2564
  }
2379
2565
  }
2566
+ // v5.1 step 2+3 — preview the land (target drift + conflict files) and warn at the decision point.
2567
+ async function driftNote(rid){
2568
+ try{
2569
+ const p = await (await fetch('/api/runs/'+rid+'/merge/preview?fetch=1')).json();
2570
+ if (!p || !p.target) return '';
2571
+ if (p.supported && !p.clean && (p.conflicts||[]).length){
2572
+ const files = p.conflicts.slice(0,4).join(', ') + (p.conflicts.length>4 ? ' +'+(p.conflicts.length-4)+' more' : '');
2573
+ return ' ⚠ landing on '+p.target+' would conflict in '+p.conflicts.length+' file'+(p.conflicts.length>1?'s':'')
2574
+ +' ('+files+'). Resolve on the branch first, or land (rebase) instead of a whole-branch merge.';
2575
+ }
2576
+ if ((p.behind||0) > 0) return ' ⚠ the base is '+p.behind+' commit'+(p.behind>1?'s':'')+' behind '+p.target
2577
+ +' — a whole-branch merge may conflict; landing (rebase onto '+p.target+') is safer.';
2578
+ if (p.supported && p.clean) return ' ✓ clean against '+p.target+'.';
2579
+ return '';
2580
+ }catch(e){ return ''; }
2581
+ }
2380
2582
  $('cmpBody').addEventListener('click', async (e)=>{
2381
2583
  const prBtn = e.target.closest('button[data-pr]');
2382
2584
  if (prBtn){
2383
2585
  const rid = Number(prBtn.dataset.pr);
2384
- const yes = await confirmUI('Open a pull request from r'+rid+'?',
2385
- { 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' });
2586
+ const yes = await confirmUI('Land r'+rid+' as a pull request?',
2587
+ { sub: 'Rebases this run onto the latest origin target, pushes it as coxpit/<task>-r'+rid+', and opens a PR against that target (needs gh signed in).'+(await driftNote(rid)), okLabel: 'Land · PR' });
2386
2588
  if (!yes) return;
2387
2589
  prBtn.disabled = true;
2388
2590
  const res = await fetch('/api/runs/'+rid+'/pr',{method:'POST'});
2389
2591
  const j = await res.json().catch(()=>({}));
2390
2592
  if (res.ok){ toast('PR opened: '+j.url, 'ok'); await paintCompare(); hydrate(); }
2593
+ else if (j.conflict){
2594
+ const cf = (j.conflicts||[]);
2595
+ const go = await confirmUI('r'+rid+' conflicts with the target in '+cf.length+' file'+(cf.length>1?'s':'')+'.',
2596
+ { sub: cf.slice(0,6).join(', ')+'. Let the agent resolve the markers, then land automatically — coxpit drives git, the agent only edits files.', okLabel: 'Agent · resolve & land' });
2597
+ if (go){
2598
+ const r2 = await fetch('/api/runs/'+rid+'/land/resolve',{method:'POST'});
2599
+ const j2 = await r2.json().catch(()=>({}));
2600
+ toast(r2.ok ? (j2.detail||'resolving…') : ('resolve: '+(j2.detail||r2.status)), r2.ok?'ok':'error');
2601
+ hydrate();
2602
+ }
2603
+ prBtn.disabled = false;
2604
+ }
2391
2605
  else { toast('PR: '+(j.detail||res.status), 'error'); prBtn.disabled = false; }
2392
2606
  return;
2393
2607
  }
2394
2608
  const btn = e.target.closest('button[data-merge]'); if(!btn) return;
2395
2609
  const rid = Number(btn.dataset.merge);
2396
2610
  const yes = await confirmUI('Merge r'+rid+' into the base branch?',
2397
- { sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.', okLabel: 'Merge' });
2611
+ { sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.'+(await driftNote(rid)), okLabel: 'Merge' });
2398
2612
  if (!yes) return;
2399
2613
  btn.disabled = true;
2400
2614
  const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
@@ -0,0 +1,93 @@
1
+ Copyright 2021 The Pixelify Sans Project Authors (https://github.com/eifetx/Pixelify-Sans)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/login.ts CHANGED
@@ -26,7 +26,11 @@ export function loginPageHTML(setup: boolean, opts: LoginOpts = {}): string {
26
26
  <meta charset="utf-8" />
27
27
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
28
28
  <title>coxpit · ${title}</title>
29
+ <link rel="icon" href="/brand/favicon.ico" sizes="any" />
30
+ <link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
31
+ <link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
29
32
  <style>
33
+ @font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
30
34
  :root{
31
35
  --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --line:#222835; --line-hi:#2f3648;
32
36
  --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
@@ -47,8 +51,10 @@ export function loginPageHTML(setup: boolean, opts: LoginOpts = {}): string {
47
51
  ::selection{background:var(--brand-dim)}
48
52
  .card{width:100%;max-width:380px;background:var(--surface);border:1px solid var(--line);
49
53
  border-radius:var(--r-card);box-shadow:var(--shadow);padding:26px 24px 22px}
54
+ .welcome{width:80px;height:auto;display:block;margin:2px auto 14px;opacity:.97;-webkit-user-drag:none}
50
55
  .mark{font-family:var(--mono);font-weight:700;color:var(--brand);font-size:17px;letter-spacing:.02em;
51
56
  display:flex;align-items:center;gap:9px}
57
+ .mark .wm{font-family:'Pixelify';font-weight:600;color:var(--ink);font-size:19px;letter-spacing:.01em}
52
58
  .glyph{font-size:18px;line-height:1}
53
59
  h1{font-size:15px;font-weight:600;margin:16px 0 4px;color:var(--ink)}
54
60
  .sub{color:var(--muted);font-size:12.5px;margin:0 0 18px;line-height:1.5}
@@ -81,7 +87,8 @@ export function loginPageHTML(setup: boolean, opts: LoginOpts = {}): string {
81
87
  ${ICON_SPRITE}
82
88
  <form class="card" id="f" method="post" action="${action}" autocomplete="off">
83
89
  <input type="hidden" name="nav" value="1">
84
- <div class="mark"><svg class="ic"><use href="#i-lock"/></svg><span>coxpit</span></div>
90
+ <img class="welcome" src="/brand/wave.png" alt="" />
91
+ <div class="mark"><svg class="ic"><use href="#i-lock"/></svg><span class="wm">coxpit</span></div>
85
92
  <h1>${setup ? 'Protect this coxpit' : 'Unlock this coxpit'}</h1>
86
93
  <p class="sub">${setup
87
94
  ? 'Set an access key. You&#39;ll enter it once per device — no accounts, no username.'
@@ -5,7 +5,7 @@ import { existsSync } from 'node:fs';
5
5
  import { mkdir, copyFile, readFile, writeFile, rm } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import type { ChildProcess } from 'node:child_process';
8
- import { eq } from 'drizzle-orm';
8
+ import { eq, inArray } from 'drizzle-orm';
9
9
  import { config } from './config';
10
10
  import { db } from './db';
11
11
  import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups } from './db/schema';
@@ -39,6 +39,26 @@ export function parseOutputs(raw: string | null | undefined): OutputType[] {
39
39
  try { return normalizeOutputs(JSON.parse(raw)); } catch { return []; }
40
40
  }
41
41
 
42
+ /**
43
+ * v5.1 A2 — a run that settled clean but did nothing it was asked to.
44
+ * Deterministic signal (primary): status 'done' with zero files changed.
45
+ * Gated on intent so an answer-only task legitimately changing nothing stays quiet.
46
+ * The 'blocked' upgrade is best-effort natural-language matching on the final message —
47
+ * no provider emits a structured "needs approval" event, so never depend on it.
48
+ */
49
+ export function noopSignal(
50
+ status: string,
51
+ filesChanged: number,
52
+ exitSummary: string | null | undefined,
53
+ taskOutputs: string | null | undefined,
54
+ ): { noop: boolean; reason: 'blocked' | 'no-changes' | null } {
55
+ if (status !== 'done' || (filesChanged ?? 0) > 0) return { noop: false, reason: null };
56
+ const declared = parseOutputs(taskOutputs);
57
+ if (declared.length > 0 && declared.every((t) => t === 'answer')) return { noop: false, reason: null };
58
+ const blocked = /\b(approval|permission|not allowed|need(s)?\s+(explicit\s+)?approv|explicit(ly)?\s+approv|allow me to)\b/i.test(exitSummary ?? '');
59
+ return { noop: true, reason: blocked ? 'blocked' : 'no-changes' };
60
+ }
61
+
42
62
  /** 프롬프트에 붙는 Deliverables 블록(A3). declared 는 비어있지 않다. */
43
63
  function deliverablesNote(declared: OutputType[]): string {
44
64
  const human: Record<OutputType, string> = {
@@ -1219,7 +1239,19 @@ export async function exportRun(runId: number, destIn?: string): Promise<{ ok: b
1219
1239
  * PR 모드 — run 브랜치를 origin 에 push 하고 gh 로 pull request 를 연다.
1220
1240
  * 팀 repo·리뷰 흐름용: 로컬 merge 대신 PR 로 결과를 보낸다.
1221
1241
  */
1222
- export async function prRun(runId: number): Promise<{ ok: boolean; detail: string; url?: string }> {
1242
+ /** Product-appropriate PR branch name: coxpit/<task-slug>-r<id> meaningful, unique, namespaced. */
1243
+ function slugify(s: string): string {
1244
+ return (s || 'run').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'run';
1245
+ }
1246
+
1247
+ /**
1248
+ * v5.1 step 4 — land a run: origin-aware. Resolve the target (origin/<x>, may differ from the
1249
+ * local base), preview conflicts, then squash the run's net diff onto a fresh branch AT the
1250
+ * target (avoids the base-merge-commit drag, C6), push under the product name, and open a PR
1251
+ * against the target branch. On conflict returns { conflict } for the integration loop (step 5).
1252
+ * Falls back to the legacy push-branch/PR-against-base when the base has no upstream.
1253
+ */
1254
+ export async function prRun(runId: number): Promise<{ ok: boolean; detail: string; url?: string; conflict?: boolean; conflicts?: string[] }> {
1223
1255
  const ctx = await loadContext(runId);
1224
1256
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
1225
1257
  const run = rr[0];
@@ -1227,37 +1259,186 @@ export async function prRun(runId: number): Promise<{ ok: boolean; detail: strin
1227
1259
  if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
1228
1260
  if (!['done', 'failed', 'stopped', 'open'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
1229
1261
  const wt = shq(run.worktreePath);
1262
+ const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
1230
1263
 
1231
- // 0) 사전 조건: origin 리모트 + gh CLI
1264
+ // 0) preconditions: origin remote + gh CLI
1232
1265
  const pre = await runShellOn(ctx.machine,
1233
1266
  `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);
1234
1267
  if (!pre.stdout.includes('R1')) return { ok: false, detail: 'no origin remote on this repo' };
1235
1268
  if (!pre.stdout.includes('G1')) return { ok: false, detail: 'GitHub CLI (gh) not found on the machine' };
1236
1269
 
1237
- // 1) worktree 미커밋 변경 자동 커밋
1238
- const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
1270
+ // 1) commit worktree changes
1239
1271
  const c1 = await runShellOn(ctx.machine,
1240
1272
  `cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: agent changes`)})`, 20000);
1241
1273
  if (!c1.ok) return { ok: false, detail: 'worktree commit failed: ' + (c1.stderr || c1.stdout).trim().slice(0, 300) };
1242
1274
 
1243
- // 2) push
1244
- const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(run.branch)} 2>&1`, 60000);
1245
- if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
1246
-
1247
- // 3) PR 생성 (동일 브랜치 PR 이 이미 있으면 그 URL 재사용)
1248
1275
  const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
1249
1276
  const title = `${tr[0]?.title ?? 'coxpit run'} (r${runId})`;
1250
1277
  const body = (run.exitSummary ? run.exitSummary + '\n\n' : '') + '🤖 Opened from a coxpit agent run';
1278
+
1279
+ // 2) resolve the land target (fresh) — origin-aware path when the base tracks a remote
1280
+ const lt = await landTarget(runId, { fetch: true });
1281
+ const target = lt.target;
1282
+ if (target) {
1283
+ const pv = await mergePreview(runId, { target });
1284
+ if (pv.supported && !pv.clean && pv.conflicts.length) {
1285
+ return { ok: false, conflict: true, conflicts: pv.conflicts,
1286
+ detail: `would conflict on ${target} in ${pv.conflicts.length} file(s): ${pv.conflicts.slice(0, 6).join(', ')} — resolve on the branch first, then land.` };
1287
+ }
1288
+ const targetBranch = target.replace(/^[^/]+\//, ''); // origin/develop -> develop
1289
+ const landBranch = `coxpit/${slugify(tr[0]?.title ?? 'run')}-r${runId}`;
1290
+ const range = shq(ctx.baseBranch + '...' + run.branch);
1291
+ // squash the run's net diff onto a fresh branch at the target (3-way apply; clean per preview)
1292
+ const land = await runShellOn(ctx.machine,
1293
+ `cd ${wt} && git checkout -B ${shq(landBranch)} ${shq(target)} 2>&1 && ` +
1294
+ `git diff ${range} | git apply --3way --index - 2>&1 && ` +
1295
+ `(git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(title)}) 2>&1`, 60000);
1296
+ if (!land.ok) return { ok: false, detail: `land (onto ${target}) failed: ` + (land.stderr || land.stdout).trim().slice(0, 300) };
1297
+ const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(landBranch)} 2>&1`, 60000);
1298
+ if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
1299
+ const pr = await runShellOn(ctx.machine,
1300
+ `cd ${wt} && gh pr create -B ${shq(targetBranch)} -H ${shq(landBranch)} -t ${shq(title)} -b ${shq(body)} 2>&1 || true`, 60000);
1301
+ const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
1302
+ if (!m) return { ok: false, detail: 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 300) };
1303
+ await setRun(runId, { prUrl: m[0] });
1304
+ await recordEvent(runId, 'pr', `${m[0]} (landed on ${targetBranch})`);
1305
+ return { ok: true, detail: `landed on ${targetBranch} · PR opened`, url: m[0] };
1306
+ }
1307
+
1308
+ // fallback: no upstream target — legacy push-branch / PR-against-base
1309
+ const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(run.branch)} 2>&1`, 60000);
1310
+ if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
1251
1311
  const pr = await runShellOn(ctx.machine,
1252
1312
  `cd ${wt} && gh pr create -B ${shq(ctx.baseBranch)} -H ${shq(run.branch)} -t ${shq(title)} -b ${shq(body)} 2>&1 || true`, 60000);
1253
1313
  const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
1254
1314
  if (!m) return { ok: false, detail: 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 300) };
1255
-
1256
1315
  await setRun(runId, { prUrl: m[0] });
1257
1316
  await recordEvent(runId, 'pr', m[0]);
1258
1317
  return { ok: true, detail: 'pull request opened', url: m[0] };
1259
1318
  }
1260
1319
 
1320
+ // v5.1 step 5 — the integration loop. coxpit owns git; the agent only edits conflict markers.
1321
+ type PendingLand = { target: string; targetBranch: string; landBranch: string; title: string; body: string };
1322
+ const pendingLand = new Map<number, PendingLand>();
1323
+
1324
+ /**
1325
+ * Start resolving a conflicting land in-app: coxpit stages the run's work, checks out a fresh
1326
+ * branch at the target and 3-way-applies the net diff (leaving conflict markers), then RESUMES
1327
+ * the run's own agent scoped to editing those markers only (no git — that would hit the sandbox
1328
+ * wall that blocked the merge in the first place). When the agent settles, finalizeLand() commits,
1329
+ * pushes and opens the PR. The agent carries the original context, so it resolves knowingly.
1330
+ */
1331
+ export async function startLandResolve(runId: number): Promise<{ ok: boolean; detail: string }> {
1332
+ const ctx = await loadContext(runId);
1333
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
1334
+ const run = rr[0];
1335
+ if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
1336
+ if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
1337
+ if (!run.sessionId) return { ok: false, detail: 'no agent session — dry runs cannot auto-resolve; open the workbench' };
1338
+ const wt = shq(run.worktreePath);
1339
+ const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
1340
+ await runShellOn(ctx.machine, `cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: agent changes`)})`, 20000);
1341
+ const lt = await landTarget(runId, { fetch: true });
1342
+ const target = lt.target;
1343
+ if (!target) return { ok: false, detail: 'no upstream target to land on' };
1344
+ const targetBranch = target.replace(/^[^/]+\//, '');
1345
+ const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
1346
+ const title = `${tr[0]?.title ?? 'coxpit run'} (r${runId})`;
1347
+ const body = (run.exitSummary ? run.exitSummary + '\n\n' : '') + '🤖 Landed from a coxpit agent run (conflicts resolved by the agent)';
1348
+ const landBranch = `coxpit/${slugify(tr[0]?.title ?? 'run')}-r${runId}`;
1349
+ const range = shq(ctx.baseBranch + '...' + run.branch);
1350
+ const prep = await runShellOn(ctx.machine,
1351
+ `cd ${wt} && git checkout -B ${shq(landBranch)} ${shq(target)} 2>&1 && { git diff ${range} | git apply --3way --index - 2>&1 || true; } && git rev-parse --abbrev-ref HEAD`, 60000);
1352
+ if (!prep.stdout.trim().endsWith(landBranch)) return { ok: false, detail: 'could not prepare land branch: ' + prep.stdout.trim().slice(-200) };
1353
+ pendingLand.set(runId, { target, targetBranch, landBranch, title, body });
1354
+ const prompt = `You are on branch ${landBranch}. Landing your change onto ${target} produced git conflict markers (<<<<<<<, =======, >>>>>>>) in one or more files. Resolve EVERY conflict marker by editing the files to the correct merged result — keep both your change and the target's changes where each belongs. Do NOT run any git commands (no add/commit/rebase/merge) — only edit the files. When every marker is gone, end your turn.`;
1355
+ await setRun(runId, { status: 'running', endedAt: null });
1356
+ await recordEvent(runId, 'integrate', `resolving conflicts to land on ${targetBranch} — the agent is editing markers`);
1357
+ const provider = getProvider(ctx.agent);
1358
+ const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
1359
+ const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
1360
+ const resume = provider.resumeCmd(run.sessionId, prompt, run.model || undefined);
1361
+ const cmd = `cd ${shq(run.worktreePath)} && ${pidPrefix}{ ${resume}; }`;
1362
+ void runAgentChild(runId, ctx.machine, run.worktreePath, cmd, provider).then(() => finalizeLand(runId)).catch(() => { pendingLand.delete(runId); });
1363
+ return { ok: true, detail: `integrating — the agent is resolving conflicts; it lands on ${targetBranch} automatically when clean` };
1364
+ }
1365
+
1366
+ /** After the resolve agent settles: verify no markers remain, then commit + push + open the PR. */
1367
+ async function finalizeLand(runId: number): Promise<void> {
1368
+ const pend = pendingLand.get(runId);
1369
+ if (!pend) return;
1370
+ pendingLand.delete(runId);
1371
+ const ctx = await loadContext(runId);
1372
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
1373
+ const run = rr[0];
1374
+ if (!ctx || !run || !run.worktreePath) return;
1375
+ const wt = shq(run.worktreePath);
1376
+ const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
1377
+ const chk = await runShellOn(ctx.machine,
1378
+ `cd ${wt} && git diff --name-only | tr '\\n' '\\0' | xargs -0 -r grep -lE '^(<<<<<<< |>>>>>>> )' 2>/dev/null | head -20`, 15000);
1379
+ const leftover = chk.stdout.trim();
1380
+ if (leftover) {
1381
+ await recordEvent(runId, 'error', 'conflict markers still present after the agent pass — resolve the rest (attach the terminal) or run resolve again:\n' + leftover.slice(0, 300));
1382
+ return;
1383
+ }
1384
+ const c = await runShellOn(ctx.machine,
1385
+ `cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(pend.title)}) 2>&1`, 30000);
1386
+ if (!c.ok) { await recordEvent(runId, 'error', 'land commit failed: ' + (c.stderr || c.stdout).trim().slice(0, 200)); return; }
1387
+ const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(pend.landBranch)} 2>&1`, 60000);
1388
+ if (!push.ok) { await recordEvent(runId, 'error', 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 200)); return; }
1389
+ const pr = await runShellOn(ctx.machine,
1390
+ `cd ${wt} && gh pr create -B ${shq(pend.targetBranch)} -H ${shq(pend.landBranch)} -t ${shq(pend.title)} -b ${shq(pend.body)} 2>&1 || true`, 60000);
1391
+ const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
1392
+ if (!m) { await recordEvent(runId, 'error', 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 200)); return; }
1393
+ await setRun(runId, { prUrl: m[0] });
1394
+ await recordEvent(runId, 'pr', `${m[0]} (landed on ${pend.targetBranch} after resolve)`);
1395
+ }
1396
+
1397
+ /**
1398
+ * Documents (문서함) — every output this workspace produced, grouped by run, newest first.
1399
+ * DB-only so it survives merge/close and never shells git: doc/page from doc_snapshots
1400
+ * (persisted), code from filesChanged, answer from the settled run's final message.
1401
+ */
1402
+ export async function listDocuments(): Promise<{ runs: Array<{
1403
+ runId: number; taskId: number; title: string; repo: string; status: string; ts: number | null;
1404
+ prUrl: string | null; outputs: Array<{ type: 'answer' | 'code' | 'doc' | 'page' | 'file'; name: string; meta: string }>;
1405
+ }> }> {
1406
+ const [allRuns, allTasks, allRepos, snaps] = await Promise.all([
1407
+ db.select().from(agentRuns),
1408
+ db.select().from(tasks),
1409
+ db.select().from(repos),
1410
+ db.select().from(docSnapshots),
1411
+ ]);
1412
+ const taskById = new Map(allTasks.map((t) => [t.id, t]));
1413
+ const repoById = new Map(allRepos.map((r) => [r.id, r.name]));
1414
+ const snapByRun = new Map<number, typeof snaps>();
1415
+ for (const s of snaps) { const a = snapByRun.get(s.runId) ?? []; a.push(s); snapByRun.set(s.runId, a); }
1416
+ const out: Array<{
1417
+ runId: number; taskId: number; title: string; repo: string; status: string; ts: number | null;
1418
+ prUrl: string | null; outputs: Array<{ type: 'answer' | 'code' | 'doc' | 'page' | 'file'; name: string; meta: string }>;
1419
+ }> = [];
1420
+ for (const r of allRuns) {
1421
+ const t = taskById.get(r.taskId);
1422
+ if (!t) continue;
1423
+ const outputs: Array<{ type: 'answer' | 'code' | 'doc' | 'page' | 'file'; name: string; meta: string }> = [];
1424
+ if (['done', 'merged', 'failed', 'stopped'].includes(r.status) && r.exitSummary
1425
+ && !/^(exit -?\d+|stopped by user|orphaned|worktree|no )/i.test(r.exitSummary)) {
1426
+ outputs.push({ type: 'answer', name: 'Final answer', meta: r.exitSummary.replace(/\s+/g, ' ').trim().slice(0, 100) });
1427
+ }
1428
+ if ((r.filesChanged ?? 0) > 0) {
1429
+ outputs.push({ type: 'code', name: 'code changes', meta: '+' + r.filesChanged + ' file' + (r.filesChanged > 1 ? 's' : '') + (r.prUrl ? ' · landed' : '') });
1430
+ }
1431
+ for (const s of (snapByRun.get(r.id) ?? [])) {
1432
+ outputs.push({ type: s.kind === 'html' ? 'page' : 'doc', name: s.path, meta: s.kind === 'html' ? 'html' : 'markdown' });
1433
+ }
1434
+ if (!outputs.length) continue;
1435
+ const ts = r.endedAt ? Math.floor(r.endedAt.getTime() / 1000) : (t.createdAt ? Math.floor(t.createdAt.getTime() / 1000) : null);
1436
+ out.push({ runId: r.id, taskId: t.id, title: t.title, repo: repoById.get(t.repoId) ?? '?', status: r.status, ts, prUrl: r.prUrl ?? null, outputs });
1437
+ }
1438
+ out.sort((a, b) => b.runId - a.runId);
1439
+ return { runs: out };
1440
+ }
1441
+
1261
1442
  /**
1262
1443
  * worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
1263
1444
  */
@@ -1293,6 +1474,139 @@ export async function taskCloseRisk(taskId: number): Promise<Array<{ runId: numb
1293
1474
  return atRisk;
1294
1475
  }
1295
1476
 
1477
+ /**
1478
+ * v5.1 A3 — sibling overlap within a group: which files two or more sibling runs both touch,
1479
+ * and a suggested land order (fewest contended files first). Read-only; on-demand (git per run),
1480
+ * never on the fleet-poll path. Uses `diff --name-only base...branch` (merge-base) = each run's
1481
+ * OWN change set, so base drift doesn't inflate it.
1482
+ */
1483
+ export async function groupOverlap(groupId: number): Promise<{
1484
+ runs: Array<{ runId: number; files: string[] }>;
1485
+ contended: Array<{ path: string; runIds: number[] }>;
1486
+ order: number[];
1487
+ }> {
1488
+ const taskRows = await db.select().from(tasks).where(eq(tasks.groupId, groupId));
1489
+ const taskIds = taskRows.map((t) => t.id);
1490
+ if (!taskIds.length) return { runs: [], contended: [], order: [] };
1491
+ const rns = await db.select().from(agentRuns).where(inArray(agentRuns.taskId, taskIds));
1492
+ const perRun: Array<{ runId: number; files: string[] }> = [];
1493
+ for (const r of rns) {
1494
+ if (!r.branch || !r.worktreePath || r.status === 'merged') continue;
1495
+ const ctx = await loadContext(r.id);
1496
+ if (!ctx) continue;
1497
+ const out = await runShellOn(
1498
+ ctx.machine,
1499
+ `git -C ${shq(ctx.repoPath)} diff --name-only ${shq(ctx.baseBranch)}...${shq(r.branch)}`,
1500
+ 10000,
1501
+ );
1502
+ const files = out.ok ? out.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [];
1503
+ perRun.push({ runId: r.id, files });
1504
+ }
1505
+ const byFile = new Map<string, number[]>();
1506
+ for (const pr of perRun) for (const f of pr.files) {
1507
+ const a = byFile.get(f) ?? []; a.push(pr.runId); byFile.set(f, a);
1508
+ }
1509
+ const contended = [...byFile.entries()]
1510
+ .filter(([, ids]) => ids.length > 1)
1511
+ .map(([path, runIds]) => ({ path, runIds }))
1512
+ .sort((a, b) => b.runIds.length - a.runIds.length || a.path.localeCompare(b.path));
1513
+ const contendedSet = new Set(contended.map((c) => c.path));
1514
+ const contendedCount = (pr: { files: string[] }) => pr.files.filter((f) => contendedSet.has(f)).length;
1515
+ const order = perRun.slice()
1516
+ .sort((a, b) => contendedCount(a) - contendedCount(b) || a.runId - b.runId)
1517
+ .map((p) => p.runId);
1518
+ return { runs: perRun, contended, order };
1519
+ }
1520
+
1521
+ /**
1522
+ * v5.1 Part C foundation — resolve the land *target* for a run and measure base drift.
1523
+ * The target is NOT assumed to be the base: default = the base branch's upstream
1524
+ * (`<base>@{u}`, e.g. main→origin/main), else `origin/<base>` if it exists, else null
1525
+ * (the caller must pick one). With { fetch }, refresh the remote first so drift is current.
1526
+ * ahead = commits the local base has that the target lacks; behind = commits the target has
1527
+ * that the local base lacks (the drift that makes a whole-branch merge explode).
1528
+ */
1529
+ export async function landTarget(runId: number, opts: { fetch?: boolean } = {}): Promise<{
1530
+ base: string; target: string | null; remote: string | null;
1531
+ ahead: number; behind: number; fetched: boolean; detail?: string;
1532
+ }> {
1533
+ const ctx = await loadContext(runId);
1534
+ if (!ctx) return { base: '', target: null, remote: null, ahead: 0, behind: 0, fetched: false, detail: 'no context' };
1535
+ const repo = shq(ctx.repoPath);
1536
+ const base = ctx.baseBranch;
1537
+ const up = await runShellOn(ctx.machine, `git -C ${repo} rev-parse --abbrev-ref ${shq(base + '@{u}')} 2>/dev/null`, 8000);
1538
+ let target: string | null = up.ok && up.stdout.trim() ? up.stdout.trim() : null;
1539
+ let remote: string | null = target ? (target.split('/')[0] ?? null) : null;
1540
+ if (!target) {
1541
+ const has = await runShellOn(ctx.machine, `git -C ${repo} rev-parse --verify --quiet ${shq('origin/' + base)}`, 8000);
1542
+ if (has.ok && has.stdout.trim()) { target = 'origin/' + base; remote = 'origin'; }
1543
+ }
1544
+ if (!target) return { base, target: null, remote: null, ahead: 0, behind: 0, fetched: false, detail: 'no upstream — pick a target' };
1545
+ let fetched = false;
1546
+ if (opts.fetch && remote) {
1547
+ const f = await runShellOn(ctx.machine, `git -C ${repo} fetch ${shq(remote)} 2>&1`, 30000);
1548
+ fetched = f.ok;
1549
+ }
1550
+ const rl = await runShellOn(ctx.machine, `git -C ${repo} rev-list --left-right --count ${shq(base + '...' + target)}`, 10000);
1551
+ let ahead = 0, behind = 0;
1552
+ if (rl.ok) { const [a = '0', b = '0'] = rl.stdout.trim().split(/\s+/); ahead = parseInt(a, 10) || 0; behind = parseInt(b, 10) || 0; }
1553
+ return { base, target, remote, ahead, behind, fetched };
1554
+ }
1555
+
1556
+ /**
1557
+ * v5.1 A1 (step 3) — conflict preview: what WOULD conflict if this run landed on the target,
1558
+ * computed with `git merge-tree --write-tree` (git ≥ 2.38) — no working tree, no commit, no
1559
+ * side effects. Previews against the land *target* (origin/<x>), not the local base (a run never
1560
+ * conflicts with the base it descends from). Folds in the drift (ahead/behind) from landTarget so
1561
+ * one call answers "is landing safe, and if not, which files".
1562
+ *
1563
+ * merge-tree output: line 0 = tree OID; on conflict, the conflicted paths follow until a blank
1564
+ * line (informational messages after). Exit 0 = clean, non-zero = conflicts.
1565
+ */
1566
+ export async function mergePreview(runId: number, opts: { fetch?: boolean; target?: string } = {}): Promise<{
1567
+ supported: boolean; clean: boolean; conflicts: string[]; target: string | null;
1568
+ ahead: number; behind: number; detail?: string;
1569
+ }> {
1570
+ const ctx = await loadContext(runId);
1571
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
1572
+ const run = rr[0];
1573
+ if (!ctx || !run || !run.branch) return { supported: true, clean: false, conflicts: [], target: null, ahead: 0, behind: 0, detail: 'no branch' };
1574
+ const lt = await landTarget(runId, { fetch: opts.fetch });
1575
+ const target = opts.target ?? lt.target;
1576
+ if (!target) return { supported: true, clean: false, conflicts: [], target: null, ahead: lt.ahead, behind: lt.behind, detail: 'no target — pick one' };
1577
+ const repo = shq(ctx.repoPath);
1578
+ // The agent edits files but does NOT commit (acceptEdits/workspace-write allow edits, not git),
1579
+ // so run.branch's tip lags the worktree. Land commits first — so preview the state land will
1580
+ // actually see: `git stash create` mints a commit of the working tree (tracked mods) without
1581
+ // touching anything, sharing the object store so merge-tree in the repo can reference it.
1582
+ let tip = run.branch;
1583
+ if (run.worktreePath) {
1584
+ const st = await runShellOn(ctx.machine, `git -C ${shq(run.worktreePath)} stash create 2>/dev/null`, 10000);
1585
+ const stashCommit = st.ok ? st.stdout.trim() : '';
1586
+ if (/^[0-9a-f]{7,40}$/.test(stashCommit)) tip = stashCommit;
1587
+ }
1588
+ const mt = await runShellOn(
1589
+ ctx.machine,
1590
+ `git -C ${repo} merge-tree --write-tree --name-only ${shq(target)} ${shq(tip)} 2>&1; echo "EXIT=$?"`,
1591
+ 20000,
1592
+ );
1593
+ const raw = mt.stdout;
1594
+ if (/usage: git merge-tree|unknown option|error: unknown/i.test(raw)) {
1595
+ return { supported: false, clean: false, conflicts: [], target, ahead: lt.ahead, behind: lt.behind, detail: 'git >= 2.38 required for conflict preview' };
1596
+ }
1597
+ const m = raw.match(/EXIT=(\d+)\s*$/);
1598
+ const code = m ? parseInt(m[1] ?? '1', 10) : 1;
1599
+ const body = raw.replace(/\n?EXIT=\d+\s*$/, '');
1600
+ const lines = body.split('\n');
1601
+ const conflicts: string[] = [];
1602
+ for (let i = 1; i < lines.length; i++) {
1603
+ if (lines[i]?.trim() === '') break;
1604
+ const p = lines[i]?.trim();
1605
+ if (p) conflicts.push(p);
1606
+ }
1607
+ return { supported: true, clean: code === 0, conflicts, target, ahead: lt.ahead, behind: lt.behind };
1608
+ }
1609
+
1296
1610
  export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail: string }> {
1297
1611
  const ctx = await loadContext(runId);
1298
1612
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
package/src/server.ts CHANGED
@@ -18,7 +18,7 @@ import { db } from './db';
18
18
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
19
19
  import { BOOKMARKLET_JS } from './design';
20
20
  import { runShellOn, shq } from './exec';
21
- import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees } from './orchestrator';
21
+ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments } from './orchestrator';
22
22
  import { openTerm } from './term';
23
23
  import { addSink, removeSink, broadcast } from './hub';
24
24
  import { getProvider, listProviders } from './providers';
@@ -64,6 +64,7 @@ function contentTypeFor(path: string): string {
64
64
  avif: 'image/avif', pdf: 'application/pdf', txt: 'text/plain; charset=utf-8',
65
65
  md: 'text/plain; charset=utf-8', json: 'application/json', csv: 'text/csv; charset=utf-8',
66
66
  html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
67
+ woff2: 'font/woff2', woff: 'font/woff',
67
68
  };
68
69
  return map[ext] ?? 'application/octet-stream';
69
70
  }
@@ -328,9 +329,13 @@ export async function buildServer(): Promise<FastifyInstance> {
328
329
  arr.push({ kind: e.kind, payload: e.payload });
329
330
  byRun.set(e.runId, arr);
330
331
  }
332
+ const taskOut = new Map(ts.map((t) => [t.id, t.outputs]));
331
333
  return {
332
334
  machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
333
- runs: rns.map((r) => ({ ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP) })),
335
+ runs: rns.map((r) => {
336
+ const sig = noopSignal(r.status, r.filesChanged, r.exitSummary, taskOut.get(r.taskId) ?? '[]');
337
+ return { ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
338
+ }),
334
339
  counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
335
340
  // 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
336
341
  // authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
@@ -343,6 +348,9 @@ export async function buildServer(): Promise<FastifyInstance> {
343
348
  };
344
349
  });
345
350
 
351
+ // v5.1 Documents (문서함) — every output the workspace produced, grouped by run (DB-only).
352
+ app.get('/api/documents', async () => await listDocuments());
353
+
346
354
  // 아카이브 — 닫힌 태스크 목록(최신순, 페이지네이션·필터). 카드가 아니라 한 줄 행.
347
355
  app.get('/api/archive', async (req) => {
348
356
  const q = (req.query ?? {}) as { offset?: string; limit?: string; q?: string; repo?: string; status?: string };
@@ -859,6 +867,24 @@ export async function buildServer(): Promise<FastifyInstance> {
859
867
  return res;
860
868
  });
861
869
 
870
+ // v5.1 Part C foundation — resolve the land target + base drift for a run.
871
+ // ?fetch=1 refreshes the remote first (network) so ahead/behind are current.
872
+ app.get('/api/runs/:id/land-target', async (req, reply) => {
873
+ const id = Number((req.params as { id: string }).id);
874
+ if (!Number.isFinite(id)) return reply.code(400).send({ error: 'bad id' });
875
+ const doFetch = ((req.query ?? {}) as { fetch?: string }).fetch === '1';
876
+ return await landTarget(id, { fetch: doFetch });
877
+ });
878
+
879
+ // v5.1 A1 — conflict preview: what would conflict if this run landed on the target (merge-tree,
880
+ // read-only). ?fetch=1 refreshes origin first; ?target=origin/x overrides the resolved target.
881
+ app.get('/api/runs/:id/merge/preview', async (req, reply) => {
882
+ const id = Number((req.params as { id: string }).id);
883
+ if (!Number.isFinite(id)) return reply.code(400).send({ error: 'bad id' });
884
+ const q = (req.query ?? {}) as { fetch?: string; target?: string };
885
+ return await mergePreview(id, { fetch: q.fetch === '1', target: q.target && q.target.trim() ? q.target.trim() : undefined });
886
+ });
887
+
862
888
  // 승자 run 머지 — run 브랜치를 repo 기본 브랜치로.
863
889
  app.post('/api/runs/:id/merge', async (req, reply) => {
864
890
  const id = Number((req.params as { id: string }).id);
@@ -961,6 +987,14 @@ export async function buildServer(): Promise<FastifyInstance> {
961
987
  };
962
988
  });
963
989
 
990
+ // v5.1 A3 — sibling overlap: which files 2+ runs in the group both touch + suggested land order.
991
+ // Read-only, on-demand (shells git per run) — never on the fleet-poll path.
992
+ app.get('/api/groups/:id/overlap', async (req, reply) => {
993
+ const id = Number((req.params as { id: string }).id);
994
+ if (!Number.isFinite(id)) return reply.code(400).send({ error: 'bad id' });
995
+ return await groupOverlap(id);
996
+ });
997
+
964
998
  // B2 — "+ New attempt": 그룹에 새 시도(들)를 발사. repo 는 그룹의 기존 태스크에서 상속.
965
999
  app.post('/api/groups/:id/spawn', async (req, reply) => {
966
1000
  const id = Number((req.params as { id: string }).id);
@@ -1061,6 +1095,17 @@ export async function buildServer(): Promise<FastifyInstance> {
1061
1095
  return res;
1062
1096
  });
1063
1097
 
1098
+ // v5.1 step 5 — resolve a conflicting land in-app: coxpit prepares the conflict state, the run's
1099
+ // own agent edits the markers, then it commits/pushes/PRs automatically when clean.
1100
+ app.post('/api/runs/:id/land/resolve', async (req, reply) => {
1101
+ const id = Number((req.params as { id: string }).id);
1102
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1103
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1104
+ const res = await startLandResolve(id);
1105
+ if (!res.ok) return reply.code(409).send(res);
1106
+ return res;
1107
+ });
1108
+
1064
1109
  // 실행 중 run 중지(SIGTERM) — close 핸들러가 stopped 로 봉인.
1065
1110
  app.post('/api/runs/:id/stop', async (req, reply) => {
1066
1111
  const id = Number((req.params as { id: string }).id);
@@ -1279,6 +1324,21 @@ export async function buildServer(): Promise<FastifyInstance> {
1279
1324
  return reply.type(v.type).header('cache-control', 'public, max-age=86400').send(body);
1280
1325
  });
1281
1326
 
1327
+ // 브랜드 에셋 서빙(로고 마크·마스코트 컷·워드마크 폰트·favicon). CDN 없음, 패키지 동봉.
1328
+ const BRAND_FILES = new Set([
1329
+ 'mark.png', 'sleep.png', 'wave.png', 'pixelify.woff2',
1330
+ 'favicon.ico', 'favicon-16.png', 'favicon-32.png',
1331
+ 'apple-touch-icon.png', 'icon-192.png', 'icon-512.png',
1332
+ ]);
1333
+ async function sendBrand(file: string, reply: import('fastify').FastifyReply) {
1334
+ if (!BRAND_FILES.has(file)) return reply.code(404).send({ error: 'not found' });
1335
+ const body = await readFile(new URL('./brand/' + file, import.meta.url));
1336
+ return reply.type(contentTypeFor(file)).header('cache-control', 'public, max-age=86400').send(body);
1337
+ }
1338
+ app.get('/brand/:file', async (req, reply) =>
1339
+ sendBrand((req.params as { file: string }).file, reply));
1340
+ app.get('/favicon.ico', async (_req, reply) => sendBrand('favicon.ico', reply));
1341
+
1282
1342
  // run 터미널 — tmux 세션에 PTY attach, WS 로 중계.
1283
1343
  // client → {t:'i',d:string} 입력 · {t:'r',cols,rows} 리사이즈 / server → {t:'o',d} 출력 · {t:'exit'}
1284
1344
  // 하드닝: ?cols&rows 초기 크기(80x24 경유 제거) · 세션 자동 소생 · keepalive · 백프레셔.