coxpit 5.15.0 → 5.17.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": "5.15.0",
3
+ "version": "5.17.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": "SEE LICENSE IN LICENSE.md",
@@ -46,6 +46,7 @@
46
46
  "@libsql/client": "^0.14",
47
47
  "@xterm/addon-fit": "^0.10.0",
48
48
  "@xterm/addon-unicode11": "^0.8.0",
49
+ "@xterm/addon-web-links": "^0.11.0",
49
50
  "@xterm/xterm": "^5.5.0",
50
51
  "dotenv": "^16",
51
52
  "drizzle-orm": "^0.38",
package/src/cockpit.ts CHANGED
@@ -255,6 +255,17 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
255
255
  .hist-refresh:hover{color:var(--ink);border-color:var(--line-hi)}
256
256
  .hist-body{flex:1;margin:0;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;
257
257
  padding:10px 12px;font-family:var(--mono);font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;color:var(--ink);background:var(--bg)}
258
+ .hist-modes{display:inline-flex;gap:2px;margin-left:12px;border:1px solid var(--line);border-radius:7px;padding:2px}
259
+ .hm-tab{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;border:none;border-radius:5px;padding:4px 9px;cursor:pointer}
260
+ .hm-tab.on{background:var(--brand-dim);color:var(--ink)}
261
+ .hist-chat{flex:1;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;display:flex;flex-direction:column;gap:10px;padding:12px;background:var(--bg)}
262
+ .ch-turn{display:flex} .ch-turn.user{justify-content:flex-end}
263
+ .ch-bubble{max-width:82%;padding:9px 12px;border-radius:12px;font-size:13px;line-height:1.55;word-break:break-word}
264
+ .ch-turn.user .ch-bubble{background:var(--brand-dim);color:var(--ink);border:1px solid rgba(78,201,176,.25);border-bottom-right-radius:4px}
265
+ .ch-turn.asst .ch-bubble{background:var(--surface);color:var(--ink);border:1px solid var(--line);border-bottom-left-radius:4px}
266
+ .ch-tools{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
267
+ .ch-tool{font-family:var(--mono);font-size:10px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:1px 7px}
268
+ .hist-empty{color:var(--faint);text-align:center;padding:30px 16px;font-size:12.5px;margin:auto}
258
269
  /* 터치 기기(아이패드 포함, 화면폭 무관): 입력바 노출 + 요청바/분할 숨김(하단 클러터·잘림 방지) */
259
270
  body.touch .term-ibar{display:flex}
260
271
  body.touch .reqbar{display:none}
@@ -295,7 +306,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
295
306
  </div>
296
307
  <div class="right">
297
308
  <span class="ws" id="ws"><span class="dot"></span><span id="wstext" class="b-txt">connecting</span></span>
298
- <button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">🔑<span class="b-txt"> Secrets</span></button>
309
+ <button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">∗<span class="b-txt"> Secrets</span></button>
299
310
  <a class="toggle" href="/" title="보드(모니터) 뷰로">←<span class="b-txt"> Board</span></a>
300
311
  </div>
301
312
  </header>
@@ -330,7 +341,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
330
341
  </div>
331
342
  <div class="term-ibar" id="termIbar">
332
343
  <div class="tkeys">
333
- <button type="button" class="tkey scroll" id="histBtn" title=" 내용 읽기 스크롤백을 스냅샷으로(읽기 전용)">📜 위로</button>
344
+ <button type="button" class="tkey scroll" id="histBtn" title="뷰어 대화/터미널로 내용 보기(읽기 전용)">뷰어</button>
334
345
  <button type="button" class="tkey scroll" data-k="copymode" title="터미널 안에서 스크롤 — tmux copy-mode 진입(⇞/↑ 로 위로, esc 로 나가기)">⇡ 스크롤</button>
335
346
  <button type="button" class="tkey" data-k="esc" title="Esc">esc</button>
336
347
  <button type="button" class="tkey" data-k="tab" title="Tab">tab</button>
@@ -402,7 +413,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
402
413
 
403
414
  <div class="modal" id="secretsModal">
404
415
  <div class="pick" style="width:min(520px,92vw)">
405
- <div class="pick-h"><span class="t">🔑 시크릿 (env 주입)</span><button class="x" id="secretsClose" title="닫기">×</button></div>
416
+ <div class="pick-h"><span class="t">시크릿 (env 주입)</span><button class="x" id="secretsClose" title="닫기">×</button></div>
406
417
  <div style="padding:10px 15px;font-size:11px;color:var(--faint);border-bottom:1px solid var(--line)">여기 등록한 값은 <b>새 세션</b>을 열 때 tmux env 로 주입됩니다(스크롤백에 안 남음). 그 안의 <code>claude</code>·스크립트가 env 에서 읽어 프롬프트가 안 뜹니다. 이미 열린 세션엔 새로 열어야 적용됩니다.</div>
407
418
  <div class="pick-list" id="secretsList"></div>
408
419
  <div class="pick-f" style="gap:7px">
@@ -415,10 +426,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
415
426
 
416
427
  <div class="modal" id="histModal">
417
428
  <div class="pick" style="width:min(680px,96vw);max-height:88vh">
418
- <div class="pick-h"><span class="t">📜 <span id="histTitle">터미널 히스토리</span></span>
429
+ <div class="pick-h"><span class="t"><span id="histTitle">뷰어</span></span>
430
+ <div class="hist-modes">
431
+ <button type="button" class="hm-tab on" id="hmChat" title="Claude Code 대화로 보기">대화</button>
432
+ <button type="button" class="hm-tab" id="hmRaw" title="터미널 스크롤백 원문">터미널</button>
433
+ </div>
419
434
  <button type="button" class="hist-refresh" id="histRefresh" title="지금 시점으로 다시 불러오기">↻</button>
420
435
  <button class="x" id="histClose" title="닫기">×</button></div>
421
- <pre class="hist-body" id="histBody">불러오는 중…</pre>
436
+ <div class="hist-chat" id="histChat"></div>
437
+ <pre class="hist-body" id="histBody" style="display:none">불러오는 중…</pre>
422
438
  </div>
423
439
  </div>
424
440
 
@@ -427,6 +443,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
427
443
  <script src="/vendor/xterm.js"></script>
428
444
  <script src="/vendor/addon-fit.js"></script>
429
445
  <script src="/vendor/addon-unicode11.js"></script>
446
+ <script src="/vendor/addon-web-links.js"></script>
430
447
  <script>
431
448
  // 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
432
449
  // (이전엔 보드로 리다이렉트했지만, 이제 cockpit 을 모바일 대응)
@@ -435,7 +452,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
435
452
  var $ = function(id){ return document.getElementById(id); };
436
453
 
437
454
  function toast(msg){ var t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toast._h); toast._h=setTimeout(function(){ t.classList.remove('show'); }, 2600); }
438
- var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:' verify' };
455
+ var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'! verify' };
439
456
  function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
440
457
 
441
458
  // ── fleet 상태 ──
@@ -590,6 +607,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
590
607
  });
591
608
  var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
592
609
  try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
610
+ // URL 링크 클릭 가능(웹=새 탭, 데스크톱 앱=시스템 브라우저 — setWindowOpenHandler 가 external 로).
611
+ try{ term.loadAddon(new window.WebLinksAddon.WebLinksAddon(function(ev, uri){ window.open(uri, '_blank', 'noopener'); })); }catch(e){}
593
612
  // term.open 은 host 가 DOM 에 붙은 뒤(attachHosts) 최초 1회 — detached 에서 open 하면 렌더러가 안 뜬다.
594
613
  var t={ runId:runId, name:tabName(runId), term:term, fit:fit, ws:null, retry:0, closing:false, host:host, ro:null, opened:false, connected:false };
595
614
  term.onData(function(d){ if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i',d:d})); });
@@ -643,7 +662,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
643
662
  ? '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
644
663
  + '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
645
664
  + '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
646
- + '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">🔒</button>'
665
+ + '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">⊟</button>'
647
666
  + '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>'
648
667
  : '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
649
668
  var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
@@ -849,22 +868,42 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
849
868
  else { var inp=$('termInput'); if(inp) inp.focus(); }
850
869
  }); });
851
870
 
852
- // ── (C) 히스토리 오버레이 스크롤백 스냅샷을 읽기 전용 스크롤 뷰로(xterm/마우스모드 우회) ──
853
- async function openHistory(){
854
- var rid=focusedRunId(); if(rid==null){ toast('포커스한 세션이 없습니다'); return; }
855
- $('histModal').classList.add('on');
856
- var t=tabs[rid]; $('histTitle').textContent=(t?t.name:('r'+rid))+' · 히스토리';
857
- var body=$('histBody'); body.textContent='불러오는 중…';
858
- try{
859
- var d=await (await fetch('/api/runs/'+rid+'/scrollback?lines=5000')).json();
860
- body.textContent = (d && d.text) ? d.text : '(스크롤백 없음)';
861
- body.scrollTop = body.scrollHeight; // 하단(최신)에서 시작 → 위로 스크롤해 과거 읽기
862
- }catch(e){ body.textContent='불러오기 실패: '+e; }
863
- }
871
+ // ── 뷰어 — 위 내용을 (대화) Claude Code 로그 / (터미널) 스크롤백 으로 읽기(읽기 전용) ──
872
+ var histMode='chat';
873
+ function openHistory(){ var rid=focusedRunId(); if(rid==null){ toast('포커스한 세션이 없습니다'); return; } $('histModal').classList.add('on'); loadHist(); }
864
874
  function closeHistory(){ $('histModal').classList.remove('on'); }
875
+ function setHistMode(m){
876
+ histMode=m; $('hmChat').classList.toggle('on',m==='chat'); $('hmRaw').classList.toggle('on',m==='raw');
877
+ $('histChat').style.display = m==='chat'?'flex':'none'; $('histBody').style.display = m==='raw'?'block':'none';
878
+ loadHist();
879
+ }
880
+ function renderTurn(t){
881
+ var body = esc(t.text||'').replace(/\\n/g,'<br>');
882
+ var tools = (t.tools && t.tools.length) ? '<div class="ch-tools">'+t.tools.map(function(x){return '<span class="ch-tool">'+esc(x)+'</span>';}).join('')+'</div>' : '';
883
+ return '<div class="ch-turn '+(t.role==='user'?'user':'asst')+'"><div class="ch-bubble">'+body+tools+'</div></div>';
884
+ }
885
+ async function loadHist(){
886
+ var rid=focusedRunId(); if(rid==null) return;
887
+ var t=tabs[rid]; $('histTitle').textContent=(t?t.name:('r'+rid))+' · 뷰어';
888
+ if (histMode==='chat'){
889
+ var c=$('histChat'); c.innerHTML='<div class="hist-empty">불러오는 중…</div>';
890
+ try{
891
+ var d=await (await fetch('/api/runs/'+rid+'/chat')).json();
892
+ if (d.turns && d.turns.length){ c.innerHTML = d.turns.map(renderTurn).join(''); c.scrollTop=c.scrollHeight; }
893
+ else { c.innerHTML='<div class="hist-empty">이 폴더에 Claude Code 대화 로그가 없습니다.<br>터미널 탭으로 원문을 보세요.</div>'; }
894
+ }catch(e){ c.innerHTML='<div class="hist-empty">불러오기 실패</div>'; }
895
+ } else {
896
+ var b=$('histBody'); b.textContent='불러오는 중…';
897
+ try{ var d2=await (await fetch('/api/runs/'+rid+'/scrollback?lines=5000')).json();
898
+ b.textContent=(d2 && d2.text)?d2.text:'(스크롤백 없음)'; b.scrollTop=b.scrollHeight;
899
+ }catch(e){ b.textContent='불러오기 실패'; }
900
+ }
901
+ }
865
902
  $('histBtn').addEventListener('click', openHistory);
866
- $('histRefresh').addEventListener('click', openHistory);
903
+ $('histRefresh').addEventListener('click', loadHist);
867
904
  $('histClose').addEventListener('click', closeHistory);
905
+ $('hmChat').addEventListener('click', function(){ setHistMode('chat'); });
906
+ $('hmRaw').addEventListener('click', function(){ setHistMode('raw'); });
868
907
  $('histModal').addEventListener('click', function(e){ if(e.target===this) closeHistory(); });
869
908
  // 터치 기기(아이패드 포함) 판별 → body.touch (화면폭 무관하게 입력바 노출)
870
909
  if (window.matchMedia('(pointer:coarse)').matches || (navigator.maxTouchPoints||0) > 0) document.body.classList.add('touch');
@@ -949,7 +988,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
949
988
  var done=false;
950
989
  function finish(send){ if(done) return; done=true;
951
990
  if(send){ try{ t.ws.send(JSON.stringify({t:'i',d:input.value+'\\r'})); }catch(e){} toast('페인에 전송(엔터 포함)'); }
952
- var nb=document.createElement('button'); nb.className='lock'; nb.setAttribute('data-lock',leafId); nb.title='이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)'; nb.textContent='🔒';
991
+ var nb=document.createElement('button'); nb.className='lock'; nb.setAttribute('data-lock',leafId); nb.title='이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)'; nb.textContent='';
953
992
  try{ input.replaceWith(nb); }catch(e){}
954
993
  }
955
994
  input.addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); finish(true); } else if(e.key==='Escape'){ finish(false); } });
@@ -677,7 +677,57 @@ export async function getRunTermInfo(runId: number): Promise<{ machine: MachineT
677
677
  }
678
678
 
679
679
  /**
680
- * tmux 페인 스크롤백 스냅샷 모바일 "위 내용 읽기"(히스토리 오버레이)용.
680
+ * 세션의 Claude Code 대화 로그(JSONL)를 대화형으로 파싱 뷰어의 "대화" 모드.
681
+ * cwd(worktreePath)를 [^a-zA-Z0-9]→'-' 로 바꾼 게 ~/.claude/projects/<...>/ 폴더명 → 최신 .jsonl.
682
+ * user/assistant turn 만 추출(tool_result 노이즈 제외, tool_use 는 칩으로).
683
+ */
684
+ export async function getSessionChat(runId: number, maxTurns = 200): Promise<{
685
+ ok: boolean; turns: Array<{ role: string; text: string; tools?: string[] }>; note?: string;
686
+ }> {
687
+ const info = await getRunTermInfo(runId);
688
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
689
+ const run = rr[0];
690
+ if (!info || !run || !run.worktreePath) return { ok: false, turns: [], note: 'no session' };
691
+ const proj = run.worktreePath.replace(/[^a-zA-Z0-9]/g, '-');
692
+ const cmd = `d=${shq(proj)}; f=$(ls -t "$HOME/.claude/projects/$d"/*.jsonl 2>/dev/null | head -1); [ -n "$f" ] && tail -n 8000 "$f" || true`;
693
+ const r = await runShellOn(info.machine, cmd, 15000);
694
+ if (!r.ok) return { ok: true, turns: [], note: 'no transcript' };
695
+ if (!r.stdout.trim()) return { ok: true, turns: [], note: 'no Claude Code transcript for this folder' };
696
+ const userText = (content: unknown): string => {
697
+ if (typeof content === 'string') return content;
698
+ if (!Array.isArray(content)) return '';
699
+ return content.filter((b) => b && typeof b === 'object' && (b as { type?: string }).type === 'text')
700
+ .map((b) => (b as { text?: string }).text ?? '').join('\n');
701
+ };
702
+ const asstParts = (content: unknown): { text: string; tools: string[] } => {
703
+ if (typeof content === 'string') return { text: content, tools: [] };
704
+ if (!Array.isArray(content)) return { text: '', tools: [] };
705
+ const text: string[] = []; const tools: string[] = [];
706
+ for (const b of content) {
707
+ const bb = b as { type?: string; text?: string; name?: string };
708
+ if (bb?.type === 'text' && bb.text) text.push(bb.text);
709
+ else if (bb?.type === 'tool_use' && bb.name) tools.push(bb.name);
710
+ }
711
+ return { text: text.join('\n'), tools };
712
+ };
713
+ const turns: Array<{ role: string; text: string; tools?: string[] }> = [];
714
+ for (const line of r.stdout.split('\n')) {
715
+ if (!line.trim()) continue;
716
+ let j: { type?: string; message?: { content?: unknown } };
717
+ try { j = JSON.parse(line); } catch { continue; }
718
+ if (j.type === 'user' && j.message) {
719
+ const text = userText(j.message.content);
720
+ if (text.trim()) turns.push({ role: 'user', text: text.slice(0, 8000) });
721
+ } else if (j.type === 'assistant' && j.message) {
722
+ const { text, tools } = asstParts(j.message.content);
723
+ if (text.trim() || tools.length) turns.push({ role: 'assistant', text: text.slice(0, 12000), tools });
724
+ }
725
+ }
726
+ return { ok: true, turns: maxTurns && turns.length > maxTurns ? turns.slice(-maxTurns) : turns };
727
+ }
728
+
729
+ /**
730
+ * tmux 페인 스크롤백 스냅샷 — 모바일 "위 내용 읽기"(뷰어의 터미널 모드)용.
681
731
  * capture-pane -S -N 으로 N 줄 위부터 현재까지 텍스트를 통째로 반환(읽기 전용).
682
732
  */
683
733
  export async function getScrollback(runId: number, lines: number): Promise<{ ok: boolean; text: string }> {
package/src/server.ts CHANGED
@@ -19,7 +19,7 @@ import { db } from './db';
19
19
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
20
20
  import { BOOKMARKLET_JS } from './design';
21
21
  import { runShellOn, shq } from './exec';
22
- 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, verifyRun, openSessionAt, getScrollback } from './orchestrator';
22
+ 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, verifyRun, openSessionAt, getScrollback, getSessionChat } from './orchestrator';
23
23
  import { openTerm } from './term';
24
24
  import { addSink, removeSink, broadcast } from './hub';
25
25
  import { getProvider, listProviders } from './providers';
@@ -35,6 +35,7 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
35
35
  'xterm.css': { pkg: '@xterm/xterm/package.json', rel: 'css/xterm.css', type: 'text/css' },
36
36
  'addon-fit.js': { pkg: '@xterm/addon-fit/package.json', rel: 'lib/addon-fit.js', type: 'text/javascript' },
37
37
  'addon-unicode11.js': { pkg: '@xterm/addon-unicode11/package.json', rel: 'lib/addon-unicode11.js', type: 'text/javascript' },
38
+ 'addon-web-links.js': { pkg: '@xterm/addon-web-links/package.json', rel: 'lib/addon-web-links.js', type: 'text/javascript' },
38
39
  };
39
40
 
40
41
  // ─── 읽기 전용 공유 페이지 (서버 렌더 스냅샷 — 스크립트 0, 액션 0) ───────────
@@ -1004,6 +1005,14 @@ export async function buildServer(): Promise<FastifyInstance> {
1004
1005
  return reply.code(202).send(res);
1005
1006
  });
1006
1007
 
1008
+ // 세션 대화 로그(Claude Code JSONL) — 뷰어의 "대화" 모드. 읽기 전용.
1009
+ app.get('/api/runs/:id/chat', async (req, reply) => {
1010
+ const id = Number((req.params as { id: string }).id);
1011
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1012
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1013
+ return await getSessionChat(id);
1014
+ });
1015
+
1007
1016
  // tmux 스크롤백 스냅샷 — 모바일 히스토리 오버레이(위 내용 읽기)용. 읽기 전용.
1008
1017
  app.get('/api/runs/:id/scrollback', async (req, reply) => {
1009
1018
  const id = Number((req.params as { id: string }).id);