coxpit 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "3.6.0",
3
+ "version": "3.8.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
@@ -290,10 +290,37 @@ 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)}
314
+ /* 모바일 입력바 — xterm 직접 타이핑은 소프트웨어 키보드 IME 에서 자모가 낱자로
315
+ 전송된다(조합 미지원). 일반 input 은 네이티브로 조합하므로 여기서 완성문을 보낸다. */
316
+ .term-ibar{display:none;gap:6px;padding:8px 10px;border-top:1px solid var(--line);
317
+ background:var(--surface2);align-items:center;
318
+ padding-bottom:calc(8px + env(safe-area-inset-bottom))}
319
+ .term-ibar input{flex:1;min-width:0}
320
+ .tkey{font-family:var(--mono);font-size:11.5px;color:var(--muted);background:var(--surface);
321
+ border:1px solid var(--line);border-radius:6px;padding:7px 9px;cursor:pointer;flex:0 0 auto}
322
+ .tkey:active{color:var(--ink);border-color:var(--brand)}
323
+ @media (max-width:860px),(pointer:coarse){.term-ibar{display:flex}}
297
324
 
298
325
  /* ── mobile · the pocket pass ───────────── */
299
326
  .menu-btn{display:none;font-size:15px;padding:5px 10px}
@@ -432,8 +459,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
432
459
  <div class="pane-c tl" id="mTimeline"></div>
433
460
  </div>
434
461
  <div class="pane">
435
- <div class="pane-h">Diff <span id="mStat" style="text-transform:none;letter-spacing:0"></span></div>
436
- <div class="pane-c"><pre class="diff" id="mDiff">loading…</pre></div>
462
+ <div class="pane-h" style="display:flex;align-items:center;gap:8px">Diff <span id="mStat" style="text-transform:none;letter-spacing:0"></span>
463
+ <button class="btn-ghost sm pane-tgl" id="mDocsTgl" hidden>Rendered</button></div>
464
+ <div class="pane-c" id="mDiffWrap"><pre class="diff" id="mDiff">loading…</pre></div>
437
465
  </div>
438
466
  </div>
439
467
  <div class="modal-f" id="steerRow" style="border-top:1px solid var(--line)">
@@ -463,6 +491,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
463
491
  <div class="modal-h">
464
492
  <span class="title" id="cmpTitle">Compare</span>
465
493
  <button class="btn sm" id="cmpAI">AI review</button>
494
+ <button class="btn-ghost sm" id="cmpDocsTgl">Rendered</button>
466
495
  <button class="btn-ghost sm" id="cmpRefresh">Refresh</button>
467
496
  <button class="x" id="cmpClose" aria-label="close">×</button>
468
497
  </div>
@@ -481,6 +510,16 @@ export const BOARD_HTML = /* html */ `<!doctype html>
481
510
  <button class="x" id="termClose" aria-label="close">×</button>
482
511
  </div>
483
512
  <div class="term-body"><div id="xterm"></div></div>
513
+ <div class="term-ibar" id="termIbar">
514
+ <button class="tkey" data-k="esc" title="Esc">esc</button>
515
+ <button class="tkey" data-k="tab" title="Tab">tab</button>
516
+ <button class="tkey" data-k="cc" title="Ctrl-C">^C</button>
517
+ <button class="tkey" data-k="up" title="Up">↑</button>
518
+ <button class="tkey" data-k="down" title="Down">↓</button>
519
+ <input id="termInput" placeholder="type here — composes IME (한글 OK), Enter sends"
520
+ autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" />
521
+ <button class="btn sm" id="termSend">⏎</button>
522
+ </div>
484
523
  </div>
485
524
  </div>
486
525
 
@@ -544,6 +583,7 @@ let repos = [], machines = [], captures = [];
544
583
 
545
584
  const $ = (id) => document.getElementById(id);
546
585
  const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
586
+ const escA = (s) => esc(s).replace(/"/g, '&quot;');
547
587
  const statusColor = (s) => 'var(--s-' + (s||'pending') + ', var(--muted))';
548
588
 
549
589
  /* ── custom toast / confirm (시스템 alert·confirm 대체) ── */
@@ -716,12 +756,23 @@ function summarize(kind, payload){
716
756
  }
717
757
  function diffHTML(text){
718
758
  if (!text.trim()) return '<span style="color:var(--faint)">no changes</span>';
759
+ let file = '';
719
760
  return text.split('\\n').map(l=>{
720
761
  const e = esc(l);
721
- if (l.startsWith('diff --git')||l.startsWith('+++')||l.startsWith('---')) return '<span class="dl-file">'+e+'</span>';
762
+ if (l.startsWith('+++')){
763
+ const f = l.replace(/^\\+\\+\\+ [ab]\\//,'').trim();
764
+ if (f && f !== '/dev/null') file = f;
765
+ return '<span class="dl-file">'+e+'</span>';
766
+ }
767
+ if (l.startsWith('---')){
768
+ const f = l.replace(/^--- [ab]\\//,'').trim();
769
+ if (f && f !== '/dev/null') file = f;
770
+ return '<span class="dl-file">'+e+'</span>';
771
+ }
772
+ if (l.startsWith('diff --git')) return '<span class="dl-file">'+e+'</span>';
722
773
  if (l.startsWith('@@')) return '<span class="dl-hunk">'+e+'</span>';
723
- if (l.startsWith('+')) return '<span class="dl-add">'+e+'</span>';
724
- if (l.startsWith('-')) return '<span class="dl-del">'+e+'</span>';
774
+ if (l.startsWith('+')) return '<span class="dl-add dl-line" data-f="'+escA(file)+'" title="click to reference in a steer">'+e+'</span>';
775
+ if (l.startsWith('-')) return '<span class="dl-del dl-line" data-f="'+escA(file)+'" title="click to reference in a steer">'+e+'</span>';
725
776
  return e;
726
777
  }).join('\\n');
727
778
  }
@@ -956,17 +1007,59 @@ function paintModal(){
956
1007
  ).join('') || '<span style="color:var(--faint)">no events yet</span>';
957
1008
  }
958
1009
  async function loadDiff(){
959
- if (openRunId==null) return;
960
- $('mDiff').textContent = 'loading…'; $('mStat').textContent='';
1010
+ if (openRunId==null || docMode) return;
1011
+ const pre = $('mDiff'); if (!pre) return;
1012
+ pre.textContent = 'loading…'; $('mStat').textContent='';
961
1013
  try{
962
1014
  const d = await fetch('/api/runs/'+openRunId+'/diff').then(x=>x.json());
963
- if (!d.ok){ $('mDiff').textContent = d.stat||'no worktree'; return; }
1015
+ if (!d.ok){ pre.textContent = d.stat||'no worktree'; return; }
964
1016
  const files = d.stat ? d.stat.split('\\n').filter(Boolean).length : 0;
965
1017
  $('mStat').textContent = files ? '· '+files+' file'+(files>1?'s':'') : '· clean';
966
- $('mDiff').innerHTML = diffHTML(d.diff||'');
967
- }catch{ $('mDiff').textContent = 'diff failed'; }
1018
+ // 변경분에 문서(md/html) 있으면 Rendered 토글 노출
1019
+ $('mDocsTgl').hidden = !/\\.(md|markdown|html?|htm)$/im.test(d.stat||'');
1020
+ pre.innerHTML = diffHTML(d.diff||'');
1021
+ }catch{ pre.textContent = 'diff failed'; }
1022
+ }
1023
+ /* ── doc 모드 — diff 대신 렌더된 문서 산출물 ── */
1024
+ let docMode = false;
1025
+ function docsHTML(docs){
1026
+ if (!docs.length) return '<span style="color:var(--faint)">no changed docs (md/html) in this worktree</span>';
1027
+ return docs.map(d=>'<div class="doc-h">'+esc(d.path)+'</div>'
1028
+ + (d.kind==='md'
1029
+ ? '<div class="doc-md">'+mdLite(d.content)+'</div>'
1030
+ : '<iframe class="doc-frame" sandbox="" srcdoc="'+escA(d.content)+'"></iframe>')).join('');
1031
+ }
1032
+ async function paintDocs(){
1033
+ if (openRunId==null) return;
1034
+ $('mDiffWrap').innerHTML = '<span style="color:var(--faint)">rendering…</span>';
1035
+ try{
1036
+ const d = await fetch('/api/runs/'+openRunId+'/docs').then(x=>x.json());
1037
+ $('mDiffWrap').innerHTML = docsHTML(d.docs||[]);
1038
+ }catch{ $('mDiffWrap').textContent = 'docs failed'; }
1039
+ }
1040
+ function setDocMode(on){
1041
+ docMode = on;
1042
+ $('mDocsTgl').textContent = on ? 'Diff' : 'Rendered';
1043
+ if (on) paintDocs();
1044
+ else { $('mDiffWrap').innerHTML = '<pre class="diff" id="mDiff">loading…</pre>'; loadDiff(); }
1045
+ }
1046
+ $('mDocsTgl').addEventListener('click', ()=>setDocMode(!docMode));
1047
+ /* diff 라인 클릭 → steer 입력에 참조 인용 (정착 run 에서만 — steerRow 표시 중일 때) */
1048
+ $('mDiffWrap').addEventListener('click', (e)=>{
1049
+ const t = e.target.closest ? e.target.closest('.dl-line') : null;
1050
+ if (!t) return;
1051
+ if ($('steerRow').style.display === 'none') return;
1052
+ const line = (t.textContent||'').slice(1).trim().slice(0,120);
1053
+ const f = t.dataset.f || '';
1054
+ $('steerInput').value = (f ? f+': ' : '') + '"'+line+'" — ';
1055
+ $('steerInput').focus();
1056
+ });
1057
+ function openModal(id){
1058
+ openRunId = id;
1059
+ if (docMode){ docMode=false; $('mDocsTgl').textContent='Rendered'; $('mDiffWrap').innerHTML='<pre class="diff" id="mDiff">loading…</pre>'; }
1060
+ $('mDocsTgl').hidden = true;
1061
+ paintModal(); $('overlay').classList.add('open'); loadDiff();
968
1062
  }
969
- function openModal(id){ openRunId = id; paintModal(); $('overlay').classList.add('open'); loadDiff(); }
970
1063
  function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
971
1064
  /* ── select mode (integrate) ── */
972
1065
  let selectMode = false;
@@ -1090,13 +1183,20 @@ $('mCloseTask').addEventListener('click', async ()=>{
1090
1183
 
1091
1184
  /* ── compare view ── */
1092
1185
  let cmpTaskId = null;
1186
+ let cmpDocMode = false;
1093
1187
  async function openCompare(taskId){
1094
1188
  cmpTaskId = taskId;
1189
+ cmpDocMode = false; $('cmpDocsTgl').textContent = 'Rendered';
1095
1190
  $('cmpReview').hidden = true; $('cmpReview').innerHTML = '';
1096
1191
  $('cmpOverlay').classList.add('open');
1097
1192
  $('cmpBody').innerHTML = '<div class="empty" style="flex:1">loading…</div>';
1098
1193
  await paintCompare();
1099
1194
  }
1195
+ $('cmpDocsTgl').addEventListener('click', async ()=>{
1196
+ cmpDocMode = !cmpDocMode;
1197
+ $('cmpDocsTgl').textContent = cmpDocMode ? 'Diff' : 'Rendered';
1198
+ await paintCompare();
1199
+ });
1100
1200
  async function paintCompare(){
1101
1201
  if (cmpTaskId==null) return;
1102
1202
  let d;
@@ -1123,6 +1223,15 @@ async function paintCompare(){
1123
1223
  + '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
1124
1224
  + '</div></div>';
1125
1225
  }).join('');
1226
+ // doc 모드 — 각 열의 diff 를 렌더된 문서로 치환 (열별 비동기, 도착 순)
1227
+ if (cmpDocMode){
1228
+ for (const r of d.runs){
1229
+ fetch('/api/runs/'+r.id+'/docs').then(x=>x.json()).then(j=>{
1230
+ const col = document.querySelector('.cmp-col[data-run="'+r.id+'"] .cmp-diff');
1231
+ if (col && cmpDocMode) col.innerHTML = docsHTML(j.docs||[]);
1232
+ }).catch(()=>{});
1233
+ }
1234
+ }
1126
1235
  }
1127
1236
  $('cmpBody').addEventListener('click', async (e)=>{
1128
1237
  const prBtn = e.target.closest('button[data-pr]');
@@ -1287,6 +1396,24 @@ function closeTerm(){
1287
1396
  if (termWS){ try{ termWS.close(); }catch{} termWS=null; }
1288
1397
  if (termObj){ try{ termObj.dispose(); }catch{} termObj=null; fitAddon=null; }
1289
1398
  }
1399
+ /* 모바일 입력바 — 조합 완료된 텍스트를 통째로 PTY 에 (IME-안전 경로) */
1400
+ function termSendRaw(d){ if (termWS && termWS.readyState===1) termWS.send(JSON.stringify({t:'i', d})); }
1401
+ function termSendLine(){
1402
+ const inp = $('termInput');
1403
+ const v = inp.value;
1404
+ if (!v) { termSendRaw('\\r'); return; }
1405
+ termSendRaw(v + '\\r');
1406
+ inp.value = '';
1407
+ }
1408
+ $('termSend').addEventListener('click', termSendLine);
1409
+ $('termInput').addEventListener('keydown', (e)=>{
1410
+ if (e.isComposing) return; // 조합 중 Enter 는 IME 확정용 — 보내지 않음
1411
+ if (e.key === 'Enter'){ e.preventDefault(); termSendLine(); }
1412
+ });
1413
+ const TKEYS = { esc:'\\x1b', tab:'\\t', cc:'\\x03', up:'\\x1b[A', down:'\\x1b[B' };
1414
+ document.querySelectorAll('#termIbar .tkey').forEach(b=>{
1415
+ b.addEventListener('click', ()=>{ const k=TKEYS[b.dataset.k]; if(k) termSendRaw(k); });
1416
+ });
1290
1417
  $('termClose').addEventListener('click', closeTerm);
1291
1418
  $('termOverlay').addEventListener('click',(e)=>{ if(e.target===$('termOverlay')) closeTerm(); });
1292
1419
  $('mTerm').addEventListener('click', ()=>{
@@ -327,6 +327,35 @@ export async function getRunDiff(runId: number): Promise<{ ok: boolean; diff: st
327
327
  return { ok: true, stat: stat.trim(), diff: diff.slice(0, 200_000) };
328
328
  }
329
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
+
330
359
  /**
331
360
  * base 동기화 — 오래 사는 세션의 worktree 에 base 브랜치 최신을 머지한다.
332
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);