coxpit 3.1.0 → 3.2.1

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.1",
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; }
package/src/config.ts CHANGED
@@ -14,8 +14,20 @@ import 'dotenv/config';
14
14
  process.env.PATH = cur.join(':');
15
15
  }
16
16
 
17
+ // launchd/systemd 데몬은 LANG 미설정(C 로케일)로 뜬다 — 그 상태로 tmux 에 attach 하면
18
+ // tmux 가 클라이언트를 UTF-8 불가로 판단해 CJK(한글 등)를 '_'/8진수로 뭉갠다.
19
+ // UTF-8 로케일을 부팅 시 1회 보장한다 (COXPIT_LANG 으로 재정의 가능).
20
+ {
21
+ const want = process.env.COXPIT_LANG
22
+ ?? (/utf-?8/i.test(process.env.LANG ?? '') ? process.env.LANG! : 'en_US.UTF-8');
23
+ process.env.LANG = want;
24
+ if (process.env.LC_ALL && !/utf-?8/i.test(process.env.LC_ALL)) delete process.env.LC_ALL;
25
+ }
26
+
17
27
  /** 런타임 설정. 시크릿은 전부 env 주입(번들 0). */
18
28
  export const config = {
29
+ // UTF-8 보장된 로케일 — PTY/원격 셸에 명시 전달용
30
+ lang: process.env.LANG!,
19
31
  host: process.env.COXPIT_HOST ?? '127.0.0.1',
20
32
  port: Number(process.env.COXPIT_PORT ?? 8210),
21
33
  dbPath: process.env.COXPIT_DB ?? './coxpit.db',
@@ -122,8 +122,10 @@ 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
+ // export LANG: 이 명령이 tmux 서버를 처음 띄우는 경우(특히 원격) C 로케일로 뜨면 CJK 깨진다.
127
+ await runShellOn(ctx.machine,
128
+ `export LANG=${shq(config.lang)}; 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
129
 
128
130
  await setRun(runId, { status: 'running' });
129
131
  await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
@@ -443,7 +445,10 @@ export async function openWorkbench(repoId: number, title: string): Promise<{
443
445
 
444
446
  const prep = await runShellOn(
445
447
  machine,
446
- `mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
448
+ // 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 '=' 정확 일치만.
449
+ // export LANG: tmux 서버 첫 기동이 C 로케일이면 세션 셸의 CJK 입력·표시가 깨진다.
450
+ `export LANG=${shq(config.lang)}; mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
451
+ ` && { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
447
452
  ` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
448
453
  20000,
449
454
  );
@@ -719,7 +724,7 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
719
724
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
720
725
  const run = rr[0];
721
726
  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);
727
+ await runShellOn(ctx.machine, `tmux kill-session -t ${shq(`=coxpit-r${runId}`)} 2>/dev/null || true`, 8000);
723
728
  const rm = await runShellOn(
724
729
  ctx.machine,
725
730
  `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.1' }));
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.1' }));
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, `export LANG=${shq(config.lang)}; 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
@@ -41,10 +41,11 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
41
41
  name: 'xterm-256color',
42
42
  cols: Math.max(20, Math.min(500, cols || 80)),
43
43
  rows: Math.max(5, Math.min(200, rows || 24)),
44
- env: { ...process.env, TERM: 'xterm-256color' } as Record<string, string>,
44
+ // LANG: C 로케일 클라이언트로 attach 하면 tmux 가 CJK 를 '_' 뭉갠다 (config 에서 UTF-8 보장)
45
+ env: { ...process.env, TERM: 'xterm-256color', LANG: config.lang } as Record<string, string>,
45
46
  };
46
47
  if (isLocal(m)) {
47
- return pty().spawn('tmux', ['attach-session', '-t', session], opts);
48
+ return pty().spawn('tmux', ['attach-session', '-t', '=' + session], opts);
48
49
  }
49
50
  const args: string[] = [
50
51
  '-tt',
@@ -55,6 +56,7 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
55
56
  if (config.sshKey) args.push('-i', config.sshKey);
56
57
  const target = m.sshUser ? `${m.sshUser}@${m.address}` : m.address;
57
58
  // 세션명은 우리가 만든 coxpit-rN 형식이라 셸 주입 여지 없음 — 그래도 인용.
58
- args.push(target, `tmux attach-session -t '${session.replace(/'/g, "'\\''")}'`);
59
+ // 원격도 UTF-8 로케일 명시 (비대화 ssh 는 LANG 미설정이 보통)
60
+ args.push(target, `export LANG='${config.lang.replace(/'/g, '')}'; tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`);
59
61
  return pty().spawn('ssh', args, opts);
60
62
  }