@triflux/remote 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/hub/pipe.mjs +579 -0
  2. package/hub/public/dashboard.html +355 -0
  3. package/hub/public/tray-icon.ico +0 -0
  4. package/hub/public/tray-icon.png +0 -0
  5. package/hub/server.mjs +1124 -0
  6. package/hub/store-adapter.mjs +851 -0
  7. package/hub/store.mjs +897 -0
  8. package/hub/team/agent-map.json +11 -0
  9. package/hub/team/ansi.mjs +379 -0
  10. package/hub/team/backend.mjs +90 -0
  11. package/hub/team/cli/commands/attach.mjs +37 -0
  12. package/hub/team/cli/commands/control.mjs +43 -0
  13. package/hub/team/cli/commands/debug.mjs +74 -0
  14. package/hub/team/cli/commands/focus.mjs +53 -0
  15. package/hub/team/cli/commands/interrupt.mjs +36 -0
  16. package/hub/team/cli/commands/kill.mjs +37 -0
  17. package/hub/team/cli/commands/list.mjs +24 -0
  18. package/hub/team/cli/commands/send.mjs +37 -0
  19. package/hub/team/cli/commands/start/index.mjs +106 -0
  20. package/hub/team/cli/commands/start/parse-args.mjs +130 -0
  21. package/hub/team/cli/commands/start/start-headless.mjs +109 -0
  22. package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
  23. package/hub/team/cli/commands/start/start-mux.mjs +73 -0
  24. package/hub/team/cli/commands/start/start-wt.mjs +69 -0
  25. package/hub/team/cli/commands/status.mjs +87 -0
  26. package/hub/team/cli/commands/stop.mjs +31 -0
  27. package/hub/team/cli/commands/task.mjs +30 -0
  28. package/hub/team/cli/commands/tasks.mjs +13 -0
  29. package/hub/team/cli/help.mjs +42 -0
  30. package/hub/team/cli/index.mjs +41 -0
  31. package/hub/team/cli/manifest.mjs +29 -0
  32. package/hub/team/cli/render.mjs +30 -0
  33. package/hub/team/cli/services/attach-fallback.mjs +54 -0
  34. package/hub/team/cli/services/hub-client.mjs +208 -0
  35. package/hub/team/cli/services/member-selector.mjs +30 -0
  36. package/hub/team/cli/services/native-control.mjs +117 -0
  37. package/hub/team/cli/services/runtime-mode.mjs +62 -0
  38. package/hub/team/cli/services/state-store.mjs +48 -0
  39. package/hub/team/cli/services/task-model.mjs +30 -0
  40. package/hub/team/dashboard-anchor.mjs +14 -0
  41. package/hub/team/dashboard-layout.mjs +33 -0
  42. package/hub/team/dashboard-open.mjs +153 -0
  43. package/hub/team/dashboard.mjs +274 -0
  44. package/hub/team/handoff.mjs +303 -0
  45. package/hub/team/headless.mjs +1149 -0
  46. package/hub/team/native-supervisor.mjs +392 -0
  47. package/hub/team/native.mjs +649 -0
  48. package/hub/team/nativeProxy.mjs +681 -0
  49. package/hub/team/orchestrator.mjs +161 -0
  50. package/hub/team/pane.mjs +153 -0
  51. package/hub/team/psmux.mjs +1354 -0
  52. package/hub/team/routing.mjs +223 -0
  53. package/hub/team/session.mjs +611 -0
  54. package/hub/team/shared.mjs +13 -0
  55. package/hub/team/staleState.mjs +361 -0
  56. package/hub/team/tui-lite.mjs +380 -0
  57. package/hub/team/tui-viewer.mjs +463 -0
  58. package/hub/team/tui.mjs +1245 -0
  59. package/hub/tools.mjs +554 -0
  60. package/hub/tray.mjs +376 -0
  61. package/hub/workers/claude-worker.mjs +475 -0
  62. package/hub/workers/codex-mcp.mjs +504 -0
  63. package/hub/workers/delegator-mcp.mjs +1076 -0
  64. package/hub/workers/factory.mjs +21 -0
  65. package/hub/workers/gemini-worker.mjs +373 -0
  66. package/hub/workers/interface.mjs +52 -0
  67. package/hub/workers/worker-utils.mjs +104 -0
  68. package/package.json +31 -0
@@ -0,0 +1,1354 @@
1
+ // hub/team/psmux.mjs — Windows psmux 세션/키바인딩/캡처/steering 관리
2
+ // 의존성: child_process, fs, os, path (Node.js 내장)만 사용
3
+ import childProcess from "node:child_process";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { tmpdir, homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
8
+ import { IS_WINDOWS } from "../platform.mjs";
9
+
10
+ const PSMUX_BIN = (() => {
11
+ if (process.env.PSMUX_BIN) return process.env.PSMUX_BIN;
12
+ // PATH에서 찾기
13
+ try {
14
+ childProcess.execFileSync("psmux", ["-V"], { stdio: "ignore", timeout: 2000, windowsHide: true });
15
+ return "psmux";
16
+ } catch { /* not in PATH */ }
17
+ // Windows 기본 설치 경로 탐색
18
+ if (IS_WINDOWS) {
19
+ const candidates = [
20
+ join(process.env.LOCALAPPDATA || "", "psmux", "psmux.exe"),
21
+ join(process.env.APPDATA || "", "npm", "psmux.cmd"),
22
+ join(homedir(), "AppData", "Local", "psmux", "psmux.exe"),
23
+ join(homedir(), "scoop", "shims", "psmux.exe"),
24
+ ];
25
+ for (const p of candidates) {
26
+ if (existsSync(p)) return p;
27
+ }
28
+ }
29
+ return "psmux"; // 최종 fallback — 원래대로
30
+ })();
31
+ const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
32
+
33
+ /** Windows psmux 세션의 기본 셸을 PowerShell로 강제한다 (pwsh7 우선, ps5 fallback). */
34
+ const PWSH_BIN = (() => {
35
+ if (!IS_WINDOWS) return "";
36
+ if (process.env.PSMUX_SHELL) return process.env.PSMUX_SHELL;
37
+ // pwsh 7 우선
38
+ try {
39
+ childProcess.execFileSync("pwsh", ["-NoLogo", "-NoProfile", "-Command", "exit 0"], { stdio: "ignore", timeout: 3000, windowsHide: true });
40
+ return "pwsh";
41
+ } catch { /* not found */ }
42
+ // powershell 5 fallback
43
+ try {
44
+ childProcess.execFileSync("powershell.exe", ["-NoLogo", "-NoProfile", "-Command", "exit 0"], { stdio: "ignore", timeout: 3000, windowsHide: true });
45
+ return "powershell.exe";
46
+ } catch { /* not found */ }
47
+ return ""; // 둘 다 없으면 psmux 기본 셸 사용
48
+ })();
49
+ const PSMUX_TIMEOUT_MS = 10000;
50
+ const COMPLETION_PREFIX = "__TRIFLUX_DONE__:";
51
+ const CAPTURE_ROOT = process.env.PSMUX_CAPTURE_ROOT || join(tmpdir(), "psmux-steering");
52
+ const CAPTURE_HELPER_PATH = join(CAPTURE_ROOT, "pipe-pane-capture.ps1");
53
+ const POLL_INTERVAL_MS = (() => {
54
+ const ms = Number.parseInt(process.env.PSMUX_POLL_INTERVAL_MS || "", 10);
55
+ if (Number.isFinite(ms) && ms > 0) return ms;
56
+ const sec = Number.parseFloat(process.env.PSMUX_POLL_INTERVAL_SEC || "1");
57
+ return Number.isFinite(sec) && sec > 0 ? Math.max(100, Math.trunc(sec * 1000)) : 1000;
58
+ })();
59
+
60
+ function quoteArg(value) {
61
+ const str = String(value);
62
+ if (!/[\s"]/u.test(str)) return str;
63
+ return `"${str.replace(/"/g, '\\"')}"`;
64
+ }
65
+
66
+ function sanitizePathPart(value) {
67
+ return String(value).replace(/[<>:"/\\|?*\u0000-\u001f']/gu, "_");
68
+ }
69
+
70
+ function toPaneTitle(index) {
71
+ return index === 0 ? "lead" : `worker-${index}`;
72
+ }
73
+
74
+ function sleepMs(ms) {
75
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
76
+ }
77
+
78
+ function sleepMsAsync(ms) {
79
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
80
+ }
81
+
82
+ function tokenizeCommand(command) {
83
+ const source = String(command || "").trim();
84
+ if (!source) return [];
85
+
86
+ const tokens = [];
87
+ let current = "";
88
+ let quote = null;
89
+
90
+ const pushCurrent = () => {
91
+ if (current.length > 0) {
92
+ tokens.push(current);
93
+ current = "";
94
+ }
95
+ };
96
+
97
+ for (let index = 0; index < source.length; index += 1) {
98
+ const char = source[index];
99
+ const next = source[index + 1];
100
+
101
+ if (quote === "'") {
102
+ if (char === "'") {
103
+ quote = null;
104
+ } else {
105
+ current += char;
106
+ }
107
+ continue;
108
+ }
109
+
110
+ if (quote === '"') {
111
+ if (char === '"') {
112
+ quote = null;
113
+ continue;
114
+ }
115
+ if (char === "\\" && (next === '"' || next === "\\")) {
116
+ current += next;
117
+ index += 1;
118
+ continue;
119
+ }
120
+ current += char;
121
+ continue;
122
+ }
123
+
124
+ if (char === "'" || char === '"') {
125
+ quote = char;
126
+ continue;
127
+ }
128
+
129
+ if (char === "\\" && next && (/[\s"'\\;]/u.test(next))) {
130
+ current += next;
131
+ index += 1;
132
+ continue;
133
+ }
134
+
135
+ if (/\s/u.test(char)) {
136
+ pushCurrent();
137
+ continue;
138
+ }
139
+
140
+ current += char;
141
+ }
142
+
143
+ if (quote) {
144
+ throw new Error(`psmux 인자 파싱 실패: 닫히지 않은 인용부호 (${command})`);
145
+ }
146
+
147
+ pushCurrent();
148
+ return tokens;
149
+ }
150
+
151
+ function normalizePsmuxArgs(args) {
152
+ if (Array.isArray(args)) {
153
+ return args.map((arg) => String(arg));
154
+ }
155
+ return tokenizeCommand(args);
156
+ }
157
+
158
+ function randomToken(prefix) {
159
+ const base = sanitizePathPart(prefix).replace(/_+/g, "-") || "pane";
160
+ const entropy = Math.random().toString(36).slice(2, 10);
161
+ return `${base}-${Date.now()}-${entropy}`;
162
+ }
163
+
164
+ function ensurePsmuxInstalled() {
165
+ if (!hasPsmux()) {
166
+ throw new Error(
167
+ "psmux가 설치되어 있지 않습니다.\n\n" +
168
+ "psmux는 Codex/Gemini CLI를 병렬 세션으로 실행하는 터미널 멀티플렉서입니다.\n" +
169
+ "설치 방법 (택 1):\n" +
170
+ `${formatPsmuxInstallGuidance(" ")}\n\n` +
171
+ "설치 후 터미널을 재시작하세요."
172
+ );
173
+ }
174
+ }
175
+
176
+ function getCaptureSessionDir(sessionName) {
177
+ return join(CAPTURE_ROOT, sanitizePathPart(sessionName));
178
+ }
179
+
180
+ function getCaptureLogPath(sessionName, paneName) {
181
+ return join(getCaptureSessionDir(sessionName), `${sanitizePathPart(paneName)}.log`);
182
+ }
183
+
184
+ function ensureCaptureHelper() {
185
+ mkdirSync(CAPTURE_ROOT, { recursive: true });
186
+ writeFileSync(
187
+ CAPTURE_HELPER_PATH,
188
+ [
189
+ "param(",
190
+ " [Parameter(Mandatory = $true)][string]$Path",
191
+ ")",
192
+ "",
193
+ "$parent = Split-Path -Parent $Path",
194
+ "if ($parent) {",
195
+ " New-Item -ItemType Directory -Force -Path $parent | Out-Null",
196
+ "}",
197
+ "",
198
+ "# Force UTF-8 encoding — CP949 등 non-UTF-8 codepage에서 Gemini stdout 캡처 실패 방지",
199
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
200
+ "",
201
+ "$reader = [Console]::In",
202
+ "while (($line = $reader.ReadLine()) -ne $null) {",
203
+ " Add-Content -LiteralPath $Path -Value $line -Encoding utf8",
204
+ "}",
205
+ "",
206
+ ].join("\n"),
207
+ "utf8",
208
+ );
209
+ return CAPTURE_HELPER_PATH;
210
+ }
211
+
212
+ function readCaptureLog(logPath) {
213
+ return existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
214
+ }
215
+
216
+ function parsePaneList(output) {
217
+ return output
218
+ .split("\n")
219
+ .map((line) => line.trim())
220
+ .filter(Boolean)
221
+ .map((line) => {
222
+ const [indexText, target] = line.split("\t");
223
+ return {
224
+ index: parseInt(indexText, 10),
225
+ target: target?.trim() || "",
226
+ };
227
+ })
228
+ .filter((entry) => Number.isFinite(entry.index) && entry.target)
229
+ .sort((a, b) => a.index - b.index)
230
+ .map((entry) => entry.target);
231
+ }
232
+
233
+ function parseSessionSummaries(output) {
234
+ return output
235
+ .split("\n")
236
+ .map((line) => line.trim())
237
+ .filter(Boolean)
238
+ .map((line) => {
239
+ const colonIndex = line.indexOf(":");
240
+ if (colonIndex === -1) {
241
+ return null;
242
+ }
243
+
244
+ const sessionName = line.slice(0, colonIndex).trim();
245
+ const flags = [...line.matchAll(/\(([^)]*)\)/g)].map((match) => match[1]).join(", ");
246
+ const attachedMatch = flags.match(/(\d+)\s+attached/);
247
+ const attachedCount = attachedMatch
248
+ ? parseInt(attachedMatch[1], 10)
249
+ : /\battached\b/.test(flags)
250
+ ? 1
251
+ : 0;
252
+
253
+ return sessionName
254
+ ? { sessionName, attachedCount }
255
+ : null;
256
+ })
257
+ .filter(Boolean);
258
+ }
259
+
260
+ function parsePaneDetails(output) {
261
+ return output
262
+ .split("\n")
263
+ .map((line) => line.trim())
264
+ .filter(Boolean)
265
+ .map((line) => {
266
+ const parts = line.split("\t");
267
+ const hasPaneIndex = parts.length >= 5;
268
+ const [paneIndexText = "", title = "", paneId = "", dead = "0", deadStatus = ""] = hasPaneIndex
269
+ ? parts
270
+ : ["", ...parts];
271
+ const exitCode = dead === "1"
272
+ ? Number.parseInt(deadStatus, 10)
273
+ : null;
274
+ const paneIndex = Number.parseInt(paneIndexText, 10);
275
+ return {
276
+ title,
277
+ paneId,
278
+ paneIndex: Number.isFinite(paneIndex) ? paneIndex : null,
279
+ isDead: dead === "1",
280
+ exitCode: Number.isFinite(exitCode) ? exitCode : dead === "1" ? 0 : null,
281
+ };
282
+ })
283
+ .filter((entry) => entry.paneId);
284
+ }
285
+
286
+ function collectSessionPanes(sessionName) {
287
+ const output = psmuxExec([
288
+ "list-panes",
289
+ "-t",
290
+ `${sessionName}:0`,
291
+ "-F",
292
+ "#{pane_index}\t#{session_name}:#{window_index}.#{pane_index}",
293
+ ]);
294
+ return parsePaneList(output);
295
+ }
296
+
297
+ function listPaneDetails(sessionName) {
298
+ const output = psmuxExec([
299
+ "list-panes",
300
+ "-t",
301
+ sessionName,
302
+ "-F",
303
+ "#{pane_index}\t#{pane_title}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_dead}\t#{pane_dead_status}",
304
+ ]);
305
+ return parsePaneDetails(output);
306
+ }
307
+
308
+ function paneTitleToIndex(name) {
309
+ const lower = String(name).toLowerCase();
310
+ if (lower === "lead") return 0;
311
+ const m = /^worker-(\d+)$/.exec(lower);
312
+ if (!m) return -1;
313
+ const idx = parseInt(m[1], 10);
314
+ // worker-0은 유효하지 않음 (lead와 충돌, toPaneTitle은 worker-0을 생성하지 않음)
315
+ return idx >= 1 ? idx : -1;
316
+ }
317
+
318
+ function resolvePane(sessionName, paneNameOrTarget) {
319
+ const wanted = String(paneNameOrTarget);
320
+ const panes = listPaneDetails(sessionName);
321
+
322
+ // 1차: title 또는 paneId 직접 매칭
323
+ const direct = panes.find((entry) => entry.title === wanted || entry.paneId === wanted);
324
+ if (direct) return direct;
325
+
326
+ // 2차: psmux title 미설정 fallback — "lead"→0, "worker-N"→N 인덱스 매칭
327
+ const idx = paneTitleToIndex(wanted);
328
+ if (idx >= 0 && idx < panes.length) return panes[idx];
329
+
330
+ throw new Error(`Pane을 찾을 수 없습니다: ${paneNameOrTarget}`);
331
+ }
332
+
333
+ function refreshCaptureSnapshot(sessionName, paneNameOrTarget) {
334
+ const pane = resolvePane(sessionName, paneNameOrTarget);
335
+ const paneName = pane.title || paneNameOrTarget;
336
+ const logPath = getCaptureLogPath(sessionName, paneName);
337
+ mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
338
+ const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p", "-S", "-"]);
339
+ writeFileSync(logPath, snapshot, "utf8");
340
+ return { paneId: pane.paneId, paneName, logPath, snapshot };
341
+ }
342
+
343
+ function disablePipeCapture(paneId) {
344
+ try {
345
+ psmuxExec(["pipe-pane", "-t", paneId]);
346
+ } catch {
347
+ // 기존 pipe가 없으면 무시
348
+ }
349
+ }
350
+
351
+ function sendLiteralToPane(paneId, text, submit = true) {
352
+ psmuxExec(["send-keys", "-t", paneId, "-l", text]);
353
+ if (submit) {
354
+ psmuxExec(["send-keys", "-t", paneId, "Enter"]);
355
+ }
356
+ }
357
+
358
+ export function sendKeysToPane(paneId, text, submit = true) {
359
+ psmuxExec(["send-keys", "-t", paneId, "-l", text]);
360
+ if (submit) {
361
+ psmuxExec(["send-keys", "-t", paneId, "Enter"]);
362
+ }
363
+ }
364
+
365
+ function toPatternRegExp(pattern) {
366
+ if (pattern instanceof RegExp) {
367
+ const flags = pattern.flags.includes("m") ? pattern.flags : `${pattern.flags}m`;
368
+ return new RegExp(pattern.source, flags);
369
+ }
370
+ return new RegExp(String(pattern), "m");
371
+ }
372
+
373
+ function escapeRegExp(value) {
374
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
375
+ }
376
+
377
+ function psmux(args, opts = {}) {
378
+ const normalizedArgs = normalizePsmuxArgs(args);
379
+ // PSMUX_SESSION 제거 — 기존 psmux 세션 내에서 호출 시 중첩 세션 차단 방지
380
+ const { PSMUX_SESSION: _, ...cleanEnv } = process.env;
381
+ try {
382
+ const result = childProcess.execFileSync(PSMUX_BIN, normalizedArgs, {
383
+ encoding: "utf8",
384
+ timeout: PSMUX_TIMEOUT_MS,
385
+ stdio: ["pipe", "pipe", "pipe"],
386
+ windowsHide: true,
387
+ env: cleanEnv,
388
+ ...opts,
389
+ });
390
+ return result != null ? String(result).trim() : "";
391
+ } catch (error) {
392
+ const stderr = typeof error?.stderr === "string"
393
+ ? error.stderr
394
+ : error?.stderr?.toString?.("utf8") || "";
395
+ const stdout = typeof error?.stdout === "string"
396
+ ? error.stdout
397
+ : error?.stdout?.toString?.("utf8") || "";
398
+ const wrapped = new Error((stderr || stdout || error.message || "psmux command failed").trim());
399
+ wrapped.status = error.status;
400
+ throw wrapped;
401
+ }
402
+ }
403
+
404
+ /** psmux 실행 가능 여부 확인 */
405
+ export function hasPsmux() {
406
+ try {
407
+ childProcess.execFileSync(PSMUX_BIN, ["-V"], {
408
+ stdio: "ignore",
409
+ timeout: 3000,
410
+ windowsHide: true,
411
+ });
412
+ return true;
413
+ } catch {
414
+ return false;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * psmux 커맨드 실행 래퍼
420
+ * @param {string|string[]} args
421
+ * @param {object} opts
422
+ * @returns {string}
423
+ */
424
+ export function psmuxExec(args, opts = {}) {
425
+ return psmux(args, opts);
426
+ }
427
+
428
+ /**
429
+ * psmux 세션 생성 + 레이아웃 분할
430
+ * @param {string} sessionName
431
+ * @param {object} opts
432
+ * @param {'2x2'|'1xN'|'Nx1'} opts.layout
433
+ * @param {number} opts.paneCount
434
+ * @returns {{ sessionName: string, panes: string[] }}
435
+ */
436
+ export function createPsmuxSession(sessionName, opts = {}) {
437
+ const layout = opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
438
+ const paneCount = Math.max(
439
+ 1,
440
+ Number.isFinite(opts.paneCount) ? Math.trunc(opts.paneCount) : 4,
441
+ );
442
+ const limitedPaneCount = layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
443
+ const sessionTarget = `${sessionName}:0`;
444
+
445
+ const newSessionArgs = [
446
+ "new-session",
447
+ "-d",
448
+ "-P",
449
+ "-F",
450
+ "#{session_name}:#{window_index}.#{pane_index}",
451
+ "-s",
452
+ sessionName,
453
+ "-x",
454
+ "220",
455
+ "-y",
456
+ "55",
457
+ ];
458
+ // Windows: psmux 기본 셸이 cmd.exe일 수 있으므로 PowerShell 강제
459
+ if (PWSH_BIN) newSessionArgs.push(PWSH_BIN, "-NoLogo", "-NoProfile");
460
+ const leadPane = psmuxExec(newSessionArgs);
461
+
462
+ // split-window로 생성되는 pane도 동일 셸 사용
463
+ if (PWSH_BIN) {
464
+ try { psmuxExec(["set-option", "-t", sessionName, "default-command", `${PWSH_BIN} -NoLogo -NoProfile`]); } catch { /* 미지원 시 무시 */ }
465
+ }
466
+
467
+ if (layout === "2x2" && limitedPaneCount >= 3) {
468
+ const rightPane = psmuxExec([
469
+ "split-window",
470
+ "-h",
471
+ "-P",
472
+ "-F",
473
+ "#{session_name}:#{window_index}.#{pane_index}",
474
+ "-t",
475
+ leadPane,
476
+ ]);
477
+ psmuxExec([
478
+ "split-window",
479
+ "-v",
480
+ "-P",
481
+ "-F",
482
+ "#{session_name}:#{window_index}.#{pane_index}",
483
+ "-t",
484
+ rightPane,
485
+ ]);
486
+ if (limitedPaneCount >= 4) {
487
+ psmuxExec([
488
+ "split-window",
489
+ "-v",
490
+ "-P",
491
+ "-F",
492
+ "#{session_name}:#{window_index}.#{pane_index}",
493
+ "-t",
494
+ leadPane,
495
+ ]);
496
+ }
497
+ psmuxExec(["select-layout", "-t", sessionTarget, "tiled"]);
498
+ } else if (layout === "1xN") {
499
+ for (let index = 1; index < limitedPaneCount; index += 1) {
500
+ psmuxExec(["split-window", "-h", "-t", sessionTarget]);
501
+ }
502
+ psmuxExec(["select-layout", "-t", sessionTarget, "even-horizontal"]);
503
+ } else {
504
+ for (let index = 1; index < limitedPaneCount; index += 1) {
505
+ psmuxExec(["split-window", "-v", "-t", sessionTarget]);
506
+ }
507
+ psmuxExec(["select-layout", "-t", sessionTarget, "even-vertical"]);
508
+ }
509
+
510
+ psmuxExec(["select-pane", "-t", leadPane]);
511
+
512
+ const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
513
+ panes.forEach((pane, index) => {
514
+ psmuxExec(["select-pane", "-t", pane, "-T", toPaneTitle(index)]);
515
+ });
516
+
517
+ return { sessionName, panes };
518
+ }
519
+
520
+ /**
521
+ * psmux 세션의 모든 pane PID를 수집
522
+ * @param {string} sessionName
523
+ * @returns {number[]}
524
+ */
525
+ function collectPanePids(sessionName) {
526
+ try {
527
+ const output = psmuxExec([
528
+ "list-panes", "-t", sessionName, "-F", "#{pane_pid}",
529
+ ]);
530
+ return output
531
+ .split(/\r?\n/)
532
+ .map((l) => Number.parseInt(l.trim(), 10))
533
+ .filter((pid) => Number.isFinite(pid) && pid > 0);
534
+ } catch {
535
+ return [];
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Windows 프로세스 트리 강제 종료 (taskkill /T /F)
541
+ * @param {number} pid
542
+ */
543
+ function killProcessTree(pid) {
544
+ if (!IS_WINDOWS || !pid) return;
545
+ try {
546
+ childProcess.execSync(`taskkill /T /F /PID ${pid}`, {
547
+ stdio: "ignore",
548
+ timeout: 5000,
549
+ });
550
+ } catch {
551
+ // 이미 종료된 프로세스 — 무시
552
+ }
553
+ }
554
+
555
+ /**
556
+ * 세션의 모든 pane에서 pipe-pane 캡처를 해제한다.
557
+ * pipe-pane을 인자 없이 호출하면 psmux가 reader 프로세스에 EOF를 보내 정상 종료시킨다.
558
+ * @param {string} sessionName
559
+ * @param {string[]} paneIds — collectSessionPanes() 결과
560
+ */
561
+ function disableAllPipeCaptures(sessionName, paneIds) {
562
+ for (const paneId of paneIds) {
563
+ try {
564
+ psmuxExec(["pipe-pane", "-t", paneId]);
565
+ } catch {
566
+ // pane이 이미 죽었거나 pipe가 없으면 무시
567
+ }
568
+ }
569
+ }
570
+
571
+ /**
572
+ * 세션과 관련된 고아 pipe-pane 헬퍼 프로세스를 찾아 종료한다.
573
+ * pipe-pane disable 후에도 reader가 종료되지 않는 경우의 안전망.
574
+ * @param {string} sessionName
575
+ */
576
+ function killOrphanPipeHelpers(sessionName) {
577
+ if (!IS_WINDOWS) return;
578
+ const safeSession = sanitizePathPart(sessionName);
579
+ try {
580
+ const output = childProcess.execSync(
581
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match 'tfx-headless[/\\\\]${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
582
+ { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
583
+ );
584
+ const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0);
585
+ for (const pid of pids) {
586
+ killProcessTree(pid);
587
+ }
588
+ } catch {
589
+ // WMI 조회 실패 — 무시
590
+ }
591
+ }
592
+
593
+ /**
594
+ * 세션이 spawn한 CLI(codex/gemini)의 고아 MCP 서버 프로세스를 찾아 종료한다.
595
+ * headless 워커가 codex/gemini를 실행하면 MCP 서버(node.exe)가 자식으로 생성되는데,
596
+ * 부모가 죽어도 Windows에서는 자식이 자동 종료되지 않아 고아가 된다.
597
+ * @param {string} sessionName
598
+ */
599
+ function killOrphanMcpProcesses(sessionName) {
600
+ if (!IS_WINDOWS) return;
601
+ const safeSession = sanitizePathPart(sessionName);
602
+
603
+ // Hub PID 보호 — Hub 프로세스를 고아로 잘못 식별하지 않도록
604
+ let hubPid = null;
605
+ try {
606
+ const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
607
+ if (existsSync(hubPidPath)) {
608
+ const hubInfo = JSON.parse(readFileSync(hubPidPath, "utf8"));
609
+ hubPid = Number(hubInfo?.pid);
610
+ }
611
+ } catch {}
612
+
613
+ try {
614
+ // 세션 결과 디렉토리 패턴으로 MCP 서버 프로세스 식별
615
+ const output = childProcess.execSync(
616
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match 'tfx-headless[/\\\\]${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
617
+ { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
618
+ );
619
+ const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0 && p !== hubPid);
620
+ for (const pid of pids) {
621
+ killProcessTree(pid);
622
+ }
623
+ } catch {
624
+ // WMI 조회 실패 — 무시
625
+ }
626
+ }
627
+
628
+ function detachAttachedClients(sessionName, waitMs = 750) {
629
+ const attachedCount = getPsmuxSessionAttachedCount(sessionName);
630
+ if (!Number.isFinite(attachedCount) || attachedCount <= 0) return false;
631
+ try {
632
+ psmuxExec(["detach-client", "-t", sessionName], { stdio: "ignore" });
633
+ sleepMs(waitMs);
634
+ return true;
635
+ } catch {
636
+ return false;
637
+ }
638
+ }
639
+
640
+ function findFallbackPane(sessionName, excludedPaneId) {
641
+ try {
642
+ const panes = listPaneDetails(sessionName)
643
+ .filter((pane) => pane.paneId !== excludedPaneId && !pane.isDead);
644
+ if (panes.length === 0) return null;
645
+ return panes.find((pane) => pane.title === "lead")
646
+ || panes.find((pane) => pane.paneIndex === 0)
647
+ || panes[0];
648
+ } catch {
649
+ return null;
650
+ }
651
+ }
652
+
653
+ /**
654
+ * psmux 세션 종료
655
+ * 순서: pipe-pane 해제 → pane 프로세스 트리 정리 → 세션 종료 → 고아 정리
656
+ * @param {string} sessionName
657
+ */
658
+ export function killPsmuxSession(sessionName) {
659
+ // attach된 WT/ConPTY 클라이언트가 있으면 먼저 안전하게 분리한다.
660
+ detachAttachedClients(sessionName);
661
+
662
+ // 1. pipe-pane 캡처 해제 — reader 프로세스에 EOF 전송하여 정상 종료 유도
663
+ let paneIds = [];
664
+ try {
665
+ paneIds = collectSessionPanes(sessionName);
666
+ } catch {
667
+ // 세션이 이미 죽었으면 pane 목록 수집 불가 — 계속 진행
668
+ }
669
+ disableAllPipeCaptures(sessionName, paneIds);
670
+
671
+ // pipe-pane reader가 EOF를 처리하고 정상 종료할 시간 확보
672
+ sleepMs(500);
673
+
674
+ // 2. pane 프로세스 트리 강제 종료 (MCP 서버 포함)
675
+ const pids = collectPanePids(sessionName);
676
+ for (const pid of pids) {
677
+ killProcessTree(pid);
678
+ }
679
+
680
+ // 3. psmux 세션 자체 종료
681
+ try {
682
+ psmuxExec(["kill-session", "-t", sessionName], { stdio: "ignore" });
683
+ } catch {
684
+ // 이미 종료된 세션 — 무시
685
+ }
686
+
687
+ // 4. 고아 프로세스 정리 (pipe-pane 헬퍼 + MCP 서버)
688
+ killOrphanPipeHelpers(sessionName);
689
+ killOrphanMcpProcesses(sessionName);
690
+ }
691
+
692
+ /**
693
+ * psmux 세션 존재 확인
694
+ * @param {string} sessionName
695
+ * @returns {boolean}
696
+ */
697
+ export function psmuxSessionExists(sessionName) {
698
+ try {
699
+ psmuxExec(["has-session", "-t", sessionName], { stdio: "ignore" });
700
+ return true;
701
+ } catch {
702
+ return false;
703
+ }
704
+ }
705
+
706
+ /**
707
+ * tfx-multi- 접두사 psmux 세션 목록
708
+ * @returns {string[]}
709
+ */
710
+ export function listPsmuxSessions() {
711
+ try {
712
+ return parseSessionSummaries(psmuxExec(["list-sessions"]))
713
+ .map((session) => session.sessionName)
714
+ .filter((sessionName) => sessionName.startsWith("tfx-multi-"));
715
+ } catch {
716
+ return [];
717
+ }
718
+ }
719
+
720
+ /**
721
+ * pane 마지막 N줄 캡처
722
+ * @param {string} target
723
+ * @param {number} lines
724
+ * @returns {string}
725
+ */
726
+ export function capturePsmuxPane(target, lines = 5) {
727
+ try {
728
+ const full = psmuxExec(["capture-pane", "-t", target, "-p", "-S", "-"]);
729
+ const nonEmpty = full.split("\n").filter((line) => line.trim() !== "");
730
+ return nonEmpty.slice(-lines).join("\n");
731
+ } catch {
732
+ return "";
733
+ }
734
+ }
735
+
736
+ /**
737
+ * psmux 세션 연결
738
+ * @param {string} sessionName
739
+ */
740
+ export function attachPsmuxSession(sessionName) {
741
+ const result = childProcess.spawnSync(PSMUX_BIN, ["attach-session", "-t", sessionName], {
742
+ stdio: "inherit",
743
+ timeout: 0,
744
+ windowsHide: false,
745
+ });
746
+ if ((result.status ?? 1) !== 0) {
747
+ throw new Error(`psmux attach 실패 (exit=${result.status})`);
748
+ }
749
+ }
750
+
751
+ /**
752
+ * 세션 attach client 수 조회
753
+ * @param {string} sessionName
754
+ * @returns {number|null}
755
+ */
756
+ export function getPsmuxSessionAttachedCount(sessionName) {
757
+ try {
758
+ const session = parseSessionSummaries(psmuxExec(["list-sessions"]))
759
+ .find((entry) => entry.sessionName === sessionName);
760
+ return session ? session.attachedCount : null;
761
+ } catch {
762
+ return null;
763
+ }
764
+ }
765
+
766
+ /**
767
+ * 팀메이트 조작 키 바인딩 설정
768
+ * @param {string} sessionName
769
+ * @param {object} opts
770
+ * @param {boolean} opts.inProcess
771
+ * @param {string} opts.taskListCommand
772
+ */
773
+ export function configurePsmuxKeybindings(sessionName, opts = {}) {
774
+ const { inProcess = false, taskListCommand = "" } = opts;
775
+ const cond = `#{==:#{session_name},${sessionName}}`;
776
+ const target = `${sessionName}:0`;
777
+ const bindNext = inProcess
778
+ ? "select-pane -t :.+ \\; resize-pane -Z"
779
+ : "select-pane -t :.+";
780
+ const bindPrev = inProcess
781
+ ? "select-pane -t :.- \\; resize-pane -Z"
782
+ : "select-pane -t :.-";
783
+
784
+ // psmux는 세션별 서버이므로 -t target으로 세션 컨텍스트를 전달해야 한다.
785
+ const bindSafe = (args) => {
786
+ try {
787
+ psmuxExec(["-t", target, ...args]);
788
+ } catch {
789
+ // 미지원 시 무시
790
+ }
791
+ };
792
+
793
+ bindSafe(["bind-key", "-T", "root", "-n", "S-Down", "if-shell", "-F", cond, bindNext, "send-keys S-Down"]);
794
+ bindSafe(["bind-key", "-T", "root", "-n", "S-Up", "if-shell", "-F", cond, bindPrev, "send-keys S-Up"]);
795
+ bindSafe(["bind-key", "-T", "root", "-n", "S-Right", "if-shell", "-F", cond, bindNext, "send-keys S-Right"]);
796
+ bindSafe(["bind-key", "-T", "root", "-n", "S-Left", "if-shell", "-F", cond, bindPrev, "send-keys S-Left"]);
797
+ bindSafe(["bind-key", "-T", "root", "-n", "BTab", "if-shell", "-F", cond, bindPrev, "send-keys BTab"]);
798
+ bindSafe(["bind-key", "-T", "root", "-n", "Escape", "if-shell", "-F", cond, "send-keys C-c", "send-keys Escape"]);
799
+
800
+ if (taskListCommand) {
801
+ bindSafe([
802
+ "bind-key",
803
+ "-T",
804
+ "root",
805
+ "-n",
806
+ "C-t",
807
+ "if-shell",
808
+ "-F",
809
+ cond,
810
+ `display-popup -E ${quoteArg(taskListCommand)}`,
811
+ "send-keys C-t",
812
+ ]);
813
+ }
814
+ }
815
+
816
+ // ─── steering 기능 ───
817
+
818
+ /**
819
+ * pane 출력 pipe-pane 캡처를 시작하고 즉시 snapshot을 기록한다.
820
+ * @param {string} sessionName
821
+ * @param {string} paneNameOrTarget
822
+ * @returns {{ paneId: string, paneName: string, logPath: string }}
823
+ */
824
+ export function startCapture(sessionName, paneNameOrTarget) {
825
+ ensurePsmuxInstalled();
826
+ const pane = resolvePane(sessionName, paneNameOrTarget);
827
+ const paneName = pane.title || paneNameOrTarget;
828
+ const logPath = getCaptureLogPath(sessionName, paneName);
829
+ const helperPath = ensureCaptureHelper();
830
+ mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
831
+ writeFileSync(logPath, "", "utf8");
832
+
833
+ disablePipeCapture(pane.paneId);
834
+ psmuxExec([
835
+ "pipe-pane",
836
+ "-t",
837
+ pane.paneId,
838
+ `powershell.exe -NoLogo -NoProfile -File ${quoteArg(helperPath)} ${quoteArg(logPath)}`,
839
+ ]);
840
+
841
+ refreshCaptureSnapshot(sessionName, pane.paneId);
842
+ return { paneId: pane.paneId, paneName, logPath };
843
+ }
844
+
845
+ /**
846
+ * PowerShell 명령을 pane에 비동기 전송하고 완료 토큰을 반환한다.
847
+ * @param {string} sessionName
848
+ * @param {string} paneNameOrTarget
849
+ * @param {string} commandText
850
+ * @returns {{ paneId: string, paneName: string, token: string, logPath: string }}
851
+ */
852
+ /**
853
+ * CLI 명령(codex/gemini)이 psmux pane의 PowerShell 환경에서 단축 플래그 충돌을
854
+ * 일으키는 문제를 방지하기 위해 bash -c '...' 로 감싼다.
855
+ * - codex -o flag → PS -OutVariable/OutBuffer 충돌
856
+ * - gemini --prompt flag (v8.6.0: -p → --prompt, PS 충돌 해소)
857
+ * @param {string} cmd
858
+ * @returns {string}
859
+ */
860
+ function wrapCliForBash(cmd) {
861
+ const trimmed = cmd.trimStart();
862
+ // PowerShell 구문(Clear-Host, Get-Content 등) 또는 completion token이 포함되면 PowerShell 직통
863
+ if (/Clear-Host|Get-Content|__TRIFLUX_DONE__/i.test(trimmed)) return cmd;
864
+ const isCli = /\b(codex|gemini)\b/u.test(trimmed);
865
+ if (!isCli) return cmd;
866
+ // 단일 따옴표 이스케이프: ' → '\''
867
+ const escaped = trimmed.replace(/'/g, "'\\''");
868
+ return `bash -c '${escaped}'`;
869
+ }
870
+
871
+ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
872
+ ensurePsmuxInstalled();
873
+ const pane = resolvePane(sessionName, paneNameOrTarget);
874
+ const paneName = pane.title || paneNameOrTarget;
875
+ const logPath = getCaptureLogPath(sessionName, paneName);
876
+
877
+ if (!existsSync(logPath)) {
878
+ startCapture(sessionName, paneName);
879
+ }
880
+
881
+ const token = randomToken(paneName);
882
+ const safeCommand = wrapCliForBash(commandText);
883
+ // CP949 등 non-UTF-8 codepage 환경에서 CLI stdout이 깨지는 문제 방지 (belt-and-suspenders)
884
+ const chcpPrefix = IS_WINDOWS ? "chcp 65001 > $null; " : "";
885
+ const wrapped = `${chcpPrefix}try { ${safeCommand} } finally { $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit" }`;
886
+
887
+ sendLiteralToPane(pane.paneId, wrapped, true);
888
+
889
+ return { paneId: pane.paneId, paneName, token, logPath };
890
+ }
891
+
892
+ /**
893
+ * pane 캡처 로그에서 정규식 패턴을 polling으로 대기한다.
894
+ * @param {string} sessionName
895
+ * @param {string} paneNameOrTarget
896
+ * @param {string|RegExp} pattern
897
+ * @param {number} timeoutSec
898
+ * @param {object} [opts]
899
+ * @param {(snapshot: {content: string, paneId: string, paneName: string, elapsed: number}) => void} [opts.onPoll] — 각 폴링 주기마다 호출
900
+ * @param {AbortSignal} [opts.signal] — 외부에서 폴링 중단 요청 시 사용
901
+ * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, aborted?: boolean }}
902
+ */
903
+ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300, opts = {}) {
904
+ ensurePsmuxInstalled();
905
+
906
+ // E4 크래시 복구: 초기 resolvePane도 세션 사망을 감지
907
+ let pane;
908
+ try {
909
+ pane = resolvePane(sessionName, paneNameOrTarget);
910
+ } catch (resolveError) {
911
+ if (!psmuxSessionExists(sessionName)) {
912
+ return {
913
+ matched: false,
914
+ paneId: "",
915
+ paneName: String(paneNameOrTarget),
916
+ logPath: "",
917
+ match: null,
918
+ sessionDead: true,
919
+ };
920
+ }
921
+ throw resolveError; // 세션은 살아있지만 pane을 못 찾음 → 원래 에러 전파
922
+ }
923
+
924
+ const paneName = pane.title || paneNameOrTarget;
925
+ // opts.logPath: dispatch 시 확정된 캡처 로그 경로 직접 지정 (타이틀 변경 내성)
926
+ const logPath = (opts.logPath && existsSync(opts.logPath))
927
+ ? opts.logPath
928
+ : getCaptureLogPath(sessionName, paneName);
929
+ if (!existsSync(logPath)) {
930
+ throw new Error(`캡처 로그가 없습니다. 먼저 startCapture(${sessionName}, ${paneName})를 호출하세요.`);
931
+ }
932
+
933
+ const startTime = Date.now();
934
+ const deadline = startTime + Math.max(0, Math.trunc(timeoutSec * 1000));
935
+ const regex = toPatternRegExp(pattern);
936
+
937
+ if (opts?.signal?.aborted) {
938
+ return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
939
+ }
940
+
941
+ while (Date.now() <= deadline) {
942
+ if (opts?.signal?.aborted) {
943
+ return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
944
+ }
945
+ // E4 크래시 복구: capture 실패 시 세션 생존 체크
946
+ try {
947
+ if (opts.logPath) {
948
+ // logPath 직접 지정 시 — 셸 타이틀 변경과 무관하게 올바른 파일에 기록
949
+ const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p", "-S", "-"]);
950
+ writeFileSync(logPath, snapshot, "utf8");
951
+ } else {
952
+ refreshCaptureSnapshot(sessionName, pane.paneId);
953
+ }
954
+ } catch {
955
+ if (!psmuxSessionExists(sessionName)) {
956
+ return {
957
+ matched: false,
958
+ paneId: pane.paneId,
959
+ paneName,
960
+ logPath,
961
+ match: null,
962
+ sessionDead: true,
963
+ };
964
+ }
965
+ // 일시적 오류 — 다음 폴링에서 재시도
966
+ }
967
+
968
+ const content = readCaptureLog(logPath);
969
+
970
+ // onPoll 콜백 — 각 폴링 주기마다 중간 상태 전달
971
+ if (opts.onPoll) {
972
+ try {
973
+ opts.onPoll({ content, paneId: pane.paneId, paneName, elapsed: Date.now() - startTime });
974
+ } catch { /* 콜백 예외는 삼킴 — 폴링 루프 보호 */ }
975
+ }
976
+
977
+ const match = regex.exec(content);
978
+ if (match) {
979
+ return {
980
+ matched: true,
981
+ paneId: pane.paneId,
982
+ paneName,
983
+ logPath,
984
+ match: match[0],
985
+ };
986
+ }
987
+
988
+ if (Date.now() > deadline) {
989
+ break;
990
+ }
991
+ await sleepMsAsync(POLL_INTERVAL_MS);
992
+ if (opts?.signal?.aborted) {
993
+ return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
994
+ }
995
+ }
996
+
997
+ return {
998
+ matched: false,
999
+ paneId: pane.paneId,
1000
+ paneName,
1001
+ logPath,
1002
+ match: null,
1003
+ };
1004
+ }
1005
+
1006
+ /**
1007
+ * 완료 토큰이 찍힐 때까지 대기하고 exit code를 파싱한다.
1008
+ * @param {string} sessionName
1009
+ * @param {string} paneNameOrTarget
1010
+ * @param {string} token
1011
+ * @param {number} timeoutSec
1012
+ * @param {object} [opts] — waitForPattern에 전달할 옵션 (onPoll 등)
1013
+ * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
1014
+ */
1015
+ export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300, opts = {}) {
1016
+ const completionRegex = new RegExp(
1017
+ `${escapeRegExp(COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
1018
+ "m",
1019
+ );
1020
+ const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec, opts);
1021
+
1022
+ // 타이밍 이슈 대응: matched=false인 경우 500ms 대기 후 최종 1회 캡처 재시도
1023
+ if (!result.matched && !result.sessionDead && result.logPath) {
1024
+ await new Promise((r) => setTimeout(r, 500));
1025
+ try {
1026
+ const pane = resolvePane(sessionName, paneNameOrTarget);
1027
+ const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p", "-S", "-"]);
1028
+ writeFileSync(result.logPath, snapshot, "utf8");
1029
+ const content = readCaptureLog(result.logPath);
1030
+ const retryMatch = completionRegex.exec(content);
1031
+ if (retryMatch) {
1032
+ return {
1033
+ ...result,
1034
+ matched: true,
1035
+ match: retryMatch[0],
1036
+ token,
1037
+ exitCode: Number.parseInt(retryMatch[1], 10),
1038
+ };
1039
+ }
1040
+ } catch {
1041
+ // 세션 이미 종료 — 무시
1042
+ }
1043
+ }
1044
+
1045
+ const exitMatch = result.match ? completionRegex.exec(result.match) : null;
1046
+ return {
1047
+ ...result,
1048
+ token,
1049
+ exitCode: exitMatch ? Number.parseInt(exitMatch[1], 10) : null,
1050
+ };
1051
+ }
1052
+
1053
+ // ─── 하이브리드 모드 워커 관리 함수 ───
1054
+
1055
+ /**
1056
+ * psmux 세션의 새 pane에서 워커 실행
1057
+ * @param {string} sessionName - 대상 psmux 세션 이름
1058
+ * @param {string} workerName - 워커 식별용 pane 타이틀
1059
+ * @param {string} cmd - 실행할 커맨드
1060
+ * @returns {{ paneId: string, workerName: string }}
1061
+ */
1062
+ export function spawnWorker(sessionName, workerName, cmd) {
1063
+ if (!hasPsmux()) {
1064
+ throw new Error(
1065
+ "psmux가 설치되어 있지 않습니다.\n" +
1066
+ `설치 방법:\n${formatPsmuxInstallGuidance(" ")}`
1067
+ );
1068
+ }
1069
+
1070
+ // remain-on-exit: 종료된 pane이 즉시 사라지는 것 방지
1071
+ try {
1072
+ psmuxExec(["set-option", "-t", sessionName, "remain-on-exit", "on"]);
1073
+ } catch {
1074
+ // 미지원 시 무시
1075
+ }
1076
+
1077
+ // Windows: pane 기본셸이 PowerShell → Git Bash로 래핑
1078
+ // psmux가 이스케이프 시퀀스를 처리하므로 포워드 슬래시 경로를 사용한다.
1079
+ const shellCmd = IS_WINDOWS
1080
+ ? `& '${GIT_BASH.replace(/\\/g, "/")}' -l -c '${cmd.replace(/'/g, "'\\''")}'`
1081
+ : cmd;
1082
+
1083
+ try {
1084
+ const paneTarget = psmuxExec([
1085
+ "split-window",
1086
+ "-t",
1087
+ sessionName,
1088
+ "-P",
1089
+ "-F",
1090
+ "#{session_name}:#{window_index}.#{pane_index}",
1091
+ shellCmd,
1092
+ ]);
1093
+ psmuxExec(["select-pane", "-t", paneTarget, "-T", workerName]);
1094
+ return { paneId: paneTarget, workerName };
1095
+ } catch (err) {
1096
+ throw new Error(`워커 생성 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * 워커 pane 실행 상태 확인
1102
+ * @param {string} sessionName - 대상 psmux 세션 이름
1103
+ * @param {string} workerName - 워커 pane 타이틀
1104
+ * @returns {{ status: "running"|"exited", exitCode: number|null, paneId: string }}
1105
+ */
1106
+ export function getWorkerStatus(sessionName, workerName) {
1107
+ if (!hasPsmux()) {
1108
+ throw new Error(`psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`);
1109
+ }
1110
+ try {
1111
+ const pane = resolvePane(sessionName, workerName);
1112
+ return {
1113
+ status: pane.isDead ? "exited" : "running",
1114
+ exitCode: pane.isDead ? pane.exitCode : null,
1115
+ paneId: pane.paneId,
1116
+ };
1117
+ } catch (err) {
1118
+ if (err.message.includes("Pane을 찾을 수 없습니다")) {
1119
+ throw new Error(`워커를 찾을 수 없습니다: ${workerName}`);
1120
+ }
1121
+ throw new Error(`워커 상태 조회 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * 워커 pane 프로세스 강제 종료
1127
+ * @param {string} sessionName - 대상 psmux 세션 이름
1128
+ * @param {string} workerName - 워커 pane 타이틀
1129
+ * @returns {{ killed: boolean }}
1130
+ */
1131
+ export function killWorker(sessionName, workerName) {
1132
+ if (!hasPsmux()) {
1133
+ throw new Error(`psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`);
1134
+ }
1135
+ try {
1136
+ const { paneId, status } = getWorkerStatus(sessionName, workerName);
1137
+ const attachedCount = getPsmuxSessionAttachedCount(sessionName);
1138
+ const fallbackPane = attachedCount > 0
1139
+ ? findFallbackPane(sessionName, paneId)
1140
+ : null;
1141
+
1142
+ if (fallbackPane?.paneId) {
1143
+ try {
1144
+ psmuxExec(["select-pane", "-t", fallbackPane.paneId]);
1145
+ } catch {
1146
+ // focus 회복 best-effort
1147
+ }
1148
+ }
1149
+
1150
+ // pipe-pane 캡처 해제 — reader 프로세스 정상 종료 유도
1151
+ disablePipeCapture(paneId);
1152
+
1153
+ // pane PID 수집 → 프로세스 트리 정리 (MCP 서버 좀비 방지)
1154
+ try {
1155
+ const pidOutput = psmuxExec(["list-panes", "-t", paneId, "-F", "#{pane_pid}"]);
1156
+ const pid = Number.parseInt(pidOutput.trim(), 10);
1157
+ if (Number.isFinite(pid) && pid > 0) killProcessTree(pid);
1158
+ } catch {
1159
+ // PID 조회 실패 — 아래에서 pane만 정리
1160
+ }
1161
+
1162
+ // 이미 종료된 워커 → pane 정리만 수행
1163
+ if (status === "exited") {
1164
+ try {
1165
+ psmuxExec(["kill-pane", "-t", paneId]);
1166
+ } catch {
1167
+ // 무시
1168
+ }
1169
+ return { killed: true };
1170
+ }
1171
+
1172
+ // running → C-c 우아한 종료 시도
1173
+ try {
1174
+ psmuxExec(["send-keys", "-t", paneId, "C-c"]);
1175
+ } catch {
1176
+ // send-keys 실패 무시
1177
+ }
1178
+
1179
+ if (!fallbackPane) {
1180
+ try {
1181
+ psmuxExec(["send-keys", "-t", paneId, "exit", "Enter"]);
1182
+ } catch {
1183
+ // send-keys 실패 무시
1184
+ }
1185
+ }
1186
+
1187
+ sleepMs(2000);
1188
+
1189
+ try {
1190
+ psmuxExec(["kill-pane", "-t", paneId]);
1191
+ } catch {
1192
+ // 이미 종료된 pane — 무시
1193
+ }
1194
+
1195
+ if (fallbackPane?.paneId) {
1196
+ try {
1197
+ psmuxExec(["select-pane", "-t", fallbackPane.paneId]);
1198
+ } catch {
1199
+ // pane 정리 후 focus 재선택 best-effort
1200
+ }
1201
+ }
1202
+ return { killed: true };
1203
+ } catch (err) {
1204
+ if (err.message.includes("워커를 찾을 수 없습니다")) {
1205
+ return { killed: true };
1206
+ }
1207
+ throw new Error(`워커 종료 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * 워커 pane 출력 마지막 N줄 캡처
1213
+ * @param {string} sessionName - 대상 psmux 세션 이름
1214
+ * @param {string} workerName - 워커 pane 타이틀
1215
+ * @param {number} lines - 캡처할 줄 수 (기본 50)
1216
+ * @returns {string} 캡처된 출력
1217
+ */
1218
+ export function captureWorkerOutput(sessionName, workerName, lines = 50) {
1219
+ if (!hasPsmux()) {
1220
+ throw new Error(`psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}`);
1221
+ }
1222
+ try {
1223
+ const { paneId } = getWorkerStatus(sessionName, workerName);
1224
+ return psmuxExec(["capture-pane", "-t", paneId, "-p", "-S", `-${lines}`]);
1225
+ } catch (err) {
1226
+ if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
1227
+ throw new Error(`출력 캡처 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
1228
+ }
1229
+ }
1230
+
1231
+ // ─── CLI 진입점 ───
1232
+
1233
+ if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
1234
+ (async () => {
1235
+ const [, , cmd, ...args] = process.argv;
1236
+
1237
+ // CLI 인자 파싱 헬퍼
1238
+ function getArg(name) {
1239
+ const idx = args.indexOf(`--${name}`);
1240
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
1241
+ }
1242
+
1243
+ try {
1244
+ switch (cmd) {
1245
+ case "spawn": {
1246
+ const session = getArg("session");
1247
+ const name = getArg("name");
1248
+ const workerCmd = getArg("cmd");
1249
+ if (!session || !name || !workerCmd) {
1250
+ console.error("사용법: node psmux.mjs spawn --session <세션> --name <워커명> --cmd <커맨드>");
1251
+ process.exit(1);
1252
+ }
1253
+ console.log(JSON.stringify(spawnWorker(session, name, workerCmd), null, 2));
1254
+ break;
1255
+ }
1256
+ case "status": {
1257
+ const session = getArg("session");
1258
+ const name = getArg("name");
1259
+ if (!session || !name) {
1260
+ console.error("사용법: node psmux.mjs status --session <세션> --name <워커명>");
1261
+ process.exit(1);
1262
+ }
1263
+ console.log(JSON.stringify(getWorkerStatus(session, name), null, 2));
1264
+ break;
1265
+ }
1266
+ case "kill": {
1267
+ const session = getArg("session");
1268
+ const name = getArg("name");
1269
+ if (!session || !name) {
1270
+ console.error("사용법: node psmux.mjs kill --session <세션> --name <워커명>");
1271
+ process.exit(1);
1272
+ }
1273
+ console.log(JSON.stringify(killWorker(session, name), null, 2));
1274
+ break;
1275
+ }
1276
+ case "output": {
1277
+ const session = getArg("session");
1278
+ const name = getArg("name");
1279
+ const lines = parseInt(getArg("lines") || "50", 10);
1280
+ if (!session || !name) {
1281
+ console.error("사용법: node psmux.mjs output --session <세션> --name <워커명> [--lines <줄수>]");
1282
+ process.exit(1);
1283
+ }
1284
+ console.log(captureWorkerOutput(session, name, lines));
1285
+ break;
1286
+ }
1287
+ case "capture-start": {
1288
+ const session = getArg("session");
1289
+ const name = getArg("name");
1290
+ if (!session || !name) {
1291
+ console.error("사용법: node psmux.mjs capture-start --session <세션> --name <pane>");
1292
+ process.exit(1);
1293
+ }
1294
+ console.log(JSON.stringify(startCapture(session, name), null, 2));
1295
+ break;
1296
+ }
1297
+ case "dispatch": {
1298
+ const session = getArg("session");
1299
+ const name = getArg("name");
1300
+ const commandText = getArg("command");
1301
+ if (!session || !name || !commandText) {
1302
+ console.error("사용법: node psmux.mjs dispatch --session <세션> --name <pane> --command <PowerShell 명령>");
1303
+ process.exit(1);
1304
+ }
1305
+ console.log(JSON.stringify(dispatchCommand(session, name, commandText), null, 2));
1306
+ break;
1307
+ }
1308
+ case "wait-pattern": {
1309
+ const session = getArg("session");
1310
+ const name = getArg("name");
1311
+ const pattern = getArg("pattern");
1312
+ const timeoutSec = parseInt(getArg("timeout") || "300", 10);
1313
+ if (!session || !name || !pattern) {
1314
+ console.error("사용법: node psmux.mjs wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
1315
+ process.exit(1);
1316
+ }
1317
+ const result = await waitForPattern(session, name, pattern, timeoutSec);
1318
+ console.log(JSON.stringify(result, null, 2));
1319
+ if (!result.matched) process.exit(2);
1320
+ break;
1321
+ }
1322
+ case "wait-completion": {
1323
+ const session = getArg("session");
1324
+ const name = getArg("name");
1325
+ const token = getArg("token");
1326
+ const timeoutSec = parseInt(getArg("timeout") || "300", 10);
1327
+ if (!session || !name || !token) {
1328
+ console.error("사용법: node psmux.mjs wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
1329
+ process.exit(1);
1330
+ }
1331
+ const result = await waitForCompletion(session, name, token, timeoutSec);
1332
+ console.log(JSON.stringify(result, null, 2));
1333
+ if (!result.matched) process.exit(2);
1334
+ break;
1335
+ }
1336
+ default:
1337
+ console.error("사용법: node psmux.mjs spawn|status|kill|output|capture-start|dispatch|wait-pattern|wait-completion [args]");
1338
+ console.error("");
1339
+ console.error(" spawn --session <세션> --name <워커명> --cmd <커맨드>");
1340
+ console.error(" status --session <세션> --name <워커명>");
1341
+ console.error(" kill --session <세션> --name <워커명>");
1342
+ console.error(" output --session <세션> --name <워커명> [--lines <줄수>]");
1343
+ console.error(" capture-start --session <세션> --name <pane>");
1344
+ console.error(" dispatch --session <세션> --name <pane> --command <PowerShell 명령>");
1345
+ console.error(" wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
1346
+ console.error(" wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
1347
+ process.exit(1);
1348
+ }
1349
+ } catch (err) {
1350
+ console.error(`오류: ${err.message}`);
1351
+ process.exit(1);
1352
+ }
1353
+ })();
1354
+ }