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 +32 -7
- package/src/board.ts +76 -20
- package/src/orchestrator.ts +6 -3
- package/src/server.ts +38 -10
- package/src/term.ts +2 -2
package/package.json
CHANGED
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "3.
|
|
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": {
|
|
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": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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:
|
|
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
|
-
|
|
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; }
|
package/src/orchestrator.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
// 세션
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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,
|
|
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
|
-
|
|
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', () => {
|
|
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 '
|
|
58
|
+
args.push(target, `tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`);
|
|
59
59
|
return pty().spawn('ssh', args, opts);
|
|
60
60
|
}
|