coxpit 5.14.4 → 5.15.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.14.4",
3
+ "version": "5.15.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",
package/src/cockpit.ts CHANGED
@@ -249,6 +249,12 @@ 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)}
252
258
  /* 터치 기기(아이패드 포함, 화면폭 무관): 입력바 노출 + 요청바/분할 숨김(하단 클러터·잘림 방지) */
253
259
  body.touch .term-ibar{display:flex}
254
260
  body.touch .reqbar{display:none}
@@ -324,6 +330,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
324
330
  </div>
325
331
  <div class="term-ibar" id="termIbar">
326
332
  <div class="tkeys">
333
+ <button type="button" class="tkey scroll" id="histBtn" title="위 내용 읽기 — 스크롤백을 스냅샷으로(읽기 전용)">📜 위로</button>
334
+ <button type="button" class="tkey scroll" data-k="copymode" title="터미널 안에서 스크롤 — tmux copy-mode 진입(⇞/↑ 로 위로, esc 로 나가기)">⇡ 스크롤</button>
327
335
  <button type="button" class="tkey" data-k="esc" title="Esc">esc</button>
328
336
  <button type="button" class="tkey" data-k="tab" title="Tab">tab</button>
329
337
  <button type="button" class="tkey" data-k="enter" title="Enter">⏎</button>
@@ -331,6 +339,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
331
339
  <button type="button" class="tkey" data-k="up" title="↑">↑</button>
332
340
  <button type="button" class="tkey" data-k="down" title="↓">↓</button>
333
341
  <button type="button" class="tkey" data-k="right" title="→">→</button>
342
+ <button type="button" class="tkey" data-k="pgup" title="Page Up">⇞</button>
343
+ <button type="button" class="tkey" data-k="pgdn" title="Page Down">⇟</button>
334
344
  <button type="button" class="tkey" data-k="cc" title="Ctrl-C">^C</button>
335
345
  <button type="button" class="tkey" data-k="cd" title="Ctrl-D">^D</button>
336
346
  <button type="button" class="tkey" data-k="cr" title="Ctrl-R (검색)">^R</button>
@@ -403,6 +413,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
403
413
  </div>
404
414
  </div>
405
415
 
416
+ <div class="modal" id="histModal">
417
+ <div class="pick" style="width:min(680px,96vw);max-height:88vh">
418
+ <div class="pick-h"><span class="t">📜 <span id="histTitle">터미널 히스토리</span></span>
419
+ <button type="button" class="hist-refresh" id="histRefresh" title="지금 시점으로 다시 불러오기">↻</button>
420
+ <button class="x" id="histClose" title="닫기">×</button></div>
421
+ <pre class="hist-body" id="histBody">불러오는 중…</pre>
422
+ </div>
423
+ </div>
424
+
406
425
  <div class="toast" id="toast"></div>
407
426
 
408
427
  <script src="/vendor/xterm.js"></script>
@@ -821,8 +840,32 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
821
840
  $('termSend').addEventListener('click', termSendLine);
822
841
  $('termInput').addEventListener('keydown', function(e){ if(e.isComposing) return; if(e.key==='Enter'){ e.preventDefault(); termSendLine(); } });
823
842
  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
- Array.prototype.forEach.call(document.querySelectorAll('#termIbar .tkey'), function(b){ b.addEventListener('click', function(){ var k=TKEYS[b.dataset.k]; if(k){ termSendRaw(k); var inp=$('termInput'); if(inp) inp.focus(); } }); });
843
+ up:'\\x1b[A', down:'\\x1b[B', right:'\\x1b[C', left:'\\x1b[D', pgup:'\\x1b[5~', pgdn:'\\x1b[6~',
844
+ copymode:'\\x02[' }; // Ctrl-b [ = tmux copy-mode 진입( 내용 스크롤; esc/q 나감)
845
+ Array.prototype.forEach.call(document.querySelectorAll('#termIbar .tkey'), function(b){ b.addEventListener('click', function(){
846
+ var k=TKEYS[b.dataset.k]; if(!k) return;
847
+ termSendRaw(k);
848
+ if (b.dataset.k==='copymode') toast('copy-mode — ⇞/↑ 로 위로 스크롤, esc 로 나가기');
849
+ else { var inp=$('termInput'); if(inp) inp.focus(); }
850
+ }); });
851
+
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
+ }
864
+ function closeHistory(){ $('histModal').classList.remove('on'); }
865
+ $('histBtn').addEventListener('click', openHistory);
866
+ $('histRefresh').addEventListener('click', openHistory);
867
+ $('histClose').addEventListener('click', closeHistory);
868
+ $('histModal').addEventListener('click', function(e){ if(e.target===this) closeHistory(); });
826
869
  // 터치 기기(아이패드 포함) 판별 → body.touch (화면폭 무관하게 입력바 노출)
827
870
  if (window.matchMedia('(pointer:coarse)').matches || (navigator.maxTouchPoints||0) > 0) document.body.classList.add('touch');
828
871
 
@@ -676,6 +676,20 @@ export async function getRunTermInfo(runId: number): Promise<{ machine: MachineT
676
676
  return { machine: ctx.machine, session: run.tmuxWindow };
677
677
  }
678
678
 
679
+ /**
680
+ * tmux 페인 스크롤백 스냅샷 — 모바일 "위 내용 읽기"(히스토리 오버레이)용.
681
+ * capture-pane -S -N 으로 N 줄 위부터 현재까지 텍스트를 통째로 반환(읽기 전용).
682
+ */
683
+ export async function getScrollback(runId: number, lines: number): Promise<{ ok: boolean; text: string }> {
684
+ const info = await getRunTermInfo(runId);
685
+ if (!info) return { ok: false, text: 'no terminal session' };
686
+ const n = Math.max(50, Math.min(20000, Math.floor(lines) || 3000));
687
+ // capture-pane 은 '=' 접두사(정확일치) 를 pane 타깃으로 못 받는다 → 세션명 그대로(존재 시 정확일치 우선).
688
+ const r = await runShellOn(info.machine, `tmux capture-pane -t ${shq(info.session)} -p -S -${n}`, 15000);
689
+ if (!r.ok) return { ok: false, text: (r.stderr || r.stdout).trim().slice(0, 500) };
690
+ return { ok: true, text: r.stdout };
691
+ }
692
+
679
693
  /**
680
694
  * 실행 중 run 중지 — 자식 프로세스 SIGTERM. close 핸들러가 status='stopped' 로 봉인.
681
695
  */
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 } from './orchestrator';
23
23
  import { openTerm } from './term';
24
24
  import { addSink, removeSink, broadcast } from './hub';
25
25
  import { getProvider, listProviders } from './providers';
@@ -1004,6 +1004,17 @@ export async function buildServer(): Promise<FastifyInstance> {
1004
1004
  return reply.code(202).send(res);
1005
1005
  });
1006
1006
 
1007
+ // tmux 스크롤백 스냅샷 — 모바일 히스토리 오버레이(위 내용 읽기)용. 읽기 전용.
1008
+ app.get('/api/runs/:id/scrollback', async (req, reply) => {
1009
+ const id = Number((req.params as { id: string }).id);
1010
+ const q = (req.query ?? {}) as { lines?: string };
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
+ const res = await getScrollback(id, Number(q.lines) || 3000);
1014
+ if (!res.ok) return reply.code(422).send(res);
1015
+ return res;
1016
+ });
1017
+
1007
1018
  // 수동 재검증 — repo.verifyCmd 를 이 run 의 worktree 에서 다시 실행(정착 자동검증과 동일 경로).
1008
1019
  app.post('/api/runs/:id/verify', async (req, reply) => {
1009
1020
  const id = Number((req.params as { id: string }).id);