@triflux/core 10.53.0 → 10.55.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.
Files changed (47) hide show
  1. package/hooks/codex-session-hook.mjs +1 -17
  2. package/hub/cli-adapter-base.mjs +3 -424
  3. package/hub/index.mjs +0 -5
  4. package/hub/lib/hosts-compat.mjs +18 -64
  5. package/hub/lib/private-tmp.mjs +35 -0
  6. package/hub/lib/process-utils.mjs +19 -220
  7. package/hub/lib/prompt-tmp.mjs +6 -24
  8. package/hub/lib/spawn-trace.mjs +112 -5
  9. package/hub/lib/timeout-defaults.mjs +34 -6
  10. package/hub/lib/worker-lifecycle.mjs +3 -0
  11. package/hub/platform.mjs +3 -1
  12. package/hub/team/agent-map.json +29 -0
  13. package/hub/team/intervention.mjs +6 -4
  14. package/hub/team/nativeProxy.mjs +2 -48
  15. package/hub/team/pane.mjs +329 -0
  16. package/hub/team/psmux.mjs +1765 -0
  17. package/hub/team/retry-state-machine.mjs +1 -1
  18. package/hub/team/session.mjs +541 -0
  19. package/hud/constants.mjs +0 -3
  20. package/hud/providers/claude.mjs +35 -15
  21. package/hud/providers/codex.mjs +169 -42
  22. package/hud/utils.mjs +0 -23
  23. package/package.json +2 -2
  24. package/scripts/lib/backup-rotation.mjs +60 -0
  25. package/scripts/lib/codex-profile-config.mjs +20 -7
  26. package/scripts/lib/codex-recovery.sh +2 -3
  27. package/scripts/lib/install-blob-history.json +3196 -0
  28. package/scripts/lib/install-retire.mjs +203 -0
  29. package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
  30. package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
  31. package/scripts/lib/machine-profile.mjs +2 -2
  32. package/scripts/lib/mcp-filter.mjs +1 -42
  33. package/scripts/lib/mcp-guard-engine.mjs +198 -19
  34. package/scripts/lib/mcp-manifest.mjs +0 -40
  35. package/scripts/lib/process-utils.mjs +19 -0
  36. package/scripts/lib/stable-node.mjs +35 -0
  37. package/scripts/lib/test-env.mjs +20 -0
  38. package/scripts/lib/tmr-install.mjs +183 -0
  39. package/hooks/agy-session-hook.mjs +0 -155
  40. package/hooks/claude-cwd-projection-refresh.mjs +0 -11
  41. package/hooks/hook-orchestrator.mjs +0 -11
  42. package/hooks/lib/resolve-root.mjs +0 -62
  43. package/hub/codex-adapter.mjs +0 -193
  44. package/hub/codex-preflight.mjs +0 -219
  45. package/hub/lib/cto-env.mjs +0 -70
  46. package/scripts/lib/remote-spawn-transfer.mjs +0 -226
  47. package/scripts/lib/session-presence.mjs +0 -151
@@ -0,0 +1,1765 @@
1
+ // hub/team/psmux.mjs — terminal multiplexer 세션/키바인딩/캡처/steering 관리
2
+ //
3
+ // 역사: psmux 는 Windows pwsh7 환경용 tmux 호환 클론 (Windows 에서 tmux 가
4
+ // 깔리지 않거나 ConPTY 호환이 부족할 때 대안). triflux 는 원래 Windows 첫
5
+ // 운영 환경이었고 그래서 변수 명에 `PSMUX_` prefix 가 남아있다. 실제 의미는
6
+ // "OS 별 primary multiplexer" 다:
7
+ // - mac/Linux: tmux 가 primary (POSIX 표준)
8
+ // - Windows: psmux 가 primary (Windows pwsh7 전용)
9
+ // 이전 코드는 mac 에서 psmux 를 시도하고 tmux 로 silent fallback 했는데,
10
+ // 운영 fact 는 mac 표준이 tmux 라 fallback 이 아닌 정식 primary 다.
11
+ // (PSMUX_BIN 변수 명은 backward-compat 위해 유지)
12
+ //
13
+ // 의존성: child_process, fs, os, path (Node.js 내장)만 사용
14
+
15
+ import {
16
+ chmodSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
25
+ import { resolveGitBashExecutable } from "../lib/bash-path.mjs";
26
+ import { privateTmpDir } from "../lib/private-tmp.mjs";
27
+ import childProcess from "../lib/spawn-trace.mjs";
28
+ import { IS_WINDOWS } from "../platform.mjs";
29
+
30
+ // OS 별 primary multiplexer 를 명시적으로 결정한다.
31
+ // - PSMUX_BIN env 가 있으면 user override (가장 우선)
32
+ // - Windows: psmux 가 primary. PATH + 기본 설치 경로에서 탐색.
33
+ // - mac/Linux: tmux 가 primary. 탐색 없이 PATH 의 tmux 를 쓴다.
34
+ // silent fallback 은 두지 않는다. primary 가 없으면 의도된 fail.
35
+ const PSMUX_BIN = (() => {
36
+ if (process.env.PSMUX_BIN) return process.env.PSMUX_BIN;
37
+
38
+ if (IS_WINDOWS) {
39
+ // Windows primary: psmux
40
+ try {
41
+ childProcess.execFileSync("psmux", ["-V"], {
42
+ stdio: "ignore",
43
+ timeout: 2000,
44
+ windowsHide: true,
45
+ });
46
+ return "psmux";
47
+ } catch {
48
+ /* not in PATH */
49
+ }
50
+ const candidates = [
51
+ join(process.env.LOCALAPPDATA || "", "psmux", "psmux.exe"),
52
+ join(process.env.APPDATA || "", "npm", "psmux.cmd"),
53
+ join(homedir(), "AppData", "Local", "psmux", "psmux.exe"),
54
+ join(homedir(), "scoop", "shims", "psmux.exe"),
55
+ ];
56
+ for (const p of candidates) {
57
+ if (existsSync(p)) return p;
58
+ }
59
+ return "psmux"; // 최종 — hasPsmux/hasMultiplexer 가 검증
60
+ }
61
+
62
+ // mac/Linux primary: tmux
63
+ return "tmux";
64
+ })();
65
+
66
+ /** Windows psmux 세션의 기본 셸을 PowerShell로 강제한다 (pwsh7 우선, ps5 fallback). */
67
+ const PWSH_BIN = (() => {
68
+ if (!IS_WINDOWS) return "";
69
+ if (process.env.PSMUX_SHELL) return process.env.PSMUX_SHELL;
70
+ // pwsh 7 우선
71
+ try {
72
+ childProcess.execFileSync(
73
+ "pwsh",
74
+ ["-NoLogo", "-NoProfile", "-Command", "exit 0"],
75
+ { stdio: "ignore", timeout: 3000, windowsHide: true },
76
+ );
77
+ return "pwsh";
78
+ } catch {
79
+ /* not found */
80
+ }
81
+ // powershell 5 fallback
82
+ try {
83
+ childProcess.execFileSync(
84
+ "powershell.exe",
85
+ ["-NoLogo", "-NoProfile", "-Command", "exit 0"],
86
+ { stdio: "ignore", timeout: 3000, windowsHide: true },
87
+ );
88
+ return "powershell.exe";
89
+ } catch {
90
+ /* not found */
91
+ }
92
+ return ""; // 둘 다 없으면 psmux 기본 셸 사용
93
+ })();
94
+ const PSMUX_TIMEOUT_MS = 10000;
95
+ const COMPLETION_PREFIX = "__TRIFLUX_DONE__:";
96
+ // 실행되는 캡처 스크립트가 있어 남이 바꿀 수 없는 사용자별 0700 디렉터리에 둔다.
97
+ function captureRoot() {
98
+ return process.env.PSMUX_CAPTURE_ROOT || privateTmpDir("psmux-steering");
99
+ }
100
+ const POLL_INTERVAL_MS = (() => {
101
+ const ms = Number.parseInt(process.env.PSMUX_POLL_INTERVAL_MS || "", 10);
102
+ if (Number.isFinite(ms) && ms > 0) return ms;
103
+ const sec = Number.parseFloat(process.env.PSMUX_POLL_INTERVAL_SEC || "1");
104
+ return Number.isFinite(sec) && sec > 0
105
+ ? Math.max(100, Math.trunc(sec * 1000))
106
+ : 1000;
107
+ })();
108
+ const PSMUX_SESSION_LIST_FORMAT =
109
+ "#{session_name}\t#{session_created}\t#{session_activity}\t#{session_attached}";
110
+
111
+ function quoteArg(value) {
112
+ const str = String(value);
113
+ if (!/[\s"]/u.test(str)) return str;
114
+ return `"${str.replace(/"/g, '\\"')}"`;
115
+ }
116
+
117
+ function sanitizePathPart(value) {
118
+ return String(value).replace(/[<>:"/\\|?*\u0000-\u001f']/gu, "_");
119
+ }
120
+
121
+ // session names retain regex metacharacters (`.`, `-`, `+`, …) even after
122
+ // sanitizePathPart, so escape before embedding into a pgrep regex.
123
+ export function escapeRegex(str) {
124
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
125
+ }
126
+
127
+ function toPaneTitle(index) {
128
+ return index === 0 ? "lead" : `worker-${index}`;
129
+ }
130
+
131
+ function sleepMs(ms) {
132
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
133
+ }
134
+
135
+ function sleepMsAsync(ms) {
136
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
137
+ }
138
+
139
+ export function tokenizeCommand(command) {
140
+ const source = String(command || "").trim();
141
+ if (!source) return [];
142
+
143
+ const tokens = [];
144
+ let current = "";
145
+ let quote = null;
146
+
147
+ const pushCurrent = () => {
148
+ if (current.length > 0) {
149
+ tokens.push(current);
150
+ current = "";
151
+ }
152
+ };
153
+
154
+ for (let index = 0; index < source.length; index += 1) {
155
+ const char = source[index];
156
+ const next = source[index + 1];
157
+
158
+ if (quote === "'") {
159
+ if (char === "'") {
160
+ quote = null;
161
+ } else {
162
+ current += char;
163
+ }
164
+ continue;
165
+ }
166
+
167
+ if (quote === '"') {
168
+ if (char === '"') {
169
+ quote = null;
170
+ continue;
171
+ }
172
+ if (char === "\\" && (next === '"' || next === "\\")) {
173
+ current += next;
174
+ index += 1;
175
+ continue;
176
+ }
177
+ current += char;
178
+ continue;
179
+ }
180
+
181
+ if (char === "'" || char === '"') {
182
+ quote = char;
183
+ continue;
184
+ }
185
+
186
+ if (char === "\\" && next && /[\s"'\\;]/u.test(next)) {
187
+ current += next;
188
+ index += 1;
189
+ continue;
190
+ }
191
+
192
+ if (/\s/u.test(char)) {
193
+ pushCurrent();
194
+ continue;
195
+ }
196
+
197
+ current += char;
198
+ }
199
+
200
+ if (quote) {
201
+ throw new Error(`psmux 인자 파싱 실패: 닫히지 않은 인용부호 (${command})`);
202
+ }
203
+
204
+ pushCurrent();
205
+ return tokens;
206
+ }
207
+
208
+ function normalizePsmuxArgs(args) {
209
+ if (Array.isArray(args)) {
210
+ return args.map((arg) => String(arg));
211
+ }
212
+ return tokenizeCommand(args);
213
+ }
214
+
215
+ function randomToken(prefix) {
216
+ const base = sanitizePathPart(prefix).replace(/_+/g, "-") || "pane";
217
+ const entropy = Math.random().toString(36).slice(2, 10);
218
+ return `${base}-${Date.now()}-${entropy}`;
219
+ }
220
+
221
+ function ensurePsmuxInstalled() {
222
+ if (!hasMultiplexer()) {
223
+ const mux = IS_WINDOWS ? "psmux" : "tmux";
224
+ throw new Error(
225
+ `${mux}가 설치되어 있지 않습니다.\n\n` +
226
+ "설치 방법 (택 1):\n" +
227
+ `${formatPsmuxInstallGuidance(" ")}\n\n` +
228
+ "설치 후 터미널을 재시작하세요.",
229
+ );
230
+ }
231
+ }
232
+
233
+ function getCaptureSessionDir(sessionName) {
234
+ return join(captureRoot(), sanitizePathPart(sessionName));
235
+ }
236
+
237
+ function getCaptureLogPath(sessionName, paneName) {
238
+ return join(
239
+ getCaptureSessionDir(sessionName),
240
+ `${sanitizePathPart(paneName)}.log`,
241
+ );
242
+ }
243
+
244
+ function ensureCaptureHelper() {
245
+ const root = captureRoot();
246
+ mkdirSync(root, { recursive: true });
247
+ const helperPath = join(
248
+ root,
249
+ IS_WINDOWS ? "pipe-pane-capture.ps1" : "pipe-pane-capture.sh",
250
+ );
251
+ if (IS_WINDOWS) {
252
+ writeFileSync(
253
+ helperPath,
254
+ [
255
+ "param(",
256
+ " [Parameter(Mandatory = $true)][string]$Path",
257
+ ")",
258
+ "",
259
+ "$parent = Split-Path -Parent $Path",
260
+ "if ($parent) {",
261
+ " New-Item -ItemType Directory -Force -Path $parent | Out-Null",
262
+ "}",
263
+ "",
264
+ "# Force UTF-8 encoding — CP949 등 non-UTF-8 codepage에서 Gemini stdout 캡처 실패 방지",
265
+ "# InputEncoding: pipe-pane에서 읽어들이는 바이트 해석",
266
+ "# OutputEncoding: 네이티브 명령(gemini/codex CLI)의 stdout 디코딩",
267
+ "# $OutputEncoding: PowerShell 파이프라인 기본 인코딩 (BOM 없는 UTF-8)",
268
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
269
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
270
+ "$OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
271
+ "",
272
+ "$reader = [Console]::In",
273
+ "while (($line = $reader.ReadLine()) -ne $null) {",
274
+ " Add-Content -LiteralPath $Path -Value $line -Encoding utf8",
275
+ "}",
276
+ "",
277
+ ].join("\n"),
278
+ "utf8",
279
+ );
280
+ } else {
281
+ // macOS/Linux: bash 스크립트로 pipe-pane 캡처
282
+ writeFileSync(
283
+ helperPath,
284
+ [
285
+ "#!/bin/bash",
286
+ 'mkdir -p "$(dirname "$1")" 2>/dev/null',
287
+ 'exec tee -a "$1"',
288
+ "",
289
+ ].join("\n"),
290
+ "utf8",
291
+ );
292
+ chmodSync(helperPath, 0o755);
293
+ }
294
+ return helperPath;
295
+ }
296
+
297
+ function readCaptureLog(logPath) {
298
+ return existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
299
+ }
300
+
301
+ function parsePaneList(output) {
302
+ return output
303
+ .split("\n")
304
+ .map((line) => line.trim())
305
+ .filter(Boolean)
306
+ .map((line) => {
307
+ const [indexText, target] = line.split("\t");
308
+ return {
309
+ index: parseInt(indexText, 10),
310
+ target: target?.trim() || "",
311
+ };
312
+ })
313
+ .filter((entry) => Number.isFinite(entry.index) && entry.target)
314
+ .sort((a, b) => a.index - b.index)
315
+ .map((entry) => entry.target);
316
+ }
317
+
318
+ function parsePsmuxEpoch(value) {
319
+ const raw = String(value ?? "").trim();
320
+ if (!raw) return null;
321
+ const numeric = Number.parseInt(raw, 10);
322
+ if (!Number.isFinite(numeric) || numeric <= 0) return null;
323
+ return numeric >= 1_000_000_000_000 ? numeric : numeric * 1000;
324
+ }
325
+
326
+ function normalizeOlderThanMs(value, fallback = 0) {
327
+ if (!Number.isFinite(value)) return fallback;
328
+ return Math.max(0, Math.trunc(value));
329
+ }
330
+
331
+ function toTitleMatcher(pattern) {
332
+ if (!pattern) return () => true;
333
+ if (pattern instanceof RegExp) return (title) => pattern.test(title);
334
+
335
+ const raw = String(pattern).trim();
336
+ if (!raw) return () => true;
337
+ const regexMatch = raw.match(/^\/(.+)\/([a-z]*)$/i);
338
+ if (regexMatch) {
339
+ const [, source, flags] = regexMatch;
340
+ const regex = new RegExp(source, flags);
341
+ return (title) => regex.test(title);
342
+ }
343
+
344
+ return (title) => String(title).startsWith(raw);
345
+ }
346
+
347
+ function parseLegacySessionInventory(output) {
348
+ return output
349
+ .split("\n")
350
+ .map((line) => line.trim())
351
+ .filter(Boolean)
352
+ .map((line) => {
353
+ const colonIndex = line.indexOf(":");
354
+ if (colonIndex === -1) return null;
355
+
356
+ const sessionName = line.slice(0, colonIndex).trim();
357
+ const flags = [...line.matchAll(/\(([^)]*)\)/g)]
358
+ .map((match) => match[1])
359
+ .join(", ");
360
+ const attachedMatch = flags.match(/(\d+)\s+attached/);
361
+ const attachedCount = attachedMatch
362
+ ? Number.parseInt(attachedMatch[1], 10)
363
+ : /\battached\b/.test(flags)
364
+ ? 1
365
+ : 0;
366
+
367
+ if (!sessionName) return null;
368
+ return Object.freeze({
369
+ sessionName,
370
+ title: sessionName,
371
+ createdAt: null,
372
+ lastActivityAt: null,
373
+ attachedCount: Number.isFinite(attachedCount) ? attachedCount : 0,
374
+ isAttached: Number.isFinite(attachedCount) ? attachedCount > 0 : false,
375
+ ageMs: null,
376
+ idleMs: null,
377
+ });
378
+ })
379
+ .filter(Boolean);
380
+ }
381
+
382
+ function parseSessionInventory(output, now = Date.now()) {
383
+ return output
384
+ .split("\n")
385
+ .map((line) => line.trim())
386
+ .filter(Boolean)
387
+ .map((line) => {
388
+ const [
389
+ sessionName = "",
390
+ createdText = "",
391
+ activityText = "",
392
+ attached = "0",
393
+ ] = line.split("\t");
394
+ const createdAt = parsePsmuxEpoch(createdText);
395
+ const lastActivityAt = parsePsmuxEpoch(activityText) ?? createdAt;
396
+ const attachedCount = Number.parseInt(attached, 10);
397
+ const ageMs = createdAt == null ? null : Math.max(0, now - createdAt);
398
+ const idleMs =
399
+ lastActivityAt == null ? ageMs : Math.max(0, now - lastActivityAt);
400
+
401
+ if (!sessionName) return null;
402
+ return Object.freeze({
403
+ sessionName,
404
+ title: sessionName,
405
+ createdAt,
406
+ lastActivityAt,
407
+ attachedCount: Number.isFinite(attachedCount) ? attachedCount : 0,
408
+ isAttached: Number.isFinite(attachedCount) ? attachedCount > 0 : false,
409
+ ageMs,
410
+ idleMs,
411
+ });
412
+ })
413
+ .filter(Boolean);
414
+ }
415
+
416
+ function listSessionInventoryRaw() {
417
+ const output = psmuxExec(["list-sessions", "-F", PSMUX_SESSION_LIST_FORMAT]);
418
+ const hasStructuredRows = output
419
+ .split("\n")
420
+ .some((line) => line.trim().includes("\t"));
421
+ return hasStructuredRows
422
+ ? parseSessionInventory(output)
423
+ : parseLegacySessionInventory(output);
424
+ }
425
+
426
+ function parsePaneDetails(output) {
427
+ return output
428
+ .split("\n")
429
+ .map((line) => line.trim())
430
+ .filter(Boolean)
431
+ .map((line) => {
432
+ const parts = line.split("\t");
433
+ // tmux는 마지막 필드가 빈 문자열이면 trailing tab을 생략할 수 있음 (4개 필드)
434
+ // psmux는 항상 5개 필드를 반환
435
+ // paneId 패턴(session:N.N)이 parts[2]에 있으면 pane_index 포함 형식
436
+ const hasPaneIndex = parts.length >= 4 && /\S+:\d+\.\d+$/.test(parts[2]);
437
+ const [
438
+ paneIndexText = "",
439
+ title = "",
440
+ paneId = "",
441
+ dead = "0",
442
+ deadStatus = "",
443
+ ] = hasPaneIndex ? parts : ["", ...parts];
444
+ const exitCode = dead === "1" ? Number.parseInt(deadStatus, 10) : null;
445
+ const paneIndex = Number.parseInt(paneIndexText, 10);
446
+ return {
447
+ title,
448
+ paneId,
449
+ paneIndex: Number.isFinite(paneIndex) ? paneIndex : null,
450
+ isDead: dead === "1",
451
+ exitCode: Number.isFinite(exitCode)
452
+ ? exitCode
453
+ : dead === "1"
454
+ ? 0
455
+ : null,
456
+ };
457
+ })
458
+ .filter((entry) => entry.paneId);
459
+ }
460
+
461
+ // tmux 는 -t 세션 이름을 접두사로도 매칭해, 대상이 이미 없으면 이름이 이어지는
462
+ // 다른 세션을 잡는다(#548). psmux 는 정확히만 매칭하고 3.3.8 전에는 '=' 를 모른다.
463
+ function exactSessionTarget(sessionName, windowSuffix = "") {
464
+ return `${IS_WINDOWS ? "" : "="}${sessionName}${windowSuffix}`;
465
+ }
466
+
467
+ function collectSessionPanes(sessionName) {
468
+ const output = psmuxExec([
469
+ "list-panes",
470
+ "-t",
471
+ exactSessionTarget(sessionName, ":0"),
472
+ "-F",
473
+ "#{pane_index}\t#{session_name}:#{window_index}.#{pane_index}",
474
+ ]);
475
+ return parsePaneList(output);
476
+ }
477
+
478
+ function listPaneDetails(sessionName) {
479
+ const output = psmuxExec([
480
+ "list-panes",
481
+ "-t",
482
+ sessionName,
483
+ "-F",
484
+ "#{pane_index}\t#{pane_title}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_dead}\t#{pane_dead_status}",
485
+ ]);
486
+ return parsePaneDetails(output);
487
+ }
488
+
489
+ function paneTitleToIndex(name) {
490
+ const lower = String(name).toLowerCase();
491
+ if (lower === "lead") return 0;
492
+ const m = /^worker-(\d+)$/.exec(lower);
493
+ if (!m) return -1;
494
+ const idx = parseInt(m[1], 10);
495
+ // worker-0은 유효하지 않음 (lead와 충돌, toPaneTitle은 worker-0을 생성하지 않음)
496
+ return idx >= 1 ? idx : -1;
497
+ }
498
+
499
+ function resolvePane(sessionName, paneNameOrTarget) {
500
+ const wanted = String(paneNameOrTarget);
501
+ const panes = listPaneDetails(sessionName);
502
+
503
+ // 1차: title 또는 paneId 직접 매칭
504
+ const direct = panes.find(
505
+ (entry) => entry.title === wanted || entry.paneId === wanted,
506
+ );
507
+ if (direct) return direct;
508
+
509
+ // 2차: psmux title 미설정 fallback — "lead"→0, "worker-N"→N 인덱스 매칭
510
+ const idx = paneTitleToIndex(wanted);
511
+ if (idx >= 0 && idx < panes.length) return panes[idx];
512
+
513
+ throw new Error(`Pane을 찾을 수 없습니다: ${paneNameOrTarget}`);
514
+ }
515
+
516
+ function refreshCaptureSnapshot(sessionName, paneNameOrTarget) {
517
+ const pane = resolvePane(sessionName, paneNameOrTarget);
518
+ const paneName = pane.title || paneNameOrTarget;
519
+ const logPath = getCaptureLogPath(sessionName, paneName);
520
+ mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
521
+ const snapshot = psmuxExec([
522
+ "capture-pane",
523
+ "-t",
524
+ pane.paneId,
525
+ "-p",
526
+ "-S",
527
+ "-",
528
+ ]);
529
+ writeFileSync(logPath, snapshot, "utf8");
530
+ return { paneId: pane.paneId, paneName, logPath, snapshot };
531
+ }
532
+
533
+ function disablePipeCapture(paneId) {
534
+ try {
535
+ psmuxExec(["pipe-pane", "-t", paneId]);
536
+ } catch {
537
+ // 기존 pipe가 없으면 무시
538
+ }
539
+ }
540
+
541
+ export function sendKeysToPane(paneId, text, submit = true) {
542
+ psmuxExec(["send-keys", "-t", paneId, "-l", text]);
543
+ if (submit) {
544
+ psmuxExec(["send-keys", "-t", paneId, "Enter"]);
545
+ }
546
+ }
547
+
548
+ function toPatternRegExp(pattern) {
549
+ if (pattern instanceof RegExp) {
550
+ const flags = pattern.flags.includes("m")
551
+ ? pattern.flags
552
+ : `${pattern.flags}m`;
553
+ return new RegExp(pattern.source, flags);
554
+ }
555
+ return new RegExp(String(pattern), "m");
556
+ }
557
+
558
+ function psmux(args, opts = {}) {
559
+ const normalizedArgs = normalizePsmuxArgs(args);
560
+ // PSMUX_SESSION 제거 — 기존 psmux 세션 내에서 호출 시 중첩 세션 차단 방지
561
+ const { PSMUX_SESSION: _, ...cleanEnv } = process.env;
562
+ try {
563
+ const result = childProcess.execFileSync(PSMUX_BIN, normalizedArgs, {
564
+ encoding: "utf8",
565
+ timeout: PSMUX_TIMEOUT_MS,
566
+ stdio: ["pipe", "pipe", "pipe"],
567
+ windowsHide: true,
568
+ env: cleanEnv,
569
+ ...opts,
570
+ });
571
+ return result != null ? String(result).trim() : "";
572
+ } catch (error) {
573
+ const stderr =
574
+ typeof error?.stderr === "string"
575
+ ? error.stderr
576
+ : error?.stderr?.toString?.("utf8") || "";
577
+ const stdout =
578
+ typeof error?.stdout === "string"
579
+ ? error.stdout
580
+ : error?.stdout?.toString?.("utf8") || "";
581
+ const wrapped = new Error(
582
+ (stderr || stdout || error.message || "psmux command failed").trim(),
583
+ );
584
+ wrapped.status = error.status;
585
+ throw wrapped;
586
+ }
587
+ }
588
+
589
+ /** primary terminal multiplexer (Windows=psmux, mac/Linux=tmux) 실행 가능 여부 */
590
+ export function hasMultiplexer() {
591
+ try {
592
+ childProcess.execFileSync(PSMUX_BIN, ["-V"], {
593
+ stdio: "ignore",
594
+ timeout: 3000,
595
+ windowsHide: true,
596
+ });
597
+ return true;
598
+ } catch {
599
+ return false;
600
+ }
601
+ }
602
+
603
+ /**
604
+ * backward-compat alias — historical 이름 `hasPsmux` 는 OS 별 primary 의 의미를
605
+ * 가린다. 외부 caller (scripts/remote-spawn.mjs,
606
+ * tests) 호환을 위해 alias 로 보존. 신규 코드는 hasMultiplexer 사용.
607
+ */
608
+ export const hasPsmux = hasMultiplexer;
609
+
610
+ /**
611
+ * 현재 resolved multiplexer 종류 — "psmux" | "tmux" | <user override path>.
612
+ * isTmuxFallback() 의 후속 — mac/Linux 에서 tmux 는 fallback 이 아닌 primary 라
613
+ * "fallback" 표현이 부정확해서 type 으로 정정.
614
+ */
615
+ export function getMultiplexerType() {
616
+ return PSMUX_BIN;
617
+ }
618
+
619
+ /**
620
+ * psmux 커맨드 실행 래퍼
621
+ * @param {string|string[]} args
622
+ * @param {object} opts
623
+ * @returns {string}
624
+ */
625
+ export function psmuxExec(args, opts = {}) {
626
+ return psmux(args, opts);
627
+ }
628
+
629
+ /**
630
+ * psmux 세션 생성 + 레이아웃 분할
631
+ * @param {string} sessionName
632
+ * @param {object} opts
633
+ * @param {'2x2'|'1xN'|'Nx1'} opts.layout
634
+ * @param {number} opts.paneCount
635
+ * @returns {{ sessionName: string, panes: string[] }}
636
+ */
637
+ export function createPsmuxSession(sessionName, opts = {}) {
638
+ const layout =
639
+ opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
640
+ const paneCount = Math.max(
641
+ 1,
642
+ Number.isFinite(opts.paneCount) ? Math.trunc(opts.paneCount) : 4,
643
+ );
644
+ const limitedPaneCount =
645
+ layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
646
+ const sessionTarget = `${sessionName}:0`;
647
+
648
+ const newSessionArgs = [
649
+ "new-session",
650
+ "-d",
651
+ "-P",
652
+ "-F",
653
+ "#{session_name}:#{window_index}.#{pane_index}",
654
+ "-s",
655
+ sessionName,
656
+ "-x",
657
+ "220",
658
+ "-y",
659
+ "55",
660
+ ];
661
+ // Windows: psmux 기본 셸이 cmd.exe일 수 있으므로 PowerShell 강제
662
+ // macOS/Linux: 기본 셸 사용 (PowerShell 플래그 불필요)
663
+ if (PWSH_BIN && IS_WINDOWS)
664
+ newSessionArgs.push(PWSH_BIN, "-NoLogo", "-NoProfile");
665
+ const leadPane = psmuxExec(newSessionArgs);
666
+
667
+ try {
668
+ // split-window로 생성되는 pane도 동일 셸 사용 (Windows: PowerShell 강제)
669
+ if (PWSH_BIN && IS_WINDOWS) {
670
+ try {
671
+ psmuxExec([
672
+ "set-option",
673
+ "-t",
674
+ sessionName,
675
+ "default-command",
676
+ `${PWSH_BIN} -NoLogo -NoProfile`,
677
+ ]);
678
+ } catch {
679
+ /* 미지원 시 무시 */
680
+ }
681
+ }
682
+
683
+ if (layout === "2x2" && limitedPaneCount >= 3) {
684
+ const rightPane = psmuxExec([
685
+ "split-window",
686
+ "-h",
687
+ "-P",
688
+ "-F",
689
+ "#{session_name}:#{window_index}.#{pane_index}",
690
+ "-t",
691
+ leadPane,
692
+ ]);
693
+ psmuxExec([
694
+ "split-window",
695
+ "-v",
696
+ "-P",
697
+ "-F",
698
+ "#{session_name}:#{window_index}.#{pane_index}",
699
+ "-t",
700
+ rightPane,
701
+ ]);
702
+ if (limitedPaneCount >= 4) {
703
+ psmuxExec([
704
+ "split-window",
705
+ "-v",
706
+ "-P",
707
+ "-F",
708
+ "#{session_name}:#{window_index}.#{pane_index}",
709
+ "-t",
710
+ leadPane,
711
+ ]);
712
+ }
713
+ psmuxExec(["select-layout", "-t", sessionTarget, "tiled"]);
714
+ } else if (layout === "1xN") {
715
+ for (let index = 1; index < limitedPaneCount; index += 1) {
716
+ psmuxExec(["split-window", "-h", "-t", sessionTarget]);
717
+ }
718
+ psmuxExec(["select-layout", "-t", sessionTarget, "even-horizontal"]);
719
+ } else {
720
+ for (let index = 1; index < limitedPaneCount; index += 1) {
721
+ psmuxExec(["split-window", "-v", "-t", sessionTarget]);
722
+ }
723
+ psmuxExec(["select-layout", "-t", sessionTarget, "even-vertical"]);
724
+ }
725
+
726
+ psmuxExec(["select-pane", "-t", leadPane]);
727
+
728
+ const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
729
+ panes.forEach((pane, index) => {
730
+ try {
731
+ psmuxExec(["select-pane", "-t", pane, "-T", toPaneTitle(index)]);
732
+ } catch {
733
+ // tmux 2.6 미만: select-pane -T 미지원 — pane title 없이 계속 진행
734
+ }
735
+ });
736
+
737
+ // CP949 등 non-UTF-8 codepage 환경에서 CLI stdout 깨짐 방지:
738
+ // pane 생성 직후 UTF-8 인코딩을 강제 설정한다.
739
+ // chcp 65001: 프로세스 코드 페이지를 UTF-8로 전환
740
+ // OutputEncoding: 네이티브 명령(gemini/codex) stdout 디코딩
741
+ // $OutputEncoding: PowerShell 파이프라인 기본 인코딩
742
+ if (IS_WINDOWS) {
743
+ const encodingInit = [
744
+ "chcp 65001 > $null",
745
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
746
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
747
+ "$OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
748
+ ].join("; ");
749
+ panes.forEach((paneId) => {
750
+ sendKeysToPane(paneId, encodingInit, true);
751
+ });
752
+ }
753
+
754
+ return { sessionName, panes };
755
+ } catch (error) {
756
+ try {
757
+ killPsmuxSession(sessionName);
758
+ } catch {
759
+ /* rollback is best-effort; preserve the initialization error */
760
+ }
761
+ throw error;
762
+ }
763
+ }
764
+
765
+ /**
766
+ * psmux 세션의 모든 pane PID를 수집
767
+ * @param {string} sessionName
768
+ * @returns {number[]}
769
+ */
770
+ function collectPanePids(sessionName) {
771
+ try {
772
+ const output = psmuxExec([
773
+ "list-panes",
774
+ "-t",
775
+ exactSessionTarget(sessionName, ":"),
776
+ "-F",
777
+ "#{pane_pid}",
778
+ ]);
779
+ return output
780
+ .split(/\r?\n/)
781
+ .map((l) => Number.parseInt(l.trim(), 10))
782
+ .filter((pid) => Number.isFinite(pid) && pid > 0);
783
+ } catch {
784
+ return [];
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Windows 프로세스 트리 강제 종료 (taskkill /T /F)
790
+ * @param {number} pid
791
+ */
792
+ function killProcessTree(pid) {
793
+ if (!pid) return;
794
+ if (!IS_WINDOWS) {
795
+ // macOS/Linux: BFS로 전체 자손 수집 → SIGTERM → SIGKILL 에스컬레이션
796
+ const collectChildren = (parentPid) => {
797
+ try {
798
+ return childProcess
799
+ .execFileSync("pgrep", ["-P", String(parentPid)], {
800
+ encoding: "utf8",
801
+ timeout: 3000,
802
+ stdio: ["ignore", "pipe", "ignore"],
803
+ })
804
+ .trim()
805
+ .split("\n")
806
+ .filter(Boolean)
807
+ .map(Number);
808
+ } catch {
809
+ return [];
810
+ }
811
+ };
812
+ const allDesc = [];
813
+ const queue = [pid];
814
+ const seen = new Set([pid]);
815
+ while (queue.length > 0) {
816
+ const cur = queue.shift();
817
+ for (const child of collectChildren(cur)) {
818
+ if (!seen.has(child)) {
819
+ seen.add(child);
820
+ allDesc.push(child);
821
+ queue.push(child);
822
+ }
823
+ }
824
+ }
825
+ // SIGTERM 먼저
826
+ for (const c of allDesc) {
827
+ try {
828
+ process.kill(c, "SIGTERM");
829
+ } catch {}
830
+ }
831
+ try {
832
+ process.kill(pid, "SIGTERM");
833
+ } catch {}
834
+ // 1초 대기 후 생존자 SIGKILL
835
+ try {
836
+ childProcess.execFileSync("sleep", ["1"], {
837
+ timeout: 2000,
838
+ stdio: "ignore",
839
+ });
840
+ } catch {}
841
+ for (const c of allDesc) {
842
+ try {
843
+ process.kill(c, "SIGKILL");
844
+ } catch {}
845
+ }
846
+ try {
847
+ process.kill(pid, "SIGKILL");
848
+ } catch {}
849
+ return;
850
+ }
851
+ try {
852
+ childProcess.execSync(`taskkill /T /F /PID ${pid}`, {
853
+ stdio: "ignore",
854
+ timeout: 5000,
855
+ });
856
+ } catch {
857
+ // 이미 종료된 프로세스 — 무시
858
+ }
859
+ }
860
+
861
+ /**
862
+ * 세션의 모든 pane에서 pipe-pane 캡처를 해제한다.
863
+ * pipe-pane을 인자 없이 호출하면 psmux가 reader 프로세스에 EOF를 보내 정상 종료시킨다.
864
+ * @param {string} sessionName
865
+ * @param {string[]} paneIds — collectSessionPanes() 결과
866
+ */
867
+ function disableAllPipeCaptures(sessionName, paneIds) {
868
+ for (const paneId of paneIds) {
869
+ try {
870
+ psmuxExec(["pipe-pane", "-t", paneId]);
871
+ } catch {
872
+ // pane이 이미 죽었거나 pipe가 없으면 무시
873
+ }
874
+ }
875
+ }
876
+
877
+ /**
878
+ * 세션과 관련된 고아 pipe-pane 헬퍼 프로세스를 찾아 종료한다.
879
+ * pipe-pane disable 후에도 reader가 종료되지 않는 경우의 안전망.
880
+ * @param {string} sessionName
881
+ */
882
+ function killOrphanPipeHelpers(sessionName) {
883
+ if (!IS_WINDOWS) {
884
+ // macOS/Linux: pipe-pane helper cmdline contains
885
+ // `<captureRoot()>/<session>/<pane>.log`, so anchor on `/<session>/`.
886
+ const safeSessionUnix = escapeRegex(sanitizePathPart(sessionName));
887
+ try {
888
+ const pids = childProcess
889
+ .execFileSync(
890
+ "pgrep",
891
+ ["-f", `pipe-pane-capture.*/${safeSessionUnix}/`],
892
+ {
893
+ encoding: "utf8",
894
+ timeout: 5000,
895
+ stdio: ["ignore", "pipe", "ignore"],
896
+ },
897
+ )
898
+ .trim();
899
+ if (pids) {
900
+ for (const p of pids.split("\n").filter(Boolean)) {
901
+ try {
902
+ process.kill(Number(p), "SIGTERM");
903
+ } catch {}
904
+ }
905
+ }
906
+ } catch {
907
+ /* 프로세스 없으면 pgrep exit 1 — 무시 */
908
+ }
909
+ return;
910
+ }
911
+ // Windows: escape regex metacharacters and require a trailing path
912
+ // boundary so sibling sessions (e.g. `<session>2-...`) don't match.
913
+ const safeSession = escapeRegex(sanitizePathPart(sessionName));
914
+ try {
915
+ const output = childProcess.execSync(
916
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match 'tfx-headless[/\\\\]${safeSession}[-./\\\\]' } | Select-Object -ExpandProperty ProcessId"`,
917
+ {
918
+ encoding: "utf8",
919
+ timeout: 8000,
920
+ stdio: ["pipe", "pipe", "pipe"],
921
+ windowsHide: true,
922
+ },
923
+ );
924
+ const pids = output
925
+ .split(/\r?\n/)
926
+ .map((l) => Number.parseInt(l.trim(), 10))
927
+ .filter((p) => Number.isFinite(p) && p > 0);
928
+ for (const pid of pids) {
929
+ killProcessTree(pid);
930
+ }
931
+ } catch {
932
+ // WMI 조회 실패 — 무시
933
+ }
934
+ }
935
+
936
+ /**
937
+ * 세션이 spawn한 CLI(codex/gemini)의 고아 MCP 서버 프로세스를 찾아 종료한다.
938
+ * headless 워커가 codex/gemini를 실행하면 MCP 서버(node.exe)가 자식으로 생성되는데,
939
+ * 부모가 죽어도 Windows에서는 자식이 자동 종료되지 않아 고아가 된다.
940
+ * @param {string} sessionName
941
+ */
942
+ function killOrphanMcpProcesses(sessionName) {
943
+ if (!IS_WINDOWS) {
944
+ // macOS/Linux: 세션별 스코핑
945
+ const safeSessionUnix = sanitizePathPart(sessionName);
946
+ try {
947
+ // MCP/result files live under `tfx-headless/<session>-<pane>.txt`, so
948
+ // match `tfx-headless/<session>` with a trailing boundary to avoid
949
+ // killing sibling sessions whose names share a prefix
950
+ // (e.g. `<session>2-worker-1.txt`).
951
+ const escSession = escapeRegex(safeSessionUnix);
952
+ const pids = childProcess
953
+ .execFileSync("pgrep", ["-f", `tfx-headless/${escSession}[-./]`], {
954
+ encoding: "utf8",
955
+ timeout: 5000,
956
+ stdio: ["ignore", "pipe", "ignore"],
957
+ })
958
+ .trim();
959
+ if (pids) {
960
+ for (const p of pids.split("\n").filter(Boolean)) {
961
+ const numPid = Number(p);
962
+ if (numPid <= 0) continue;
963
+ try {
964
+ process.kill(numPid, "SIGTERM");
965
+ } catch {}
966
+ }
967
+ }
968
+ } catch {}
969
+ return;
970
+ }
971
+ // Windows: escape + trailing boundary (mirror of the macOS branch).
972
+ const safeSession = escapeRegex(sanitizePathPart(sessionName));
973
+
974
+ try {
975
+ // 세션 결과 디렉토리 패턴으로 MCP 서버 프로세스 식별
976
+ const output = childProcess.execSync(
977
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match 'tfx-headless[/\\\\]${safeSession}[-./\\\\]' } | Select-Object -ExpandProperty ProcessId"`,
978
+ {
979
+ encoding: "utf8",
980
+ timeout: 8000,
981
+ stdio: ["pipe", "pipe", "pipe"],
982
+ windowsHide: true,
983
+ },
984
+ );
985
+ const pids = output
986
+ .split(/\r?\n/)
987
+ .map((l) => Number.parseInt(l.trim(), 10))
988
+ .filter((p) => Number.isFinite(p) && p > 0);
989
+ for (const pid of pids) {
990
+ killProcessTree(pid);
991
+ }
992
+ } catch {
993
+ // WMI 조회 실패 — 무시
994
+ }
995
+ }
996
+
997
+ function detachAttachedClients(sessionName, waitMs = 750) {
998
+ const attachedCount = getPsmuxSessionAttachedCount(sessionName);
999
+ if (!Number.isFinite(attachedCount) || attachedCount <= 0) return false;
1000
+ try {
1001
+ psmuxExec(["detach-client", "-t", sessionName], { stdio: "ignore" });
1002
+ sleepMs(waitMs);
1003
+ return true;
1004
+ } catch {
1005
+ return false;
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * psmux 세션 종료
1011
+ * 순서: pipe-pane 해제 → pane 프로세스 트리 정리 → 세션 종료 → 고아 정리
1012
+ * @param {string} sessionName
1013
+ */
1014
+ export function killPsmuxSession(sessionName) {
1015
+ // attach된 WT/ConPTY 클라이언트가 있으면 먼저 안전하게 분리한다.
1016
+ detachAttachedClients(sessionName);
1017
+
1018
+ // 1. pipe-pane 캡처 해제 — reader 프로세스에 EOF 전송하여 정상 종료 유도
1019
+ let paneIds = [];
1020
+ try {
1021
+ paneIds = collectSessionPanes(sessionName);
1022
+ } catch {
1023
+ // 세션이 이미 죽었으면 pane 목록 수집 불가 — 계속 진행
1024
+ }
1025
+ disableAllPipeCaptures(sessionName, paneIds);
1026
+
1027
+ // pipe-pane reader가 EOF를 처리하고 정상 종료할 시간 확보
1028
+ sleepMs(500);
1029
+
1030
+ // 2. pane 프로세스 트리 강제 종료 (MCP 서버 포함)
1031
+ const pids = collectPanePids(sessionName);
1032
+ for (const pid of pids) {
1033
+ killProcessTree(pid);
1034
+ }
1035
+
1036
+ // 3. psmux 세션 자체 종료
1037
+ try {
1038
+ psmuxExec(["kill-session", "-t", exactSessionTarget(sessionName)], {
1039
+ stdio: "ignore",
1040
+ });
1041
+ } catch {
1042
+ // 이미 종료된 세션 — 무시
1043
+ }
1044
+
1045
+ // 4. 고아 프로세스 정리 (pipe-pane 헬퍼 + MCP 서버)
1046
+ killOrphanPipeHelpers(sessionName);
1047
+ killOrphanMcpProcesses(sessionName);
1048
+ }
1049
+
1050
+ /**
1051
+ * 활성 psmux 세션 메타데이터 조회
1052
+ * @param {object} [opts]
1053
+ * @param {string|RegExp} [opts.filterTitle] - 세션명 prefix 또는 /regex/flags
1054
+ * @param {number} [opts.olderThanMs] - 생성 후 경과 시간 필터
1055
+ * @returns {Array<{sessionName: string, title: string, createdAt: number|null, lastActivityAt: number|null, attachedCount: number, isAttached: boolean, ageMs: number|null, idleMs: number|null}>}
1056
+ */
1057
+ export function listSessions(opts = {}) {
1058
+ ensurePsmuxInstalled();
1059
+ const matcher = toTitleMatcher(opts.filterTitle);
1060
+ const olderThanMs = normalizeOlderThanMs(opts.olderThanMs, 0);
1061
+
1062
+ return listSessionInventoryRaw().filter((session) => {
1063
+ if (!matcher(session.title)) return false;
1064
+ if (olderThanMs <= 0) return true;
1065
+ return Number.isFinite(session.ageMs) && session.ageMs >= olderThanMs;
1066
+ });
1067
+ }
1068
+
1069
+ /**
1070
+ * 세션명 prefix/regex 역조회 후 세션 종료
1071
+ * @param {string|RegExp} titlePattern
1072
+ * @returns {{ matchedCount: number, killedCount: number, sessions: string[] }}
1073
+ */
1074
+ export function killSessionByTitle(titlePattern) {
1075
+ ensurePsmuxInstalled();
1076
+ const sessions = listSessions({ filterTitle: titlePattern });
1077
+ const killed = [];
1078
+ for (const session of sessions) {
1079
+ psmuxExec(["kill-session", "-t", exactSessionTarget(session.sessionName)], {
1080
+ stdio: "ignore",
1081
+ });
1082
+ killed.push(session.sessionName);
1083
+ }
1084
+ return {
1085
+ matchedCount: sessions.length,
1086
+ killedCount: killed.length,
1087
+ sessions: killed,
1088
+ };
1089
+ }
1090
+
1091
+ /**
1092
+ * 오래 idle 상태인 detached 세션 일괄 정리
1093
+ * @param {object} [opts]
1094
+ * @param {number} [opts.olderThanMs=3600000]
1095
+ * @param {boolean} [opts.dryRun=false]
1096
+ * @returns {{ dryRun: boolean, matchedCount: number, killedCount: number, sessions: string[] }}
1097
+ */
1098
+ export function pruneStale(opts = {}) {
1099
+ ensurePsmuxInstalled();
1100
+ const olderThanMs = normalizeOlderThanMs(opts.olderThanMs, 3600000);
1101
+ const dryRun = opts.dryRun === true;
1102
+ const sessions = listSessionInventoryRaw().filter((session) => {
1103
+ if (session.isAttached) return false;
1104
+ return Number.isFinite(session.idleMs) && session.idleMs >= olderThanMs;
1105
+ });
1106
+
1107
+ if (dryRun) {
1108
+ return {
1109
+ dryRun: true,
1110
+ matchedCount: sessions.length,
1111
+ killedCount: 0,
1112
+ sessions: sessions.map((session) => session.sessionName),
1113
+ };
1114
+ }
1115
+
1116
+ if (sessions.length === 0) {
1117
+ return {
1118
+ dryRun: false,
1119
+ matchedCount: 0,
1120
+ killedCount: 0,
1121
+ sessions: [],
1122
+ };
1123
+ }
1124
+
1125
+ const result = killSessionByTitle(
1126
+ new RegExp(
1127
+ `^(?:${sessions
1128
+ .map((session) => escapeRegex(session.title))
1129
+ .join("|")})$`,
1130
+ ),
1131
+ );
1132
+ return {
1133
+ dryRun: false,
1134
+ matchedCount: sessions.length,
1135
+ killedCount: result.killedCount,
1136
+ sessions: result.sessions,
1137
+ };
1138
+ }
1139
+
1140
+ /**
1141
+ * psmux 세션 존재 확인
1142
+ * @param {string} sessionName
1143
+ * @returns {boolean}
1144
+ */
1145
+ export function psmuxSessionExists(sessionName) {
1146
+ try {
1147
+ psmuxExec(["has-session", "-t", exactSessionTarget(sessionName)], {
1148
+ stdio: "ignore",
1149
+ });
1150
+ return true;
1151
+ } catch {
1152
+ return false;
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * tfx-multi- 접두사 psmux 세션 목록
1158
+ * @returns {string[]}
1159
+ */
1160
+ export function listPsmuxSessions() {
1161
+ try {
1162
+ return listSessionInventoryRaw()
1163
+ .map((session) => session.sessionName)
1164
+ .filter((sessionName) => sessionName.startsWith("tfx-multi-"));
1165
+ } catch {
1166
+ return [];
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * pane 마지막 N줄 캡처
1172
+ * @param {string} target
1173
+ * @param {number} lines
1174
+ * @returns {string}
1175
+ */
1176
+ export function capturePsmuxPane(target, lines = 5) {
1177
+ try {
1178
+ const full = psmuxExec(["capture-pane", "-t", target, "-p", "-S", "-"]);
1179
+ const nonEmpty = full.split("\n").filter((line) => line.trim() !== "");
1180
+ return nonEmpty.slice(-lines).join("\n");
1181
+ } catch {
1182
+ return "";
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * psmux 세션 연결
1188
+ * @param {string} sessionName
1189
+ */
1190
+ export function attachPsmuxSession(sessionName) {
1191
+ const result = childProcess.spawnSync(
1192
+ PSMUX_BIN,
1193
+ ["attach-session", "-t", sessionName],
1194
+ {
1195
+ stdio: "inherit",
1196
+ timeout: 0,
1197
+ windowsHide: false,
1198
+ },
1199
+ );
1200
+ if ((result.status ?? 1) !== 0) {
1201
+ throw new Error(`psmux attach 실패 (exit=${result.status})`);
1202
+ }
1203
+ }
1204
+
1205
+ /**
1206
+ * 세션 attach client 수 조회
1207
+ * @param {string} sessionName
1208
+ * @returns {number|null}
1209
+ */
1210
+ export function getPsmuxSessionAttachedCount(sessionName) {
1211
+ try {
1212
+ const session = listSessionInventoryRaw().find(
1213
+ (entry) => entry.sessionName === sessionName,
1214
+ );
1215
+ return session ? session.attachedCount : null;
1216
+ } catch {
1217
+ return null;
1218
+ }
1219
+ }
1220
+
1221
+ /**
1222
+ * 팀메이트 조작 키 바인딩 설정
1223
+ * @param {string} sessionName
1224
+ * @param {object} opts
1225
+ * @param {boolean} opts.inProcess
1226
+ * @param {string} opts.taskListCommand
1227
+ */
1228
+ export function configurePsmuxKeybindings(sessionName, opts = {}) {
1229
+ const { inProcess = false, taskListCommand = "" } = opts;
1230
+ const cond = `#{==:#{session_name},${sessionName}}`;
1231
+ const target = `${sessionName}:0`;
1232
+ const bindNext = inProcess
1233
+ ? "select-pane -t :.+ \\; resize-pane -Z"
1234
+ : "select-pane -t :.+";
1235
+ const bindPrev = inProcess
1236
+ ? "select-pane -t :.- \\; resize-pane -Z"
1237
+ : "select-pane -t :.-";
1238
+
1239
+ // psmux는 세션별 서버이므로 -t target으로 세션 컨텍스트를 전달해야 한다.
1240
+ const bindSafe = (args) => {
1241
+ try {
1242
+ psmuxExec(["-t", target, ...args]);
1243
+ } catch {
1244
+ // 미지원 시 무시
1245
+ }
1246
+ };
1247
+
1248
+ bindSafe([
1249
+ "bind-key",
1250
+ "-T",
1251
+ "root",
1252
+ "-n",
1253
+ "S-Down",
1254
+ "if-shell",
1255
+ "-F",
1256
+ cond,
1257
+ bindNext,
1258
+ "send-keys S-Down",
1259
+ ]);
1260
+ bindSafe([
1261
+ "bind-key",
1262
+ "-T",
1263
+ "root",
1264
+ "-n",
1265
+ "S-Up",
1266
+ "if-shell",
1267
+ "-F",
1268
+ cond,
1269
+ bindPrev,
1270
+ "send-keys S-Up",
1271
+ ]);
1272
+ bindSafe([
1273
+ "bind-key",
1274
+ "-T",
1275
+ "root",
1276
+ "-n",
1277
+ "S-Right",
1278
+ "if-shell",
1279
+ "-F",
1280
+ cond,
1281
+ bindNext,
1282
+ "send-keys S-Right",
1283
+ ]);
1284
+ bindSafe([
1285
+ "bind-key",
1286
+ "-T",
1287
+ "root",
1288
+ "-n",
1289
+ "S-Left",
1290
+ "if-shell",
1291
+ "-F",
1292
+ cond,
1293
+ bindPrev,
1294
+ "send-keys S-Left",
1295
+ ]);
1296
+ bindSafe([
1297
+ "bind-key",
1298
+ "-T",
1299
+ "root",
1300
+ "-n",
1301
+ "BTab",
1302
+ "if-shell",
1303
+ "-F",
1304
+ cond,
1305
+ bindPrev,
1306
+ "send-keys BTab",
1307
+ ]);
1308
+ bindSafe([
1309
+ "bind-key",
1310
+ "-T",
1311
+ "root",
1312
+ "-n",
1313
+ "Escape",
1314
+ "if-shell",
1315
+ "-F",
1316
+ cond,
1317
+ "send-keys C-c",
1318
+ "send-keys Escape",
1319
+ ]);
1320
+
1321
+ if (taskListCommand) {
1322
+ bindSafe([
1323
+ "bind-key",
1324
+ "-T",
1325
+ "root",
1326
+ "-n",
1327
+ "C-t",
1328
+ "if-shell",
1329
+ "-F",
1330
+ cond,
1331
+ `display-popup -E ${quoteArg(taskListCommand)}`,
1332
+ "send-keys C-t",
1333
+ ]);
1334
+ }
1335
+ }
1336
+
1337
+ // ─── steering 기능 ───
1338
+
1339
+ /**
1340
+ * pane 출력 pipe-pane 캡처를 시작하고 즉시 snapshot을 기록한다.
1341
+ * @param {string} sessionName
1342
+ * @param {string} paneNameOrTarget
1343
+ * @returns {{ paneId: string, paneName: string, logPath: string }}
1344
+ */
1345
+ export function startCapture(sessionName, paneNameOrTarget) {
1346
+ ensurePsmuxInstalled();
1347
+ const pane = resolvePane(sessionName, paneNameOrTarget);
1348
+ const paneName = pane.title || paneNameOrTarget;
1349
+ const logPath = getCaptureLogPath(sessionName, paneName);
1350
+ const helperPath = ensureCaptureHelper();
1351
+ mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
1352
+ writeFileSync(logPath, "", "utf8");
1353
+
1354
+ disablePipeCapture(pane.paneId);
1355
+ const pipePaneCmd = IS_WINDOWS
1356
+ ? `powershell.exe -NoLogo -NoProfile -File ${quoteArg(helperPath)} ${quoteArg(logPath)}`
1357
+ : `${quoteArg(helperPath)} ${quoteArg(logPath)}`;
1358
+ psmuxExec(["pipe-pane", "-t", pane.paneId, pipePaneCmd]);
1359
+
1360
+ refreshCaptureSnapshot(sessionName, pane.paneId);
1361
+ return { paneId: pane.paneId, paneName, logPath };
1362
+ }
1363
+
1364
+ /**
1365
+ * PowerShell 명령을 pane에 비동기 전송하고 완료 토큰을 반환한다.
1366
+ * @param {string} sessionName
1367
+ * @param {string} paneNameOrTarget
1368
+ * @param {string} commandText
1369
+ * @returns {{ paneId: string, paneName: string, token: string, logPath: string }}
1370
+ */
1371
+ /**
1372
+ * CLI 명령(codex/legacy Gemini)이 psmux pane의 PowerShell 환경에서 단축 플래그 충돌을
1373
+ * 일으키는 문제를 방지하기 위해 bash -c '...' 로 감싼다.
1374
+ * - codex -o flag → PS -OutVariable/OutBuffer 충돌
1375
+ * - legacy Gemini prompt flag (v8.6.0: -p → long-form prompt flag, PS 충돌 해소)
1376
+ * @param {string} cmd
1377
+ * @returns {string}
1378
+ */
1379
+ // Windows: PowerShell의 bash가 WSL을 가리킬 수 있음. Git Bash를 명시적으로 사용.
1380
+ // WSL bash에서는 /c/Users/... 경로가 유효하지 않아 exit 127 발생.
1381
+ // psmux pane 용 (PowerShell → bash 실행): & "path" 형식
1382
+ // Node.js execSync 용 (직접 실행): "path" 형식
1383
+ const _gitBashPath = IS_WINDOWS ? resolveGitBashExecutable() : null;
1384
+ // PowerShell call operator(&)가 필요: & "C:\...\bash.exe" -c '...'
1385
+ const GIT_BASH_BIN_PS = _gitBashPath ? `& "${_gitBashPath}"` : "bash";
1386
+ // Node.js execSync에서 직접 실행할 때는 따옴표만
1387
+ const GIT_BASH_BIN_EXEC = _gitBashPath ? `"${_gitBashPath}"` : "bash";
1388
+
1389
+ // CLI 바이너리 절대 경로 캐시 (세션 중 1회만 resolve)
1390
+ const _cliPathCache = new Map();
1391
+ function resolveCliAbsPath(name) {
1392
+ if (_cliPathCache.has(name)) return _cliPathCache.get(name);
1393
+ try {
1394
+ const resolved = childProcess
1395
+ .execSync(`${GIT_BASH_BIN_EXEC} -c "which ${name}"`, {
1396
+ encoding: "utf8",
1397
+ timeout: 3000,
1398
+ })
1399
+ .trim();
1400
+ if (resolved) _cliPathCache.set(name, resolved);
1401
+ return resolved || name;
1402
+ } catch {
1403
+ return name;
1404
+ }
1405
+ }
1406
+
1407
+ function wrapCliForBash(cmd) {
1408
+ const trimmed = cmd.trimStart();
1409
+ // PowerShell 구문(Clear-Host, Get-Content 등) 또는 completion token이 포함되면 PowerShell 직통
1410
+ if (/Clear-Host|Get-Content|__TRIFLUX_DONE__/i.test(trimmed)) return cmd;
1411
+ const cliMatch = trimmed.match(/^(codex|gemini)\b/u);
1412
+ if (!cliMatch) return cmd;
1413
+ // Node.js 측에서 절대 경로 resolve → psmux pane의 bash PATH 불일치 문제 해결 (exit 127)
1414
+ const absPath = resolveCliAbsPath(cliMatch[1]);
1415
+ const resolved =
1416
+ absPath !== cliMatch[1]
1417
+ ? trimmed.replace(new RegExp(`^${cliMatch[1]}\\b`), absPath)
1418
+ : trimmed;
1419
+ // 단일 따옴표 이스케이프: ' → '\''
1420
+ const escaped = resolved.replace(/'/g, "'\\''");
1421
+ return `${GIT_BASH_BIN_PS} -c '${escaped}'`;
1422
+ }
1423
+
1424
+ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
1425
+ ensurePsmuxInstalled();
1426
+ const pane = resolvePane(sessionName, paneNameOrTarget);
1427
+ const paneName = pane.title || paneNameOrTarget;
1428
+ const logPath = getCaptureLogPath(sessionName, paneName);
1429
+
1430
+ if (!existsSync(logPath)) {
1431
+ startCapture(sessionName, paneName);
1432
+ }
1433
+
1434
+ const token = randomToken(paneName);
1435
+ const safeCommand = IS_WINDOWS ? wrapCliForBash(commandText) : commandText;
1436
+
1437
+ let wrapped;
1438
+ if (IS_WINDOWS) {
1439
+ // CP949 등 non-UTF-8 codepage 환경에서 CLI stdout이 깨지는 문제 방지 (belt-and-suspenders)
1440
+ const chcpPrefix = "chcp 65001 > $null; ";
1441
+ wrapped = `${chcpPrefix}try { ${safeCommand} } finally { $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit" }`;
1442
+ } else {
1443
+ // macOS/Linux: bash 구문으로 완료 토큰 출력
1444
+ wrapped = `{ ${safeCommand}; __ec=$?; echo "${COMPLETION_PREFIX}${token}:$__ec"; }`;
1445
+ }
1446
+
1447
+ sendKeysToPane(pane.paneId, wrapped, true);
1448
+
1449
+ return { paneId: pane.paneId, paneName, token, logPath };
1450
+ }
1451
+
1452
+ /**
1453
+ * pane 캡처 로그에서 정규식 패턴을 polling으로 대기한다.
1454
+ * @param {string} sessionName
1455
+ * @param {string} paneNameOrTarget
1456
+ * @param {string|RegExp} pattern
1457
+ * @param {number} timeoutSec
1458
+ * @param {object} [opts]
1459
+ * @param {(snapshot: {content: string, paneId: string, paneName: string, elapsed: number}) => void} [opts.onPoll] — 각 폴링 주기마다 호출
1460
+ * @param {AbortSignal} [opts.signal] — 외부에서 폴링 중단 요청 시 사용
1461
+ * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, aborted?: boolean }}
1462
+ */
1463
+ export async function waitForPattern(
1464
+ sessionName,
1465
+ paneNameOrTarget,
1466
+ pattern,
1467
+ timeoutSec = 300,
1468
+ opts = {},
1469
+ ) {
1470
+ ensurePsmuxInstalled();
1471
+
1472
+ // E4 크래시 복구: 초기 resolvePane도 세션 사망을 감지
1473
+ let pane;
1474
+ try {
1475
+ pane = resolvePane(sessionName, paneNameOrTarget);
1476
+ } catch (resolveError) {
1477
+ if (!psmuxSessionExists(sessionName)) {
1478
+ return {
1479
+ matched: false,
1480
+ paneId: "",
1481
+ paneName: String(paneNameOrTarget),
1482
+ logPath: "",
1483
+ match: null,
1484
+ sessionDead: true,
1485
+ };
1486
+ }
1487
+ throw resolveError; // 세션은 살아있지만 pane을 못 찾음 → 원래 에러 전파
1488
+ }
1489
+
1490
+ const paneName = pane.title || paneNameOrTarget;
1491
+ // opts.logPath: dispatch 시 확정된 캡처 로그 경로 직접 지정 (타이틀 변경 내성)
1492
+ const logPath =
1493
+ opts.logPath && existsSync(opts.logPath)
1494
+ ? opts.logPath
1495
+ : getCaptureLogPath(sessionName, paneName);
1496
+ if (!existsSync(logPath)) {
1497
+ throw new Error(
1498
+ `캡처 로그가 없습니다. 먼저 startCapture(${sessionName}, ${paneName})를 호출하세요.`,
1499
+ );
1500
+ }
1501
+
1502
+ const startTime = Date.now();
1503
+ const deadline = startTime + Math.max(0, Math.trunc(timeoutSec * 1000));
1504
+ const regex = toPatternRegExp(pattern);
1505
+
1506
+ if (opts?.signal?.aborted) {
1507
+ return {
1508
+ matched: false,
1509
+ paneId: pane.paneId,
1510
+ paneName,
1511
+ logPath,
1512
+ match: null,
1513
+ aborted: true,
1514
+ };
1515
+ }
1516
+
1517
+ while (Date.now() <= deadline) {
1518
+ if (opts?.signal?.aborted) {
1519
+ return {
1520
+ matched: false,
1521
+ paneId: pane.paneId,
1522
+ paneName,
1523
+ logPath,
1524
+ match: null,
1525
+ aborted: true,
1526
+ };
1527
+ }
1528
+ // E4 크래시 복구: capture 실패 시 세션 생존 체크
1529
+ try {
1530
+ if (opts.logPath) {
1531
+ // logPath 직접 지정 시 — 셸 타이틀 변경과 무관하게 올바른 파일에 기록
1532
+ const snapshot = psmuxExec([
1533
+ "capture-pane",
1534
+ "-t",
1535
+ pane.paneId,
1536
+ "-p",
1537
+ "-S",
1538
+ "-",
1539
+ ]);
1540
+ writeFileSync(logPath, snapshot, "utf8");
1541
+ } else {
1542
+ refreshCaptureSnapshot(sessionName, pane.paneId);
1543
+ }
1544
+ } catch {
1545
+ if (!psmuxSessionExists(sessionName)) {
1546
+ return {
1547
+ matched: false,
1548
+ paneId: pane.paneId,
1549
+ paneName,
1550
+ logPath,
1551
+ match: null,
1552
+ sessionDead: true,
1553
+ };
1554
+ }
1555
+ // 일시적 오류 — 다음 폴링에서 재시도
1556
+ }
1557
+
1558
+ const content = readCaptureLog(logPath);
1559
+
1560
+ // onPoll 콜백 — 각 폴링 주기마다 중간 상태 전달
1561
+ if (opts.onPoll) {
1562
+ try {
1563
+ opts.onPoll({
1564
+ content,
1565
+ paneId: pane.paneId,
1566
+ paneName,
1567
+ elapsed: Date.now() - startTime,
1568
+ });
1569
+ } catch {
1570
+ /* 콜백 예외는 삼킴 — 폴링 루프 보호 */
1571
+ }
1572
+ }
1573
+
1574
+ const match = regex.exec(content);
1575
+ if (match) {
1576
+ return {
1577
+ matched: true,
1578
+ paneId: pane.paneId,
1579
+ paneName,
1580
+ logPath,
1581
+ match: match[0],
1582
+ };
1583
+ }
1584
+
1585
+ if (Date.now() > deadline) {
1586
+ break;
1587
+ }
1588
+ await sleepMsAsync(POLL_INTERVAL_MS);
1589
+ if (opts?.signal?.aborted) {
1590
+ return {
1591
+ matched: false,
1592
+ paneId: pane.paneId,
1593
+ paneName,
1594
+ logPath,
1595
+ match: null,
1596
+ aborted: true,
1597
+ };
1598
+ }
1599
+ }
1600
+
1601
+ return {
1602
+ matched: false,
1603
+ paneId: pane.paneId,
1604
+ paneName,
1605
+ logPath,
1606
+ match: null,
1607
+ };
1608
+ }
1609
+
1610
+ /**
1611
+ * 완료 토큰이 찍힐 때까지 대기하고 exit code를 파싱한다.
1612
+ * @param {string} sessionName
1613
+ * @param {string} paneNameOrTarget
1614
+ * @param {string} token
1615
+ * @param {number} timeoutSec
1616
+ * @param {object} [opts] — waitForPattern에 전달할 옵션 (onPoll 등)
1617
+ * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
1618
+ */
1619
+ export async function waitForCompletion(
1620
+ sessionName,
1621
+ paneNameOrTarget,
1622
+ token,
1623
+ timeoutSec = 300,
1624
+ opts = {},
1625
+ ) {
1626
+ const completionRegex = new RegExp(
1627
+ `${escapeRegex(COMPLETION_PREFIX)}${escapeRegex(token)}:(\\d+)`,
1628
+ "m",
1629
+ );
1630
+ const result = await waitForPattern(
1631
+ sessionName,
1632
+ paneNameOrTarget,
1633
+ completionRegex,
1634
+ timeoutSec,
1635
+ opts,
1636
+ );
1637
+
1638
+ // 타이밍 이슈 대응: matched=false인 경우 500ms 대기 후 최종 1회 캡처 재시도
1639
+ if (!result.matched && !result.sessionDead && result.logPath) {
1640
+ await new Promise((r) => setTimeout(r, 500));
1641
+ try {
1642
+ const pane = resolvePane(sessionName, paneNameOrTarget);
1643
+ const snapshot = psmuxExec([
1644
+ "capture-pane",
1645
+ "-t",
1646
+ pane.paneId,
1647
+ "-p",
1648
+ "-S",
1649
+ "-",
1650
+ ]);
1651
+ writeFileSync(result.logPath, snapshot, "utf8");
1652
+ const content = readCaptureLog(result.logPath);
1653
+ const retryMatch = completionRegex.exec(content);
1654
+ if (retryMatch) {
1655
+ return {
1656
+ ...result,
1657
+ matched: true,
1658
+ match: retryMatch[0],
1659
+ token,
1660
+ exitCode: Number.parseInt(retryMatch[1], 10),
1661
+ };
1662
+ }
1663
+ } catch {
1664
+ // 세션 이미 종료 — 무시
1665
+ }
1666
+ }
1667
+
1668
+ const exitMatch = result.match ? completionRegex.exec(result.match) : null;
1669
+ return {
1670
+ ...result,
1671
+ token,
1672
+ exitCode: exitMatch ? Number.parseInt(exitMatch[1], 10) : null,
1673
+ };
1674
+ }
1675
+
1676
+ // ─── CLI 진입점 ───
1677
+
1678
+ if (process.argv[1]?.endsWith("psmux.mjs")) {
1679
+ (() => {
1680
+ const rawArgs = process.argv.slice(2);
1681
+ const internalMode = rawArgs[0] === "--internal";
1682
+ const cmd = internalMode ? rawArgs[1] : rawArgs[0];
1683
+ const args = internalMode ? rawArgs.slice(2) : rawArgs.slice(1);
1684
+
1685
+ // CLI 인자 파싱 헬퍼
1686
+ function getArg(name) {
1687
+ const idx = args.indexOf(`--${name}`);
1688
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
1689
+ }
1690
+
1691
+ try {
1692
+ if (internalMode) {
1693
+ switch (cmd) {
1694
+ case "list": {
1695
+ const olderThanMs = getArg("olderThanMs");
1696
+ const filterTitle = getArg("filterTitle");
1697
+ console.log(
1698
+ JSON.stringify(
1699
+ listSessions({
1700
+ filterTitle,
1701
+ olderThanMs:
1702
+ olderThanMs == null
1703
+ ? undefined
1704
+ : Number.parseInt(olderThanMs, 10),
1705
+ }),
1706
+ null,
1707
+ 2,
1708
+ ),
1709
+ );
1710
+ break;
1711
+ }
1712
+ case "kill-by-title": {
1713
+ const pattern = args[0];
1714
+ if (!pattern) {
1715
+ console.error(
1716
+ "사용법: node psmux.mjs --internal kill-by-title <title-prefix|/regex/>",
1717
+ );
1718
+ process.exit(1);
1719
+ }
1720
+ console.log(JSON.stringify(killSessionByTitle(pattern), null, 2));
1721
+ break;
1722
+ }
1723
+ case "prune-stale": {
1724
+ const olderThanMs = getArg("olderThanMs");
1725
+ const dryRun = args.includes("--dry-run");
1726
+ console.log(
1727
+ JSON.stringify(
1728
+ pruneStale({
1729
+ olderThanMs:
1730
+ olderThanMs == null
1731
+ ? undefined
1732
+ : Number.parseInt(olderThanMs, 10),
1733
+ dryRun,
1734
+ }),
1735
+ null,
1736
+ 2,
1737
+ ),
1738
+ );
1739
+ break;
1740
+ }
1741
+ default:
1742
+ console.error(
1743
+ "사용법: node psmux.mjs --internal list [--filterTitle <prefix|/regex/>] [--olderThanMs <ms>]",
1744
+ );
1745
+ console.error(
1746
+ " 또는: node psmux.mjs --internal kill-by-title <prefix|/regex/>",
1747
+ );
1748
+ console.error(
1749
+ " 또는: node psmux.mjs --internal prune-stale [--olderThanMs <ms>] [--dry-run]",
1750
+ );
1751
+ process.exit(1);
1752
+ }
1753
+ return;
1754
+ }
1755
+
1756
+ console.error(
1757
+ "사용법: node psmux.mjs --internal list|kill-by-title|prune-stale [args]",
1758
+ );
1759
+ process.exit(1);
1760
+ } catch (err) {
1761
+ console.error(`오류: ${err.message}`);
1762
+ process.exit(1);
1763
+ }
1764
+ })();
1765
+ }