coxpit 3.1.0 → 3.2.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,16 +1,40 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "3.1.0",
3
+ "version": "3.2.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",
7
- "repository": { "type": "git", "url": "git+https://github.com/hanmariyang/coxpit-oss.git" },
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/hanmariyang/coxpit-oss.git"
10
+ },
8
11
  "homepage": "https://hanmariyang.github.io/coxpit-oss/",
9
- "bugs": { "url": "https://github.com/hanmariyang/coxpit-oss/issues" },
10
- "keywords": ["ai-agents", "claude-code", "git-worktree", "tmux", "self-hosted", "fleet", "coding-agents", "cockpit"],
11
- "engines": { "node": ">=20" },
12
- "bin": { "coxpit": "bin/coxpit.js" },
13
- "files": ["bin", "src", "README.md", "LICENSE", ".env.example"],
12
+ "bugs": {
13
+ "url": "https://github.com/hanmariyang/coxpit-oss/issues"
14
+ },
15
+ "keywords": [
16
+ "ai-agents",
17
+ "claude-code",
18
+ "git-worktree",
19
+ "tmux",
20
+ "self-hosted",
21
+ "fleet",
22
+ "coding-agents",
23
+ "cockpit"
24
+ ],
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "bin": {
29
+ "coxpit": "bin/coxpit.js"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "src",
34
+ "README.md",
35
+ "LICENSE",
36
+ ".env.example"
37
+ ],
14
38
  "scripts": {
15
39
  "dev": "tsx watch src/index.ts",
16
40
  "start": "tsx src/index.ts",
@@ -21,6 +45,7 @@
21
45
  "@fastify/websocket": "^11",
22
46
  "@libsql/client": "^0.14",
23
47
  "@xterm/addon-fit": "^0.11.0",
48
+ "@xterm/addon-unicode11": "^0.9.0",
24
49
  "@xterm/xterm": "^6.0.0",
25
50
  "dotenv": "^16",
26
51
  "drizzle-orm": "^0.38",
package/src/board.ts CHANGED
@@ -229,7 +229,17 @@ export const BOARD_HTML = /* html */ `<!doctype html>
229
229
  .modal{width:min(1020px,100%);max-height:92vh;background:var(--surface);border:1px solid var(--line);
230
230
  border-radius:14px;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--shadow)}
231
231
  .modal.wide{width:min(1460px,100%)}
232
- .modal.term{width:min(1200px,100%);height:min(760px,92vh)}
232
+ /* terminal = 전면 뷰 (모달 아님) — 개인 coxpit 터미널 페이지 패리티 */
233
+ #termOverlay.open{padding:0}
234
+ .modal.term{width:100%;height:100%;max-height:none;border:none;border-radius:0}
235
+ .modal.term .modal-h{padding:9px 14px;background:var(--surface2)}
236
+ .modal.term .modal-h .title{flex:0 1 auto;max-width:240px}
237
+ .term-tabs{display:flex;gap:6px;overflow-x:auto;flex:1;min-width:0;scrollbar-width:none}
238
+ .term-tabs::-webkit-scrollbar{display:none}
239
+ .ttab{font-family:var(--mono);font-size:11px;color:var(--muted);background:var(--surface);
240
+ border:1px solid var(--line);border-radius:6px;padding:3px 10px;cursor:pointer;white-space:nowrap;flex:0 0 auto}
241
+ .ttab:hover{color:var(--ink)}
242
+ .ttab.on{color:var(--ink);border-color:var(--brand);background:rgba(78,201,176,.08)}
233
243
  .modal-h{display:flex;align-items:center;gap:10px;padding:13px 18px;border-bottom:1px solid var(--line)}
234
244
  .modal-h .rid{font-family:var(--mono);font-size:12px;color:var(--faint)}
235
245
  .modal-h .title{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -428,7 +438,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
428
438
  <div class="modal-h">
429
439
  <span class="rid" id="termRid"></span>
430
440
  <span class="title" id="termTitle">terminal</span>
431
- <span class="term-hint">tmux session · Ctrl-b d detaches</span>
441
+ <div class="term-tabs" id="termTabs"></div>
442
+ <span class="term-hint">tmux session · Ctrl-b d detaches · Esc closes</span>
432
443
  <button class="x" id="termClose" aria-label="close">×</button>
433
444
  </div>
434
445
  <div class="term-body"><div id="xterm"></div></div>
@@ -487,6 +498,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
487
498
 
488
499
  <script src="/vendor/xterm.js"></script>
489
500
  <script src="/vendor/addon-fit.js"></script>
501
+ <script src="/vendor/addon-unicode11.js"></script>
490
502
  <script>
491
503
  const runs = new Map(); // runId -> run object
492
504
  const tasks = new Map(); // taskId -> task
@@ -686,6 +698,7 @@ function render(){
686
698
  $('empty').style.display = list.length ? 'none' : 'flex';
687
699
  if (!list.length) paintOnboarding();
688
700
  $('grid').innerHTML = list.map(cardHTML).join('');
701
+ if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
689
702
  }
690
703
 
691
704
  /* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
@@ -1124,38 +1137,79 @@ $('mCompare').addEventListener('click', ()=>{
1124
1137
  closeModal(); openCompare(r.taskId);
1125
1138
  });
1126
1139
 
1127
- /* ── terminal ── */
1140
+ /* ── terminal — 초기크기 접속·자동 재연결(백오프)·unicode11·CJK 폰트 ── */
1128
1141
  let termWS = null, termObj = null, fitAddon = null, termResizeObs = null;
1142
+ let termRunId = null, termClosing = false, termRetry = 0, termRetryTimer = null;
1143
+ function termConnect(){
1144
+ if (termRunId==null || termClosing || !termObj) return;
1145
+ const proto = location.protocol==='https:'?'wss':'ws';
1146
+ termWS = new WebSocket(proto+'://'+location.host+'/ws/term/'+termRunId
1147
+ +'?cols='+termObj.cols+'&rows='+termObj.rows);
1148
+ termWS.onopen = ()=>{
1149
+ termRetry = 0;
1150
+ $('termTitle').textContent = ((runs.get(termRunId)||{}).tmuxWindow||'terminal');
1151
+ termObj.focus();
1152
+ };
1153
+ termWS.onmessage = (m)=>{
1154
+ try{
1155
+ const d = JSON.parse(m.data);
1156
+ if (d.t==='o') termObj.write(d.d);
1157
+ else if (d.t==='err') termObj.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
1158
+ else if (d.t==='exit') termObj.write('\\r\\n\\x1b[90m[session ended — reconnecting will revive it]\\x1b[0m\\r\\n');
1159
+ }catch{}
1160
+ };
1161
+ termWS.onclose = ()=>{
1162
+ if (termClosing || termRunId==null) return;
1163
+ // 예기치 않은 끊김 — 백오프 재연결(서버가 죽은 세션도 소생시킴)
1164
+ const delay = Math.min(8000, 800 * Math.pow(2, termRetry++));
1165
+ $('termTitle').textContent = 'reconnecting…';
1166
+ termRetryTimer = setTimeout(termConnect, delay);
1167
+ };
1168
+ }
1169
+ /* 세션 탭 — 살아있는 세션(running·open) 사이를 터미널 안에서 바로 전환 */
1170
+ function termTabsRender(){
1171
+ const el = $('termTabs');
1172
+ const list = [...runs.values()].filter(r=>r.status==='running'||r.status==='open').sort((a,b)=>a.id-b.id);
1173
+ el.innerHTML = list.map(r=>{
1174
+ const kind = r.agent==='workbench' ? 'bench' : (r.agent||'agent');
1175
+ return '<button class="ttab'+(r.id===termRunId?' on':'')+'" data-id="'+r.id+'">r'+r.id+' · '+esc(kind)+'</button>';
1176
+ }).join('');
1177
+ el.querySelectorAll('.ttab').forEach(b=>b.addEventListener('click',()=>termSwitch(+b.dataset.id)));
1178
+ }
1179
+ function termSwitch(id){
1180
+ if (id===termRunId || !termObj) return;
1181
+ if (termRetryTimer){ clearTimeout(termRetryTimer); termRetryTimer=null; }
1182
+ if (termWS){ termClosing=true; try{ termWS.close(); }catch{} termWS=null; }
1183
+ termClosing = false; termRetry = 0; termRunId = id;
1184
+ $('termRid').textContent = 'r'+id;
1185
+ $('termTitle').textContent = ((runs.get(id)||{}).tmuxWindow||'terminal');
1186
+ termObj.reset();
1187
+ termTabsRender();
1188
+ termConnect();
1189
+ }
1129
1190
  function openTerm(runId){
1130
1191
  const r = runs.get(runId); if(!r) return;
1192
+ termRunId = runId; termClosing = false; termRetry = 0;
1131
1193
  $('termRid').textContent = 'r'+runId;
1132
1194
  $('termTitle').textContent = (r.tmuxWindow||'terminal');
1195
+ termTabsRender();
1133
1196
  $('termOverlay').classList.add('open');
1134
1197
  const el = $('xterm'); el.innerHTML = '';
1135
1198
  termObj = new window.Terminal({
1136
- fontFamily: 'ui-monospace, SF Mono, Menlo, Consolas, monospace',
1137
- fontSize: 12.5, cursorBlink: true,
1199
+ fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', monospace",
1200
+ fontSize: 12.5, cursorBlink: true, allowProposedApi: true,
1138
1201
  theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0',
1139
1202
  selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
1140
1203
  });
1141
1204
  fitAddon = new window.FitAddon.FitAddon();
1142
1205
  termObj.loadAddon(fitAddon);
1206
+ try{ // 이모지·CJK 폭 보정 — TUI(claude) 줄 밀림 방지
1207
+ termObj.loadAddon(new window.Unicode11Addon.Unicode11Addon());
1208
+ termObj.unicode.activeVersion = '11';
1209
+ }catch{}
1143
1210
  termObj.open(el);
1144
- fitAddon.fit();
1145
- const proto = location.protocol==='https:'?'wss':'ws';
1146
- termWS = new WebSocket(proto+'://'+location.host+'/ws/term/'+runId);
1147
- termWS.onopen = ()=>{
1148
- termWS.send(JSON.stringify({t:'r',cols:termObj.cols,rows:termObj.rows}));
1149
- termObj.focus();
1150
- };
1151
- termWS.onmessage = (m)=>{
1152
- try{
1153
- const d = JSON.parse(m.data);
1154
- if (d.t==='o') termObj.write(d.d);
1155
- else if (d.t==='err') termObj.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
1156
- else if (d.t==='exit') termObj.write('\\r\\n\\x1b[90m[detached]\\x1b[0m\\r\\n');
1157
- }catch{}
1158
- };
1211
+ fitAddon.fit(); // 접속 전에 크기 확정 → 80x24 경유 없이 바로 정사이즈 attach
1212
+ termConnect();
1159
1213
  termObj.onData((d)=>{ if(termWS && termWS.readyState===1) termWS.send(JSON.stringify({t:'i',d})); });
1160
1214
  termResizeObs = new ResizeObserver(()=>{
1161
1215
  if (!fitAddon || !termObj) return;
@@ -1165,6 +1219,8 @@ function openTerm(runId){
1165
1219
  termResizeObs.observe(el);
1166
1220
  }
1167
1221
  function closeTerm(){
1222
+ termClosing = true; termRunId = null;
1223
+ if (termRetryTimer){ clearTimeout(termRetryTimer); termRetryTimer=null; }
1168
1224
  $('termOverlay').classList.remove('open');
1169
1225
  if (termResizeObs){ termResizeObs.disconnect(); termResizeObs=null; }
1170
1226
  if (termWS){ try{ termWS.close(); }catch{} termWS=null; }
@@ -122,8 +122,9 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
122
122
  return;
123
123
  }
124
124
 
125
- // 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort
126
- await runShellOn(ctx.machine, `tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
125
+ // 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort. 동명 잔재는 선제 정리('=' 정확 일치)
126
+ await runShellOn(ctx.machine,
127
+ `tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
127
128
 
128
129
  await setRun(runId, { status: 'running' });
129
130
  await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
@@ -443,7 +444,9 @@ export async function openWorkbench(repoId: number, title: string): Promise<{
443
444
 
444
445
  const prep = await runShellOn(
445
446
  machine,
447
+ // 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 — '=' 정확 일치만
446
448
  `mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
449
+ ` && { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
447
450
  ` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
448
451
  20000,
449
452
  );
@@ -719,7 +722,7 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
719
722
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
720
723
  const run = rr[0];
721
724
  if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
722
- await runShellOn(ctx.machine, `tmux kill-session -t ${shq(`coxpit-r${runId}`)} 2>/dev/null || true`, 8000);
725
+ await runShellOn(ctx.machine, `tmux kill-session -t ${shq(`=coxpit-r${runId}`)} 2>/dev/null || true`, 8000);
723
726
  const rm = await runShellOn(
724
727
  ctx.machine,
725
728
  `git -C ${shq(ctx.repoPath)} worktree remove --force ${shq(run.worktreePath)} 2>&1` +
package/src/server.ts CHANGED
@@ -24,6 +24,7 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
24
24
  'xterm.js': { pkg: '@xterm/xterm/package.json', rel: 'lib/xterm.js', type: 'text/javascript' },
25
25
  'xterm.css': { pkg: '@xterm/xterm/package.json', rel: 'css/xterm.css', type: 'text/css' },
26
26
  'addon-fit.js': { pkg: '@xterm/addon-fit/package.json', rel: 'lib/addon-fit.js', type: 'text/javascript' },
27
+ 'addon-unicode11.js': { pkg: '@xterm/addon-unicode11/package.json', rel: 'lib/addon-unicode11.js', type: 'text/javascript' },
27
28
  };
28
29
 
29
30
  export async function buildServer(): Promise<FastifyInstance> {
@@ -32,7 +33,7 @@ export async function buildServer(): Promise<FastifyInstance> {
32
33
  app.addHook('onRequest', authGate);
33
34
 
34
35
  // 무인증 헬스(외부 감시용)
35
- app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.1.0' }));
36
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.2.0' }));
36
37
 
37
38
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
39
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -501,7 +502,7 @@ export async function buildServer(): Promise<FastifyInstance> {
501
502
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
502
503
  app.get('/ws', { websocket: true }, (socket) => {
503
504
  addSink(socket);
504
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.1.0' }));
505
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.2.0' }));
505
506
  socket.on('close', () => removeSink(socket));
506
507
  });
507
508
 
@@ -517,30 +518,54 @@ export async function buildServer(): Promise<FastifyInstance> {
517
518
 
518
519
  // run 터미널 — tmux 세션에 PTY attach, WS 로 중계.
519
520
  // client → {t:'i',d:string} 입력 · {t:'r',cols,rows} 리사이즈 / server → {t:'o',d} 출력 · {t:'exit'}
521
+ // 하드닝: ?cols&rows 초기 크기(80x24 경유 제거) · 세션 자동 소생 · keepalive · 백프레셔.
520
522
  app.get('/ws/term/:id', { websocket: true }, async (socket, req) => {
521
523
  const id = Number((req.params as { id: string }).id);
524
+ const q = (req.query ?? {}) as { cols?: string; rows?: string };
525
+ const cols = Math.max(20, Math.min(500, Number(q.cols) || 80));
526
+ const rows = Math.max(5, Math.min(200, Number(q.rows) || 24));
522
527
  const info = await getRunTermInfo(id);
523
528
  if (!info) {
524
529
  socket.send(JSON.stringify({ t: 'err', d: 'run or tmux session not found' }));
525
530
  socket.close();
526
531
  return;
527
532
  }
528
- // 세션 생존 확인(정리됐거나 머신 재부팅이면 attach바로 죽는다)
529
- const has = await runShellOn(info.machine, `tmux has-session -t ${shq(info.session)} 2>&1`, 8000);
533
+ // 세션 자동 소생 exit 등으로 죽었어도 worktree 살아있으면 그 자리에서 재생성
534
+ const has = await runShellOn(info.machine, `tmux has-session -t ${shq('=' + info.session)} 2>&1`, 8000);
530
535
  if (!has.ok) {
531
- socket.send(JSON.stringify({ t: 'err', d: `tmux session '${info.session}' not available` }));
532
- socket.close();
533
- return;
536
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
537
+ const wt = rr[0]?.worktreePath ?? '';
538
+ const revive = wt
539
+ ? await runShellOn(info.machine, `test -d ${shq(wt)} && tmux new-session -d -s ${shq(info.session)} -c ${shq(wt)}`, 10000)
540
+ : { ok: false } as { ok: boolean };
541
+ if (!revive.ok) {
542
+ socket.send(JSON.stringify({ t: 'err', d: `tmux session '${info.session}' gone and could not be revived (worktree missing?)` }));
543
+ socket.close();
544
+ return;
545
+ }
546
+ socket.send(JSON.stringify({ t: 'o', d: '\r\n\x1b[90m[coxpit] session revived in the worktree\x1b[0m\r\n' }));
534
547
  }
535
548
  let term;
536
549
  try {
537
- term = openTerm(info.machine, info.session, 80, 24);
550
+ term = openTerm(info.machine, info.session, cols, rows);
538
551
  } catch (e) {
539
552
  socket.send(JSON.stringify({ t: 'err', d: 'pty spawn failed: ' + String(e).slice(0, 200) }));
540
553
  socket.close();
541
554
  return;
542
555
  }
543
- term.onData((d) => { try { socket.send(JSON.stringify({ t: 'o', d })); } catch { /* closed */ } });
556
+ // 백프레셔 WS 송신 버퍼가 차면 pty 잠시 멈춰 폭주 방지
557
+ let paused = false;
558
+ term.onData((d) => {
559
+ try {
560
+ socket.send(JSON.stringify({ t: 'o', d }));
561
+ if (!paused && socket.bufferedAmount > 800_000) { paused = true; try { term.pause(); } catch { /* n/a */ } }
562
+ } catch { /* closed */ }
563
+ });
564
+ const drain = setInterval(() => {
565
+ if (paused && socket.bufferedAmount < 100_000) { paused = false; try { term.resume(); } catch { /* n/a */ } }
566
+ }, 200);
567
+ const keepalive = setInterval(() => { try { socket.ping(); } catch { /* closed */ } }, 30_000);
568
+
544
569
  term.onExit(() => { try { socket.send(JSON.stringify({ t: 'exit' })); socket.close(); } catch { /* closed */ } });
545
570
  socket.on('message', (raw: Buffer) => {
546
571
  try {
@@ -549,7 +574,10 @@ export async function buildServer(): Promise<FastifyInstance> {
549
574
  else if (m.t === 'r' && m.cols && m.rows) term.resize(Math.max(20, Math.min(500, m.cols)), Math.max(5, Math.min(200, m.rows)));
550
575
  } catch { /* ignore */ }
551
576
  });
552
- socket.on('close', () => { try { term.kill(); } catch { /* gone */ } });
577
+ socket.on('close', () => {
578
+ clearInterval(drain); clearInterval(keepalive);
579
+ try { term.kill(); } catch { /* gone */ }
580
+ });
553
581
  });
554
582
 
555
583
  return app;
package/src/term.ts CHANGED
@@ -44,7 +44,7 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
44
44
  env: { ...process.env, TERM: 'xterm-256color' } as Record<string, string>,
45
45
  };
46
46
  if (isLocal(m)) {
47
- return pty().spawn('tmux', ['attach-session', '-t', session], opts);
47
+ return pty().spawn('tmux', ['attach-session', '-t', '=' + session], opts);
48
48
  }
49
49
  const args: string[] = [
50
50
  '-tt',
@@ -55,6 +55,6 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
55
55
  if (config.sshKey) args.push('-i', config.sshKey);
56
56
  const target = m.sshUser ? `${m.sshUser}@${m.address}` : m.address;
57
57
  // 세션명은 우리가 만든 coxpit-rN 형식이라 셸 주입 여지 없음 — 그래도 인용.
58
- args.push(target, `tmux attach-session -t '${session.replace(/'/g, "'\\''")}'`);
58
+ args.push(target, `tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`);
59
59
  return pty().spawn('ssh', args, opts);
60
60
  }