@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.
- package/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +68 -7
- package/hub/server.mjs +2181 -701
- package/hub/store-adapter.mjs +101 -37
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +84 -17
- package/hub/team/build-worker-prompt.mjs +42 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +219 -0
- package/hub/team/claude-native-bridge.mjs +784 -0
- package/hub/team/claude-session-projection.mjs +65 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +154 -33
- package/hub/team/cli/commands/start/parse-args.mjs +89 -12
- package/hub/team/cli/commands/start/start-headless.mjs +127 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +45 -17
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +55 -1
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +21 -119
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +241 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2065 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/launcher-template.mjs +23 -13
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +74 -35
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -15
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1067 -264
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +69 -53
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +62 -34
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +605 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1526 -198
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +317 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +288 -0
- package/hub/team/tui-lite.mjs +234 -146
- package/hub/team/tui-remote-adapter.mjs +21 -37
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +104 -45
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +936 -449
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +93 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +518 -64
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +474 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +663 -337
- package/hub/workers/factory.mjs +100 -21
- package/hub/workers/gemini-worker.mjs +179 -86
- package/hub/workers/interface.mjs +23 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +22 -16
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +179 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
package/hub/team/psmux.mjs
CHANGED
|
@@ -1,21 +1,51 @@
|
|
|
1
|
-
// hub/team/psmux.mjs —
|
|
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
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
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
|
-
|
|
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 "
|
|
71
|
+
return "tmux";
|
|
30
72
|
})();
|
|
31
|
-
const GIT_BASH =
|
|
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(
|
|
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 {
|
|
88
|
+
} catch {
|
|
89
|
+
/* not found */
|
|
90
|
+
}
|
|
42
91
|
// powershell 5 fallback
|
|
43
92
|
try {
|
|
44
|
-
childProcess.execFileSync(
|
|
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 {
|
|
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 =
|
|
52
|
-
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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)]
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
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)
|
|
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(
|
|
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([
|
|
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")
|
|
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 =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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",
|
|
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 (!
|
|
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)
|
|
578
|
-
|
|
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
|
-
{
|
|
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
|
|
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)
|
|
601
|
-
|
|
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(
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
1044
|
+
const panes = listPaneDetails(sessionName).filter(
|
|
1045
|
+
(pane) => pane.paneId !== excludedPaneId && !pane.isDead,
|
|
1046
|
+
);
|
|
644
1047
|
if (panes.length === 0) return null;
|
|
645
|
-
return
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
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(
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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 =
|
|
759
|
-
|
|
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([
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
|
865
|
-
if (!
|
|
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 =
|
|
868
|
-
return
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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(
|
|
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 =
|
|
927
|
-
|
|
928
|
-
|
|
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(
|
|
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 {
|
|
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 {
|
|
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([
|
|
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({
|
|
974
|
-
|
|
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 {
|
|
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(
|
|
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(
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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([
|
|
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(
|
|
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(
|
|
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(
|
|
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]
|
|
1923
|
+
if (process.argv[1]?.endsWith("psmux.mjs")) {
|
|
1234
1924
|
(async () => {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1254
|
-
break;
|
|
1998
|
+
return;
|
|
1255
1999
|
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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
|
}
|