coxpit 3.5.0 → 3.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/README.md CHANGED
@@ -75,6 +75,7 @@ Your keys and login never touch coxpit's config or database.
75
75
  | `COXPIT_CODEX_BIN` | `codex` | Codex CLI command (optional second provider) |
76
76
  | `COXPIT_CODEX_SANDBOX` | `workspace-write` | Codex sandbox policy (`danger-full-access` for full autonomy) |
77
77
  | `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
78
+ | `COXPIT_PUBLIC_URL` | — | if set, the webhook payload adds `url: <base>/?run=<id>` — tap it on your phone and the board opens that run |
78
79
 
79
80
  Running on the open internet? Put it behind your own front door (Tailscale, Cloudflare Access, a reverse proxy with TLS) and keep basic auth on — it exposes shells.
80
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "3.5.0",
3
+ "version": "3.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
@@ -4,7 +4,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
4
4
  <html lang="en">
5
5
  <head>
6
6
  <meta charset="utf-8" />
7
- <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
8
8
  <title>coxpit · fleet</title>
9
9
  <link rel="stylesheet" href="/vendor/xterm.css" />
10
10
  <style>
@@ -290,27 +290,73 @@ export const BOARD_HTML = /* html */ `<!doctype html>
290
290
  .cmp-review code{font-family:var(--mono);font-size:.9em;background:#0e1118;padding:1px 5px;border-radius:4px;color:var(--brand)}
291
291
  .cmp-review p{margin:0 0 8px}
292
292
 
293
+ /* ── doc mode (rendered output) ─────────── */
294
+ .doc-h{font-family:var(--mono);font-size:10.5px;color:var(--brand);padding:8px 0 4px;
295
+ border-bottom:1px solid var(--line);margin-bottom:8px;word-break:break-all}
296
+ .doc-md{font-size:13px;line-height:1.65;color:var(--muted);margin-bottom:16px;font-family:var(--sans)}
297
+ .doc-md h1,.doc-md h2{font-size:14px;color:var(--ink);margin:12px 0 6px}
298
+ .doc-md h3{font-size:12.5px;color:var(--ink);margin:10px 0 4px}
299
+ .doc-md ul{margin:4px 0 8px;padding-left:18px}
300
+ .doc-md li{margin-bottom:3px}
301
+ .doc-md strong{color:var(--ink)}
302
+ .doc-md code{font-family:var(--mono);font-size:.9em;background:#0e1118;padding:1px 5px;border-radius:4px;color:var(--brand)}
303
+ .doc-md p{margin:0 0 8px}
304
+ .doc-frame{width:100%;height:420px;border:1px solid var(--line);border-radius:8px;background:#fff;margin-bottom:16px}
305
+ .pane-tgl{margin-left:auto;font-size:10px;padding:2px 9px;text-transform:none;letter-spacing:0}
306
+ /* run 모달의 diff 라인 = steer 참조 클릭 타깃 */
307
+ #mDiffWrap .dl-line{cursor:pointer;border-radius:3px}
308
+ #mDiffWrap .dl-line:hover{background:rgba(78,201,176,.09)}
309
+
293
310
  /* ── terminal ───────────────────────────── */
294
311
  .term-body{flex:1;min-height:0;background:#0b0d12;padding:8px 4px 4px 10px}
295
312
  #xterm{width:100%;height:100%}
296
313
  .term-hint{font-family:var(--mono);font-size:11px;color:var(--faint)}
297
314
 
315
+ /* ── mobile · the pocket pass ───────────── */
316
+ .menu-btn{display:none;font-size:15px;padding:5px 10px}
317
+ .scrim{position:fixed;inset:0;top:54px;background:rgba(5,7,10,.5);z-index:29;display:none}
298
318
  @media (max-width:860px){
299
- .layout{grid-template-columns:1fr}
300
- aside{border-right:none;border-bottom:1px solid var(--line)}
301
- .modal-b{grid-template-columns:1fr}
319
+ .layout{grid-template-columns:1fr;min-height:calc(100dvh - 55px)}
320
+ /* 사이드바 = 오프캔버스 드로어 — 플릿이 첫 화면, 런처는 ☰ 뒤에 */
321
+ .menu-btn{display:inline-flex}
322
+ aside{position:fixed;top:54px;bottom:0;left:0;width:min(86vw,340px);z-index:30;
323
+ transform:translateX(-103%);transition:transform .22s ease;overflow-y:auto;
324
+ -webkit-overflow-scrolling:touch;box-shadow:var(--shadow)}
325
+ aside.open{transform:translateX(0)}
326
+ .scrim.on{display:block}
327
+ header{padding:0 12px;gap:9px}
328
+ .brand .sub,.daemon-badge,.machines{display:none}
329
+ main{padding:14px}
330
+ .grid{grid-template-columns:1fr}
331
+ /* 모달 = 풀블리드 */
332
+ .overlay.open{padding:0}
333
+ .modal,.modal.wide{width:100%;height:100dvh;max-height:none;border:none;border-radius:0}
334
+ .modal-b{grid-template-columns:1fr;grid-template-rows:1fr 1fr}
302
335
  .pane{border-right:none;border-bottom:1px solid var(--line)}
336
+ .modal-f{flex-wrap:wrap;padding:10px 12px}
337
+ #steerRow .seg{flex:0 0 108px}
338
+ /* iOS: 16px 미만 input 포커스 시 강제 줌 — 폰에서만 16px 로 */
339
+ input,textarea,select,.dd-btn{font-size:16px}
340
+ /* 온보딩 패널이 좁은 화면을 넘치지 않게 */
341
+ .empty{padding:24px 0}
342
+ .setup{max-width:100%;width:100%;margin:8px 0}
343
+ .chk .v{min-width:0}
344
+ .cmp{flex-direction:column;overflow-y:auto;overflow-x:hidden}
345
+ .cmp-col{min-width:0;border-right:none;border-bottom:1px solid var(--line);flex:0 0 auto;max-height:72vh}
303
346
  }
347
+ @media (prefers-reduced-motion:reduce){aside{transition:none}}
304
348
  </style>
305
349
  </head>
306
350
  <body>
307
351
  <header>
352
+ <button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
308
353
  <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
309
354
  <span class="daemon-badge" id="daemonBadge" style="display:none"></span>
310
355
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
311
356
  <button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
312
357
  <div class="machines" id="machines"></div>
313
358
  </header>
359
+ <div class="scrim" id="scrim"></div>
314
360
  <div class="layout">
315
361
  <aside>
316
362
  <div class="sect">
@@ -403,8 +449,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
403
449
  <div class="pane-c tl" id="mTimeline"></div>
404
450
  </div>
405
451
  <div class="pane">
406
- <div class="pane-h">Diff <span id="mStat" style="text-transform:none;letter-spacing:0"></span></div>
407
- <div class="pane-c"><pre class="diff" id="mDiff">loading…</pre></div>
452
+ <div class="pane-h" style="display:flex;align-items:center;gap:8px">Diff <span id="mStat" style="text-transform:none;letter-spacing:0"></span>
453
+ <button class="btn-ghost sm pane-tgl" id="mDocsTgl" hidden>Rendered</button></div>
454
+ <div class="pane-c" id="mDiffWrap"><pre class="diff" id="mDiff">loading…</pre></div>
408
455
  </div>
409
456
  </div>
410
457
  <div class="modal-f" id="steerRow" style="border-top:1px solid var(--line)">
@@ -434,6 +481,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
434
481
  <div class="modal-h">
435
482
  <span class="title" id="cmpTitle">Compare</span>
436
483
  <button class="btn sm" id="cmpAI">AI review</button>
484
+ <button class="btn-ghost sm" id="cmpDocsTgl">Rendered</button>
437
485
  <button class="btn-ghost sm" id="cmpRefresh">Refresh</button>
438
486
  <button class="x" id="cmpClose" aria-label="close">×</button>
439
487
  </div>
@@ -515,6 +563,7 @@ let repos = [], machines = [], captures = [];
515
563
 
516
564
  const $ = (id) => document.getElementById(id);
517
565
  const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
566
+ const escA = (s) => esc(s).replace(/"/g, '&quot;');
518
567
  const statusColor = (s) => 'var(--s-' + (s||'pending') + ', var(--muted))';
519
568
 
520
569
  /* ── custom toast / confirm (시스템 alert·confirm 대체) ── */
@@ -687,12 +736,23 @@ function summarize(kind, payload){
687
736
  }
688
737
  function diffHTML(text){
689
738
  if (!text.trim()) return '<span style="color:var(--faint)">no changes</span>';
739
+ let file = '';
690
740
  return text.split('\\n').map(l=>{
691
741
  const e = esc(l);
692
- if (l.startsWith('diff --git')||l.startsWith('+++')||l.startsWith('---')) return '<span class="dl-file">'+e+'</span>';
742
+ if (l.startsWith('+++')){
743
+ const f = l.replace(/^\\+\\+\\+ [ab]\\//,'').trim();
744
+ if (f && f !== '/dev/null') file = f;
745
+ return '<span class="dl-file">'+e+'</span>';
746
+ }
747
+ if (l.startsWith('---')){
748
+ const f = l.replace(/^--- [ab]\\//,'').trim();
749
+ if (f && f !== '/dev/null') file = f;
750
+ return '<span class="dl-file">'+e+'</span>';
751
+ }
752
+ if (l.startsWith('diff --git')) return '<span class="dl-file">'+e+'</span>';
693
753
  if (l.startsWith('@@')) return '<span class="dl-hunk">'+e+'</span>';
694
- if (l.startsWith('+')) return '<span class="dl-add">'+e+'</span>';
695
- if (l.startsWith('-')) return '<span class="dl-del">'+e+'</span>';
754
+ if (l.startsWith('+')) return '<span class="dl-add dl-line" data-f="'+escA(file)+'" title="click to reference in a steer">'+e+'</span>';
755
+ if (l.startsWith('-')) return '<span class="dl-del dl-line" data-f="'+escA(file)+'" title="click to reference in a steer">'+e+'</span>';
696
756
  return e;
697
757
  }).join('\\n');
698
758
  }
@@ -868,10 +928,12 @@ function notifySettleUI(ev){
868
928
  if (!notifyOn) return;
869
929
  const t = tasks.get(ev.taskId ?? (runs.get(ev.runId)||{}).taskId);
870
930
  try {
871
- new Notification('coxpit · r'+ev.runId+' '+ev.status, {
931
+ const n = new Notification('coxpit · r'+ev.runId+' '+ev.status, {
872
932
  body: (t ? t.title+' — ' : '') + (ev.filesChanged??0)+' file(s) changed',
873
933
  tag: 'coxpit-r'+ev.runId,
874
934
  });
935
+ // 딥링크 — 알림 탭이 곧 그 run 의 모달
936
+ n.onclick = ()=>{ try{ window.focus(); }catch{} if (runs.has(ev.runId)) openModal(ev.runId); n.close(); };
875
937
  } catch {}
876
938
  }
877
939
 
@@ -925,17 +987,59 @@ function paintModal(){
925
987
  ).join('') || '<span style="color:var(--faint)">no events yet</span>';
926
988
  }
927
989
  async function loadDiff(){
928
- if (openRunId==null) return;
929
- $('mDiff').textContent = 'loading…'; $('mStat').textContent='';
990
+ if (openRunId==null || docMode) return;
991
+ const pre = $('mDiff'); if (!pre) return;
992
+ pre.textContent = 'loading…'; $('mStat').textContent='';
930
993
  try{
931
994
  const d = await fetch('/api/runs/'+openRunId+'/diff').then(x=>x.json());
932
- if (!d.ok){ $('mDiff').textContent = d.stat||'no worktree'; return; }
995
+ if (!d.ok){ pre.textContent = d.stat||'no worktree'; return; }
933
996
  const files = d.stat ? d.stat.split('\\n').filter(Boolean).length : 0;
934
997
  $('mStat').textContent = files ? '· '+files+' file'+(files>1?'s':'') : '· clean';
935
- $('mDiff').innerHTML = diffHTML(d.diff||'');
936
- }catch{ $('mDiff').textContent = 'diff failed'; }
998
+ // 변경분에 문서(md/html) 있으면 Rendered 토글 노출
999
+ $('mDocsTgl').hidden = !/\\.(md|markdown|html?|htm)$/im.test(d.stat||'');
1000
+ pre.innerHTML = diffHTML(d.diff||'');
1001
+ }catch{ pre.textContent = 'diff failed'; }
1002
+ }
1003
+ /* ── doc 모드 — diff 대신 렌더된 문서 산출물 ── */
1004
+ let docMode = false;
1005
+ function docsHTML(docs){
1006
+ if (!docs.length) return '<span style="color:var(--faint)">no changed docs (md/html) in this worktree</span>';
1007
+ return docs.map(d=>'<div class="doc-h">'+esc(d.path)+'</div>'
1008
+ + (d.kind==='md'
1009
+ ? '<div class="doc-md">'+mdLite(d.content)+'</div>'
1010
+ : '<iframe class="doc-frame" sandbox="" srcdoc="'+escA(d.content)+'"></iframe>')).join('');
1011
+ }
1012
+ async function paintDocs(){
1013
+ if (openRunId==null) return;
1014
+ $('mDiffWrap').innerHTML = '<span style="color:var(--faint)">rendering…</span>';
1015
+ try{
1016
+ const d = await fetch('/api/runs/'+openRunId+'/docs').then(x=>x.json());
1017
+ $('mDiffWrap').innerHTML = docsHTML(d.docs||[]);
1018
+ }catch{ $('mDiffWrap').textContent = 'docs failed'; }
1019
+ }
1020
+ function setDocMode(on){
1021
+ docMode = on;
1022
+ $('mDocsTgl').textContent = on ? 'Diff' : 'Rendered';
1023
+ if (on) paintDocs();
1024
+ else { $('mDiffWrap').innerHTML = '<pre class="diff" id="mDiff">loading…</pre>'; loadDiff(); }
1025
+ }
1026
+ $('mDocsTgl').addEventListener('click', ()=>setDocMode(!docMode));
1027
+ /* diff 라인 클릭 → steer 입력에 참조 인용 (정착 run 에서만 — steerRow 표시 중일 때) */
1028
+ $('mDiffWrap').addEventListener('click', (e)=>{
1029
+ const t = e.target.closest ? e.target.closest('.dl-line') : null;
1030
+ if (!t) return;
1031
+ if ($('steerRow').style.display === 'none') return;
1032
+ const line = (t.textContent||'').slice(1).trim().slice(0,120);
1033
+ const f = t.dataset.f || '';
1034
+ $('steerInput').value = (f ? f+': ' : '') + '"'+line+'" — ';
1035
+ $('steerInput').focus();
1036
+ });
1037
+ function openModal(id){
1038
+ openRunId = id;
1039
+ if (docMode){ docMode=false; $('mDocsTgl').textContent='Rendered'; $('mDiffWrap').innerHTML='<pre class="diff" id="mDiff">loading…</pre>'; }
1040
+ $('mDocsTgl').hidden = true;
1041
+ paintModal(); $('overlay').classList.add('open'); loadDiff();
937
1042
  }
938
- function openModal(id){ openRunId = id; paintModal(); $('overlay').classList.add('open'); loadDiff(); }
939
1043
  function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
940
1044
  /* ── select mode (integrate) ── */
941
1045
  let selectMode = false;
@@ -1059,13 +1163,20 @@ $('mCloseTask').addEventListener('click', async ()=>{
1059
1163
 
1060
1164
  /* ── compare view ── */
1061
1165
  let cmpTaskId = null;
1166
+ let cmpDocMode = false;
1062
1167
  async function openCompare(taskId){
1063
1168
  cmpTaskId = taskId;
1169
+ cmpDocMode = false; $('cmpDocsTgl').textContent = 'Rendered';
1064
1170
  $('cmpReview').hidden = true; $('cmpReview').innerHTML = '';
1065
1171
  $('cmpOverlay').classList.add('open');
1066
1172
  $('cmpBody').innerHTML = '<div class="empty" style="flex:1">loading…</div>';
1067
1173
  await paintCompare();
1068
1174
  }
1175
+ $('cmpDocsTgl').addEventListener('click', async ()=>{
1176
+ cmpDocMode = !cmpDocMode;
1177
+ $('cmpDocsTgl').textContent = cmpDocMode ? 'Diff' : 'Rendered';
1178
+ await paintCompare();
1179
+ });
1069
1180
  async function paintCompare(){
1070
1181
  if (cmpTaskId==null) return;
1071
1182
  let d;
@@ -1092,6 +1203,15 @@ async function paintCompare(){
1092
1203
  + '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
1093
1204
  + '</div></div>';
1094
1205
  }).join('');
1206
+ // doc 모드 — 각 열의 diff 를 렌더된 문서로 치환 (열별 비동기, 도착 순)
1207
+ if (cmpDocMode){
1208
+ for (const r of d.runs){
1209
+ fetch('/api/runs/'+r.id+'/docs').then(x=>x.json()).then(j=>{
1210
+ const col = document.querySelector('.cmp-col[data-run="'+r.id+'"] .cmp-diff');
1211
+ if (col && cmpDocMode) col.innerHTML = docsHTML(j.docs||[]);
1212
+ }).catch(()=>{});
1213
+ }
1214
+ }
1095
1215
  }
1096
1216
  $('cmpBody').addEventListener('click', async (e)=>{
1097
1217
  const prBtn = e.target.closest('button[data-pr]');
@@ -1323,6 +1443,7 @@ $('repoForm').addEventListener('submit', async (e)=>{
1323
1443
  });
1324
1444
  $('taskForm').addEventListener('submit', async (e)=>{
1325
1445
  e.preventDefault();
1446
+ setDrawer(false); // 모바일: 발사하면 드로어를 닫고 플릿을 보여준다
1326
1447
  if (lTab === 'goal') return submitGoal();
1327
1448
  if (lTab === 'bench') return submitBench();
1328
1449
  const repoId = Number($('taskRepo').value);
@@ -1371,7 +1492,21 @@ let savedAgent = null;
1371
1492
  try { savedAgent = localStorage.getItem('coxpit.agent'); } catch {}
1372
1493
  if (savedAgent === 'codex') setProvider('codex', false);
1373
1494
 
1374
- hydrate().then(connectWS);
1495
+ /* ── mobile drawer ── */
1496
+ const asideEl = document.querySelector('aside');
1497
+ function setDrawer(on){ asideEl.classList.toggle('open', on); $('scrim').classList.toggle('on', on); }
1498
+ $('menuBtn').addEventListener('click', ()=>setDrawer(!asideEl.classList.contains('open')));
1499
+ $('scrim').addEventListener('click', ()=>setDrawer(false));
1500
+
1501
+ /* 딥링크 — /?run=N 이면 하이드레이션 후 그 run 모달을 연다 (웹훅 링크·알림용) */
1502
+ function openFromURL(){
1503
+ const q = new URLSearchParams(location.search).get('run');
1504
+ const id = Number(q);
1505
+ if (q && runs.has(id)){ openModal(id); }
1506
+ if (q) history.replaceState(null, '', location.pathname);
1507
+ }
1508
+
1509
+ hydrate().then(()=>{ connectWS(); openFromURL(); });
1375
1510
  </script>
1376
1511
  </body>
1377
1512
  </html>`;
package/src/config.ts CHANGED
@@ -65,6 +65,8 @@ export const config = {
65
65
  sshKey: process.env.COXPIT_SSH_KEY ?? '',
66
66
  // run 정착 시 POST 할 웹훅(선택) — 텔레그램 브릿지 등 사용자 연결용.
67
67
  webhookUrl: process.env.COXPIT_WEBHOOK_URL ?? '',
68
+ // 웹훅 payload 의 딥링크 base(선택) — 설정 시 url: <base>/?run=<id> 포함 (폰에서 탭 → 그 run)
69
+ publicUrl: (process.env.COXPIT_PUBLIC_URL ?? '').replace(/\/+$/, ''),
68
70
  agent: {
69
71
  // 기본 드라이런(모의 에이전트). 실제 CLI 실행은 켤 때만(크레딧 소모).
70
72
  real: process.env.COXPIT_AGENT_REAL === '1',
@@ -218,6 +218,8 @@ async function notifySettle(runId: number, status: string, filesChanged: number,
218
218
  body: JSON.stringify({
219
219
  event: 'run.settled',
220
220
  run: { id: runId, status, filesChanged, exitSummary: exitSummary.slice(0, 300), task: tr[0]?.title ?? '' },
221
+ // COXPIT_PUBLIC_URL 설정 시 폰에서 탭 → 보드가 그 run 모달을 딥링크로 연다
222
+ ...(config.publicUrl ? { url: `${config.publicUrl}/?run=${runId}` } : {}),
221
223
  }),
222
224
  signal: AbortSignal.timeout(8000),
223
225
  });
@@ -325,6 +327,35 @@ export async function getRunDiff(runId: number): Promise<{ ok: boolean; diff: st
325
327
  return { ok: true, stat: stat.trim(), diff: diff.slice(0, 200_000) };
326
328
  }
327
329
 
330
+ /**
331
+ * Doc 모드 — run worktree 의 변경 파일 중 문서(md/html)만 내용째 회수.
332
+ * 보드가 diff 대신 "렌더된 산출물"을 보여줄 때 사용 (읽기 전용).
333
+ */
334
+ export async function getRunDocs(runId: number): Promise<{
335
+ ok: boolean; docs: Array<{ path: string; kind: 'md' | 'html'; content: string }>; detail?: string;
336
+ }> {
337
+ const ctx = await loadContext(runId);
338
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
339
+ const run = rr[0];
340
+ if (!ctx || !run || !run.worktreePath) return { ok: false, docs: [], detail: 'no worktree' };
341
+ const wt = run.worktreePath;
342
+ // 변경 파일 목록 = tracked 수정 + untracked (status --porcelain 경로 필드)
343
+ const ls = await runShellOn(ctx.machine, `git -C ${shq(wt)} status --porcelain`, 15000);
344
+ if (!ls.ok) return { ok: false, docs: [], detail: 'status failed' };
345
+ const kindOf = (p: string): 'md' | 'html' | null =>
346
+ /\.(md|markdown)$/i.test(p) ? 'md' : /\.(html?|htm)$/i.test(p) ? 'html' : null;
347
+ const paths = ls.stdout.split('\n')
348
+ .map((l) => l.slice(3).trim().replace(/^"|"$/g, ''))
349
+ .filter((p) => p && kindOf(p) !== null)
350
+ .slice(0, 6); // 문서 비교 용도 — 상한
351
+ const docs: Array<{ path: string; kind: 'md' | 'html'; content: string }> = [];
352
+ for (const p of paths) {
353
+ const cat = await runShellOn(ctx.machine, `head -c 200000 ${shq(ppath.join(wt, p))}`, 15000);
354
+ if (cat.ok) docs.push({ path: p, kind: kindOf(p)!, content: cat.stdout });
355
+ }
356
+ return { ok: true, docs };
357
+ }
358
+
328
359
  /**
329
360
  * base 동기화 — 오래 사는 세션의 worktree 에 base 브랜치 최신을 머지한다.
330
361
  * 충돌 시 자동 abort — 그땐 steer 로 에이전트에게 머지를 맡기라고 안내.
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, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench } from './orchestrator';
15
+ import { launchRun, cleanupRun, stopRun, getRunDiff, getRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench } from './orchestrator';
16
16
  import { openTerm } from './term';
17
17
  import { addSink, removeSink, broadcast } from './hub';
18
18
  import { getProvider, listProviders } from './providers';
@@ -509,6 +509,14 @@ export async function buildServer(): Promise<FastifyInstance> {
509
509
  return getRunDiff(id);
510
510
  });
511
511
 
512
+ // Doc 모드 — 변경된 문서(md/html)를 내용째 (렌더 비교용, 읽기 전용)
513
+ app.get('/api/runs/:id/docs', async (req, reply) => {
514
+ const id = Number((req.params as { id: string }).id);
515
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
516
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
517
+ return getRunDocs(id);
518
+ });
519
+
512
520
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
513
521
  app.get('/ws', { websocket: true }, (socket) => {
514
522
  addSink(socket);