@triflux/remote 10.0.1 → 10.25.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.
Files changed (155) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +68 -7
  3. package/hub/server.mjs +2181 -701
  4. package/hub/store-adapter.mjs +101 -37
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +84 -17
  9. package/hub/team/build-worker-prompt.mjs +42 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +219 -0
  12. package/hub/team/claude-native-bridge.mjs +784 -0
  13. package/hub/team/claude-session-projection.mjs +65 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +154 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +89 -12
  24. package/hub/team/cli/commands/start/start-headless.mjs +127 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/index.mjs +2 -2
  33. package/hub/team/cli/manifest.mjs +38 -8
  34. package/hub/team/cli/render.mjs +52 -30
  35. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  36. package/hub/team/cli/services/hub-client.mjs +45 -17
  37. package/hub/team/cli/services/member-selector.mjs +37 -30
  38. package/hub/team/cli/services/native-control.mjs +144 -116
  39. package/hub/team/cli/services/runtime-mode.mjs +55 -1
  40. package/hub/team/cli/services/state-store.mjs +66 -9
  41. package/hub/team/cli/services/task-model.mjs +40 -30
  42. package/hub/team/codex-review.mjs +521 -0
  43. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  44. package/hub/team/conductor-registry.mjs +121 -0
  45. package/hub/team/conductor.mjs +1379 -671
  46. package/hub/team/consensus-meta.mjs +176 -0
  47. package/hub/team/dashboard-anchor.mjs +4 -5
  48. package/hub/team/dashboard-layout.mjs +3 -1
  49. package/hub/team/dashboard-open.mjs +21 -119
  50. package/hub/team/dashboard.mjs +76 -28
  51. package/hub/team/event-log.mjs +18 -10
  52. package/hub/team/execution-mode.mjs +241 -0
  53. package/hub/team/extract-completion-payload.mjs +110 -0
  54. package/hub/team/git-preflight.mjs +286 -0
  55. package/hub/team/handoff.mjs +49 -18
  56. package/hub/team/headless-bridge-session.mjs +17 -0
  57. package/hub/team/headless.mjs +2065 -1156
  58. package/hub/team/health-probe.mjs +232 -69
  59. package/hub/team/launcher-template.mjs +23 -13
  60. package/hub/team/lead-control.mjs +91 -4
  61. package/hub/team/mcp-selector.mjs +145 -0
  62. package/hub/team/native-supervisor.mjs +90 -26
  63. package/hub/team/native.mjs +74 -35
  64. package/hub/team/nativeProxy.mjs +184 -113
  65. package/hub/team/notify.mjs +147 -78
  66. package/hub/team/orchestrator.mjs +173 -161
  67. package/hub/team/pane.mjs +43 -15
  68. package/hub/team/process-cleanup.mjs +69 -25
  69. package/hub/team/psmux.mjs +1067 -264
  70. package/hub/team/recovery-store.mjs +149 -0
  71. package/hub/team/remote-probe.mjs +69 -53
  72. package/hub/team/remote-session.mjs +131 -59
  73. package/hub/team/remote-watcher.mjs +62 -34
  74. package/hub/team/retry-state-machine.mjs +267 -0
  75. package/hub/team/routing.mjs +51 -25
  76. package/hub/team/runtime-strategy.mjs +200 -0
  77. package/hub/team/schemas/worker-completion.json +39 -0
  78. package/hub/team/sentinel-capture.mjs +127 -0
  79. package/hub/team/session-sync.mjs +153 -6
  80. package/hub/team/session.mjs +605 -611
  81. package/hub/team/shared.mjs +13 -13
  82. package/hub/team/staleState.mjs +72 -30
  83. package/hub/team/swarm-cli.mjs +390 -0
  84. package/hub/team/swarm-hypervisor.mjs +1526 -198
  85. package/hub/team/swarm-intent.mjs +216 -0
  86. package/hub/team/swarm-locks.mjs +140 -25
  87. package/hub/team/swarm-planner.mjs +121 -21
  88. package/hub/team/swarm-preflight.mjs +317 -0
  89. package/hub/team/swarm-reconciler.mjs +128 -23
  90. package/hub/team/synapse-cli.mjs +458 -0
  91. package/hub/team/synapse-http.mjs +60 -0
  92. package/hub/team/synapse-registry.mjs +329 -0
  93. package/hub/team/terminal-opener.mjs +216 -0
  94. package/hub/team/tui-core.mjs +288 -0
  95. package/hub/team/tui-lite.mjs +234 -146
  96. package/hub/team/tui-remote-adapter.mjs +21 -37
  97. package/hub/team/tui-synapse.mjs +223 -0
  98. package/hub/team/tui-viewer.mjs +104 -45
  99. package/hub/team/tui-widgets.mjs +262 -0
  100. package/hub/team/tui.mjs +936 -449
  101. package/hub/team/worker-completion-validator.mjs +84 -0
  102. package/hub/team/worker-sandbox.mjs +93 -0
  103. package/hub/team/worker-signal.mjs +263 -0
  104. package/hub/team/worker-signal.types.d.ts +52 -0
  105. package/hub/team/worktree-lifecycle.mjs +518 -64
  106. package/hub/team/wt-manager.mjs +297 -26
  107. package/hub/tools.mjs +474 -252
  108. package/hub/tray.mjs +411 -368
  109. package/hub/workers/claude-worker.mjs +162 -118
  110. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  111. package/hub/workers/codex-mcp.mjs +724 -507
  112. package/hub/workers/delegator-mcp.mjs +663 -337
  113. package/hub/workers/factory.mjs +100 -21
  114. package/hub/workers/gemini-worker.mjs +179 -86
  115. package/hub/workers/interface.mjs +23 -2
  116. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  117. package/hub/workers/worker-utils.mjs +22 -16
  118. package/package.json +4 -3
  119. package/scripts/lib/agent-json.mjs +27 -0
  120. package/scripts/lib/async.mjs +174 -0
  121. package/scripts/lib/claudemd-scanner.mjs +223 -0
  122. package/scripts/lib/cli-agy.mjs +62 -0
  123. package/scripts/lib/cli-claude.mjs +78 -0
  124. package/scripts/lib/cli-codex.mjs +199 -0
  125. package/scripts/lib/cli-gemini.mjs +67 -0
  126. package/scripts/lib/codex-recovery.sh +50 -0
  127. package/scripts/lib/context.mjs +67 -0
  128. package/scripts/lib/cross-review-utils.mjs +59 -0
  129. package/scripts/lib/doctor-env-checks.mjs +121 -0
  130. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  131. package/scripts/lib/env-probe.mjs +269 -0
  132. package/scripts/lib/env.mjs +14 -0
  133. package/scripts/lib/gemini-profiles.mjs +119 -0
  134. package/scripts/lib/handoff.mjs +187 -0
  135. package/scripts/lib/hook-utils.mjs +16 -0
  136. package/scripts/lib/hub.mjs +48 -0
  137. package/scripts/lib/keyword-rules.mjs +195 -0
  138. package/scripts/lib/logger.mjs +105 -0
  139. package/scripts/lib/mcp-filter.mjs +883 -0
  140. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  141. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  142. package/scripts/lib/mcp-health.mjs +773 -0
  143. package/scripts/lib/mcp-manifest.mjs +89 -0
  144. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  145. package/scripts/lib/pid.mjs +63 -0
  146. package/scripts/lib/process-utils.mjs +26 -0
  147. package/scripts/lib/psmux-info.mjs +179 -0
  148. package/scripts/lib/quota.mjs +47 -0
  149. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  150. package/scripts/lib/skill-state.mjs +220 -0
  151. package/scripts/lib/skill-template.mjs +375 -0
  152. package/scripts/lib/team.mjs +75 -0
  153. package/scripts/lib/timeout.mjs +66 -0
  154. package/scripts/lib/tmp.mjs +26 -0
  155. package/scripts/lib/toml.mjs +113 -0
@@ -1,21 +1,51 @@
1
- // hub/team/psmux.mjs — Windows psmux 세션/키바인딩/캡처/steering 관리
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
+ //
2
13
  // 의존성: child_process, fs, os, path (Node.js 내장)만 사용
3
- import childProcess from "node:child_process";
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { tmpdir, homedir } from "node:os";
14
+
15
+ import {
16
+ chmodSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import { homedir, tmpdir } from "node:os";
6
23
  import { join } from "node:path";
7
24
  import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
25
+ import { resolveGitBashExecutable } from "@triflux/core/hub/lib/bash-path.mjs";
26
+ import childProcess from "@triflux/core/hub/lib/spawn-trace.mjs";
8
27
  import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
9
28
 
29
+ // OS 별 primary multiplexer 를 명시적으로 결정한다.
30
+ // - PSMUX_BIN env 가 있으면 user override (가장 우선)
31
+ // - Windows: psmux 가 primary. PATH + 기본 설치 경로에서 탐색.
32
+ // - mac/Linux: tmux 가 primary. PATH 에서만 찾는다 (HomeBrew, apt 등 표준 위치).
33
+ // silent fallback 은 두지 않는다 — primary 가 없으면 의도된 fail.
10
34
  const PSMUX_BIN = (() => {
11
35
  if (process.env.PSMUX_BIN) return process.env.PSMUX_BIN;
12
- // PATH에서 찾기
13
- try {
14
- childProcess.execFileSync("psmux", ["-V"], { stdio: "ignore", timeout: 2000, windowsHide: true });
15
- return "psmux";
16
- } catch { /* not in PATH */ }
17
- // Windows 기본 설치 경로 탐색
36
+
18
37
  if (IS_WINDOWS) {
38
+ // Windows primary: psmux
39
+ try {
40
+ childProcess.execFileSync("psmux", ["-V"], {
41
+ stdio: "ignore",
42
+ timeout: 2000,
43
+ windowsHide: true,
44
+ });
45
+ return "psmux";
46
+ } catch {
47
+ /* not in PATH */
48
+ }
19
49
  const candidates = [
20
50
  join(process.env.LOCALAPPDATA || "", "psmux", "psmux.exe"),
21
51
  join(process.env.APPDATA || "", "npm", "psmux.cmd"),
@@ -25,10 +55,23 @@ const PSMUX_BIN = (() => {
25
55
  for (const p of candidates) {
26
56
  if (existsSync(p)) return p;
27
57
  }
58
+ return "psmux"; // 최종 — hasPsmux/hasMultiplexer 가 검증
59
+ }
60
+
61
+ // mac/Linux primary: tmux (POSIX 표준). silent fallback 아님 — 정식 primary.
62
+ try {
63
+ childProcess.execFileSync("tmux", ["-V"], {
64
+ stdio: "ignore",
65
+ timeout: 2000,
66
+ });
67
+ return "tmux";
68
+ } catch {
69
+ /* tmux not in PATH — hasMultiplexer 가 false 반환할 것 */
28
70
  }
29
- return "psmux"; // 최종 fallback — 원래대로
71
+ return "tmux";
30
72
  })();
31
- const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
73
+ const GIT_BASH =
74
+ process.env.GIT_BASH_PATH || resolveGitBashExecutable() || "bash";
32
75
 
33
76
  /** Windows psmux 세션의 기본 셸을 PowerShell로 강제한다 (pwsh7 우선, ps5 fallback). */
34
77
  const PWSH_BIN = (() => {
@@ -36,26 +79,46 @@ const PWSH_BIN = (() => {
36
79
  if (process.env.PSMUX_SHELL) return process.env.PSMUX_SHELL;
37
80
  // pwsh 7 우선
38
81
  try {
39
- childProcess.execFileSync("pwsh", ["-NoLogo", "-NoProfile", "-Command", "exit 0"], { stdio: "ignore", timeout: 3000, windowsHide: true });
82
+ childProcess.execFileSync(
83
+ "pwsh",
84
+ ["-NoLogo", "-NoProfile", "-Command", "exit 0"],
85
+ { stdio: "ignore", timeout: 3000, windowsHide: true },
86
+ );
40
87
  return "pwsh";
41
- } catch { /* not found */ }
88
+ } catch {
89
+ /* not found */
90
+ }
42
91
  // powershell 5 fallback
43
92
  try {
44
- childProcess.execFileSync("powershell.exe", ["-NoLogo", "-NoProfile", "-Command", "exit 0"], { stdio: "ignore", timeout: 3000, windowsHide: true });
93
+ childProcess.execFileSync(
94
+ "powershell.exe",
95
+ ["-NoLogo", "-NoProfile", "-Command", "exit 0"],
96
+ { stdio: "ignore", timeout: 3000, windowsHide: true },
97
+ );
45
98
  return "powershell.exe";
46
- } catch { /* not found */ }
99
+ } catch {
100
+ /* not found */
101
+ }
47
102
  return ""; // 둘 다 없으면 psmux 기본 셸 사용
48
103
  })();
49
104
  const PSMUX_TIMEOUT_MS = 10000;
50
105
  const COMPLETION_PREFIX = "__TRIFLUX_DONE__:";
51
- const CAPTURE_ROOT = process.env.PSMUX_CAPTURE_ROOT || join(tmpdir(), "psmux-steering");
52
- const CAPTURE_HELPER_PATH = join(CAPTURE_ROOT, "pipe-pane-capture.ps1");
106
+ const CAPTURE_ROOT =
107
+ process.env.PSMUX_CAPTURE_ROOT || join(tmpdir(), "psmux-steering");
108
+ const CAPTURE_HELPER_PATH = join(
109
+ CAPTURE_ROOT,
110
+ IS_WINDOWS ? "pipe-pane-capture.ps1" : "pipe-pane-capture.sh",
111
+ );
53
112
  const POLL_INTERVAL_MS = (() => {
54
113
  const ms = Number.parseInt(process.env.PSMUX_POLL_INTERVAL_MS || "", 10);
55
114
  if (Number.isFinite(ms) && ms > 0) return ms;
56
115
  const sec = Number.parseFloat(process.env.PSMUX_POLL_INTERVAL_SEC || "1");
57
- return Number.isFinite(sec) && sec > 0 ? Math.max(100, Math.trunc(sec * 1000)) : 1000;
116
+ return Number.isFinite(sec) && sec > 0
117
+ ? Math.max(100, Math.trunc(sec * 1000))
118
+ : 1000;
58
119
  })();
120
+ const PSMUX_SESSION_LIST_FORMAT =
121
+ "#{session_name}\t#{session_created}\t#{session_activity}\t#{session_attached}";
59
122
 
60
123
  function quoteArg(value) {
61
124
  const str = String(value);
@@ -67,6 +130,12 @@ function sanitizePathPart(value) {
67
130
  return String(value).replace(/[<>:"/\\|?*\u0000-\u001f']/gu, "_");
68
131
  }
69
132
 
133
+ // session names retain regex metacharacters (`.`, `-`, `+`, …) even after
134
+ // sanitizePathPart, so escape before embedding into a pgrep regex.
135
+ export function escapeRegex(str) {
136
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
137
+ }
138
+
70
139
  function toPaneTitle(index) {
71
140
  return index === 0 ? "lead" : `worker-${index}`;
72
141
  }
@@ -126,7 +195,7 @@ function tokenizeCommand(command) {
126
195
  continue;
127
196
  }
128
197
 
129
- if (char === "\\" && next && (/[\s"'\\;]/u.test(next))) {
198
+ if (char === "\\" && next && /[\s"'\\;]/u.test(next)) {
130
199
  current += next;
131
200
  index += 1;
132
201
  continue;
@@ -165,10 +234,10 @@ function ensurePsmuxInstalled() {
165
234
  if (!hasPsmux()) {
166
235
  throw new Error(
167
236
  "psmux가 설치되어 있지 않습니다.\n\n" +
168
- "psmux는 Codex/Gemini CLI를 병렬 세션으로 실행하는 터미널 멀티플렉서입니다.\n" +
169
- "설치 방법 (택 1):\n" +
170
- `${formatPsmuxInstallGuidance(" ")}\n\n` +
171
- "설치 후 터미널을 재시작하세요."
237
+ "psmux는 Codex/Gemini CLI를 병렬 세션으로 실행하는 터미널 멀티플렉서입니다.\n" +
238
+ "설치 방법 (택 1):\n" +
239
+ `${formatPsmuxInstallGuidance(" ")}\n\n` +
240
+ "설치 후 터미널을 재시작하세요.",
172
241
  );
173
242
  }
174
243
  }
@@ -178,34 +247,57 @@ function getCaptureSessionDir(sessionName) {
178
247
  }
179
248
 
180
249
  function getCaptureLogPath(sessionName, paneName) {
181
- return join(getCaptureSessionDir(sessionName), `${sanitizePathPart(paneName)}.log`);
250
+ return join(
251
+ getCaptureSessionDir(sessionName),
252
+ `${sanitizePathPart(paneName)}.log`,
253
+ );
182
254
  }
183
255
 
184
256
  function ensureCaptureHelper() {
185
257
  mkdirSync(CAPTURE_ROOT, { recursive: true });
186
- writeFileSync(
187
- CAPTURE_HELPER_PATH,
188
- [
189
- "param(",
190
- " [Parameter(Mandatory = $true)][string]$Path",
191
- ")",
192
- "",
193
- "$parent = Split-Path -Parent $Path",
194
- "if ($parent) {",
195
- " New-Item -ItemType Directory -Force -Path $parent | Out-Null",
196
- "}",
197
- "",
198
- "# Force UTF-8 encoding — CP949 등 non-UTF-8 codepage에서 Gemini stdout 캡처 실패 방지",
199
- "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
200
- "",
201
- "$reader = [Console]::In",
202
- "while (($line = $reader.ReadLine()) -ne $null) {",
203
- " Add-Content -LiteralPath $Path -Value $line -Encoding utf8",
204
- "}",
205
- "",
206
- ].join("\n"),
207
- "utf8",
208
- );
258
+ if (IS_WINDOWS) {
259
+ writeFileSync(
260
+ CAPTURE_HELPER_PATH,
261
+ [
262
+ "param(",
263
+ " [Parameter(Mandatory = $true)][string]$Path",
264
+ ")",
265
+ "",
266
+ "$parent = Split-Path -Parent $Path",
267
+ "if ($parent) {",
268
+ " New-Item -ItemType Directory -Force -Path $parent | Out-Null",
269
+ "}",
270
+ "",
271
+ "# Force UTF-8 encoding — CP949 등 non-UTF-8 codepage에서 Gemini stdout 캡처 실패 방지",
272
+ "# InputEncoding: pipe-pane에서 읽어들이는 바이트 해석",
273
+ "# OutputEncoding: 네이티브 명령(gemini/codex CLI)의 stdout 디코딩",
274
+ "# $OutputEncoding: PowerShell 파이프라인 기본 인코딩 (BOM 없는 UTF-8)",
275
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
276
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
277
+ "$OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
278
+ "",
279
+ "$reader = [Console]::In",
280
+ "while (($line = $reader.ReadLine()) -ne $null) {",
281
+ " Add-Content -LiteralPath $Path -Value $line -Encoding utf8",
282
+ "}",
283
+ "",
284
+ ].join("\n"),
285
+ "utf8",
286
+ );
287
+ } else {
288
+ // macOS/Linux: bash 스크립트로 pipe-pane 캡처
289
+ writeFileSync(
290
+ CAPTURE_HELPER_PATH,
291
+ [
292
+ "#!/bin/bash",
293
+ 'mkdir -p "$(dirname "$1")" 2>/dev/null',
294
+ 'exec tee -a "$1"',
295
+ "",
296
+ ].join("\n"),
297
+ "utf8",
298
+ );
299
+ chmodSync(CAPTURE_HELPER_PATH, 0o755);
300
+ }
209
301
  return CAPTURE_HELPER_PATH;
210
302
  }
211
303
 
@@ -230,33 +322,114 @@ function parsePaneList(output) {
230
322
  .map((entry) => entry.target);
231
323
  }
232
324
 
233
- function parseSessionSummaries(output) {
325
+ function parsePsmuxEpoch(value) {
326
+ const raw = String(value ?? "").trim();
327
+ if (!raw) return null;
328
+ const numeric = Number.parseInt(raw, 10);
329
+ if (!Number.isFinite(numeric) || numeric <= 0) return null;
330
+ return numeric >= 1_000_000_000_000 ? numeric : numeric * 1000;
331
+ }
332
+
333
+ function normalizeOlderThanMs(value, fallback = 0) {
334
+ if (!Number.isFinite(value)) return fallback;
335
+ return Math.max(0, Math.trunc(value));
336
+ }
337
+
338
+ function toTitleMatcher(pattern) {
339
+ if (!pattern) return () => true;
340
+ if (pattern instanceof RegExp) return (title) => pattern.test(title);
341
+
342
+ const raw = String(pattern).trim();
343
+ if (!raw) return () => true;
344
+ const regexMatch = raw.match(/^\/(.+)\/([a-z]*)$/i);
345
+ if (regexMatch) {
346
+ const [, source, flags] = regexMatch;
347
+ const regex = new RegExp(source, flags);
348
+ return (title) => regex.test(title);
349
+ }
350
+
351
+ return (title) => String(title).startsWith(raw);
352
+ }
353
+
354
+ function parseLegacySessionInventory(output) {
234
355
  return output
235
356
  .split("\n")
236
357
  .map((line) => line.trim())
237
358
  .filter(Boolean)
238
359
  .map((line) => {
239
360
  const colonIndex = line.indexOf(":");
240
- if (colonIndex === -1) {
241
- return null;
242
- }
361
+ if (colonIndex === -1) return null;
243
362
 
244
363
  const sessionName = line.slice(0, colonIndex).trim();
245
- const flags = [...line.matchAll(/\(([^)]*)\)/g)].map((match) => match[1]).join(", ");
364
+ const flags = [...line.matchAll(/\(([^)]*)\)/g)]
365
+ .map((match) => match[1])
366
+ .join(", ");
246
367
  const attachedMatch = flags.match(/(\d+)\s+attached/);
247
368
  const attachedCount = attachedMatch
248
- ? parseInt(attachedMatch[1], 10)
369
+ ? Number.parseInt(attachedMatch[1], 10)
249
370
  : /\battached\b/.test(flags)
250
371
  ? 1
251
372
  : 0;
252
373
 
253
- return sessionName
254
- ? { sessionName, attachedCount }
255
- : null;
374
+ if (!sessionName) return null;
375
+ return Object.freeze({
376
+ sessionName,
377
+ title: sessionName,
378
+ createdAt: null,
379
+ lastActivityAt: null,
380
+ attachedCount: Number.isFinite(attachedCount) ? attachedCount : 0,
381
+ isAttached: Number.isFinite(attachedCount) ? attachedCount > 0 : false,
382
+ ageMs: null,
383
+ idleMs: null,
384
+ });
385
+ })
386
+ .filter(Boolean);
387
+ }
388
+
389
+ function parseSessionInventory(output, now = Date.now()) {
390
+ return output
391
+ .split("\n")
392
+ .map((line) => line.trim())
393
+ .filter(Boolean)
394
+ .map((line) => {
395
+ const [
396
+ sessionName = "",
397
+ createdText = "",
398
+ activityText = "",
399
+ attached = "0",
400
+ ] = line.split("\t");
401
+ const createdAt = parsePsmuxEpoch(createdText);
402
+ const lastActivityAt = parsePsmuxEpoch(activityText) ?? createdAt;
403
+ const attachedCount = Number.parseInt(attached, 10);
404
+ const ageMs = createdAt == null ? null : Math.max(0, now - createdAt);
405
+ const idleMs =
406
+ lastActivityAt == null ? ageMs : Math.max(0, now - lastActivityAt);
407
+
408
+ if (!sessionName) return null;
409
+ return Object.freeze({
410
+ sessionName,
411
+ title: sessionName,
412
+ createdAt,
413
+ lastActivityAt,
414
+ attachedCount: Number.isFinite(attachedCount) ? attachedCount : 0,
415
+ isAttached: Number.isFinite(attachedCount) ? attachedCount > 0 : false,
416
+ ageMs,
417
+ idleMs,
418
+ });
256
419
  })
257
420
  .filter(Boolean);
258
421
  }
259
422
 
423
+ function listSessionInventoryRaw() {
424
+ const output = psmuxExec(["list-sessions", "-F", PSMUX_SESSION_LIST_FORMAT]);
425
+ const hasStructuredRows = output
426
+ .split("\n")
427
+ .some((line) => line.trim().includes("\t"));
428
+ return hasStructuredRows
429
+ ? parseSessionInventory(output)
430
+ : parseLegacySessionInventory(output);
431
+ }
432
+
260
433
  function parsePaneDetails(output) {
261
434
  return output
262
435
  .split("\n")
@@ -264,20 +437,29 @@ function parsePaneDetails(output) {
264
437
  .filter(Boolean)
265
438
  .map((line) => {
266
439
  const parts = line.split("\t");
267
- const hasPaneIndex = parts.length >= 5;
268
- const [paneIndexText = "", title = "", paneId = "", dead = "0", deadStatus = ""] = hasPaneIndex
269
- ? parts
270
- : ["", ...parts];
271
- const exitCode = dead === "1"
272
- ? Number.parseInt(deadStatus, 10)
273
- : null;
440
+ // tmux는 마지막 필드가 빈 문자열이면 trailing tab을 생략할 수 있음 (4개 필드)
441
+ // psmux는 항상 5개 필드를 반환
442
+ // paneId 패턴(session:N.N)이 parts[2]에 있으면 pane_index 포함 형식
443
+ const hasPaneIndex = parts.length >= 4 && /\S+:\d+\.\d+$/.test(parts[2]);
444
+ const [
445
+ paneIndexText = "",
446
+ title = "",
447
+ paneId = "",
448
+ dead = "0",
449
+ deadStatus = "",
450
+ ] = hasPaneIndex ? parts : ["", ...parts];
451
+ const exitCode = dead === "1" ? Number.parseInt(deadStatus, 10) : null;
274
452
  const paneIndex = Number.parseInt(paneIndexText, 10);
275
453
  return {
276
454
  title,
277
455
  paneId,
278
456
  paneIndex: Number.isFinite(paneIndex) ? paneIndex : null,
279
457
  isDead: dead === "1",
280
- exitCode: Number.isFinite(exitCode) ? exitCode : dead === "1" ? 0 : null,
458
+ exitCode: Number.isFinite(exitCode)
459
+ ? exitCode
460
+ : dead === "1"
461
+ ? 0
462
+ : null,
281
463
  };
282
464
  })
283
465
  .filter((entry) => entry.paneId);
@@ -320,7 +502,9 @@ function resolvePane(sessionName, paneNameOrTarget) {
320
502
  const panes = listPaneDetails(sessionName);
321
503
 
322
504
  // 1차: title 또는 paneId 직접 매칭
323
- const direct = panes.find((entry) => entry.title === wanted || entry.paneId === wanted);
505
+ const direct = panes.find(
506
+ (entry) => entry.title === wanted || entry.paneId === wanted,
507
+ );
324
508
  if (direct) return direct;
325
509
 
326
510
  // 2차: psmux title 미설정 fallback — "lead"→0, "worker-N"→N 인덱스 매칭
@@ -335,7 +519,14 @@ function refreshCaptureSnapshot(sessionName, paneNameOrTarget) {
335
519
  const paneName = pane.title || paneNameOrTarget;
336
520
  const logPath = getCaptureLogPath(sessionName, paneName);
337
521
  mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
338
- const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p", "-S", "-"]);
522
+ const snapshot = psmuxExec([
523
+ "capture-pane",
524
+ "-t",
525
+ pane.paneId,
526
+ "-p",
527
+ "-S",
528
+ "-",
529
+ ]);
339
530
  writeFileSync(logPath, snapshot, "utf8");
340
531
  return { paneId: pane.paneId, paneName, logPath, snapshot };
341
532
  }
@@ -364,7 +555,9 @@ export function sendKeysToPane(paneId, text, submit = true) {
364
555
 
365
556
  function toPatternRegExp(pattern) {
366
557
  if (pattern instanceof RegExp) {
367
- const flags = pattern.flags.includes("m") ? pattern.flags : `${pattern.flags}m`;
558
+ const flags = pattern.flags.includes("m")
559
+ ? pattern.flags
560
+ : `${pattern.flags}m`;
368
561
  return new RegExp(pattern.source, flags);
369
562
  }
370
563
  return new RegExp(String(pattern), "m");
@@ -389,20 +582,24 @@ function psmux(args, opts = {}) {
389
582
  });
390
583
  return result != null ? String(result).trim() : "";
391
584
  } catch (error) {
392
- const stderr = typeof error?.stderr === "string"
393
- ? error.stderr
394
- : error?.stderr?.toString?.("utf8") || "";
395
- const stdout = typeof error?.stdout === "string"
396
- ? error.stdout
397
- : error?.stdout?.toString?.("utf8") || "";
398
- const wrapped = new Error((stderr || stdout || error.message || "psmux command failed").trim());
585
+ const stderr =
586
+ typeof error?.stderr === "string"
587
+ ? error.stderr
588
+ : error?.stderr?.toString?.("utf8") || "";
589
+ const stdout =
590
+ typeof error?.stdout === "string"
591
+ ? error.stdout
592
+ : error?.stdout?.toString?.("utf8") || "";
593
+ const wrapped = new Error(
594
+ (stderr || stdout || error.message || "psmux command failed").trim(),
595
+ );
399
596
  wrapped.status = error.status;
400
597
  throw wrapped;
401
598
  }
402
599
  }
403
600
 
404
- /** psmux 실행 가능 여부 확인 */
405
- export function hasPsmux() {
601
+ /** primary terminal multiplexer (Windows=psmux, mac/Linux=tmux) 실행 가능 여부 */
602
+ export function hasMultiplexer() {
406
603
  try {
407
604
  childProcess.execFileSync(PSMUX_BIN, ["-V"], {
408
605
  stdio: "ignore",
@@ -415,6 +612,22 @@ export function hasPsmux() {
415
612
  }
416
613
  }
417
614
 
615
+ /**
616
+ * backward-compat alias — historical 이름 `hasPsmux` 는 OS 별 primary 의 의미를
617
+ * 가린다. 외부 caller (scripts/remote-spawn.mjs, scripts/session-spawn-helper.mjs,
618
+ * tests) 호환을 위해 alias 로 보존. 신규 코드는 hasMultiplexer 사용.
619
+ */
620
+ export const hasPsmux = hasMultiplexer;
621
+
622
+ /**
623
+ * 현재 resolved multiplexer 종류 — "psmux" | "tmux" | <user override path>.
624
+ * isTmuxFallback() 의 후속 — mac/Linux 에서 tmux 는 fallback 이 아닌 primary 라
625
+ * "fallback" 표현이 부정확해서 type 으로 정정.
626
+ */
627
+ export function getMultiplexerType() {
628
+ return PSMUX_BIN;
629
+ }
630
+
418
631
  /**
419
632
  * psmux 커맨드 실행 래퍼
420
633
  * @param {string|string[]} args
@@ -434,12 +647,14 @@ export function psmuxExec(args, opts = {}) {
434
647
  * @returns {{ sessionName: string, panes: string[] }}
435
648
  */
436
649
  export function createPsmuxSession(sessionName, opts = {}) {
437
- const layout = opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
650
+ const layout =
651
+ opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
438
652
  const paneCount = Math.max(
439
653
  1,
440
654
  Number.isFinite(opts.paneCount) ? Math.trunc(opts.paneCount) : 4,
441
655
  );
442
- const limitedPaneCount = layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
656
+ const limitedPaneCount =
657
+ layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
443
658
  const sessionTarget = `${sessionName}:0`;
444
659
 
445
660
  const newSessionArgs = [
@@ -456,12 +671,24 @@ export function createPsmuxSession(sessionName, opts = {}) {
456
671
  "55",
457
672
  ];
458
673
  // Windows: psmux 기본 셸이 cmd.exe일 수 있으므로 PowerShell 강제
459
- if (PWSH_BIN) newSessionArgs.push(PWSH_BIN, "-NoLogo", "-NoProfile");
674
+ // macOS/Linux: 기본 셸 사용 (PowerShell 플래그 불필요)
675
+ if (PWSH_BIN && IS_WINDOWS)
676
+ newSessionArgs.push(PWSH_BIN, "-NoLogo", "-NoProfile");
460
677
  const leadPane = psmuxExec(newSessionArgs);
461
678
 
462
- // split-window로 생성되는 pane도 동일 셸 사용
463
- if (PWSH_BIN) {
464
- try { psmuxExec(["set-option", "-t", sessionName, "default-command", `${PWSH_BIN} -NoLogo -NoProfile`]); } catch { /* 미지원 시 무시 */ }
679
+ // split-window로 생성되는 pane도 동일 셸 사용 (Windows: PowerShell 강제)
680
+ if (PWSH_BIN && IS_WINDOWS) {
681
+ try {
682
+ psmuxExec([
683
+ "set-option",
684
+ "-t",
685
+ sessionName,
686
+ "default-command",
687
+ `${PWSH_BIN} -NoLogo -NoProfile`,
688
+ ]);
689
+ } catch {
690
+ /* 미지원 시 무시 */
691
+ }
465
692
  }
466
693
 
467
694
  if (layout === "2x2" && limitedPaneCount >= 3) {
@@ -511,9 +738,30 @@ export function createPsmuxSession(sessionName, opts = {}) {
511
738
 
512
739
  const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
513
740
  panes.forEach((pane, index) => {
514
- psmuxExec(["select-pane", "-t", pane, "-T", toPaneTitle(index)]);
741
+ try {
742
+ psmuxExec(["select-pane", "-t", pane, "-T", toPaneTitle(index)]);
743
+ } catch {
744
+ // tmux 2.6 미만: select-pane -T 미지원 — pane title 없이 계속 진행
745
+ }
515
746
  });
516
747
 
748
+ // CP949 등 non-UTF-8 codepage 환경에서 CLI stdout 깨짐 방지:
749
+ // pane 생성 직후 UTF-8 인코딩을 강제 설정한다.
750
+ // chcp 65001: 프로세스 코드 페이지를 UTF-8로 전환
751
+ // OutputEncoding: 네이티브 명령(gemini/codex) stdout 디코딩
752
+ // $OutputEncoding: PowerShell 파이프라인 기본 인코딩
753
+ if (IS_WINDOWS) {
754
+ const encodingInit = [
755
+ "chcp 65001 > $null",
756
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
757
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
758
+ "$OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
759
+ ].join("; ");
760
+ panes.forEach((paneId) => {
761
+ sendLiteralToPane(paneId, encodingInit, true);
762
+ });
763
+ }
764
+
517
765
  return { sessionName, panes };
518
766
  }
519
767
 
@@ -525,7 +773,11 @@ export function createPsmuxSession(sessionName, opts = {}) {
525
773
  function collectPanePids(sessionName) {
526
774
  try {
527
775
  const output = psmuxExec([
528
- "list-panes", "-t", sessionName, "-F", "#{pane_pid}",
776
+ "list-panes",
777
+ "-t",
778
+ sessionName,
779
+ "-F",
780
+ "#{pane_pid}",
529
781
  ]);
530
782
  return output
531
783
  .split(/\r?\n/)
@@ -541,7 +793,64 @@ function collectPanePids(sessionName) {
541
793
  * @param {number} pid
542
794
  */
543
795
  function killProcessTree(pid) {
544
- if (!IS_WINDOWS || !pid) return;
796
+ if (!pid) return;
797
+ if (!IS_WINDOWS) {
798
+ // macOS/Linux: BFS로 전체 자손 수집 → SIGTERM → SIGKILL 에스컬레이션
799
+ const collectChildren = (parentPid) => {
800
+ try {
801
+ return childProcess
802
+ .execFileSync("pgrep", ["-P", String(parentPid)], {
803
+ encoding: "utf8",
804
+ timeout: 3000,
805
+ stdio: ["ignore", "pipe", "ignore"],
806
+ })
807
+ .trim()
808
+ .split("\n")
809
+ .filter(Boolean)
810
+ .map(Number);
811
+ } catch {
812
+ return [];
813
+ }
814
+ };
815
+ const allDesc = [];
816
+ const queue = [pid];
817
+ const seen = new Set([pid]);
818
+ while (queue.length > 0) {
819
+ const cur = queue.shift();
820
+ for (const child of collectChildren(cur)) {
821
+ if (!seen.has(child)) {
822
+ seen.add(child);
823
+ allDesc.push(child);
824
+ queue.push(child);
825
+ }
826
+ }
827
+ }
828
+ // SIGTERM 먼저
829
+ for (const c of allDesc) {
830
+ try {
831
+ process.kill(c, "SIGTERM");
832
+ } catch {}
833
+ }
834
+ try {
835
+ process.kill(pid, "SIGTERM");
836
+ } catch {}
837
+ // 1초 대기 후 생존자 SIGKILL
838
+ try {
839
+ childProcess.execFileSync("sleep", ["1"], {
840
+ timeout: 2000,
841
+ stdio: "ignore",
842
+ });
843
+ } catch {}
844
+ for (const c of allDesc) {
845
+ try {
846
+ process.kill(c, "SIGKILL");
847
+ } catch {}
848
+ }
849
+ try {
850
+ process.kill(pid, "SIGKILL");
851
+ } catch {}
852
+ return;
853
+ }
545
854
  try {
546
855
  childProcess.execSync(`taskkill /T /F /PID ${pid}`, {
547
856
  stdio: "ignore",
@@ -574,14 +883,51 @@ function disableAllPipeCaptures(sessionName, paneIds) {
574
883
  * @param {string} sessionName
575
884
  */
576
885
  function killOrphanPipeHelpers(sessionName) {
577
- if (!IS_WINDOWS) return;
578
- const safeSession = sanitizePathPart(sessionName);
886
+ if (!IS_WINDOWS) {
887
+ // macOS/Linux: pipe-pane helper cmdline contains
888
+ // `<CAPTURE_ROOT>/<session>/<pane>.log`, so anchor on `/<session>/`.
889
+ const safeSessionUnix = escapeRegex(sanitizePathPart(sessionName));
890
+ try {
891
+ const pids = childProcess
892
+ .execFileSync(
893
+ "pgrep",
894
+ ["-f", `pipe-pane-capture.*/${safeSessionUnix}/`],
895
+ {
896
+ encoding: "utf8",
897
+ timeout: 5000,
898
+ stdio: ["ignore", "pipe", "ignore"],
899
+ },
900
+ )
901
+ .trim();
902
+ if (pids) {
903
+ for (const p of pids.split("\n").filter(Boolean)) {
904
+ try {
905
+ process.kill(Number(p), "SIGTERM");
906
+ } catch {}
907
+ }
908
+ }
909
+ } catch {
910
+ /* 프로세스 없으면 pgrep exit 1 — 무시 */
911
+ }
912
+ return;
913
+ }
914
+ // Windows: escape regex metacharacters and require a trailing path
915
+ // boundary so sibling sessions (e.g. `<session>2-...`) don't match.
916
+ const safeSession = escapeRegex(sanitizePathPart(sessionName));
579
917
  try {
580
918
  const output = childProcess.execSync(
581
- `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"`,
582
- { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
919
+ `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"`,
920
+ {
921
+ encoding: "utf8",
922
+ timeout: 8000,
923
+ stdio: ["pipe", "pipe", "pipe"],
924
+ windowsHide: true,
925
+ },
583
926
  );
584
- const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0);
927
+ const pids = output
928
+ .split(/\r?\n/)
929
+ .map((l) => Number.parseInt(l.trim(), 10))
930
+ .filter((p) => Number.isFinite(p) && p > 0);
585
931
  for (const pid of pids) {
586
932
  killProcessTree(pid);
587
933
  }
@@ -597,13 +943,61 @@ function killOrphanPipeHelpers(sessionName) {
597
943
  * @param {string} sessionName
598
944
  */
599
945
  function killOrphanMcpProcesses(sessionName) {
600
- if (!IS_WINDOWS) return;
601
- const safeSession = sanitizePathPart(sessionName);
946
+ if (!IS_WINDOWS) {
947
+ // macOS/Linux: 세션별 스코핑 + Hub PID 보호
948
+ const safeSessionUnix = sanitizePathPart(sessionName);
949
+ let hubPidUnix = 0;
950
+ try {
951
+ const hubPidFile = join(
952
+ homedir(),
953
+ ".claude",
954
+ "cache",
955
+ "tfx-hub",
956
+ "hub.pid",
957
+ );
958
+ if (existsSync(hubPidFile)) {
959
+ const info = JSON.parse(readFileSync(hubPidFile, "utf8"));
960
+ hubPidUnix = Number(info.pid) || 0;
961
+ }
962
+ } catch {}
963
+ try {
964
+ // MCP/result files live under `tfx-headless/<session>-<pane>.txt`, so
965
+ // match `tfx-headless/<session>` with a trailing boundary to avoid
966
+ // killing sibling sessions whose names share a prefix
967
+ // (e.g. `<session>2-worker-1.txt`).
968
+ const escSession = escapeRegex(safeSessionUnix);
969
+ const pids = childProcess
970
+ .execFileSync("pgrep", ["-f", `tfx-headless/${escSession}[-./]`], {
971
+ encoding: "utf8",
972
+ timeout: 5000,
973
+ stdio: ["ignore", "pipe", "ignore"],
974
+ })
975
+ .trim();
976
+ if (pids) {
977
+ for (const p of pids.split("\n").filter(Boolean)) {
978
+ const numPid = Number(p);
979
+ if (numPid === hubPidUnix || numPid <= 0) continue;
980
+ try {
981
+ process.kill(numPid, "SIGTERM");
982
+ } catch {}
983
+ }
984
+ }
985
+ } catch {}
986
+ return;
987
+ }
988
+ // Windows: escape + trailing boundary (mirror of the macOS branch).
989
+ const safeSession = escapeRegex(sanitizePathPart(sessionName));
602
990
 
603
991
  // Hub PID 보호 — Hub 프로세스를 고아로 잘못 식별하지 않도록
604
992
  let hubPid = null;
605
993
  try {
606
- const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
994
+ const hubPidPath = join(
995
+ homedir(),
996
+ ".claude",
997
+ "cache",
998
+ "tfx-hub",
999
+ "hub.pid",
1000
+ );
607
1001
  if (existsSync(hubPidPath)) {
608
1002
  const hubInfo = JSON.parse(readFileSync(hubPidPath, "utf8"));
609
1003
  hubPid = Number(hubInfo?.pid);
@@ -613,10 +1007,18 @@ function killOrphanMcpProcesses(sessionName) {
613
1007
  try {
614
1008
  // 세션 결과 디렉토리 패턴으로 MCP 서버 프로세스 식별
615
1009
  const output = childProcess.execSync(
616
- `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"`,
617
- { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
1010
+ `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"`,
1011
+ {
1012
+ encoding: "utf8",
1013
+ timeout: 8000,
1014
+ stdio: ["pipe", "pipe", "pipe"],
1015
+ windowsHide: true,
1016
+ },
618
1017
  );
619
- const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0 && p !== hubPid);
1018
+ const pids = output
1019
+ .split(/\r?\n/)
1020
+ .map((l) => Number.parseInt(l.trim(), 10))
1021
+ .filter((p) => Number.isFinite(p) && p > 0 && p !== hubPid);
620
1022
  for (const pid of pids) {
621
1023
  killProcessTree(pid);
622
1024
  }
@@ -639,12 +1041,15 @@ function detachAttachedClients(sessionName, waitMs = 750) {
639
1041
 
640
1042
  function findFallbackPane(sessionName, excludedPaneId) {
641
1043
  try {
642
- const panes = listPaneDetails(sessionName)
643
- .filter((pane) => pane.paneId !== excludedPaneId && !pane.isDead);
1044
+ const panes = listPaneDetails(sessionName).filter(
1045
+ (pane) => pane.paneId !== excludedPaneId && !pane.isDead,
1046
+ );
644
1047
  if (panes.length === 0) return null;
645
- return panes.find((pane) => pane.title === "lead")
646
- || panes.find((pane) => pane.paneIndex === 0)
647
- || panes[0];
1048
+ return (
1049
+ panes.find((pane) => pane.title === "lead") ||
1050
+ panes.find((pane) => pane.paneIndex === 0) ||
1051
+ panes[0]
1052
+ );
648
1053
  } catch {
649
1054
  return null;
650
1055
  }
@@ -689,6 +1094,94 @@ export function killPsmuxSession(sessionName) {
689
1094
  killOrphanMcpProcesses(sessionName);
690
1095
  }
691
1096
 
1097
+ /**
1098
+ * 활성 psmux 세션 메타데이터 조회
1099
+ * @param {object} [opts]
1100
+ * @param {string|RegExp} [opts.filterTitle] - 세션명 prefix 또는 /regex/flags
1101
+ * @param {number} [opts.olderThanMs] - 생성 후 경과 시간 필터
1102
+ * @returns {Array<{sessionName: string, title: string, createdAt: number|null, lastActivityAt: number|null, attachedCount: number, isAttached: boolean, ageMs: number|null, idleMs: number|null}>}
1103
+ */
1104
+ export function listSessions(opts = {}) {
1105
+ ensurePsmuxInstalled();
1106
+ const matcher = toTitleMatcher(opts.filterTitle);
1107
+ const olderThanMs = normalizeOlderThanMs(opts.olderThanMs, 0);
1108
+
1109
+ return listSessionInventoryRaw().filter((session) => {
1110
+ if (!matcher(session.title)) return false;
1111
+ if (olderThanMs <= 0) return true;
1112
+ return Number.isFinite(session.ageMs) && session.ageMs >= olderThanMs;
1113
+ });
1114
+ }
1115
+
1116
+ /**
1117
+ * 세션명 prefix/regex 역조회 후 세션 종료
1118
+ * @param {string|RegExp} titlePattern
1119
+ * @returns {{ matchedCount: number, killedCount: number, sessions: string[] }}
1120
+ */
1121
+ export function killSessionByTitle(titlePattern) {
1122
+ ensurePsmuxInstalled();
1123
+ const sessions = listSessions({ filterTitle: titlePattern });
1124
+ const killed = [];
1125
+ for (const session of sessions) {
1126
+ psmuxExec(["kill-session", "-t", session.sessionName], { stdio: "ignore" });
1127
+ killed.push(session.sessionName);
1128
+ }
1129
+ return {
1130
+ matchedCount: sessions.length,
1131
+ killedCount: killed.length,
1132
+ sessions: killed,
1133
+ };
1134
+ }
1135
+
1136
+ /**
1137
+ * 오래 idle 상태인 detached 세션 일괄 정리
1138
+ * @param {object} [opts]
1139
+ * @param {number} [opts.olderThanMs=3600000]
1140
+ * @param {boolean} [opts.dryRun=false]
1141
+ * @returns {{ dryRun: boolean, matchedCount: number, killedCount: number, sessions: string[] }}
1142
+ */
1143
+ export function pruneStale(opts = {}) {
1144
+ ensurePsmuxInstalled();
1145
+ const olderThanMs = normalizeOlderThanMs(opts.olderThanMs, 3600000);
1146
+ const dryRun = opts.dryRun === true;
1147
+ const sessions = listSessionInventoryRaw().filter((session) => {
1148
+ if (session.isAttached) return false;
1149
+ return Number.isFinite(session.idleMs) && session.idleMs >= olderThanMs;
1150
+ });
1151
+
1152
+ if (dryRun) {
1153
+ return {
1154
+ dryRun: true,
1155
+ matchedCount: sessions.length,
1156
+ killedCount: 0,
1157
+ sessions: sessions.map((session) => session.sessionName),
1158
+ };
1159
+ }
1160
+
1161
+ if (sessions.length === 0) {
1162
+ return {
1163
+ dryRun: false,
1164
+ matchedCount: 0,
1165
+ killedCount: 0,
1166
+ sessions: [],
1167
+ };
1168
+ }
1169
+
1170
+ const result = killSessionByTitle(
1171
+ new RegExp(
1172
+ `^(?:${sessions
1173
+ .map((session) => escapeRegExp(session.title))
1174
+ .join("|")})$`,
1175
+ ),
1176
+ );
1177
+ return {
1178
+ dryRun: false,
1179
+ matchedCount: sessions.length,
1180
+ killedCount: result.killedCount,
1181
+ sessions: result.sessions,
1182
+ };
1183
+ }
1184
+
692
1185
  /**
693
1186
  * psmux 세션 존재 확인
694
1187
  * @param {string} sessionName
@@ -709,7 +1202,7 @@ export function psmuxSessionExists(sessionName) {
709
1202
  */
710
1203
  export function listPsmuxSessions() {
711
1204
  try {
712
- return parseSessionSummaries(psmuxExec(["list-sessions"]))
1205
+ return listSessionInventoryRaw()
713
1206
  .map((session) => session.sessionName)
714
1207
  .filter((sessionName) => sessionName.startsWith("tfx-multi-"));
715
1208
  } catch {
@@ -738,11 +1231,15 @@ export function capturePsmuxPane(target, lines = 5) {
738
1231
  * @param {string} sessionName
739
1232
  */
740
1233
  export function attachPsmuxSession(sessionName) {
741
- const result = childProcess.spawnSync(PSMUX_BIN, ["attach-session", "-t", sessionName], {
742
- stdio: "inherit",
743
- timeout: 0,
744
- windowsHide: false,
745
- });
1234
+ const result = childProcess.spawnSync(
1235
+ PSMUX_BIN,
1236
+ ["attach-session", "-t", sessionName],
1237
+ {
1238
+ stdio: "inherit",
1239
+ timeout: 0,
1240
+ windowsHide: false,
1241
+ },
1242
+ );
746
1243
  if ((result.status ?? 1) !== 0) {
747
1244
  throw new Error(`psmux attach 실패 (exit=${result.status})`);
748
1245
  }
@@ -755,8 +1252,9 @@ export function attachPsmuxSession(sessionName) {
755
1252
  */
756
1253
  export function getPsmuxSessionAttachedCount(sessionName) {
757
1254
  try {
758
- const session = parseSessionSummaries(psmuxExec(["list-sessions"]))
759
- .find((entry) => entry.sessionName === sessionName);
1255
+ const session = listSessionInventoryRaw().find(
1256
+ (entry) => entry.sessionName === sessionName,
1257
+ );
760
1258
  return session ? session.attachedCount : null;
761
1259
  } catch {
762
1260
  return null;
@@ -790,12 +1288,78 @@ export function configurePsmuxKeybindings(sessionName, opts = {}) {
790
1288
  }
791
1289
  };
792
1290
 
793
- bindSafe(["bind-key", "-T", "root", "-n", "S-Down", "if-shell", "-F", cond, bindNext, "send-keys S-Down"]);
794
- bindSafe(["bind-key", "-T", "root", "-n", "S-Up", "if-shell", "-F", cond, bindPrev, "send-keys S-Up"]);
795
- bindSafe(["bind-key", "-T", "root", "-n", "S-Right", "if-shell", "-F", cond, bindNext, "send-keys S-Right"]);
796
- bindSafe(["bind-key", "-T", "root", "-n", "S-Left", "if-shell", "-F", cond, bindPrev, "send-keys S-Left"]);
797
- bindSafe(["bind-key", "-T", "root", "-n", "BTab", "if-shell", "-F", cond, bindPrev, "send-keys BTab"]);
798
- bindSafe(["bind-key", "-T", "root", "-n", "Escape", "if-shell", "-F", cond, "send-keys C-c", "send-keys Escape"]);
1291
+ bindSafe([
1292
+ "bind-key",
1293
+ "-T",
1294
+ "root",
1295
+ "-n",
1296
+ "S-Down",
1297
+ "if-shell",
1298
+ "-F",
1299
+ cond,
1300
+ bindNext,
1301
+ "send-keys S-Down",
1302
+ ]);
1303
+ bindSafe([
1304
+ "bind-key",
1305
+ "-T",
1306
+ "root",
1307
+ "-n",
1308
+ "S-Up",
1309
+ "if-shell",
1310
+ "-F",
1311
+ cond,
1312
+ bindPrev,
1313
+ "send-keys S-Up",
1314
+ ]);
1315
+ bindSafe([
1316
+ "bind-key",
1317
+ "-T",
1318
+ "root",
1319
+ "-n",
1320
+ "S-Right",
1321
+ "if-shell",
1322
+ "-F",
1323
+ cond,
1324
+ bindNext,
1325
+ "send-keys S-Right",
1326
+ ]);
1327
+ bindSafe([
1328
+ "bind-key",
1329
+ "-T",
1330
+ "root",
1331
+ "-n",
1332
+ "S-Left",
1333
+ "if-shell",
1334
+ "-F",
1335
+ cond,
1336
+ bindPrev,
1337
+ "send-keys S-Left",
1338
+ ]);
1339
+ bindSafe([
1340
+ "bind-key",
1341
+ "-T",
1342
+ "root",
1343
+ "-n",
1344
+ "BTab",
1345
+ "if-shell",
1346
+ "-F",
1347
+ cond,
1348
+ bindPrev,
1349
+ "send-keys BTab",
1350
+ ]);
1351
+ bindSafe([
1352
+ "bind-key",
1353
+ "-T",
1354
+ "root",
1355
+ "-n",
1356
+ "Escape",
1357
+ "if-shell",
1358
+ "-F",
1359
+ cond,
1360
+ "send-keys C-c",
1361
+ "send-keys Escape",
1362
+ ]);
799
1363
 
800
1364
  if (taskListCommand) {
801
1365
  bindSafe([
@@ -831,12 +1395,10 @@ export function startCapture(sessionName, paneNameOrTarget) {
831
1395
  writeFileSync(logPath, "", "utf8");
832
1396
 
833
1397
  disablePipeCapture(pane.paneId);
834
- psmuxExec([
835
- "pipe-pane",
836
- "-t",
837
- pane.paneId,
838
- `powershell.exe -NoLogo -NoProfile -File ${quoteArg(helperPath)} ${quoteArg(logPath)}`,
839
- ]);
1398
+ const pipePaneCmd = IS_WINDOWS
1399
+ ? `powershell.exe -NoLogo -NoProfile -File ${quoteArg(helperPath)} ${quoteArg(logPath)}`
1400
+ : `${quoteArg(helperPath)} ${quoteArg(logPath)}`;
1401
+ psmuxExec(["pipe-pane", "-t", pane.paneId, pipePaneCmd]);
840
1402
 
841
1403
  refreshCaptureSnapshot(sessionName, pane.paneId);
842
1404
  return { paneId: pane.paneId, paneName, logPath };
@@ -857,15 +1419,49 @@ export function startCapture(sessionName, paneNameOrTarget) {
857
1419
  * @param {string} cmd
858
1420
  * @returns {string}
859
1421
  */
1422
+ // Windows: PowerShell의 bash가 WSL을 가리킬 수 있음. Git Bash를 명시적으로 사용.
1423
+ // WSL bash에서는 /c/Users/... 경로가 유효하지 않아 exit 127 발생.
1424
+ // psmux pane 용 (PowerShell → bash 실행): & "path" 형식
1425
+ // Node.js execSync 용 (직접 실행): "path" 형식
1426
+ const _gitBashPath = IS_WINDOWS ? resolveGitBashExecutable() : null;
1427
+ // PowerShell call operator(&)가 필요: & "C:\...\bash.exe" -c '...'
1428
+ const GIT_BASH_BIN_PS = _gitBashPath ? `& "${_gitBashPath}"` : "bash";
1429
+ // Node.js execSync에서 직접 실행할 때는 따옴표만
1430
+ const GIT_BASH_BIN_EXEC = _gitBashPath ? `"${_gitBashPath}"` : "bash";
1431
+
1432
+ // CLI 바이너리 절대 경로 캐시 (세션 중 1회만 resolve)
1433
+ const _cliPathCache = new Map();
1434
+ function resolveCliAbsPath(name) {
1435
+ if (_cliPathCache.has(name)) return _cliPathCache.get(name);
1436
+ try {
1437
+ const resolved = childProcess
1438
+ .execSync(`${GIT_BASH_BIN_EXEC} -c "which ${name}"`, {
1439
+ encoding: "utf8",
1440
+ timeout: 3000,
1441
+ })
1442
+ .trim();
1443
+ if (resolved) _cliPathCache.set(name, resolved);
1444
+ return resolved || name;
1445
+ } catch {
1446
+ return name;
1447
+ }
1448
+ }
1449
+
860
1450
  function wrapCliForBash(cmd) {
861
1451
  const trimmed = cmd.trimStart();
862
1452
  // PowerShell 구문(Clear-Host, Get-Content 등) 또는 completion token이 포함되면 PowerShell 직통
863
1453
  if (/Clear-Host|Get-Content|__TRIFLUX_DONE__/i.test(trimmed)) return cmd;
864
- const isCli = /\b(codex|gemini)\b/u.test(trimmed);
865
- if (!isCli) return cmd;
1454
+ const cliMatch = trimmed.match(/^(codex|gemini)\b/u);
1455
+ if (!cliMatch) return cmd;
1456
+ // Node.js 측에서 절대 경로 resolve → psmux pane의 bash PATH 불일치 문제 해결 (exit 127)
1457
+ const absPath = resolveCliAbsPath(cliMatch[1]);
1458
+ const resolved =
1459
+ absPath !== cliMatch[1]
1460
+ ? trimmed.replace(new RegExp(`^${cliMatch[1]}\\b`), absPath)
1461
+ : trimmed;
866
1462
  // 단일 따옴표 이스케이프: ' → '\''
867
- const escaped = trimmed.replace(/'/g, "'\\''");
868
- return `bash -c '${escaped}'`;
1463
+ const escaped = resolved.replace(/'/g, "'\\''");
1464
+ return `${GIT_BASH_BIN_PS} -c '${escaped}'`;
869
1465
  }
870
1466
 
871
1467
  export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
@@ -879,10 +1475,17 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
879
1475
  }
880
1476
 
881
1477
  const token = randomToken(paneName);
882
- const safeCommand = wrapCliForBash(commandText);
883
- // CP949 등 non-UTF-8 codepage 환경에서 CLI stdout이 깨지는 문제 방지 (belt-and-suspenders)
884
- const chcpPrefix = IS_WINDOWS ? "chcp 65001 > $null; " : "";
885
- const wrapped = `${chcpPrefix}try { ${safeCommand} } finally { $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit" }`;
1478
+ const safeCommand = IS_WINDOWS ? wrapCliForBash(commandText) : commandText;
1479
+
1480
+ let wrapped;
1481
+ if (IS_WINDOWS) {
1482
+ // CP949 등 non-UTF-8 codepage 환경에서 CLI stdout이 깨지는 문제 방지 (belt-and-suspenders)
1483
+ const chcpPrefix = "chcp 65001 > $null; ";
1484
+ wrapped = `${chcpPrefix}try { ${safeCommand} } finally { $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit" }`;
1485
+ } else {
1486
+ // macOS/Linux: bash 구문으로 완료 토큰 출력
1487
+ wrapped = `{ ${safeCommand}; __ec=$?; echo "${COMPLETION_PREFIX}${token}:$__ec"; }`;
1488
+ }
886
1489
 
887
1490
  sendLiteralToPane(pane.paneId, wrapped, true);
888
1491
 
@@ -900,7 +1503,13 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
900
1503
  * @param {AbortSignal} [opts.signal] — 외부에서 폴링 중단 요청 시 사용
901
1504
  * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, aborted?: boolean }}
902
1505
  */
903
- export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300, opts = {}) {
1506
+ export async function waitForPattern(
1507
+ sessionName,
1508
+ paneNameOrTarget,
1509
+ pattern,
1510
+ timeoutSec = 300,
1511
+ opts = {},
1512
+ ) {
904
1513
  ensurePsmuxInstalled();
905
1514
 
906
1515
  // E4 크래시 복구: 초기 resolvePane도 세션 사망을 감지
@@ -923,11 +1532,14 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
923
1532
 
924
1533
  const paneName = pane.title || paneNameOrTarget;
925
1534
  // opts.logPath: dispatch 시 확정된 캡처 로그 경로 직접 지정 (타이틀 변경 내성)
926
- const logPath = (opts.logPath && existsSync(opts.logPath))
927
- ? opts.logPath
928
- : getCaptureLogPath(sessionName, paneName);
1535
+ const logPath =
1536
+ opts.logPath && existsSync(opts.logPath)
1537
+ ? opts.logPath
1538
+ : getCaptureLogPath(sessionName, paneName);
929
1539
  if (!existsSync(logPath)) {
930
- throw new Error(`캡처 로그가 없습니다. 먼저 startCapture(${sessionName}, ${paneName})를 호출하세요.`);
1540
+ throw new Error(
1541
+ `캡처 로그가 없습니다. 먼저 startCapture(${sessionName}, ${paneName})를 호출하세요.`,
1542
+ );
931
1543
  }
932
1544
 
933
1545
  const startTime = Date.now();
@@ -935,18 +1547,39 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
935
1547
  const regex = toPatternRegExp(pattern);
936
1548
 
937
1549
  if (opts?.signal?.aborted) {
938
- return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
1550
+ return {
1551
+ matched: false,
1552
+ paneId: pane.paneId,
1553
+ paneName,
1554
+ logPath,
1555
+ match: null,
1556
+ aborted: true,
1557
+ };
939
1558
  }
940
1559
 
941
1560
  while (Date.now() <= deadline) {
942
1561
  if (opts?.signal?.aborted) {
943
- return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
1562
+ return {
1563
+ matched: false,
1564
+ paneId: pane.paneId,
1565
+ paneName,
1566
+ logPath,
1567
+ match: null,
1568
+ aborted: true,
1569
+ };
944
1570
  }
945
1571
  // E4 크래시 복구: capture 실패 시 세션 생존 체크
946
1572
  try {
947
1573
  if (opts.logPath) {
948
1574
  // logPath 직접 지정 시 — 셸 타이틀 변경과 무관하게 올바른 파일에 기록
949
- const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p", "-S", "-"]);
1575
+ const snapshot = psmuxExec([
1576
+ "capture-pane",
1577
+ "-t",
1578
+ pane.paneId,
1579
+ "-p",
1580
+ "-S",
1581
+ "-",
1582
+ ]);
950
1583
  writeFileSync(logPath, snapshot, "utf8");
951
1584
  } else {
952
1585
  refreshCaptureSnapshot(sessionName, pane.paneId);
@@ -970,8 +1603,15 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
970
1603
  // onPoll 콜백 — 각 폴링 주기마다 중간 상태 전달
971
1604
  if (opts.onPoll) {
972
1605
  try {
973
- opts.onPoll({ content, paneId: pane.paneId, paneName, elapsed: Date.now() - startTime });
974
- } catch { /* 콜백 예외는 삼킴 — 폴링 루프 보호 */ }
1606
+ opts.onPoll({
1607
+ content,
1608
+ paneId: pane.paneId,
1609
+ paneName,
1610
+ elapsed: Date.now() - startTime,
1611
+ });
1612
+ } catch {
1613
+ /* 콜백 예외는 삼킴 — 폴링 루프 보호 */
1614
+ }
975
1615
  }
976
1616
 
977
1617
  const match = regex.exec(content);
@@ -990,7 +1630,14 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
990
1630
  }
991
1631
  await sleepMsAsync(POLL_INTERVAL_MS);
992
1632
  if (opts?.signal?.aborted) {
993
- return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
1633
+ return {
1634
+ matched: false,
1635
+ paneId: pane.paneId,
1636
+ paneName,
1637
+ logPath,
1638
+ match: null,
1639
+ aborted: true,
1640
+ };
994
1641
  }
995
1642
  }
996
1643
 
@@ -1005,6 +1652,7 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
1005
1652
 
1006
1653
  /**
1007
1654
  * 완료 토큰이 찍힐 때까지 대기하고 exit code를 파싱한다.
1655
+ * NOTE: 주 채널은 headless.waitForCompletionWithStallDetect이며, 본 함수는 fallback 채널이다.
1008
1656
  * @param {string} sessionName
1009
1657
  * @param {string} paneNameOrTarget
1010
1658
  * @param {string} token
@@ -1012,19 +1660,38 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
1012
1660
  * @param {object} [opts] — waitForPattern에 전달할 옵션 (onPoll 등)
1013
1661
  * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
1014
1662
  */
1015
- export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300, opts = {}) {
1663
+ export async function waitForCompletion(
1664
+ sessionName,
1665
+ paneNameOrTarget,
1666
+ token,
1667
+ timeoutSec = 300,
1668
+ opts = {},
1669
+ ) {
1016
1670
  const completionRegex = new RegExp(
1017
1671
  `${escapeRegExp(COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
1018
1672
  "m",
1019
1673
  );
1020
- const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec, opts);
1674
+ const result = await waitForPattern(
1675
+ sessionName,
1676
+ paneNameOrTarget,
1677
+ completionRegex,
1678
+ timeoutSec,
1679
+ opts,
1680
+ );
1021
1681
 
1022
1682
  // 타이밍 이슈 대응: matched=false인 경우 500ms 대기 후 최종 1회 캡처 재시도
1023
1683
  if (!result.matched && !result.sessionDead && result.logPath) {
1024
1684
  await new Promise((r) => setTimeout(r, 500));
1025
1685
  try {
1026
1686
  const pane = resolvePane(sessionName, paneNameOrTarget);
1027
- const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p", "-S", "-"]);
1687
+ const snapshot = psmuxExec([
1688
+ "capture-pane",
1689
+ "-t",
1690
+ pane.paneId,
1691
+ "-p",
1692
+ "-S",
1693
+ "-",
1694
+ ]);
1028
1695
  writeFileSync(result.logPath, snapshot, "utf8");
1029
1696
  const content = readCaptureLog(result.logPath);
1030
1697
  const retryMatch = completionRegex.exec(content);
@@ -1063,7 +1730,7 @@ export function spawnWorker(sessionName, workerName, cmd) {
1063
1730
  if (!hasPsmux()) {
1064
1731
  throw new Error(
1065
1732
  "psmux가 설치되어 있지 않습니다.\n" +
1066
- `설치 방법:\n${formatPsmuxInstallGuidance(" ")}`
1733
+ `설치 방법:\n${formatPsmuxInstallGuidance(" ")}`,
1067
1734
  );
1068
1735
  }
1069
1736
 
@@ -1090,10 +1757,16 @@ export function spawnWorker(sessionName, workerName, cmd) {
1090
1757
  "#{session_name}:#{window_index}.#{pane_index}",
1091
1758
  shellCmd,
1092
1759
  ]);
1093
- psmuxExec(["select-pane", "-t", paneTarget, "-T", workerName]);
1760
+ try {
1761
+ psmuxExec(["select-pane", "-t", paneTarget, "-T", workerName]);
1762
+ } catch {
1763
+ // tmux 2.6 미만: select-pane -T 미지원 — 무시
1764
+ }
1094
1765
  return { paneId: paneTarget, workerName };
1095
1766
  } catch (err) {
1096
- throw new Error(`워커 생성 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
1767
+ throw new Error(
1768
+ `워커 생성 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`,
1769
+ );
1097
1770
  }
1098
1771
  }
1099
1772
 
@@ -1105,7 +1778,9 @@ export function spawnWorker(sessionName, workerName, cmd) {
1105
1778
  */
1106
1779
  export function getWorkerStatus(sessionName, workerName) {
1107
1780
  if (!hasPsmux()) {
1108
- throw new Error(`psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`);
1781
+ throw new Error(
1782
+ `psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`,
1783
+ );
1109
1784
  }
1110
1785
  try {
1111
1786
  const pane = resolvePane(sessionName, workerName);
@@ -1118,7 +1793,9 @@ export function getWorkerStatus(sessionName, workerName) {
1118
1793
  if (err.message.includes("Pane을 찾을 수 없습니다")) {
1119
1794
  throw new Error(`워커를 찾을 수 없습니다: ${workerName}`);
1120
1795
  }
1121
- throw new Error(`워커 상태 조회 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
1796
+ throw new Error(
1797
+ `워커 상태 조회 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`,
1798
+ );
1122
1799
  }
1123
1800
  }
1124
1801
 
@@ -1130,14 +1807,15 @@ export function getWorkerStatus(sessionName, workerName) {
1130
1807
  */
1131
1808
  export function killWorker(sessionName, workerName) {
1132
1809
  if (!hasPsmux()) {
1133
- throw new Error(`psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`);
1810
+ throw new Error(
1811
+ `psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`,
1812
+ );
1134
1813
  }
1135
1814
  try {
1136
1815
  const { paneId, status } = getWorkerStatus(sessionName, workerName);
1137
1816
  const attachedCount = getPsmuxSessionAttachedCount(sessionName);
1138
- const fallbackPane = attachedCount > 0
1139
- ? findFallbackPane(sessionName, paneId)
1140
- : null;
1817
+ const fallbackPane =
1818
+ attachedCount > 0 ? findFallbackPane(sessionName, paneId) : null;
1141
1819
 
1142
1820
  if (fallbackPane?.paneId) {
1143
1821
  try {
@@ -1152,7 +1830,13 @@ export function killWorker(sessionName, workerName) {
1152
1830
 
1153
1831
  // pane PID 수집 → 프로세스 트리 정리 (MCP 서버 좀비 방지)
1154
1832
  try {
1155
- const pidOutput = psmuxExec(["list-panes", "-t", paneId, "-F", "#{pane_pid}"]);
1833
+ const pidOutput = psmuxExec([
1834
+ "list-panes",
1835
+ "-t",
1836
+ paneId,
1837
+ "-F",
1838
+ "#{pane_pid}",
1839
+ ]);
1156
1840
  const pid = Number.parseInt(pidOutput.trim(), 10);
1157
1841
  if (Number.isFinite(pid) && pid > 0) killProcessTree(pid);
1158
1842
  } catch {
@@ -1204,7 +1888,9 @@ export function killWorker(sessionName, workerName) {
1204
1888
  if (err.message.includes("워커를 찾을 수 없습니다")) {
1205
1889
  return { killed: true };
1206
1890
  }
1207
- throw new Error(`워커 종료 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
1891
+ throw new Error(
1892
+ `워커 종료 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`,
1893
+ );
1208
1894
  }
1209
1895
  }
1210
1896
 
@@ -1217,138 +1903,255 @@ export function killWorker(sessionName, workerName) {
1217
1903
  */
1218
1904
  export function captureWorkerOutput(sessionName, workerName, lines = 50) {
1219
1905
  if (!hasPsmux()) {
1220
- throw new Error(`psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`);
1906
+ throw new Error(
1907
+ `psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`,
1908
+ );
1221
1909
  }
1222
1910
  try {
1223
1911
  const { paneId } = getWorkerStatus(sessionName, workerName);
1224
1912
  return psmuxExec(["capture-pane", "-t", paneId, "-p", "-S", `-${lines}`]);
1225
1913
  } catch (err) {
1226
1914
  if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
1227
- throw new Error(`출력 캡처 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
1915
+ throw new Error(
1916
+ `출력 캡처 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`,
1917
+ );
1228
1918
  }
1229
1919
  }
1230
1920
 
1231
1921
  // ─── CLI 진입점 ───
1232
1922
 
1233
- if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
1923
+ if (process.argv[1]?.endsWith("psmux.mjs")) {
1234
1924
  (async () => {
1235
- const [, , cmd, ...args] = process.argv;
1236
-
1237
- // CLI 인자 파싱 헬퍼
1238
- function getArg(name) {
1239
- const idx = args.indexOf(`--${name}`);
1240
- return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
1241
- }
1925
+ const rawArgs = process.argv.slice(2);
1926
+ const internalMode = rawArgs[0] === "--internal";
1927
+ const cmd = internalMode ? rawArgs[1] : rawArgs[0];
1928
+ const args = internalMode ? rawArgs.slice(2) : rawArgs.slice(1);
1929
+
1930
+ // CLI 인자 파싱 헬퍼
1931
+ function getArg(name) {
1932
+ const idx = args.indexOf(`--${name}`);
1933
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
1934
+ }
1242
1935
 
1243
- try {
1244
- switch (cmd) {
1245
- case "spawn": {
1246
- const session = getArg("session");
1247
- const name = getArg("name");
1248
- const workerCmd = getArg("cmd");
1249
- if (!session || !name || !workerCmd) {
1250
- console.error("사용법: node psmux.mjs spawn --session <세션> --name <워커명> --cmd <커맨드>");
1251
- process.exit(1);
1936
+ try {
1937
+ if (internalMode) {
1938
+ switch (cmd) {
1939
+ case "list": {
1940
+ const olderThanMs = getArg("olderThanMs");
1941
+ const filterTitle = getArg("filterTitle");
1942
+ console.log(
1943
+ JSON.stringify(
1944
+ listSessions({
1945
+ filterTitle,
1946
+ olderThanMs:
1947
+ olderThanMs == null
1948
+ ? undefined
1949
+ : Number.parseInt(olderThanMs, 10),
1950
+ }),
1951
+ null,
1952
+ 2,
1953
+ ),
1954
+ );
1955
+ break;
1956
+ }
1957
+ case "kill-by-title": {
1958
+ const pattern = args[0];
1959
+ if (!pattern) {
1960
+ console.error(
1961
+ "사용법: node psmux.mjs --internal kill-by-title <title-prefix|/regex/>",
1962
+ );
1963
+ process.exit(1);
1964
+ }
1965
+ console.log(JSON.stringify(killSessionByTitle(pattern), null, 2));
1966
+ break;
1967
+ }
1968
+ case "prune-stale": {
1969
+ const olderThanMs = getArg("olderThanMs");
1970
+ const dryRun = args.includes("--dry-run");
1971
+ console.log(
1972
+ JSON.stringify(
1973
+ pruneStale({
1974
+ olderThanMs:
1975
+ olderThanMs == null
1976
+ ? undefined
1977
+ : Number.parseInt(olderThanMs, 10),
1978
+ dryRun,
1979
+ }),
1980
+ null,
1981
+ 2,
1982
+ ),
1983
+ );
1984
+ break;
1985
+ }
1986
+ default:
1987
+ console.error(
1988
+ "사용법: node psmux.mjs --internal list [--filterTitle <prefix|/regex/>] [--olderThanMs <ms>]",
1989
+ );
1990
+ console.error(
1991
+ " 또는: node psmux.mjs --internal kill-by-title <prefix|/regex/>",
1992
+ );
1993
+ console.error(
1994
+ " 또는: node psmux.mjs --internal prune-stale [--olderThanMs <ms>] [--dry-run]",
1995
+ );
1996
+ process.exit(1);
1252
1997
  }
1253
- console.log(JSON.stringify(spawnWorker(session, name, workerCmd), null, 2));
1254
- break;
1998
+ return;
1255
1999
  }
1256
- case "status": {
1257
- const session = getArg("session");
1258
- const name = getArg("name");
1259
- if (!session || !name) {
1260
- console.error("사용법: node psmux.mjs status --session <세션> --name <워커명>");
1261
- process.exit(1);
2000
+
2001
+ switch (cmd) {
2002
+ case "spawn": {
2003
+ const session = getArg("session");
2004
+ const name = getArg("name");
2005
+ const workerCmd = getArg("cmd");
2006
+ if (!session || !name || !workerCmd) {
2007
+ console.error(
2008
+ "사용법: node psmux.mjs spawn --session <세션> --name <워커명> --cmd <커맨드>",
2009
+ );
2010
+ process.exit(1);
2011
+ }
2012
+ console.log(
2013
+ JSON.stringify(spawnWorker(session, name, workerCmd), null, 2),
2014
+ );
2015
+ break;
1262
2016
  }
1263
- console.log(JSON.stringify(getWorkerStatus(session, name), null, 2));
1264
- break;
1265
- }
1266
- case "kill": {
1267
- const session = getArg("session");
1268
- const name = getArg("name");
1269
- if (!session || !name) {
1270
- console.error("사용법: node psmux.mjs kill --session <세션> --name <워커명>");
1271
- process.exit(1);
2017
+ case "status": {
2018
+ const session = getArg("session");
2019
+ const name = getArg("name");
2020
+ if (!session || !name) {
2021
+ console.error(
2022
+ "사용법: node psmux.mjs status --session <세션> --name <워커명>",
2023
+ );
2024
+ process.exit(1);
2025
+ }
2026
+ console.log(JSON.stringify(getWorkerStatus(session, name), null, 2));
2027
+ break;
1272
2028
  }
1273
- console.log(JSON.stringify(killWorker(session, name), null, 2));
1274
- break;
1275
- }
1276
- case "output": {
1277
- const session = getArg("session");
1278
- const name = getArg("name");
1279
- const lines = parseInt(getArg("lines") || "50", 10);
1280
- if (!session || !name) {
1281
- console.error("사용법: node psmux.mjs output --session <세션> --name <워커명> [--lines <줄수>]");
1282
- process.exit(1);
2029
+ case "kill": {
2030
+ const session = getArg("session");
2031
+ const name = getArg("name");
2032
+ if (!session || !name) {
2033
+ console.error(
2034
+ "사용법: node psmux.mjs kill --session <세션> --name <워커명>",
2035
+ );
2036
+ process.exit(1);
2037
+ }
2038
+ console.log(JSON.stringify(killWorker(session, name), null, 2));
2039
+ break;
1283
2040
  }
1284
- console.log(captureWorkerOutput(session, name, lines));
1285
- break;
1286
- }
1287
- case "capture-start": {
1288
- const session = getArg("session");
1289
- const name = getArg("name");
1290
- if (!session || !name) {
1291
- console.error("사용법: node psmux.mjs capture-start --session <세션> --name <pane>");
1292
- process.exit(1);
2041
+ case "output": {
2042
+ const session = getArg("session");
2043
+ const name = getArg("name");
2044
+ const lines = parseInt(getArg("lines") || "50", 10);
2045
+ if (!session || !name) {
2046
+ console.error(
2047
+ "사용법: node psmux.mjs output --session <세션> --name <워커명> [--lines <줄수>]",
2048
+ );
2049
+ process.exit(1);
2050
+ }
2051
+ console.log(captureWorkerOutput(session, name, lines));
2052
+ break;
1293
2053
  }
1294
- console.log(JSON.stringify(startCapture(session, name), null, 2));
1295
- break;
1296
- }
1297
- case "dispatch": {
1298
- const session = getArg("session");
1299
- const name = getArg("name");
1300
- const commandText = getArg("command");
1301
- if (!session || !name || !commandText) {
1302
- console.error("사용법: node psmux.mjs dispatch --session <세션> --name <pane> --command <PowerShell 명령>");
1303
- process.exit(1);
2054
+ case "capture-start": {
2055
+ const session = getArg("session");
2056
+ const name = getArg("name");
2057
+ if (!session || !name) {
2058
+ console.error(
2059
+ "사용법: node psmux.mjs capture-start --session <세션> --name <pane>",
2060
+ );
2061
+ process.exit(1);
2062
+ }
2063
+ console.log(JSON.stringify(startCapture(session, name), null, 2));
2064
+ break;
1304
2065
  }
1305
- console.log(JSON.stringify(dispatchCommand(session, name, commandText), null, 2));
1306
- break;
1307
- }
1308
- case "wait-pattern": {
1309
- const session = getArg("session");
1310
- const name = getArg("name");
1311
- const pattern = getArg("pattern");
1312
- const timeoutSec = parseInt(getArg("timeout") || "300", 10);
1313
- if (!session || !name || !pattern) {
1314
- console.error("사용법: node psmux.mjs wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
1315
- process.exit(1);
2066
+ case "dispatch": {
2067
+ const session = getArg("session");
2068
+ const name = getArg("name");
2069
+ const commandText = getArg("command");
2070
+ if (!session || !name || !commandText) {
2071
+ console.error(
2072
+ "사용법: node psmux.mjs dispatch --session <세션> --name <pane> --command <PowerShell 명령>",
2073
+ );
2074
+ process.exit(1);
2075
+ }
2076
+ console.log(
2077
+ JSON.stringify(
2078
+ dispatchCommand(session, name, commandText),
2079
+ null,
2080
+ 2,
2081
+ ),
2082
+ );
2083
+ break;
1316
2084
  }
1317
- const result = await waitForPattern(session, name, pattern, timeoutSec);
1318
- console.log(JSON.stringify(result, null, 2));
1319
- if (!result.matched) process.exit(2);
1320
- break;
1321
- }
1322
- case "wait-completion": {
1323
- const session = getArg("session");
1324
- const name = getArg("name");
1325
- const token = getArg("token");
1326
- const timeoutSec = parseInt(getArg("timeout") || "300", 10);
1327
- if (!session || !name || !token) {
1328
- console.error("사용법: node psmux.mjs wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
1329
- process.exit(1);
2085
+ case "wait-pattern": {
2086
+ const session = getArg("session");
2087
+ const name = getArg("name");
2088
+ const pattern = getArg("pattern");
2089
+ const timeoutSec = parseInt(getArg("timeout") || "300", 10);
2090
+ if (!session || !name || !pattern) {
2091
+ console.error(
2092
+ "사용법: node psmux.mjs wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]",
2093
+ );
2094
+ process.exit(1);
2095
+ }
2096
+ const result = await waitForPattern(
2097
+ session,
2098
+ name,
2099
+ pattern,
2100
+ timeoutSec,
2101
+ );
2102
+ console.log(JSON.stringify(result, null, 2));
2103
+ if (!result.matched) process.exit(2);
2104
+ break;
2105
+ }
2106
+ case "wait-completion": {
2107
+ const session = getArg("session");
2108
+ const name = getArg("name");
2109
+ const token = getArg("token");
2110
+ const timeoutSec = parseInt(getArg("timeout") || "300", 10);
2111
+ if (!session || !name || !token) {
2112
+ console.error(
2113
+ "사용법: node psmux.mjs wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]",
2114
+ );
2115
+ process.exit(1);
2116
+ }
2117
+ const result = await waitForCompletion(
2118
+ session,
2119
+ name,
2120
+ token,
2121
+ timeoutSec,
2122
+ );
2123
+ console.log(JSON.stringify(result, null, 2));
2124
+ if (!result.matched) process.exit(2);
2125
+ break;
1330
2126
  }
1331
- const result = await waitForCompletion(session, name, token, timeoutSec);
1332
- console.log(JSON.stringify(result, null, 2));
1333
- if (!result.matched) process.exit(2);
1334
- break;
2127
+ default:
2128
+ console.error(
2129
+ "사용법: node psmux.mjs spawn|status|kill|output|capture-start|dispatch|wait-pattern|wait-completion [args]",
2130
+ );
2131
+ console.error("");
2132
+ console.error(
2133
+ " spawn --session <세션> --name <워커명> --cmd <커맨드>",
2134
+ );
2135
+ console.error(" status --session <세션> --name <워커명>");
2136
+ console.error(" kill --session <세션> --name <워커명>");
2137
+ console.error(
2138
+ " output --session <세션> --name <워커명> [--lines <줄수>]",
2139
+ );
2140
+ console.error(" capture-start --session <세션> --name <pane>");
2141
+ console.error(
2142
+ " dispatch --session <세션> --name <pane> --command <PowerShell 명령>",
2143
+ );
2144
+ console.error(
2145
+ " wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]",
2146
+ );
2147
+ console.error(
2148
+ " wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]",
2149
+ );
2150
+ process.exit(1);
1335
2151
  }
1336
- default:
1337
- console.error("사용법: node psmux.mjs spawn|status|kill|output|capture-start|dispatch|wait-pattern|wait-completion [args]");
1338
- console.error("");
1339
- console.error(" spawn --session <세션> --name <워커명> --cmd <커맨드>");
1340
- console.error(" status --session <세션> --name <워커명>");
1341
- console.error(" kill --session <세션> --name <워커명>");
1342
- console.error(" output --session <세션> --name <워커명> [--lines <줄수>]");
1343
- console.error(" capture-start --session <세션> --name <pane>");
1344
- console.error(" dispatch --session <세션> --name <pane> --command <PowerShell 명령>");
1345
- console.error(" wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
1346
- console.error(" wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
1347
- process.exit(1);
2152
+ } catch (err) {
2153
+ console.error(`오류: ${err.message}`);
2154
+ process.exit(1);
1348
2155
  }
1349
- } catch (err) {
1350
- console.error(`오류: ${err.message}`);
1351
- process.exit(1);
1352
- }
1353
2156
  })();
1354
2157
  }