coxpit 5.14.4 → 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 +2 -1
- package/src/cockpit.ts +89 -7
- package/src/orchestrator.ts +64 -0
- package/src/server.ts +21 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
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
|
@@ -249,6 +249,23 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
249
249
|
.tkey{font-family:var(--mono);font-size:13px;color:var(--muted);background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:8px 11px;cursor:pointer;flex:0 0 auto;min-width:42px}
|
|
250
250
|
.tkey:active{color:var(--ink);border-color:var(--brand);background:var(--brand-dim)}
|
|
251
251
|
.tsend{font-family:var(--mono);font-size:13px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:9px 14px;cursor:pointer;flex:0 0 auto}
|
|
252
|
+
.tkey.scroll{color:var(--brand);border-color:rgba(78,201,176,.4)}
|
|
253
|
+
/* 히스토리 오버레이(읽기 전용, 자유 스크롤) — xterm·마우스모드 우회 */
|
|
254
|
+
.hist-refresh{margin-left:auto;background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:13px;padding:2px 8px;cursor:pointer;font-family:var(--mono)}
|
|
255
|
+
.hist-refresh:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
256
|
+
.hist-body{flex:1;margin:0;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;
|
|
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}
|
|
252
269
|
/* 터치 기기(아이패드 포함, 화면폭 무관): 입력바 노출 + 요청바/분할 숨김(하단 클러터·잘림 방지) */
|
|
253
270
|
body.touch .term-ibar{display:flex}
|
|
254
271
|
body.touch .reqbar{display:none}
|
|
@@ -289,7 +306,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
289
306
|
</div>
|
|
290
307
|
<div class="right">
|
|
291
308
|
<span class="ws" id="ws"><span class="dot"></span><span id="wstext" class="b-txt">connecting</span></span>
|
|
292
|
-
<button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입"
|
|
309
|
+
<button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">∗<span class="b-txt"> Secrets</span></button>
|
|
293
310
|
<a class="toggle" href="/" title="보드(모니터) 뷰로">←<span class="b-txt"> Board</span></a>
|
|
294
311
|
</div>
|
|
295
312
|
</header>
|
|
@@ -324,6 +341,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
324
341
|
</div>
|
|
325
342
|
<div class="term-ibar" id="termIbar">
|
|
326
343
|
<div class="tkeys">
|
|
344
|
+
<button type="button" class="tkey scroll" id="histBtn" title="뷰어 — 대화/터미널로 위 내용 보기(읽기 전용)">뷰어</button>
|
|
345
|
+
<button type="button" class="tkey scroll" data-k="copymode" title="터미널 안에서 스크롤 — tmux copy-mode 진입(⇞/↑ 로 위로, esc 로 나가기)">⇡ 스크롤</button>
|
|
327
346
|
<button type="button" class="tkey" data-k="esc" title="Esc">esc</button>
|
|
328
347
|
<button type="button" class="tkey" data-k="tab" title="Tab">tab</button>
|
|
329
348
|
<button type="button" class="tkey" data-k="enter" title="Enter">⏎</button>
|
|
@@ -331,6 +350,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
331
350
|
<button type="button" class="tkey" data-k="up" title="↑">↑</button>
|
|
332
351
|
<button type="button" class="tkey" data-k="down" title="↓">↓</button>
|
|
333
352
|
<button type="button" class="tkey" data-k="right" title="→">→</button>
|
|
353
|
+
<button type="button" class="tkey" data-k="pgup" title="Page Up">⇞</button>
|
|
354
|
+
<button type="button" class="tkey" data-k="pgdn" title="Page Down">⇟</button>
|
|
334
355
|
<button type="button" class="tkey" data-k="cc" title="Ctrl-C">^C</button>
|
|
335
356
|
<button type="button" class="tkey" data-k="cd" title="Ctrl-D">^D</button>
|
|
336
357
|
<button type="button" class="tkey" data-k="cr" title="Ctrl-R (검색)">^R</button>
|
|
@@ -392,7 +413,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
392
413
|
|
|
393
414
|
<div class="modal" id="secretsModal">
|
|
394
415
|
<div class="pick" style="width:min(520px,92vw)">
|
|
395
|
-
<div class="pick-h"><span class="t"
|
|
416
|
+
<div class="pick-h"><span class="t">시크릿 (env 주입)</span><button class="x" id="secretsClose" title="닫기">×</button></div>
|
|
396
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>
|
|
397
418
|
<div class="pick-list" id="secretsList"></div>
|
|
398
419
|
<div class="pick-f" style="gap:7px">
|
|
@@ -403,11 +424,26 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
403
424
|
</div>
|
|
404
425
|
</div>
|
|
405
426
|
|
|
427
|
+
<div class="modal" id="histModal">
|
|
428
|
+
<div class="pick" style="width:min(680px,96vw);max-height:88vh">
|
|
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>
|
|
434
|
+
<button type="button" class="hist-refresh" id="histRefresh" title="지금 시점으로 다시 불러오기">↻</button>
|
|
435
|
+
<button class="x" id="histClose" title="닫기">×</button></div>
|
|
436
|
+
<div class="hist-chat" id="histChat"></div>
|
|
437
|
+
<pre class="hist-body" id="histBody" style="display:none">불러오는 중…</pre>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
406
441
|
<div class="toast" id="toast"></div>
|
|
407
442
|
|
|
408
443
|
<script src="/vendor/xterm.js"></script>
|
|
409
444
|
<script src="/vendor/addon-fit.js"></script>
|
|
410
445
|
<script src="/vendor/addon-unicode11.js"></script>
|
|
446
|
+
<script src="/vendor/addon-web-links.js"></script>
|
|
411
447
|
<script>
|
|
412
448
|
// 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
|
|
413
449
|
// (이전엔 보드로 리다이렉트했지만, 이제 cockpit 을 모바일 대응)
|
|
@@ -416,7 +452,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
416
452
|
var $ = function(id){ return document.getElementById(id); };
|
|
417
453
|
|
|
418
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); }
|
|
419
|
-
var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'
|
|
455
|
+
var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'! verify' };
|
|
420
456
|
function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
|
|
421
457
|
|
|
422
458
|
// ── fleet 상태 ──
|
|
@@ -571,6 +607,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
571
607
|
});
|
|
572
608
|
var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
|
|
573
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){}
|
|
574
612
|
// term.open 은 host 가 DOM 에 붙은 뒤(attachHosts) 최초 1회 — detached 에서 open 하면 렌더러가 안 뜬다.
|
|
575
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 };
|
|
576
614
|
term.onData(function(d){ if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i',d:d})); });
|
|
@@ -624,7 +662,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
624
662
|
? '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
|
|
625
663
|
+ '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
|
|
626
664
|
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
627
|
-
+ '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)"
|
|
665
|
+
+ '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">⊟</button>'
|
|
628
666
|
+ '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>'
|
|
629
667
|
: '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
|
|
630
668
|
var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
|
|
@@ -821,8 +859,52 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
821
859
|
$('termSend').addEventListener('click', termSendLine);
|
|
822
860
|
$('termInput').addEventListener('keydown', function(e){ if(e.isComposing) return; if(e.key==='Enter'){ e.preventDefault(); termSendLine(); } });
|
|
823
861
|
var TKEYS={ esc:'\\x1b', tab:'\\t', enter:'\\r', cc:'\\x03', cd:'\\x04', cr:'\\x12', cu:'\\x15',
|
|
824
|
-
up:'\\x1b[A', down:'\\x1b[B', right:'\\x1b[C', left:'\\x1b[D'
|
|
825
|
-
|
|
862
|
+
up:'\\x1b[A', down:'\\x1b[B', right:'\\x1b[C', left:'\\x1b[D', pgup:'\\x1b[5~', pgdn:'\\x1b[6~',
|
|
863
|
+
copymode:'\\x02[' }; // Ctrl-b [ = tmux copy-mode 진입(위 내용 스크롤; esc/q 로 나감)
|
|
864
|
+
Array.prototype.forEach.call(document.querySelectorAll('#termIbar .tkey'), function(b){ b.addEventListener('click', function(){
|
|
865
|
+
var k=TKEYS[b.dataset.k]; if(!k) return;
|
|
866
|
+
termSendRaw(k);
|
|
867
|
+
if (b.dataset.k==='copymode') toast('copy-mode — ⇞/↑ 로 위로 스크롤, esc 로 나가기');
|
|
868
|
+
else { var inp=$('termInput'); if(inp) inp.focus(); }
|
|
869
|
+
}); });
|
|
870
|
+
|
|
871
|
+
// ── 뷰어 — 위 내용을 (대화) Claude Code 로그 / (터미널) 스크롤백 으로 읽기(읽기 전용) ──
|
|
872
|
+
var histMode='chat';
|
|
873
|
+
function openHistory(){ var rid=focusedRunId(); if(rid==null){ toast('포커스한 세션이 없습니다'); return; } $('histModal').classList.add('on'); loadHist(); }
|
|
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
|
+
}
|
|
902
|
+
$('histBtn').addEventListener('click', openHistory);
|
|
903
|
+
$('histRefresh').addEventListener('click', loadHist);
|
|
904
|
+
$('histClose').addEventListener('click', closeHistory);
|
|
905
|
+
$('hmChat').addEventListener('click', function(){ setHistMode('chat'); });
|
|
906
|
+
$('hmRaw').addEventListener('click', function(){ setHistMode('raw'); });
|
|
907
|
+
$('histModal').addEventListener('click', function(e){ if(e.target===this) closeHistory(); });
|
|
826
908
|
// 터치 기기(아이패드 포함) 판별 → body.touch (화면폭 무관하게 입력바 노출)
|
|
827
909
|
if (window.matchMedia('(pointer:coarse)').matches || (navigator.maxTouchPoints||0) > 0) document.body.classList.add('touch');
|
|
828
910
|
|
|
@@ -906,7 +988,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
906
988
|
var done=false;
|
|
907
989
|
function finish(send){ if(done) return; done=true;
|
|
908
990
|
if(send){ try{ t.ws.send(JSON.stringify({t:'i',d:input.value+'\\r'})); }catch(e){} toast('페인에 전송(엔터 포함)'); }
|
|
909
|
-
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='⊟';
|
|
910
992
|
try{ input.replaceWith(nb); }catch(e){}
|
|
911
993
|
}
|
|
912
994
|
input.addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); finish(true); } else if(e.key==='Escape'){ finish(false); } });
|
package/src/orchestrator.ts
CHANGED
|
@@ -676,6 +676,70 @@ export async function getRunTermInfo(runId: number): Promise<{ machine: MachineT
|
|
|
676
676
|
return { machine: ctx.machine, session: run.tmuxWindow };
|
|
677
677
|
}
|
|
678
678
|
|
|
679
|
+
/**
|
|
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 페인 스크롤백 스냅샷 — 모바일 "위 내용 읽기"(뷰어의 터미널 모드)용.
|
|
731
|
+
* capture-pane -S -N 으로 N 줄 위부터 현재까지 텍스트를 통째로 반환(읽기 전용).
|
|
732
|
+
*/
|
|
733
|
+
export async function getScrollback(runId: number, lines: number): Promise<{ ok: boolean; text: string }> {
|
|
734
|
+
const info = await getRunTermInfo(runId);
|
|
735
|
+
if (!info) return { ok: false, text: 'no terminal session' };
|
|
736
|
+
const n = Math.max(50, Math.min(20000, Math.floor(lines) || 3000));
|
|
737
|
+
// capture-pane 은 '=' 접두사(정확일치) 를 pane 타깃으로 못 받는다 → 세션명 그대로(존재 시 정확일치 우선).
|
|
738
|
+
const r = await runShellOn(info.machine, `tmux capture-pane -t ${shq(info.session)} -p -S -${n}`, 15000);
|
|
739
|
+
if (!r.ok) return { ok: false, text: (r.stderr || r.stdout).trim().slice(0, 500) };
|
|
740
|
+
return { ok: true, text: r.stdout };
|
|
741
|
+
}
|
|
742
|
+
|
|
679
743
|
/**
|
|
680
744
|
* 실행 중 run 중지 — 자식 프로세스 SIGTERM. close 핸들러가 status='stopped' 로 봉인.
|
|
681
745
|
*/
|
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 } 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,25 @@ 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
|
+
|
|
1016
|
+
// tmux 스크롤백 스냅샷 — 모바일 히스토리 오버레이(위 내용 읽기)용. 읽기 전용.
|
|
1017
|
+
app.get('/api/runs/:id/scrollback', async (req, reply) => {
|
|
1018
|
+
const id = Number((req.params as { id: string }).id);
|
|
1019
|
+
const q = (req.query ?? {}) as { lines?: string };
|
|
1020
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1021
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1022
|
+
const res = await getScrollback(id, Number(q.lines) || 3000);
|
|
1023
|
+
if (!res.ok) return reply.code(422).send(res);
|
|
1024
|
+
return res;
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1007
1027
|
// 수동 재검증 — repo.verifyCmd 를 이 run 의 worktree 에서 다시 실행(정착 자동검증과 동일 경로).
|
|
1008
1028
|
app.post('/api/runs/:id/verify', async (req, reply) => {
|
|
1009
1029
|
const id = Number((req.params as { id: string }).id);
|