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