@triflux/remote 10.1.0 → 10.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +61 -0
- package/hub/server.mjs +2179 -709
- package/hub/store-adapter.mjs +59 -26
- 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 +78 -17
- package/hub/team/build-worker-prompt.mjs +73 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +1130 -0
- package/hub/team/claude-native-bridge.mjs +1011 -0
- package/hub/team/claude-session-projection.mjs +74 -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 +151 -33
- package/hub/team/cli/commands/start/parse-args.mjs +97 -13
- package/hub/team/cli/commands/start/start-headless.mjs +155 -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/help.mjs +1 -1
- 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 +44 -16
- 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 +61 -4
- 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 +20 -118
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +256 -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 +2213 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/interactive-native-launcher.mjs +106 -0
- package/hub/team/interactive-native-worker.mjs +80 -0
- package/hub/team/interactive-tui-transport.mjs +465 -0
- package/hub/team/launcher-template.mjs +49 -17
- 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 +120 -42
- 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 -20
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1022 -281
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +68 -52
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +61 -33
- package/hub/team/retry-state-machine.mjs +336 -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 +574 -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 +1544 -193
- 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 +319 -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 +292 -0
- package/hub/team/tui-lite.mjs +237 -148
- package/hub/team/tui-remote-adapter.mjs +18 -34
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +108 -48
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +931 -439
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +97 -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 +517 -63
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +477 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/antigravity-route-worker.mjs +169 -0
- 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 +570 -499
- package/hub/workers/factory.mjs +103 -21
- package/hub/workers/gemini-worker.mjs +126 -284
- package/hub/workers/interface.mjs +24 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +23 -17
- 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 +37 -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-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -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 +224 -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/Antigravity CLI를 병렬 세션으로 실행하는 터미널 멀티플렉서입니다.\n" +
|
|
238
|
+
"설치 방법 (택 1):\n" +
|
|
239
|
+
`${formatPsmuxInstallGuidance(" ")}\n\n` +
|
|
240
|
+
"설치 후 터미널을 재시작하세요.",
|
|
172
241
|
);
|
|
173
242
|
}
|
|
174
243
|
}
|
|
@@ -178,39 +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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
}
|
|
214
301
|
return CAPTURE_HELPER_PATH;
|
|
215
302
|
}
|
|
216
303
|
|
|
@@ -235,33 +322,114 @@ function parsePaneList(output) {
|
|
|
235
322
|
.map((entry) => entry.target);
|
|
236
323
|
}
|
|
237
324
|
|
|
238
|
-
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) {
|
|
239
355
|
return output
|
|
240
356
|
.split("\n")
|
|
241
357
|
.map((line) => line.trim())
|
|
242
358
|
.filter(Boolean)
|
|
243
359
|
.map((line) => {
|
|
244
360
|
const colonIndex = line.indexOf(":");
|
|
245
|
-
if (colonIndex === -1)
|
|
246
|
-
return null;
|
|
247
|
-
}
|
|
361
|
+
if (colonIndex === -1) return null;
|
|
248
362
|
|
|
249
363
|
const sessionName = line.slice(0, colonIndex).trim();
|
|
250
|
-
const flags = [...line.matchAll(/\(([^)]*)\)/g)]
|
|
364
|
+
const flags = [...line.matchAll(/\(([^)]*)\)/g)]
|
|
365
|
+
.map((match) => match[1])
|
|
366
|
+
.join(", ");
|
|
251
367
|
const attachedMatch = flags.match(/(\d+)\s+attached/);
|
|
252
368
|
const attachedCount = attachedMatch
|
|
253
|
-
? parseInt(attachedMatch[1], 10)
|
|
369
|
+
? Number.parseInt(attachedMatch[1], 10)
|
|
254
370
|
: /\battached\b/.test(flags)
|
|
255
371
|
? 1
|
|
256
372
|
: 0;
|
|
257
373
|
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
|
|
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
|
+
});
|
|
261
419
|
})
|
|
262
420
|
.filter(Boolean);
|
|
263
421
|
}
|
|
264
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
|
+
|
|
265
433
|
function parsePaneDetails(output) {
|
|
266
434
|
return output
|
|
267
435
|
.split("\n")
|
|
@@ -269,20 +437,29 @@ function parsePaneDetails(output) {
|
|
|
269
437
|
.filter(Boolean)
|
|
270
438
|
.map((line) => {
|
|
271
439
|
const parts = line.split("\t");
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
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;
|
|
279
452
|
const paneIndex = Number.parseInt(paneIndexText, 10);
|
|
280
453
|
return {
|
|
281
454
|
title,
|
|
282
455
|
paneId,
|
|
283
456
|
paneIndex: Number.isFinite(paneIndex) ? paneIndex : null,
|
|
284
457
|
isDead: dead === "1",
|
|
285
|
-
exitCode: Number.isFinite(exitCode)
|
|
458
|
+
exitCode: Number.isFinite(exitCode)
|
|
459
|
+
? exitCode
|
|
460
|
+
: dead === "1"
|
|
461
|
+
? 0
|
|
462
|
+
: null,
|
|
286
463
|
};
|
|
287
464
|
})
|
|
288
465
|
.filter((entry) => entry.paneId);
|
|
@@ -325,7 +502,9 @@ function resolvePane(sessionName, paneNameOrTarget) {
|
|
|
325
502
|
const panes = listPaneDetails(sessionName);
|
|
326
503
|
|
|
327
504
|
// 1차: title 또는 paneId 직접 매칭
|
|
328
|
-
const direct = panes.find(
|
|
505
|
+
const direct = panes.find(
|
|
506
|
+
(entry) => entry.title === wanted || entry.paneId === wanted,
|
|
507
|
+
);
|
|
329
508
|
if (direct) return direct;
|
|
330
509
|
|
|
331
510
|
// 2차: psmux title 미설정 fallback — "lead"→0, "worker-N"→N 인덱스 매칭
|
|
@@ -340,7 +519,14 @@ function refreshCaptureSnapshot(sessionName, paneNameOrTarget) {
|
|
|
340
519
|
const paneName = pane.title || paneNameOrTarget;
|
|
341
520
|
const logPath = getCaptureLogPath(sessionName, paneName);
|
|
342
521
|
mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
|
|
343
|
-
const snapshot = psmuxExec([
|
|
522
|
+
const snapshot = psmuxExec([
|
|
523
|
+
"capture-pane",
|
|
524
|
+
"-t",
|
|
525
|
+
pane.paneId,
|
|
526
|
+
"-p",
|
|
527
|
+
"-S",
|
|
528
|
+
"-",
|
|
529
|
+
]);
|
|
344
530
|
writeFileSync(logPath, snapshot, "utf8");
|
|
345
531
|
return { paneId: pane.paneId, paneName, logPath, snapshot };
|
|
346
532
|
}
|
|
@@ -369,7 +555,9 @@ export function sendKeysToPane(paneId, text, submit = true) {
|
|
|
369
555
|
|
|
370
556
|
function toPatternRegExp(pattern) {
|
|
371
557
|
if (pattern instanceof RegExp) {
|
|
372
|
-
const flags = pattern.flags.includes("m")
|
|
558
|
+
const flags = pattern.flags.includes("m")
|
|
559
|
+
? pattern.flags
|
|
560
|
+
: `${pattern.flags}m`;
|
|
373
561
|
return new RegExp(pattern.source, flags);
|
|
374
562
|
}
|
|
375
563
|
return new RegExp(String(pattern), "m");
|
|
@@ -394,20 +582,24 @@ function psmux(args, opts = {}) {
|
|
|
394
582
|
});
|
|
395
583
|
return result != null ? String(result).trim() : "";
|
|
396
584
|
} catch (error) {
|
|
397
|
-
const stderr =
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
+
);
|
|
404
596
|
wrapped.status = error.status;
|
|
405
597
|
throw wrapped;
|
|
406
598
|
}
|
|
407
599
|
}
|
|
408
600
|
|
|
409
|
-
/** psmux 실행 가능 여부
|
|
410
|
-
export function
|
|
601
|
+
/** primary terminal multiplexer (Windows=psmux, mac/Linux=tmux) 실행 가능 여부 */
|
|
602
|
+
export function hasMultiplexer() {
|
|
411
603
|
try {
|
|
412
604
|
childProcess.execFileSync(PSMUX_BIN, ["-V"], {
|
|
413
605
|
stdio: "ignore",
|
|
@@ -420,6 +612,22 @@ export function hasPsmux() {
|
|
|
420
612
|
}
|
|
421
613
|
}
|
|
422
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
|
+
|
|
423
631
|
/**
|
|
424
632
|
* psmux 커맨드 실행 래퍼
|
|
425
633
|
* @param {string|string[]} args
|
|
@@ -439,12 +647,14 @@ export function psmuxExec(args, opts = {}) {
|
|
|
439
647
|
* @returns {{ sessionName: string, panes: string[] }}
|
|
440
648
|
*/
|
|
441
649
|
export function createPsmuxSession(sessionName, opts = {}) {
|
|
442
|
-
const layout =
|
|
650
|
+
const layout =
|
|
651
|
+
opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
|
|
443
652
|
const paneCount = Math.max(
|
|
444
653
|
1,
|
|
445
654
|
Number.isFinite(opts.paneCount) ? Math.trunc(opts.paneCount) : 4,
|
|
446
655
|
);
|
|
447
|
-
const limitedPaneCount =
|
|
656
|
+
const limitedPaneCount =
|
|
657
|
+
layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
|
|
448
658
|
const sessionTarget = `${sessionName}:0`;
|
|
449
659
|
|
|
450
660
|
const newSessionArgs = [
|
|
@@ -461,12 +671,24 @@ export function createPsmuxSession(sessionName, opts = {}) {
|
|
|
461
671
|
"55",
|
|
462
672
|
];
|
|
463
673
|
// Windows: psmux 기본 셸이 cmd.exe일 수 있으므로 PowerShell 강제
|
|
464
|
-
|
|
674
|
+
// macOS/Linux: 기본 셸 사용 (PowerShell 플래그 불필요)
|
|
675
|
+
if (PWSH_BIN && IS_WINDOWS)
|
|
676
|
+
newSessionArgs.push(PWSH_BIN, "-NoLogo", "-NoProfile");
|
|
465
677
|
const leadPane = psmuxExec(newSessionArgs);
|
|
466
678
|
|
|
467
|
-
// split-window로 생성되는 pane도 동일 셸 사용
|
|
468
|
-
if (PWSH_BIN) {
|
|
469
|
-
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
|
+
}
|
|
470
692
|
}
|
|
471
693
|
|
|
472
694
|
if (layout === "2x2" && limitedPaneCount >= 3) {
|
|
@@ -516,7 +738,11 @@ export function createPsmuxSession(sessionName, opts = {}) {
|
|
|
516
738
|
|
|
517
739
|
const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
|
|
518
740
|
panes.forEach((pane, index) => {
|
|
519
|
-
|
|
741
|
+
try {
|
|
742
|
+
psmuxExec(["select-pane", "-t", pane, "-T", toPaneTitle(index)]);
|
|
743
|
+
} catch {
|
|
744
|
+
// tmux 2.6 미만: select-pane -T 미지원 — pane title 없이 계속 진행
|
|
745
|
+
}
|
|
520
746
|
});
|
|
521
747
|
|
|
522
748
|
// CP949 등 non-UTF-8 codepage 환경에서 CLI stdout 깨짐 방지:
|
|
@@ -547,7 +773,11 @@ export function createPsmuxSession(sessionName, opts = {}) {
|
|
|
547
773
|
function collectPanePids(sessionName) {
|
|
548
774
|
try {
|
|
549
775
|
const output = psmuxExec([
|
|
550
|
-
"list-panes",
|
|
776
|
+
"list-panes",
|
|
777
|
+
"-t",
|
|
778
|
+
sessionName,
|
|
779
|
+
"-F",
|
|
780
|
+
"#{pane_pid}",
|
|
551
781
|
]);
|
|
552
782
|
return output
|
|
553
783
|
.split(/\r?\n/)
|
|
@@ -563,7 +793,64 @@ function collectPanePids(sessionName) {
|
|
|
563
793
|
* @param {number} pid
|
|
564
794
|
*/
|
|
565
795
|
function killProcessTree(pid) {
|
|
566
|
-
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
|
+
}
|
|
567
854
|
try {
|
|
568
855
|
childProcess.execSync(`taskkill /T /F /PID ${pid}`, {
|
|
569
856
|
stdio: "ignore",
|
|
@@ -596,14 +883,51 @@ function disableAllPipeCaptures(sessionName, paneIds) {
|
|
|
596
883
|
* @param {string} sessionName
|
|
597
884
|
*/
|
|
598
885
|
function killOrphanPipeHelpers(sessionName) {
|
|
599
|
-
if (!IS_WINDOWS)
|
|
600
|
-
|
|
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));
|
|
601
917
|
try {
|
|
602
918
|
const output = childProcess.execSync(
|
|
603
|
-
`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"`,
|
|
604
|
-
{
|
|
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
|
+
},
|
|
605
926
|
);
|
|
606
|
-
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);
|
|
607
931
|
for (const pid of pids) {
|
|
608
932
|
killProcessTree(pid);
|
|
609
933
|
}
|
|
@@ -619,13 +943,61 @@ function killOrphanPipeHelpers(sessionName) {
|
|
|
619
943
|
* @param {string} sessionName
|
|
620
944
|
*/
|
|
621
945
|
function killOrphanMcpProcesses(sessionName) {
|
|
622
|
-
if (!IS_WINDOWS)
|
|
623
|
-
|
|
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));
|
|
624
990
|
|
|
625
991
|
// Hub PID 보호 — Hub 프로세스를 고아로 잘못 식별하지 않도록
|
|
626
992
|
let hubPid = null;
|
|
627
993
|
try {
|
|
628
|
-
const hubPidPath = join(
|
|
994
|
+
const hubPidPath = join(
|
|
995
|
+
homedir(),
|
|
996
|
+
".claude",
|
|
997
|
+
"cache",
|
|
998
|
+
"tfx-hub",
|
|
999
|
+
"hub.pid",
|
|
1000
|
+
);
|
|
629
1001
|
if (existsSync(hubPidPath)) {
|
|
630
1002
|
const hubInfo = JSON.parse(readFileSync(hubPidPath, "utf8"));
|
|
631
1003
|
hubPid = Number(hubInfo?.pid);
|
|
@@ -635,10 +1007,18 @@ function killOrphanMcpProcesses(sessionName) {
|
|
|
635
1007
|
try {
|
|
636
1008
|
// 세션 결과 디렉토리 패턴으로 MCP 서버 프로세스 식별
|
|
637
1009
|
const output = childProcess.execSync(
|
|
638
|
-
`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"`,
|
|
639
|
-
{
|
|
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
|
+
},
|
|
640
1017
|
);
|
|
641
|
-
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);
|
|
642
1022
|
for (const pid of pids) {
|
|
643
1023
|
killProcessTree(pid);
|
|
644
1024
|
}
|
|
@@ -661,12 +1041,15 @@ function detachAttachedClients(sessionName, waitMs = 750) {
|
|
|
661
1041
|
|
|
662
1042
|
function findFallbackPane(sessionName, excludedPaneId) {
|
|
663
1043
|
try {
|
|
664
|
-
const panes = listPaneDetails(sessionName)
|
|
665
|
-
|
|
1044
|
+
const panes = listPaneDetails(sessionName).filter(
|
|
1045
|
+
(pane) => pane.paneId !== excludedPaneId && !pane.isDead,
|
|
1046
|
+
);
|
|
666
1047
|
if (panes.length === 0) return null;
|
|
667
|
-
return
|
|
668
|
-
|
|
669
|
-
|
|
1048
|
+
return (
|
|
1049
|
+
panes.find((pane) => pane.title === "lead") ||
|
|
1050
|
+
panes.find((pane) => pane.paneIndex === 0) ||
|
|
1051
|
+
panes[0]
|
|
1052
|
+
);
|
|
670
1053
|
} catch {
|
|
671
1054
|
return null;
|
|
672
1055
|
}
|
|
@@ -711,6 +1094,94 @@ export function killPsmuxSession(sessionName) {
|
|
|
711
1094
|
killOrphanMcpProcesses(sessionName);
|
|
712
1095
|
}
|
|
713
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
|
+
|
|
714
1185
|
/**
|
|
715
1186
|
* psmux 세션 존재 확인
|
|
716
1187
|
* @param {string} sessionName
|
|
@@ -731,7 +1202,7 @@ export function psmuxSessionExists(sessionName) {
|
|
|
731
1202
|
*/
|
|
732
1203
|
export function listPsmuxSessions() {
|
|
733
1204
|
try {
|
|
734
|
-
return
|
|
1205
|
+
return listSessionInventoryRaw()
|
|
735
1206
|
.map((session) => session.sessionName)
|
|
736
1207
|
.filter((sessionName) => sessionName.startsWith("tfx-multi-"));
|
|
737
1208
|
} catch {
|
|
@@ -760,11 +1231,15 @@ export function capturePsmuxPane(target, lines = 5) {
|
|
|
760
1231
|
* @param {string} sessionName
|
|
761
1232
|
*/
|
|
762
1233
|
export function attachPsmuxSession(sessionName) {
|
|
763
|
-
const result = childProcess.spawnSync(
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
+
);
|
|
768
1243
|
if ((result.status ?? 1) !== 0) {
|
|
769
1244
|
throw new Error(`psmux attach 실패 (exit=${result.status})`);
|
|
770
1245
|
}
|
|
@@ -777,8 +1252,9 @@ export function attachPsmuxSession(sessionName) {
|
|
|
777
1252
|
*/
|
|
778
1253
|
export function getPsmuxSessionAttachedCount(sessionName) {
|
|
779
1254
|
try {
|
|
780
|
-
const session =
|
|
781
|
-
|
|
1255
|
+
const session = listSessionInventoryRaw().find(
|
|
1256
|
+
(entry) => entry.sessionName === sessionName,
|
|
1257
|
+
);
|
|
782
1258
|
return session ? session.attachedCount : null;
|
|
783
1259
|
} catch {
|
|
784
1260
|
return null;
|
|
@@ -812,12 +1288,78 @@ export function configurePsmuxKeybindings(sessionName, opts = {}) {
|
|
|
812
1288
|
}
|
|
813
1289
|
};
|
|
814
1290
|
|
|
815
|
-
bindSafe([
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
+
]);
|
|
821
1363
|
|
|
822
1364
|
if (taskListCommand) {
|
|
823
1365
|
bindSafe([
|
|
@@ -853,12 +1395,10 @@ export function startCapture(sessionName, paneNameOrTarget) {
|
|
|
853
1395
|
writeFileSync(logPath, "", "utf8");
|
|
854
1396
|
|
|
855
1397
|
disablePipeCapture(pane.paneId);
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
`powershell.exe -NoLogo -NoProfile -File ${quoteArg(helperPath)} ${quoteArg(logPath)}`,
|
|
861
|
-
]);
|
|
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]);
|
|
862
1402
|
|
|
863
1403
|
refreshCaptureSnapshot(sessionName, pane.paneId);
|
|
864
1404
|
return { paneId: pane.paneId, paneName, logPath };
|
|
@@ -872,10 +1412,10 @@ export function startCapture(sessionName, paneNameOrTarget) {
|
|
|
872
1412
|
* @returns {{ paneId: string, paneName: string, token: string, logPath: string }}
|
|
873
1413
|
*/
|
|
874
1414
|
/**
|
|
875
|
-
* CLI 명령(codex/
|
|
1415
|
+
* CLI 명령(codex/legacy Gemini)이 psmux pane의 PowerShell 환경에서 단축 플래그 충돌을
|
|
876
1416
|
* 일으키는 문제를 방지하기 위해 bash -c '...' 로 감싼다.
|
|
877
1417
|
* - codex -o flag → PS -OutVariable/OutBuffer 충돌
|
|
878
|
-
* -
|
|
1418
|
+
* - legacy Gemini prompt flag (v8.6.0: -p → long-form prompt flag, PS 충돌 해소)
|
|
879
1419
|
* @param {string} cmd
|
|
880
1420
|
* @returns {string}
|
|
881
1421
|
*/
|
|
@@ -883,17 +1423,7 @@ export function startCapture(sessionName, paneNameOrTarget) {
|
|
|
883
1423
|
// WSL bash에서는 /c/Users/... 경로가 유효하지 않아 exit 127 발생.
|
|
884
1424
|
// psmux pane 용 (PowerShell → bash 실행): & "path" 형식
|
|
885
1425
|
// Node.js execSync 용 (직접 실행): "path" 형식
|
|
886
|
-
const _gitBashPath = (
|
|
887
|
-
if (!IS_WINDOWS) return null;
|
|
888
|
-
const candidates = [
|
|
889
|
-
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
|
890
|
-
"C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe",
|
|
891
|
-
];
|
|
892
|
-
for (const p of candidates) {
|
|
893
|
-
if (existsSync(p)) return p;
|
|
894
|
-
}
|
|
895
|
-
return null;
|
|
896
|
-
})();
|
|
1426
|
+
const _gitBashPath = IS_WINDOWS ? resolveGitBashExecutable() : null;
|
|
897
1427
|
// PowerShell call operator(&)가 필요: & "C:\...\bash.exe" -c '...'
|
|
898
1428
|
const GIT_BASH_BIN_PS = _gitBashPath ? `& "${_gitBashPath}"` : "bash";
|
|
899
1429
|
// Node.js execSync에서 직접 실행할 때는 따옴표만
|
|
@@ -905,7 +1435,10 @@ function resolveCliAbsPath(name) {
|
|
|
905
1435
|
if (_cliPathCache.has(name)) return _cliPathCache.get(name);
|
|
906
1436
|
try {
|
|
907
1437
|
const resolved = childProcess
|
|
908
|
-
.execSync(`${GIT_BASH_BIN_EXEC} -c "which ${name}"`, {
|
|
1438
|
+
.execSync(`${GIT_BASH_BIN_EXEC} -c "which ${name}"`, {
|
|
1439
|
+
encoding: "utf8",
|
|
1440
|
+
timeout: 3000,
|
|
1441
|
+
})
|
|
909
1442
|
.trim();
|
|
910
1443
|
if (resolved) _cliPathCache.set(name, resolved);
|
|
911
1444
|
return resolved || name;
|
|
@@ -922,9 +1455,10 @@ function wrapCliForBash(cmd) {
|
|
|
922
1455
|
if (!cliMatch) return cmd;
|
|
923
1456
|
// Node.js 측에서 절대 경로 resolve → psmux pane의 bash PATH 불일치 문제 해결 (exit 127)
|
|
924
1457
|
const absPath = resolveCliAbsPath(cliMatch[1]);
|
|
925
|
-
const resolved =
|
|
926
|
-
|
|
927
|
-
|
|
1458
|
+
const resolved =
|
|
1459
|
+
absPath !== cliMatch[1]
|
|
1460
|
+
? trimmed.replace(new RegExp(`^${cliMatch[1]}\\b`), absPath)
|
|
1461
|
+
: trimmed;
|
|
928
1462
|
// 단일 따옴표 이스케이프: ' → '\''
|
|
929
1463
|
const escaped = resolved.replace(/'/g, "'\\''");
|
|
930
1464
|
return `${GIT_BASH_BIN_PS} -c '${escaped}'`;
|
|
@@ -941,10 +1475,17 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
|
|
|
941
1475
|
}
|
|
942
1476
|
|
|
943
1477
|
const token = randomToken(paneName);
|
|
944
|
-
const safeCommand = wrapCliForBash(commandText);
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
+
}
|
|
948
1489
|
|
|
949
1490
|
sendLiteralToPane(pane.paneId, wrapped, true);
|
|
950
1491
|
|
|
@@ -962,7 +1503,13 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
|
|
|
962
1503
|
* @param {AbortSignal} [opts.signal] — 외부에서 폴링 중단 요청 시 사용
|
|
963
1504
|
* @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, aborted?: boolean }}
|
|
964
1505
|
*/
|
|
965
|
-
export async function waitForPattern(
|
|
1506
|
+
export async function waitForPattern(
|
|
1507
|
+
sessionName,
|
|
1508
|
+
paneNameOrTarget,
|
|
1509
|
+
pattern,
|
|
1510
|
+
timeoutSec = 300,
|
|
1511
|
+
opts = {},
|
|
1512
|
+
) {
|
|
966
1513
|
ensurePsmuxInstalled();
|
|
967
1514
|
|
|
968
1515
|
// E4 크래시 복구: 초기 resolvePane도 세션 사망을 감지
|
|
@@ -985,11 +1532,14 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
985
1532
|
|
|
986
1533
|
const paneName = pane.title || paneNameOrTarget;
|
|
987
1534
|
// opts.logPath: dispatch 시 확정된 캡처 로그 경로 직접 지정 (타이틀 변경 내성)
|
|
988
|
-
const logPath =
|
|
989
|
-
|
|
990
|
-
|
|
1535
|
+
const logPath =
|
|
1536
|
+
opts.logPath && existsSync(opts.logPath)
|
|
1537
|
+
? opts.logPath
|
|
1538
|
+
: getCaptureLogPath(sessionName, paneName);
|
|
991
1539
|
if (!existsSync(logPath)) {
|
|
992
|
-
throw new Error(
|
|
1540
|
+
throw new Error(
|
|
1541
|
+
`캡처 로그가 없습니다. 먼저 startCapture(${sessionName}, ${paneName})를 호출하세요.`,
|
|
1542
|
+
);
|
|
993
1543
|
}
|
|
994
1544
|
|
|
995
1545
|
const startTime = Date.now();
|
|
@@ -997,18 +1547,39 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
997
1547
|
const regex = toPatternRegExp(pattern);
|
|
998
1548
|
|
|
999
1549
|
if (opts?.signal?.aborted) {
|
|
1000
|
-
return {
|
|
1550
|
+
return {
|
|
1551
|
+
matched: false,
|
|
1552
|
+
paneId: pane.paneId,
|
|
1553
|
+
paneName,
|
|
1554
|
+
logPath,
|
|
1555
|
+
match: null,
|
|
1556
|
+
aborted: true,
|
|
1557
|
+
};
|
|
1001
1558
|
}
|
|
1002
1559
|
|
|
1003
1560
|
while (Date.now() <= deadline) {
|
|
1004
1561
|
if (opts?.signal?.aborted) {
|
|
1005
|
-
return {
|
|
1562
|
+
return {
|
|
1563
|
+
matched: false,
|
|
1564
|
+
paneId: pane.paneId,
|
|
1565
|
+
paneName,
|
|
1566
|
+
logPath,
|
|
1567
|
+
match: null,
|
|
1568
|
+
aborted: true,
|
|
1569
|
+
};
|
|
1006
1570
|
}
|
|
1007
1571
|
// E4 크래시 복구: capture 실패 시 세션 생존 체크
|
|
1008
1572
|
try {
|
|
1009
1573
|
if (opts.logPath) {
|
|
1010
1574
|
// logPath 직접 지정 시 — 셸 타이틀 변경과 무관하게 올바른 파일에 기록
|
|
1011
|
-
const snapshot = psmuxExec([
|
|
1575
|
+
const snapshot = psmuxExec([
|
|
1576
|
+
"capture-pane",
|
|
1577
|
+
"-t",
|
|
1578
|
+
pane.paneId,
|
|
1579
|
+
"-p",
|
|
1580
|
+
"-S",
|
|
1581
|
+
"-",
|
|
1582
|
+
]);
|
|
1012
1583
|
writeFileSync(logPath, snapshot, "utf8");
|
|
1013
1584
|
} else {
|
|
1014
1585
|
refreshCaptureSnapshot(sessionName, pane.paneId);
|
|
@@ -1032,8 +1603,15 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
1032
1603
|
// onPoll 콜백 — 각 폴링 주기마다 중간 상태 전달
|
|
1033
1604
|
if (opts.onPoll) {
|
|
1034
1605
|
try {
|
|
1035
|
-
opts.onPoll({
|
|
1036
|
-
|
|
1606
|
+
opts.onPoll({
|
|
1607
|
+
content,
|
|
1608
|
+
paneId: pane.paneId,
|
|
1609
|
+
paneName,
|
|
1610
|
+
elapsed: Date.now() - startTime,
|
|
1611
|
+
});
|
|
1612
|
+
} catch {
|
|
1613
|
+
/* 콜백 예외는 삼킴 — 폴링 루프 보호 */
|
|
1614
|
+
}
|
|
1037
1615
|
}
|
|
1038
1616
|
|
|
1039
1617
|
const match = regex.exec(content);
|
|
@@ -1052,7 +1630,14 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
1052
1630
|
}
|
|
1053
1631
|
await sleepMsAsync(POLL_INTERVAL_MS);
|
|
1054
1632
|
if (opts?.signal?.aborted) {
|
|
1055
|
-
return {
|
|
1633
|
+
return {
|
|
1634
|
+
matched: false,
|
|
1635
|
+
paneId: pane.paneId,
|
|
1636
|
+
paneName,
|
|
1637
|
+
logPath,
|
|
1638
|
+
match: null,
|
|
1639
|
+
aborted: true,
|
|
1640
|
+
};
|
|
1056
1641
|
}
|
|
1057
1642
|
}
|
|
1058
1643
|
|
|
@@ -1067,6 +1652,7 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
1067
1652
|
|
|
1068
1653
|
/**
|
|
1069
1654
|
* 완료 토큰이 찍힐 때까지 대기하고 exit code를 파싱한다.
|
|
1655
|
+
* NOTE: 주 채널은 headless.waitForCompletionWithStallDetect이며, 본 함수는 fallback 채널이다.
|
|
1070
1656
|
* @param {string} sessionName
|
|
1071
1657
|
* @param {string} paneNameOrTarget
|
|
1072
1658
|
* @param {string} token
|
|
@@ -1074,19 +1660,38 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
1074
1660
|
* @param {object} [opts] — waitForPattern에 전달할 옵션 (onPoll 등)
|
|
1075
1661
|
* @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
|
|
1076
1662
|
*/
|
|
1077
|
-
export async function waitForCompletion(
|
|
1663
|
+
export async function waitForCompletion(
|
|
1664
|
+
sessionName,
|
|
1665
|
+
paneNameOrTarget,
|
|
1666
|
+
token,
|
|
1667
|
+
timeoutSec = 300,
|
|
1668
|
+
opts = {},
|
|
1669
|
+
) {
|
|
1078
1670
|
const completionRegex = new RegExp(
|
|
1079
1671
|
`${escapeRegExp(COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
|
|
1080
1672
|
"m",
|
|
1081
1673
|
);
|
|
1082
|
-
const result = await waitForPattern(
|
|
1674
|
+
const result = await waitForPattern(
|
|
1675
|
+
sessionName,
|
|
1676
|
+
paneNameOrTarget,
|
|
1677
|
+
completionRegex,
|
|
1678
|
+
timeoutSec,
|
|
1679
|
+
opts,
|
|
1680
|
+
);
|
|
1083
1681
|
|
|
1084
1682
|
// 타이밍 이슈 대응: matched=false인 경우 500ms 대기 후 최종 1회 캡처 재시도
|
|
1085
1683
|
if (!result.matched && !result.sessionDead && result.logPath) {
|
|
1086
1684
|
await new Promise((r) => setTimeout(r, 500));
|
|
1087
1685
|
try {
|
|
1088
1686
|
const pane = resolvePane(sessionName, paneNameOrTarget);
|
|
1089
|
-
const snapshot = psmuxExec([
|
|
1687
|
+
const snapshot = psmuxExec([
|
|
1688
|
+
"capture-pane",
|
|
1689
|
+
"-t",
|
|
1690
|
+
pane.paneId,
|
|
1691
|
+
"-p",
|
|
1692
|
+
"-S",
|
|
1693
|
+
"-",
|
|
1694
|
+
]);
|
|
1090
1695
|
writeFileSync(result.logPath, snapshot, "utf8");
|
|
1091
1696
|
const content = readCaptureLog(result.logPath);
|
|
1092
1697
|
const retryMatch = completionRegex.exec(content);
|
|
@@ -1125,7 +1730,7 @@ export function spawnWorker(sessionName, workerName, cmd) {
|
|
|
1125
1730
|
if (!hasPsmux()) {
|
|
1126
1731
|
throw new Error(
|
|
1127
1732
|
"psmux가 설치되어 있지 않습니다.\n" +
|
|
1128
|
-
|
|
1733
|
+
`설치 방법:\n${formatPsmuxInstallGuidance(" ")}`,
|
|
1129
1734
|
);
|
|
1130
1735
|
}
|
|
1131
1736
|
|
|
@@ -1152,10 +1757,16 @@ export function spawnWorker(sessionName, workerName, cmd) {
|
|
|
1152
1757
|
"#{session_name}:#{window_index}.#{pane_index}",
|
|
1153
1758
|
shellCmd,
|
|
1154
1759
|
]);
|
|
1155
|
-
|
|
1760
|
+
try {
|
|
1761
|
+
psmuxExec(["select-pane", "-t", paneTarget, "-T", workerName]);
|
|
1762
|
+
} catch {
|
|
1763
|
+
// tmux 2.6 미만: select-pane -T 미지원 — 무시
|
|
1764
|
+
}
|
|
1156
1765
|
return { paneId: paneTarget, workerName };
|
|
1157
1766
|
} catch (err) {
|
|
1158
|
-
throw new Error(
|
|
1767
|
+
throw new Error(
|
|
1768
|
+
`워커 생성 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`,
|
|
1769
|
+
);
|
|
1159
1770
|
}
|
|
1160
1771
|
}
|
|
1161
1772
|
|
|
@@ -1167,7 +1778,9 @@ export function spawnWorker(sessionName, workerName, cmd) {
|
|
|
1167
1778
|
*/
|
|
1168
1779
|
export function getWorkerStatus(sessionName, workerName) {
|
|
1169
1780
|
if (!hasPsmux()) {
|
|
1170
|
-
throw new Error(
|
|
1781
|
+
throw new Error(
|
|
1782
|
+
`psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`,
|
|
1783
|
+
);
|
|
1171
1784
|
}
|
|
1172
1785
|
try {
|
|
1173
1786
|
const pane = resolvePane(sessionName, workerName);
|
|
@@ -1180,7 +1793,9 @@ export function getWorkerStatus(sessionName, workerName) {
|
|
|
1180
1793
|
if (err.message.includes("Pane을 찾을 수 없습니다")) {
|
|
1181
1794
|
throw new Error(`워커를 찾을 수 없습니다: ${workerName}`);
|
|
1182
1795
|
}
|
|
1183
|
-
throw new Error(
|
|
1796
|
+
throw new Error(
|
|
1797
|
+
`워커 상태 조회 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`,
|
|
1798
|
+
);
|
|
1184
1799
|
}
|
|
1185
1800
|
}
|
|
1186
1801
|
|
|
@@ -1192,14 +1807,15 @@ export function getWorkerStatus(sessionName, workerName) {
|
|
|
1192
1807
|
*/
|
|
1193
1808
|
export function killWorker(sessionName, workerName) {
|
|
1194
1809
|
if (!hasPsmux()) {
|
|
1195
|
-
throw new Error(
|
|
1810
|
+
throw new Error(
|
|
1811
|
+
`psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`,
|
|
1812
|
+
);
|
|
1196
1813
|
}
|
|
1197
1814
|
try {
|
|
1198
1815
|
const { paneId, status } = getWorkerStatus(sessionName, workerName);
|
|
1199
1816
|
const attachedCount = getPsmuxSessionAttachedCount(sessionName);
|
|
1200
|
-
const fallbackPane =
|
|
1201
|
-
? findFallbackPane(sessionName, paneId)
|
|
1202
|
-
: null;
|
|
1817
|
+
const fallbackPane =
|
|
1818
|
+
attachedCount > 0 ? findFallbackPane(sessionName, paneId) : null;
|
|
1203
1819
|
|
|
1204
1820
|
if (fallbackPane?.paneId) {
|
|
1205
1821
|
try {
|
|
@@ -1214,7 +1830,13 @@ export function killWorker(sessionName, workerName) {
|
|
|
1214
1830
|
|
|
1215
1831
|
// pane PID 수집 → 프로세스 트리 정리 (MCP 서버 좀비 방지)
|
|
1216
1832
|
try {
|
|
1217
|
-
const pidOutput = psmuxExec([
|
|
1833
|
+
const pidOutput = psmuxExec([
|
|
1834
|
+
"list-panes",
|
|
1835
|
+
"-t",
|
|
1836
|
+
paneId,
|
|
1837
|
+
"-F",
|
|
1838
|
+
"#{pane_pid}",
|
|
1839
|
+
]);
|
|
1218
1840
|
const pid = Number.parseInt(pidOutput.trim(), 10);
|
|
1219
1841
|
if (Number.isFinite(pid) && pid > 0) killProcessTree(pid);
|
|
1220
1842
|
} catch {
|
|
@@ -1266,7 +1888,9 @@ export function killWorker(sessionName, workerName) {
|
|
|
1266
1888
|
if (err.message.includes("워커를 찾을 수 없습니다")) {
|
|
1267
1889
|
return { killed: true };
|
|
1268
1890
|
}
|
|
1269
|
-
throw new Error(
|
|
1891
|
+
throw new Error(
|
|
1892
|
+
`워커 종료 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`,
|
|
1893
|
+
);
|
|
1270
1894
|
}
|
|
1271
1895
|
}
|
|
1272
1896
|
|
|
@@ -1279,14 +1903,18 @@ export function killWorker(sessionName, workerName) {
|
|
|
1279
1903
|
*/
|
|
1280
1904
|
export function captureWorkerOutput(sessionName, workerName, lines = 50) {
|
|
1281
1905
|
if (!hasPsmux()) {
|
|
1282
|
-
throw new Error(
|
|
1906
|
+
throw new Error(
|
|
1907
|
+
`psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`,
|
|
1908
|
+
);
|
|
1283
1909
|
}
|
|
1284
1910
|
try {
|
|
1285
1911
|
const { paneId } = getWorkerStatus(sessionName, workerName);
|
|
1286
1912
|
return psmuxExec(["capture-pane", "-t", paneId, "-p", "-S", `-${lines}`]);
|
|
1287
1913
|
} catch (err) {
|
|
1288
1914
|
if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
|
|
1289
|
-
throw new Error(
|
|
1915
|
+
throw new Error(
|
|
1916
|
+
`출력 캡처 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`,
|
|
1917
|
+
);
|
|
1290
1918
|
}
|
|
1291
1919
|
}
|
|
1292
1920
|
|
|
@@ -1294,123 +1922,236 @@ export function captureWorkerOutput(sessionName, workerName, lines = 50) {
|
|
|
1294
1922
|
|
|
1295
1923
|
if (process.argv[1]?.endsWith("psmux.mjs")) {
|
|
1296
1924
|
(async () => {
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
+
}
|
|
1304
1935
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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);
|
|
1314
1997
|
}
|
|
1315
|
-
|
|
1316
|
-
break;
|
|
1998
|
+
return;
|
|
1317
1999
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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;
|
|
1324
2016
|
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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;
|
|
1334
2028
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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;
|
|
1345
2040
|
}
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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;
|
|
1355
2053
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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;
|
|
1366
2065
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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;
|
|
1378
2084
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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;
|
|
1392
2126
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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);
|
|
1397
2151
|
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
console.error(" spawn --session <세션> --name <워커명> --cmd <커맨드>");
|
|
1402
|
-
console.error(" status --session <세션> --name <워커명>");
|
|
1403
|
-
console.error(" kill --session <세션> --name <워커명>");
|
|
1404
|
-
console.error(" output --session <세션> --name <워커명> [--lines <줄수>]");
|
|
1405
|
-
console.error(" capture-start --session <세션> --name <pane>");
|
|
1406
|
-
console.error(" dispatch --session <세션> --name <pane> --command <PowerShell 명령>");
|
|
1407
|
-
console.error(" wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
|
|
1408
|
-
console.error(" wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
|
|
1409
|
-
process.exit(1);
|
|
2152
|
+
} catch (err) {
|
|
2153
|
+
console.error(`오류: ${err.message}`);
|
|
2154
|
+
process.exit(1);
|
|
1410
2155
|
}
|
|
1411
|
-
} catch (err) {
|
|
1412
|
-
console.error(`오류: ${err.message}`);
|
|
1413
|
-
process.exit(1);
|
|
1414
|
-
}
|
|
1415
2156
|
})();
|
|
1416
2157
|
}
|