triflux 7.3.2 → 7.5.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.
Files changed (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.ko.md +145 -145
  3. package/README.md +145 -145
  4. package/hub/pipeline/index.mjs +318 -318
  5. package/hub/schema.sql +146 -146
  6. package/hub/team/agent-map.json +2 -1
  7. package/hub/team/backend.mjs +3 -3
  8. package/hub/team/cli/commands/kill.mjs +37 -37
  9. package/hub/team/cli/commands/start/parse-args.mjs +4 -2
  10. package/hub/team/cli/commands/stop.mjs +31 -31
  11. package/hub/team/cli/commands/task.mjs +30 -30
  12. package/hub/team/cli/services/hub-client.mjs +208 -208
  13. package/hub/team/cli/services/native-control.mjs +4 -1
  14. package/hub/team/cli/services/runtime-mode.mjs +62 -62
  15. package/hub/team/cli/services/state-store.mjs +48 -48
  16. package/hub/team/codex-compat.mjs +78 -0
  17. package/hub/team/dashboard.mjs +274 -274
  18. package/hub/team/native.mjs +649 -649
  19. package/hub/team/pane.mjs +154 -150
  20. package/hub/team/psmux.mjs +1041 -1023
  21. package/hub/team/tui-viewer.mjs +2 -2
  22. package/hub/team/tui.mjs +12 -1
  23. package/hub/tools.mjs +554 -554
  24. package/hud/constants.mjs +3 -0
  25. package/package.json +1 -1
  26. package/scripts/claude-logged.ps1 +54 -0
  27. package/scripts/headless-guard.mjs +94 -7
  28. package/scripts/lib/mcp-filter.mjs +720 -720
  29. package/scripts/preflight-cache.mjs +137 -137
  30. package/scripts/remote-spawn.mjs +222 -0
  31. package/scripts/setup.mjs +84 -1
  32. package/scripts/tfx-gate-activate.mjs +89 -0
  33. package/scripts/tfx-route-post.mjs +17 -13
  34. package/scripts/tfx-route.sh +118 -46
  35. package/scripts/token-snapshot.mjs +575 -575
  36. package/skills/remote-spawn/SKILL.md +63 -0
  37. package/skills/tfx-auto/SKILL.md +1 -1
  38. package/skills/tfx-multi/SKILL.md +1 -1
@@ -1,1023 +1,1041 @@
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 } from "node:os";
6
- import { join } from "node:path";
7
-
8
- const PSMUX_BIN = process.env.PSMUX_BIN || "psmux";
9
- const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
10
- const IS_WINDOWS = process.platform === "win32";
11
- const PSMUX_TIMEOUT_MS = 10000;
12
- const COMPLETION_PREFIX = "__TRIFLUX_DONE__:";
13
- const CAPTURE_ROOT = process.env.PSMUX_CAPTURE_ROOT || join(tmpdir(), "psmux-steering");
14
- const CAPTURE_HELPER_PATH = join(CAPTURE_ROOT, "pipe-pane-capture.ps1");
15
- const POLL_INTERVAL_MS = (() => {
16
- const ms = Number.parseInt(process.env.PSMUX_POLL_INTERVAL_MS || "", 10);
17
- if (Number.isFinite(ms) && ms > 0) return ms;
18
- const sec = Number.parseFloat(process.env.PSMUX_POLL_INTERVAL_SEC || "1");
19
- return Number.isFinite(sec) && sec > 0 ? Math.max(100, Math.trunc(sec * 1000)) : 1000;
20
- })();
21
-
22
- function quoteArg(value) {
23
- const str = String(value);
24
- if (!/[\s"]/u.test(str)) return str;
25
- return `"${str.replace(/"/g, '\\"')}"`;
26
- }
27
-
28
- function sanitizePathPart(value) {
29
- return String(value).replace(/[<>:"/\\|?*\u0000-\u001f]/gu, "_");
30
- }
31
-
32
- function toPaneTitle(index) {
33
- return index === 0 ? "lead" : `worker-${index}`;
34
- }
35
-
36
- function sleepMs(ms) {
37
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
38
- }
39
-
40
- function sleepMsAsync(ms) {
41
- return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
42
- }
43
-
44
- function tokenizeCommand(command) {
45
- const source = String(command || "").trim();
46
- if (!source) return [];
47
-
48
- const tokens = [];
49
- let current = "";
50
- let quote = null;
51
-
52
- const pushCurrent = () => {
53
- if (current.length > 0) {
54
- tokens.push(current);
55
- current = "";
56
- }
57
- };
58
-
59
- for (let index = 0; index < source.length; index += 1) {
60
- const char = source[index];
61
- const next = source[index + 1];
62
-
63
- if (quote === "'") {
64
- if (char === "'") {
65
- quote = null;
66
- } else {
67
- current += char;
68
- }
69
- continue;
70
- }
71
-
72
- if (quote === '"') {
73
- if (char === '"') {
74
- quote = null;
75
- continue;
76
- }
77
- if (char === "\\" && (next === '"' || next === "\\")) {
78
- current += next;
79
- index += 1;
80
- continue;
81
- }
82
- current += char;
83
- continue;
84
- }
85
-
86
- if (char === "'" || char === '"') {
87
- quote = char;
88
- continue;
89
- }
90
-
91
- if (char === "\\" && next && (/[\s"'\\;]/u.test(next))) {
92
- current += next;
93
- index += 1;
94
- continue;
95
- }
96
-
97
- if (/\s/u.test(char)) {
98
- pushCurrent();
99
- continue;
100
- }
101
-
102
- current += char;
103
- }
104
-
105
- if (quote) {
106
- throw new Error(`psmux 인자 파싱 실패: 닫히지 않은 인용부호 (${command})`);
107
- }
108
-
109
- pushCurrent();
110
- return tokens;
111
- }
112
-
113
- function normalizePsmuxArgs(args) {
114
- if (Array.isArray(args)) {
115
- return args.map((arg) => String(arg));
116
- }
117
- return tokenizeCommand(args);
118
- }
119
-
120
- function randomToken(prefix) {
121
- const base = sanitizePathPart(prefix).replace(/_+/g, "-") || "pane";
122
- const entropy = Math.random().toString(36).slice(2, 10);
123
- return `${base}-${Date.now()}-${entropy}`;
124
- }
125
-
126
- function ensurePsmuxInstalled() {
127
- if (!hasPsmux()) {
128
- throw new Error("psmux가 설치되어 있지 않습니다.");
129
- }
130
- }
131
-
132
- function getCaptureSessionDir(sessionName) {
133
- return join(CAPTURE_ROOT, sanitizePathPart(sessionName));
134
- }
135
-
136
- function getCaptureLogPath(sessionName, paneName) {
137
- return join(getCaptureSessionDir(sessionName), `${sanitizePathPart(paneName)}.log`);
138
- }
139
-
140
- function ensureCaptureHelper() {
141
- mkdirSync(CAPTURE_ROOT, { recursive: true });
142
- writeFileSync(
143
- CAPTURE_HELPER_PATH,
144
- [
145
- "param(",
146
- " [Parameter(Mandatory = $true)][string]$Path",
147
- ")",
148
- "",
149
- "$parent = Split-Path -Parent $Path",
150
- "if ($parent) {",
151
- " New-Item -ItemType Directory -Force -Path $parent | Out-Null",
152
- "}",
153
- "",
154
- "$reader = [Console]::In",
155
- "while (($line = $reader.ReadLine()) -ne $null) {",
156
- " Add-Content -LiteralPath $Path -Value $line -Encoding utf8",
157
- "}",
158
- "",
159
- ].join("\n"),
160
- "utf8",
161
- );
162
- return CAPTURE_HELPER_PATH;
163
- }
164
-
165
- function readCaptureLog(logPath) {
166
- return existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
167
- }
168
-
169
- function parsePaneList(output) {
170
- return output
171
- .split("\n")
172
- .map((line) => line.trim())
173
- .filter(Boolean)
174
- .map((line) => {
175
- const [indexText, target] = line.split("\t");
176
- return {
177
- index: parseInt(indexText, 10),
178
- target: target?.trim() || "",
179
- };
180
- })
181
- .filter((entry) => Number.isFinite(entry.index) && entry.target)
182
- .sort((a, b) => a.index - b.index)
183
- .map((entry) => entry.target);
184
- }
185
-
186
- function parseSessionSummaries(output) {
187
- return output
188
- .split("\n")
189
- .map((line) => line.trim())
190
- .filter(Boolean)
191
- .map((line) => {
192
- const colonIndex = line.indexOf(":");
193
- if (colonIndex === -1) {
194
- return null;
195
- }
196
-
197
- const sessionName = line.slice(0, colonIndex).trim();
198
- const flags = [...line.matchAll(/\(([^)]*)\)/g)].map((match) => match[1]).join(", ");
199
- const attachedMatch = flags.match(/(\d+)\s+attached/);
200
- const attachedCount = attachedMatch
201
- ? parseInt(attachedMatch[1], 10)
202
- : /\battached\b/.test(flags)
203
- ? 1
204
- : 0;
205
-
206
- return sessionName
207
- ? { sessionName, attachedCount }
208
- : null;
209
- })
210
- .filter(Boolean);
211
- }
212
-
213
- function parsePaneDetails(output) {
214
- return output
215
- .split("\n")
216
- .map((line) => line.trim())
217
- .filter(Boolean)
218
- .map((line) => {
219
- const [title = "", paneId = "", dead = "0", deadStatus = ""] = line.split("\t");
220
- const exitCode = dead === "1"
221
- ? Number.parseInt(deadStatus, 10)
222
- : null;
223
- return {
224
- title,
225
- paneId,
226
- isDead: dead === "1",
227
- exitCode: Number.isFinite(exitCode) ? exitCode : dead === "1" ? 0 : null,
228
- };
229
- })
230
- .filter((entry) => entry.paneId);
231
- }
232
-
233
- function collectSessionPanes(sessionName) {
234
- const output = psmuxExec([
235
- "list-panes",
236
- "-t",
237
- `${sessionName}:0`,
238
- "-F",
239
- "#{pane_index}\t#{session_name}:#{window_index}.#{pane_index}",
240
- ]);
241
- return parsePaneList(output);
242
- }
243
-
244
- function listPaneDetails(sessionName) {
245
- const output = psmuxExec([
246
- "list-panes",
247
- "-t",
248
- sessionName,
249
- "-F",
250
- "#{pane_title}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_dead}\t#{pane_dead_status}",
251
- ]);
252
- return parsePaneDetails(output);
253
- }
254
-
255
- function paneTitleToIndex(name) {
256
- const lower = String(name).toLowerCase();
257
- if (lower === "lead") return 0;
258
- const m = /^worker-(\d+)$/.exec(lower);
259
- if (!m) return -1;
260
- const idx = parseInt(m[1], 10);
261
- // worker-0은 유효하지 않음 (lead와 충돌, toPaneTitle은 worker-0을 생성하지 않음)
262
- return idx >= 1 ? idx : -1;
263
- }
264
-
265
- function resolvePane(sessionName, paneNameOrTarget) {
266
- const wanted = String(paneNameOrTarget);
267
- const panes = listPaneDetails(sessionName);
268
-
269
- // 1차: title 또는 paneId 직접 매칭
270
- const direct = panes.find((entry) => entry.title === wanted || entry.paneId === wanted);
271
- if (direct) return direct;
272
-
273
- // 2차: psmux title 미설정 fallback — "lead"→0, "worker-N"→N 인덱스 매칭
274
- const idx = paneTitleToIndex(wanted);
275
- if (idx >= 0 && idx < panes.length) return panes[idx];
276
-
277
- throw new Error(`Pane을 찾을 수 없습니다: ${paneNameOrTarget}`);
278
- }
279
-
280
- function refreshCaptureSnapshot(sessionName, paneNameOrTarget) {
281
- const pane = resolvePane(sessionName, paneNameOrTarget);
282
- const paneName = pane.title || paneNameOrTarget;
283
- const logPath = getCaptureLogPath(sessionName, paneName);
284
- mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
285
- const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p"]);
286
- writeFileSync(logPath, snapshot, "utf8");
287
- return { paneId: pane.paneId, paneName, logPath, snapshot };
288
- }
289
-
290
- function disablePipeCapture(paneId) {
291
- try {
292
- psmuxExec(["pipe-pane", "-t", paneId]);
293
- } catch {
294
- // 기존 pipe가 없으면 무시
295
- }
296
- }
297
-
298
- function sendLiteralToPane(paneId, text, submit = true) {
299
- psmuxExec(["send-keys", "-t", paneId, "-l", text]);
300
- if (submit) {
301
- psmuxExec(["send-keys", "-t", paneId, "Enter"]);
302
- }
303
- }
304
-
305
- function toPatternRegExp(pattern) {
306
- if (pattern instanceof RegExp) {
307
- const flags = pattern.flags.includes("m") ? pattern.flags : `${pattern.flags}m`;
308
- return new RegExp(pattern.source, flags);
309
- }
310
- return new RegExp(String(pattern), "m");
311
- }
312
-
313
- function escapeRegExp(value) {
314
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
315
- }
316
-
317
- function psmux(args, opts = {}) {
318
- const normalizedArgs = normalizePsmuxArgs(args);
319
- try {
320
- const result = childProcess.execFileSync(PSMUX_BIN, normalizedArgs, {
321
- encoding: "utf8",
322
- timeout: PSMUX_TIMEOUT_MS,
323
- stdio: ["pipe", "pipe", "pipe"],
324
- windowsHide: true,
325
- ...opts,
326
- });
327
- return result != null ? String(result).trim() : "";
328
- } catch (error) {
329
- const stderr = typeof error?.stderr === "string"
330
- ? error.stderr
331
- : error?.stderr?.toString?.("utf8") || "";
332
- const stdout = typeof error?.stdout === "string"
333
- ? error.stdout
334
- : error?.stdout?.toString?.("utf8") || "";
335
- const wrapped = new Error((stderr || stdout || error.message || "psmux command failed").trim());
336
- wrapped.status = error.status;
337
- throw wrapped;
338
- }
339
- }
340
-
341
- /** psmux 실행 가능 여부 확인 */
342
- export function hasPsmux() {
343
- try {
344
- childProcess.execFileSync(PSMUX_BIN, ["-V"], {
345
- stdio: "ignore",
346
- timeout: 3000,
347
- windowsHide: true,
348
- });
349
- return true;
350
- } catch {
351
- return false;
352
- }
353
- }
354
-
355
- /**
356
- * psmux 커맨드 실행 래퍼
357
- * @param {string|string[]} args
358
- * @param {object} opts
359
- * @returns {string}
360
- */
361
- export function psmuxExec(args, opts = {}) {
362
- return psmux(args, opts);
363
- }
364
-
365
- /**
366
- * psmux 세션 생성 + 레이아웃 분할
367
- * @param {string} sessionName
368
- * @param {object} opts
369
- * @param {'2x2'|'1xN'|'Nx1'} opts.layout
370
- * @param {number} opts.paneCount
371
- * @returns {{ sessionName: string, panes: string[] }}
372
- */
373
- export function createPsmuxSession(sessionName, opts = {}) {
374
- const layout = opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
375
- const paneCount = Math.max(
376
- 1,
377
- Number.isFinite(opts.paneCount) ? Math.trunc(opts.paneCount) : 4,
378
- );
379
- const limitedPaneCount = layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
380
- const sessionTarget = `${sessionName}:0`;
381
-
382
- const leadPane = psmuxExec([
383
- "new-session",
384
- "-d",
385
- "-P",
386
- "-F",
387
- "#{session_name}:#{window_index}.#{pane_index}",
388
- "-s",
389
- sessionName,
390
- "-x",
391
- "220",
392
- "-y",
393
- "55",
394
- ]);
395
-
396
- if (layout === "2x2" && limitedPaneCount >= 3) {
397
- const rightPane = psmuxExec([
398
- "split-window",
399
- "-h",
400
- "-P",
401
- "-F",
402
- "#{session_name}:#{window_index}.#{pane_index}",
403
- "-t",
404
- leadPane,
405
- ]);
406
- psmuxExec([
407
- "split-window",
408
- "-v",
409
- "-P",
410
- "-F",
411
- "#{session_name}:#{window_index}.#{pane_index}",
412
- "-t",
413
- rightPane,
414
- ]);
415
- if (limitedPaneCount >= 4) {
416
- psmuxExec([
417
- "split-window",
418
- "-v",
419
- "-P",
420
- "-F",
421
- "#{session_name}:#{window_index}.#{pane_index}",
422
- "-t",
423
- leadPane,
424
- ]);
425
- }
426
- psmuxExec(["select-layout", "-t", sessionTarget, "tiled"]);
427
- } else if (layout === "1xN") {
428
- for (let index = 1; index < limitedPaneCount; index += 1) {
429
- psmuxExec(["split-window", "-h", "-t", sessionTarget]);
430
- }
431
- psmuxExec(["select-layout", "-t", sessionTarget, "even-horizontal"]);
432
- } else {
433
- for (let index = 1; index < limitedPaneCount; index += 1) {
434
- psmuxExec(["split-window", "-v", "-t", sessionTarget]);
435
- }
436
- psmuxExec(["select-layout", "-t", sessionTarget, "even-vertical"]);
437
- }
438
-
439
- psmuxExec(["select-pane", "-t", leadPane]);
440
-
441
- const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
442
- panes.forEach((pane, index) => {
443
- psmuxExec(["select-pane", "-t", pane, "-T", toPaneTitle(index)]);
444
- });
445
-
446
- return { sessionName, panes };
447
- }
448
-
449
- /**
450
- * psmux 세션 종료
451
- * @param {string} sessionName
452
- */
453
- export function killPsmuxSession(sessionName) {
454
- try {
455
- psmuxExec(["kill-session", "-t", sessionName], { stdio: "ignore" });
456
- } catch {
457
- // 이미 종료된 세션 — 무시
458
- }
459
- }
460
-
461
- /**
462
- * psmux 세션 존재 확인
463
- * @param {string} sessionName
464
- * @returns {boolean}
465
- */
466
- export function psmuxSessionExists(sessionName) {
467
- try {
468
- psmuxExec(["has-session", "-t", sessionName], { stdio: "ignore" });
469
- return true;
470
- } catch {
471
- return false;
472
- }
473
- }
474
-
475
- /**
476
- * tfx-multi- 접두사 psmux 세션 목록
477
- * @returns {string[]}
478
- */
479
- export function listPsmuxSessions() {
480
- try {
481
- return parseSessionSummaries(psmuxExec(["list-sessions"]))
482
- .map((session) => session.sessionName)
483
- .filter((sessionName) => sessionName.startsWith("tfx-multi-"));
484
- } catch {
485
- return [];
486
- }
487
- }
488
-
489
- /**
490
- * pane 마지막 N줄 캡처
491
- * @param {string} target
492
- * @param {number} lines
493
- * @returns {string}
494
- */
495
- export function capturePsmuxPane(target, lines = 5) {
496
- try {
497
- const full = psmuxExec(["capture-pane", "-t", target, "-p"]);
498
- const nonEmpty = full.split("\n").filter((line) => line.trim() !== "");
499
- return nonEmpty.slice(-lines).join("\n");
500
- } catch {
501
- return "";
502
- }
503
- }
504
-
505
- /**
506
- * psmux 세션 연결
507
- * @param {string} sessionName
508
- */
509
- export function attachPsmuxSession(sessionName) {
510
- const result = childProcess.spawnSync(PSMUX_BIN, ["attach-session", "-t", sessionName], {
511
- stdio: "inherit",
512
- timeout: 0,
513
- windowsHide: false,
514
- });
515
- if ((result.status ?? 1) !== 0) {
516
- throw new Error(`psmux attach 실패 (exit=${result.status})`);
517
- }
518
- }
519
-
520
- /**
521
- * 세션 attach client 수 조회
522
- * @param {string} sessionName
523
- * @returns {number|null}
524
- */
525
- export function getPsmuxSessionAttachedCount(sessionName) {
526
- try {
527
- const session = parseSessionSummaries(psmuxExec(["list-sessions"]))
528
- .find((entry) => entry.sessionName === sessionName);
529
- return session ? session.attachedCount : null;
530
- } catch {
531
- return null;
532
- }
533
- }
534
-
535
- /**
536
- * 팀메이트 조작 키 바인딩 설정
537
- * @param {string} sessionName
538
- * @param {object} opts
539
- * @param {boolean} opts.inProcess
540
- * @param {string} opts.taskListCommand
541
- */
542
- export function configurePsmuxKeybindings(sessionName, opts = {}) {
543
- const { inProcess = false, taskListCommand = "" } = opts;
544
- const cond = `#{==:#{session_name},${sessionName}}`;
545
- const target = `${sessionName}:0`;
546
- const bindNext = inProcess
547
- ? "select-pane -t :.+ \\; resize-pane -Z"
548
- : "select-pane -t :.+";
549
- const bindPrev = inProcess
550
- ? "select-pane -t :.- \\; resize-pane -Z"
551
- : "select-pane -t :.-";
552
-
553
- // psmux는 세션별 서버이므로 -t target으로 세션 컨텍스트를 전달해야 한다.
554
- const bindSafe = (args) => {
555
- try {
556
- psmuxExec(["-t", target, ...args]);
557
- } catch {
558
- // 미지원 시 무시
559
- }
560
- };
561
-
562
- bindSafe(["bind-key", "-T", "root", "-n", "S-Down", "if-shell", "-F", cond, bindNext, "send-keys S-Down"]);
563
- bindSafe(["bind-key", "-T", "root", "-n", "S-Up", "if-shell", "-F", cond, bindPrev, "send-keys S-Up"]);
564
- bindSafe(["bind-key", "-T", "root", "-n", "S-Right", "if-shell", "-F", cond, bindNext, "send-keys S-Right"]);
565
- bindSafe(["bind-key", "-T", "root", "-n", "S-Left", "if-shell", "-F", cond, bindPrev, "send-keys S-Left"]);
566
- bindSafe(["bind-key", "-T", "root", "-n", "BTab", "if-shell", "-F", cond, bindPrev, "send-keys BTab"]);
567
- bindSafe(["bind-key", "-T", "root", "-n", "Escape", "if-shell", "-F", cond, "send-keys C-c", "send-keys Escape"]);
568
-
569
- if (taskListCommand) {
570
- bindSafe([
571
- "bind-key",
572
- "-T",
573
- "root",
574
- "-n",
575
- "C-t",
576
- "if-shell",
577
- "-F",
578
- cond,
579
- `display-popup -E ${quoteArg(taskListCommand)}`,
580
- "send-keys C-t",
581
- ]);
582
- }
583
- }
584
-
585
- // ─── steering 기능 ───
586
-
587
- /**
588
- * pane 출력 pipe-pane 캡처를 시작하고 즉시 snapshot을 기록한다.
589
- * @param {string} sessionName
590
- * @param {string} paneNameOrTarget
591
- * @returns {{ paneId: string, paneName: string, logPath: string }}
592
- */
593
- export function startCapture(sessionName, paneNameOrTarget) {
594
- ensurePsmuxInstalled();
595
- const pane = resolvePane(sessionName, paneNameOrTarget);
596
- const paneName = pane.title || paneNameOrTarget;
597
- const logPath = getCaptureLogPath(sessionName, paneName);
598
- const helperPath = ensureCaptureHelper();
599
- mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
600
- writeFileSync(logPath, "", "utf8");
601
-
602
- disablePipeCapture(pane.paneId);
603
- psmuxExec([
604
- "pipe-pane",
605
- "-t",
606
- pane.paneId,
607
- `powershell.exe -NoLogo -NoProfile -File ${quoteArg(helperPath)} ${quoteArg(logPath)}`,
608
- ]);
609
-
610
- refreshCaptureSnapshot(sessionName, pane.paneId);
611
- return { paneId: pane.paneId, paneName, logPath };
612
- }
613
-
614
- /**
615
- * PowerShell 명령을 pane에 비동기 전송하고 완료 토큰을 반환한다.
616
- * @param {string} sessionName
617
- * @param {string} paneNameOrTarget
618
- * @param {string} commandText
619
- * @returns {{ paneId: string, paneName: string, token: string, logPath: string }}
620
- */
621
- export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
622
- ensurePsmuxInstalled();
623
- const pane = resolvePane(sessionName, paneNameOrTarget);
624
- const paneName = pane.title || paneNameOrTarget;
625
- const logPath = getCaptureLogPath(sessionName, paneName);
626
-
627
- if (!existsSync(logPath)) {
628
- startCapture(sessionName, paneName);
629
- }
630
-
631
- const token = randomToken(paneName);
632
- const wrapped = `${commandText}; $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit"`;
633
-
634
- sendLiteralToPane(pane.paneId, wrapped, true);
635
-
636
- return { paneId: pane.paneId, paneName, token, logPath };
637
- }
638
-
639
- /**
640
- * pane 캡처 로그에서 정규식 패턴을 polling으로 대기한다.
641
- * @param {string} sessionName
642
- * @param {string} paneNameOrTarget
643
- * @param {string|RegExp} pattern
644
- * @param {number} timeoutSec
645
- * @param {object} [opts]
646
- * @param {(snapshot: {content: string, paneId: string, paneName: string, elapsed: number}) => void} [opts.onPoll] — 각 폴링 주기마다 호출
647
- * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null }}
648
- */
649
- export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300, opts = {}) {
650
- ensurePsmuxInstalled();
651
-
652
- // E4 크래시 복구: 초기 resolvePane도 세션 사망을 감지
653
- let pane;
654
- try {
655
- pane = resolvePane(sessionName, paneNameOrTarget);
656
- } catch (resolveError) {
657
- if (!psmuxSessionExists(sessionName)) {
658
- return {
659
- matched: false,
660
- paneId: "",
661
- paneName: String(paneNameOrTarget),
662
- logPath: "",
663
- match: null,
664
- sessionDead: true,
665
- };
666
- }
667
- throw resolveError; // 세션은 살아있지만 pane을 찾음 원래 에러 전파
668
- }
669
-
670
- const paneName = pane.title || paneNameOrTarget;
671
- // opts.logPath: dispatch 시 확정된 캡처 로그 경로 직접 지정 (타이틀 변경 내성)
672
- const logPath = (opts.logPath && existsSync(opts.logPath))
673
- ? opts.logPath
674
- : getCaptureLogPath(sessionName, paneName);
675
- if (!existsSync(logPath)) {
676
- throw new Error(`캡처 로그가 없습니다. 먼저 startCapture(${sessionName}, ${paneName})를 호출하세요.`);
677
- }
678
-
679
- const startTime = Date.now();
680
- const deadline = startTime + Math.max(0, Math.trunc(timeoutSec * 1000));
681
- const regex = toPatternRegExp(pattern);
682
-
683
- while (Date.now() <= deadline) {
684
- // E4 크래시 복구: capture 실패 시 세션 생존 체크
685
- try {
686
- if (opts.logPath) {
687
- // logPath 직접 지정 시 — 셸 타이틀 변경과 무관하게 올바른 파일에 기록
688
- const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p"]);
689
- writeFileSync(logPath, snapshot, "utf8");
690
- } else {
691
- refreshCaptureSnapshot(sessionName, pane.paneId);
692
- }
693
- } catch {
694
- if (!psmuxSessionExists(sessionName)) {
695
- return {
696
- matched: false,
697
- paneId: pane.paneId,
698
- paneName,
699
- logPath,
700
- match: null,
701
- sessionDead: true,
702
- };
703
- }
704
- // 일시적 오류 — 다음 폴링에서 재시도
705
- }
706
-
707
- const content = readCaptureLog(logPath);
708
-
709
- // onPoll 콜백 — 각 폴링 주기마다 중간 상태 전달
710
- if (opts.onPoll) {
711
- try {
712
- opts.onPoll({ content, paneId: pane.paneId, paneName, elapsed: Date.now() - startTime });
713
- } catch { /* 콜백 예외는 삼킴 — 폴링 루프 보호 */ }
714
- }
715
-
716
- const match = regex.exec(content);
717
- if (match) {
718
- return {
719
- matched: true,
720
- paneId: pane.paneId,
721
- paneName,
722
- logPath,
723
- match: match[0],
724
- };
725
- }
726
-
727
- if (Date.now() > deadline) {
728
- break;
729
- }
730
- await sleepMsAsync(POLL_INTERVAL_MS);
731
- }
732
-
733
- return {
734
- matched: false,
735
- paneId: pane.paneId,
736
- paneName,
737
- logPath,
738
- match: null,
739
- };
740
- }
741
-
742
- /**
743
- * 완료 토큰이 찍힐 때까지 대기하고 exit code를 파싱한다.
744
- * @param {string} sessionName
745
- * @param {string} paneNameOrTarget
746
- * @param {string} token
747
- * @param {number} timeoutSec
748
- * @param {object} [opts] — waitForPattern에 전달할 옵션 (onPoll 등)
749
- * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
750
- */
751
- export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300, opts = {}) {
752
- const completionRegex = new RegExp(
753
- `${escapeRegExp(COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
754
- "m",
755
- );
756
- const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec, opts);
757
- const exitMatch = result.match ? completionRegex.exec(result.match) : null;
758
- return {
759
- ...result,
760
- token,
761
- exitCode: exitMatch ? Number.parseInt(exitMatch[1], 10) : null,
762
- };
763
- }
764
-
765
- // ─── 하이브리드 모드 워커 관리 함수 ───
766
-
767
- /**
768
- * psmux 세션의 새 pane에서 워커 실행
769
- * @param {string} sessionName - 대상 psmux 세션 이름
770
- * @param {string} workerName - 워커 식별용 pane 타이틀
771
- * @param {string} cmd - 실행할 커맨드
772
- * @returns {{ paneId: string, workerName: string }}
773
- */
774
- export function spawnWorker(sessionName, workerName, cmd) {
775
- if (!hasPsmux()) {
776
- throw new Error("psmux가 설치되어 있지 않습니다. psmux를 먼저 설치하세요.");
777
- }
778
-
779
- // remain-on-exit: 종료된 pane이 즉시 사라지는 방지
780
- try {
781
- psmuxExec(["set-option", "-t", sessionName, "remain-on-exit", "on"]);
782
- } catch {
783
- // 미지원 무시
784
- }
785
-
786
- // Windows: pane 기본셸이 PowerShell Git Bash로 래핑
787
- // psmux가 이스케이프 시퀀스를 처리하므로 포워드 슬래시 경로를 사용한다.
788
- const shellCmd = IS_WINDOWS
789
- ? `& '${GIT_BASH.replace(/\\/g, "/")}' -l -c '${cmd.replace(/'/g, "'\\''")}'`
790
- : cmd;
791
-
792
- try {
793
- const paneTarget = psmuxExec([
794
- "split-window",
795
- "-t",
796
- sessionName,
797
- "-P",
798
- "-F",
799
- "#{session_name}:#{window_index}.#{pane_index}",
800
- shellCmd,
801
- ]);
802
- psmuxExec(["select-pane", "-t", paneTarget, "-T", workerName]);
803
- return { paneId: paneTarget, workerName };
804
- } catch (err) {
805
- throw new Error(`워커 생성 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
806
- }
807
- }
808
-
809
- /**
810
- * 워커 pane 실행 상태 확인
811
- * @param {string} sessionName - 대상 psmux 세션 이름
812
- * @param {string} workerName - 워커 pane 타이틀
813
- * @returns {{ status: "running"|"exited", exitCode: number|null, paneId: string }}
814
- */
815
- export function getWorkerStatus(sessionName, workerName) {
816
- if (!hasPsmux()) {
817
- throw new Error("psmux가 설치되어 있지 않습니다.");
818
- }
819
- try {
820
- const pane = resolvePane(sessionName, workerName);
821
- return {
822
- status: pane.isDead ? "exited" : "running",
823
- exitCode: pane.isDead ? pane.exitCode : null,
824
- paneId: pane.paneId,
825
- };
826
- } catch (err) {
827
- if (err.message.includes("Pane을 찾을 수 없습니다")) {
828
- throw new Error(`워커를 찾을 없습니다: ${workerName}`);
829
- }
830
- throw new Error(`워커 상태 조회 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
831
- }
832
- }
833
-
834
- /**
835
- * 워커 pane 프로세스 강제 종료
836
- * @param {string} sessionName - 대상 psmux 세션 이름
837
- * @param {string} workerName - 워커 pane 타이틀
838
- * @returns {{ killed: boolean }}
839
- */
840
- export function killWorker(sessionName, workerName) {
841
- if (!hasPsmux()) {
842
- throw new Error("psmux가 설치되어 있지 않습니다.");
843
- }
844
- try {
845
- const { paneId, status } = getWorkerStatus(sessionName, workerName);
846
-
847
- // 이미 종료된 워커 → pane 정리만 수행
848
- if (status === "exited") {
849
- try {
850
- psmuxExec(["kill-pane", "-t", paneId]);
851
- } catch {
852
- // 무시
853
- }
854
- return { killed: true };
855
- }
856
-
857
- // running → C-c 우아한 종료 시도
858
- try {
859
- psmuxExec(["send-keys", "-t", paneId, "C-c"]);
860
- } catch {
861
- // send-keys 실패 무시
862
- }
863
-
864
- sleepMs(1000);
865
-
866
- try {
867
- psmuxExec(["kill-pane", "-t", paneId]);
868
- } catch {
869
- // 이미 종료된 pane — 무시
870
- }
871
- return { killed: true };
872
- } catch (err) {
873
- if (err.message.includes("워커를 찾을 수 없습니다")) {
874
- return { killed: true };
875
- }
876
- throw new Error(`워커 종료 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
877
- }
878
- }
879
-
880
- /**
881
- * 워커 pane 출력 마지막 N줄 캡처
882
- * @param {string} sessionName - 대상 psmux 세션 이름
883
- * @param {string} workerName - 워커 pane 타이틀
884
- * @param {number} lines - 캡처할 줄 수 (기본 50)
885
- * @returns {string} 캡처된 출력
886
- */
887
- export function captureWorkerOutput(sessionName, workerName, lines = 50) {
888
- if (!hasPsmux()) {
889
- throw new Error("psmux가 설치되어 있지 않습니다.");
890
- }
891
- try {
892
- const { paneId } = getWorkerStatus(sessionName, workerName);
893
- return psmuxExec(["capture-pane", "-t", paneId, "-p", "-S", `-${lines}`]);
894
- } catch (err) {
895
- if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
896
- throw new Error(`출력 캡처 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
897
- }
898
- }
899
-
900
- // ─── CLI 진입점 ───
901
-
902
- if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
903
- (async () => {
904
- const [, , cmd, ...args] = process.argv;
905
-
906
- // CLI 인자 파싱 헬퍼
907
- function getArg(name) {
908
- const idx = args.indexOf(`--${name}`);
909
- return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
910
- }
911
-
912
- try {
913
- switch (cmd) {
914
- case "spawn": {
915
- const session = getArg("session");
916
- const name = getArg("name");
917
- const workerCmd = getArg("cmd");
918
- if (!session || !name || !workerCmd) {
919
- console.error("사용법: node psmux.mjs spawn --session <세션> --name <워커명> --cmd <커맨드>");
920
- process.exit(1);
921
- }
922
- console.log(JSON.stringify(spawnWorker(session, name, workerCmd), null, 2));
923
- break;
924
- }
925
- case "status": {
926
- const session = getArg("session");
927
- const name = getArg("name");
928
- if (!session || !name) {
929
- console.error("사용법: node psmux.mjs status --session <세션> --name <워커명>");
930
- process.exit(1);
931
- }
932
- console.log(JSON.stringify(getWorkerStatus(session, name), null, 2));
933
- break;
934
- }
935
- case "kill": {
936
- const session = getArg("session");
937
- const name = getArg("name");
938
- if (!session || !name) {
939
- console.error("사용법: node psmux.mjs kill --session <세션> --name <워커명>");
940
- process.exit(1);
941
- }
942
- console.log(JSON.stringify(killWorker(session, name), null, 2));
943
- break;
944
- }
945
- case "output": {
946
- const session = getArg("session");
947
- const name = getArg("name");
948
- const lines = parseInt(getArg("lines") || "50", 10);
949
- if (!session || !name) {
950
- console.error("사용법: node psmux.mjs output --session <세션> --name <워커명> [--lines <줄수>]");
951
- process.exit(1);
952
- }
953
- console.log(captureWorkerOutput(session, name, lines));
954
- break;
955
- }
956
- case "capture-start": {
957
- const session = getArg("session");
958
- const name = getArg("name");
959
- if (!session || !name) {
960
- console.error("사용법: node psmux.mjs capture-start --session <세션> --name <pane>");
961
- process.exit(1);
962
- }
963
- console.log(JSON.stringify(startCapture(session, name), null, 2));
964
- break;
965
- }
966
- case "dispatch": {
967
- const session = getArg("session");
968
- const name = getArg("name");
969
- const commandText = getArg("command");
970
- if (!session || !name || !commandText) {
971
- console.error("사용법: node psmux.mjs dispatch --session <세션> --name <pane> --command <PowerShell 명령>");
972
- process.exit(1);
973
- }
974
- console.log(JSON.stringify(dispatchCommand(session, name, commandText), null, 2));
975
- break;
976
- }
977
- case "wait-pattern": {
978
- const session = getArg("session");
979
- const name = getArg("name");
980
- const pattern = getArg("pattern");
981
- const timeoutSec = parseInt(getArg("timeout") || "300", 10);
982
- if (!session || !name || !pattern) {
983
- console.error("사용법: node psmux.mjs wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
984
- process.exit(1);
985
- }
986
- const result = await waitForPattern(session, name, pattern, timeoutSec);
987
- console.log(JSON.stringify(result, null, 2));
988
- if (!result.matched) process.exit(2);
989
- break;
990
- }
991
- case "wait-completion": {
992
- const session = getArg("session");
993
- const name = getArg("name");
994
- const token = getArg("token");
995
- const timeoutSec = parseInt(getArg("timeout") || "300", 10);
996
- if (!session || !name || !token) {
997
- console.error("사용법: node psmux.mjs wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
998
- process.exit(1);
999
- }
1000
- const result = await waitForCompletion(session, name, token, timeoutSec);
1001
- console.log(JSON.stringify(result, null, 2));
1002
- if (!result.matched) process.exit(2);
1003
- break;
1004
- }
1005
- default:
1006
- console.error("사용법: node psmux.mjs spawn|status|kill|output|capture-start|dispatch|wait-pattern|wait-completion [args]");
1007
- console.error("");
1008
- console.error(" spawn --session <세션> --name <워커명> --cmd <커맨드>");
1009
- console.error(" status --session <세션> --name <워커명>");
1010
- console.error(" kill --session <세션> --name <워커명>");
1011
- console.error(" output --session <세션> --name <워커명> [--lines <줄수>]");
1012
- console.error(" capture-start --session <세션> --name <pane>");
1013
- console.error(" dispatch --session <세션> --name <pane> --command <PowerShell 명령>");
1014
- console.error(" wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
1015
- console.error(" wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
1016
- process.exit(1);
1017
- }
1018
- } catch (err) {
1019
- console.error(`오류: ${err.message}`);
1020
- process.exit(1);
1021
- }
1022
- })();
1023
- }
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 } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ const PSMUX_BIN = process.env.PSMUX_BIN || "psmux";
9
+ const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
10
+ const IS_WINDOWS = process.platform === "win32";
11
+ const PSMUX_TIMEOUT_MS = 10000;
12
+ const COMPLETION_PREFIX = "__TRIFLUX_DONE__:";
13
+ const CAPTURE_ROOT = process.env.PSMUX_CAPTURE_ROOT || join(tmpdir(), "psmux-steering");
14
+ const CAPTURE_HELPER_PATH = join(CAPTURE_ROOT, "pipe-pane-capture.ps1");
15
+ const POLL_INTERVAL_MS = (() => {
16
+ const ms = Number.parseInt(process.env.PSMUX_POLL_INTERVAL_MS || "", 10);
17
+ if (Number.isFinite(ms) && ms > 0) return ms;
18
+ const sec = Number.parseFloat(process.env.PSMUX_POLL_INTERVAL_SEC || "1");
19
+ return Number.isFinite(sec) && sec > 0 ? Math.max(100, Math.trunc(sec * 1000)) : 1000;
20
+ })();
21
+
22
+ function quoteArg(value) {
23
+ const str = String(value);
24
+ if (!/[\s"]/u.test(str)) return str;
25
+ return `"${str.replace(/"/g, '\\"')}"`;
26
+ }
27
+
28
+ function sanitizePathPart(value) {
29
+ return String(value).replace(/[<>:"/\\|?*\u0000-\u001f]/gu, "_");
30
+ }
31
+
32
+ function toPaneTitle(index) {
33
+ return index === 0 ? "lead" : `worker-${index}`;
34
+ }
35
+
36
+ function sleepMs(ms) {
37
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
38
+ }
39
+
40
+ function sleepMsAsync(ms) {
41
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
42
+ }
43
+
44
+ function tokenizeCommand(command) {
45
+ const source = String(command || "").trim();
46
+ if (!source) return [];
47
+
48
+ const tokens = [];
49
+ let current = "";
50
+ let quote = null;
51
+
52
+ const pushCurrent = () => {
53
+ if (current.length > 0) {
54
+ tokens.push(current);
55
+ current = "";
56
+ }
57
+ };
58
+
59
+ for (let index = 0; index < source.length; index += 1) {
60
+ const char = source[index];
61
+ const next = source[index + 1];
62
+
63
+ if (quote === "'") {
64
+ if (char === "'") {
65
+ quote = null;
66
+ } else {
67
+ current += char;
68
+ }
69
+ continue;
70
+ }
71
+
72
+ if (quote === '"') {
73
+ if (char === '"') {
74
+ quote = null;
75
+ continue;
76
+ }
77
+ if (char === "\\" && (next === '"' || next === "\\")) {
78
+ current += next;
79
+ index += 1;
80
+ continue;
81
+ }
82
+ current += char;
83
+ continue;
84
+ }
85
+
86
+ if (char === "'" || char === '"') {
87
+ quote = char;
88
+ continue;
89
+ }
90
+
91
+ if (char === "\\" && next && (/[\s"'\\;]/u.test(next))) {
92
+ current += next;
93
+ index += 1;
94
+ continue;
95
+ }
96
+
97
+ if (/\s/u.test(char)) {
98
+ pushCurrent();
99
+ continue;
100
+ }
101
+
102
+ current += char;
103
+ }
104
+
105
+ if (quote) {
106
+ throw new Error(`psmux 인자 파싱 실패: 닫히지 않은 인용부호 (${command})`);
107
+ }
108
+
109
+ pushCurrent();
110
+ return tokens;
111
+ }
112
+
113
+ function normalizePsmuxArgs(args) {
114
+ if (Array.isArray(args)) {
115
+ return args.map((arg) => String(arg));
116
+ }
117
+ return tokenizeCommand(args);
118
+ }
119
+
120
+ function randomToken(prefix) {
121
+ const base = sanitizePathPart(prefix).replace(/_+/g, "-") || "pane";
122
+ const entropy = Math.random().toString(36).slice(2, 10);
123
+ return `${base}-${Date.now()}-${entropy}`;
124
+ }
125
+
126
+ function ensurePsmuxInstalled() {
127
+ if (!hasPsmux()) {
128
+ throw new Error("psmux가 설치되어 있지 않습니다.");
129
+ }
130
+ }
131
+
132
+ function getCaptureSessionDir(sessionName) {
133
+ return join(CAPTURE_ROOT, sanitizePathPart(sessionName));
134
+ }
135
+
136
+ function getCaptureLogPath(sessionName, paneName) {
137
+ return join(getCaptureSessionDir(sessionName), `${sanitizePathPart(paneName)}.log`);
138
+ }
139
+
140
+ function ensureCaptureHelper() {
141
+ mkdirSync(CAPTURE_ROOT, { recursive: true });
142
+ writeFileSync(
143
+ CAPTURE_HELPER_PATH,
144
+ [
145
+ "param(",
146
+ " [Parameter(Mandatory = $true)][string]$Path",
147
+ ")",
148
+ "",
149
+ "$parent = Split-Path -Parent $Path",
150
+ "if ($parent) {",
151
+ " New-Item -ItemType Directory -Force -Path $parent | Out-Null",
152
+ "}",
153
+ "",
154
+ "$reader = [Console]::In",
155
+ "while (($line = $reader.ReadLine()) -ne $null) {",
156
+ " Add-Content -LiteralPath $Path -Value $line -Encoding utf8",
157
+ "}",
158
+ "",
159
+ ].join("\n"),
160
+ "utf8",
161
+ );
162
+ return CAPTURE_HELPER_PATH;
163
+ }
164
+
165
+ function readCaptureLog(logPath) {
166
+ return existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
167
+ }
168
+
169
+ function parsePaneList(output) {
170
+ return output
171
+ .split("\n")
172
+ .map((line) => line.trim())
173
+ .filter(Boolean)
174
+ .map((line) => {
175
+ const [indexText, target] = line.split("\t");
176
+ return {
177
+ index: parseInt(indexText, 10),
178
+ target: target?.trim() || "",
179
+ };
180
+ })
181
+ .filter((entry) => Number.isFinite(entry.index) && entry.target)
182
+ .sort((a, b) => a.index - b.index)
183
+ .map((entry) => entry.target);
184
+ }
185
+
186
+ function parseSessionSummaries(output) {
187
+ return output
188
+ .split("\n")
189
+ .map((line) => line.trim())
190
+ .filter(Boolean)
191
+ .map((line) => {
192
+ const colonIndex = line.indexOf(":");
193
+ if (colonIndex === -1) {
194
+ return null;
195
+ }
196
+
197
+ const sessionName = line.slice(0, colonIndex).trim();
198
+ const flags = [...line.matchAll(/\(([^)]*)\)/g)].map((match) => match[1]).join(", ");
199
+ const attachedMatch = flags.match(/(\d+)\s+attached/);
200
+ const attachedCount = attachedMatch
201
+ ? parseInt(attachedMatch[1], 10)
202
+ : /\battached\b/.test(flags)
203
+ ? 1
204
+ : 0;
205
+
206
+ return sessionName
207
+ ? { sessionName, attachedCount }
208
+ : null;
209
+ })
210
+ .filter(Boolean);
211
+ }
212
+
213
+ function parsePaneDetails(output) {
214
+ return output
215
+ .split("\n")
216
+ .map((line) => line.trim())
217
+ .filter(Boolean)
218
+ .map((line) => {
219
+ const [title = "", paneId = "", dead = "0", deadStatus = ""] = line.split("\t");
220
+ const exitCode = dead === "1"
221
+ ? Number.parseInt(deadStatus, 10)
222
+ : null;
223
+ return {
224
+ title,
225
+ paneId,
226
+ isDead: dead === "1",
227
+ exitCode: Number.isFinite(exitCode) ? exitCode : dead === "1" ? 0 : null,
228
+ };
229
+ })
230
+ .filter((entry) => entry.paneId);
231
+ }
232
+
233
+ function collectSessionPanes(sessionName) {
234
+ const output = psmuxExec([
235
+ "list-panes",
236
+ "-t",
237
+ `${sessionName}:0`,
238
+ "-F",
239
+ "#{pane_index}\t#{session_name}:#{window_index}.#{pane_index}",
240
+ ]);
241
+ return parsePaneList(output);
242
+ }
243
+
244
+ function listPaneDetails(sessionName) {
245
+ const output = psmuxExec([
246
+ "list-panes",
247
+ "-t",
248
+ sessionName,
249
+ "-F",
250
+ "#{pane_title}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_dead}\t#{pane_dead_status}",
251
+ ]);
252
+ return parsePaneDetails(output);
253
+ }
254
+
255
+ function paneTitleToIndex(name) {
256
+ const lower = String(name).toLowerCase();
257
+ if (lower === "lead") return 0;
258
+ const m = /^worker-(\d+)$/.exec(lower);
259
+ if (!m) return -1;
260
+ const idx = parseInt(m[1], 10);
261
+ // worker-0은 유효하지 않음 (lead와 충돌, toPaneTitle은 worker-0을 생성하지 않음)
262
+ return idx >= 1 ? idx : -1;
263
+ }
264
+
265
+ function resolvePane(sessionName, paneNameOrTarget) {
266
+ const wanted = String(paneNameOrTarget);
267
+ const panes = listPaneDetails(sessionName);
268
+
269
+ // 1차: title 또는 paneId 직접 매칭
270
+ const direct = panes.find((entry) => entry.title === wanted || entry.paneId === wanted);
271
+ if (direct) return direct;
272
+
273
+ // 2차: psmux title 미설정 fallback — "lead"→0, "worker-N"→N 인덱스 매칭
274
+ const idx = paneTitleToIndex(wanted);
275
+ if (idx >= 0 && idx < panes.length) return panes[idx];
276
+
277
+ throw new Error(`Pane을 찾을 수 없습니다: ${paneNameOrTarget}`);
278
+ }
279
+
280
+ function refreshCaptureSnapshot(sessionName, paneNameOrTarget) {
281
+ const pane = resolvePane(sessionName, paneNameOrTarget);
282
+ const paneName = pane.title || paneNameOrTarget;
283
+ const logPath = getCaptureLogPath(sessionName, paneName);
284
+ mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
285
+ const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p"]);
286
+ writeFileSync(logPath, snapshot, "utf8");
287
+ return { paneId: pane.paneId, paneName, logPath, snapshot };
288
+ }
289
+
290
+ function disablePipeCapture(paneId) {
291
+ try {
292
+ psmuxExec(["pipe-pane", "-t", paneId]);
293
+ } catch {
294
+ // 기존 pipe가 없으면 무시
295
+ }
296
+ }
297
+
298
+ function sendLiteralToPane(paneId, text, submit = true) {
299
+ psmuxExec(["send-keys", "-t", paneId, "-l", text]);
300
+ if (submit) {
301
+ psmuxExec(["send-keys", "-t", paneId, "Enter"]);
302
+ }
303
+ }
304
+
305
+ function toPatternRegExp(pattern) {
306
+ if (pattern instanceof RegExp) {
307
+ const flags = pattern.flags.includes("m") ? pattern.flags : `${pattern.flags}m`;
308
+ return new RegExp(pattern.source, flags);
309
+ }
310
+ return new RegExp(String(pattern), "m");
311
+ }
312
+
313
+ function escapeRegExp(value) {
314
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
315
+ }
316
+
317
+ function psmux(args, opts = {}) {
318
+ const normalizedArgs = normalizePsmuxArgs(args);
319
+ try {
320
+ const result = childProcess.execFileSync(PSMUX_BIN, normalizedArgs, {
321
+ encoding: "utf8",
322
+ timeout: PSMUX_TIMEOUT_MS,
323
+ stdio: ["pipe", "pipe", "pipe"],
324
+ windowsHide: true,
325
+ ...opts,
326
+ });
327
+ return result != null ? String(result).trim() : "";
328
+ } catch (error) {
329
+ const stderr = typeof error?.stderr === "string"
330
+ ? error.stderr
331
+ : error?.stderr?.toString?.("utf8") || "";
332
+ const stdout = typeof error?.stdout === "string"
333
+ ? error.stdout
334
+ : error?.stdout?.toString?.("utf8") || "";
335
+ const wrapped = new Error((stderr || stdout || error.message || "psmux command failed").trim());
336
+ wrapped.status = error.status;
337
+ throw wrapped;
338
+ }
339
+ }
340
+
341
+ /** psmux 실행 가능 여부 확인 */
342
+ export function hasPsmux() {
343
+ try {
344
+ childProcess.execFileSync(PSMUX_BIN, ["-V"], {
345
+ stdio: "ignore",
346
+ timeout: 3000,
347
+ windowsHide: true,
348
+ });
349
+ return true;
350
+ } catch {
351
+ return false;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * psmux 커맨드 실행 래퍼
357
+ * @param {string|string[]} args
358
+ * @param {object} opts
359
+ * @returns {string}
360
+ */
361
+ export function psmuxExec(args, opts = {}) {
362
+ return psmux(args, opts);
363
+ }
364
+
365
+ /**
366
+ * psmux 세션 생성 + 레이아웃 분할
367
+ * @param {string} sessionName
368
+ * @param {object} opts
369
+ * @param {'2x2'|'1xN'|'Nx1'} opts.layout
370
+ * @param {number} opts.paneCount
371
+ * @returns {{ sessionName: string, panes: string[] }}
372
+ */
373
+ export function createPsmuxSession(sessionName, opts = {}) {
374
+ const layout = opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
375
+ const paneCount = Math.max(
376
+ 1,
377
+ Number.isFinite(opts.paneCount) ? Math.trunc(opts.paneCount) : 4,
378
+ );
379
+ const limitedPaneCount = layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
380
+ const sessionTarget = `${sessionName}:0`;
381
+
382
+ const leadPane = psmuxExec([
383
+ "new-session",
384
+ "-d",
385
+ "-P",
386
+ "-F",
387
+ "#{session_name}:#{window_index}.#{pane_index}",
388
+ "-s",
389
+ sessionName,
390
+ "-x",
391
+ "220",
392
+ "-y",
393
+ "55",
394
+ ]);
395
+
396
+ if (layout === "2x2" && limitedPaneCount >= 3) {
397
+ const rightPane = psmuxExec([
398
+ "split-window",
399
+ "-h",
400
+ "-P",
401
+ "-F",
402
+ "#{session_name}:#{window_index}.#{pane_index}",
403
+ "-t",
404
+ leadPane,
405
+ ]);
406
+ psmuxExec([
407
+ "split-window",
408
+ "-v",
409
+ "-P",
410
+ "-F",
411
+ "#{session_name}:#{window_index}.#{pane_index}",
412
+ "-t",
413
+ rightPane,
414
+ ]);
415
+ if (limitedPaneCount >= 4) {
416
+ psmuxExec([
417
+ "split-window",
418
+ "-v",
419
+ "-P",
420
+ "-F",
421
+ "#{session_name}:#{window_index}.#{pane_index}",
422
+ "-t",
423
+ leadPane,
424
+ ]);
425
+ }
426
+ psmuxExec(["select-layout", "-t", sessionTarget, "tiled"]);
427
+ } else if (layout === "1xN") {
428
+ for (let index = 1; index < limitedPaneCount; index += 1) {
429
+ psmuxExec(["split-window", "-h", "-t", sessionTarget]);
430
+ }
431
+ psmuxExec(["select-layout", "-t", sessionTarget, "even-horizontal"]);
432
+ } else {
433
+ for (let index = 1; index < limitedPaneCount; index += 1) {
434
+ psmuxExec(["split-window", "-v", "-t", sessionTarget]);
435
+ }
436
+ psmuxExec(["select-layout", "-t", sessionTarget, "even-vertical"]);
437
+ }
438
+
439
+ psmuxExec(["select-pane", "-t", leadPane]);
440
+
441
+ const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
442
+ panes.forEach((pane, index) => {
443
+ psmuxExec(["select-pane", "-t", pane, "-T", toPaneTitle(index)]);
444
+ });
445
+
446
+ return { sessionName, panes };
447
+ }
448
+
449
+ /**
450
+ * psmux 세션 종료
451
+ * @param {string} sessionName
452
+ */
453
+ export function killPsmuxSession(sessionName) {
454
+ try {
455
+ psmuxExec(["kill-session", "-t", sessionName], { stdio: "ignore" });
456
+ } catch {
457
+ // 이미 종료된 세션 — 무시
458
+ }
459
+ }
460
+
461
+ /**
462
+ * psmux 세션 존재 확인
463
+ * @param {string} sessionName
464
+ * @returns {boolean}
465
+ */
466
+ export function psmuxSessionExists(sessionName) {
467
+ try {
468
+ psmuxExec(["has-session", "-t", sessionName], { stdio: "ignore" });
469
+ return true;
470
+ } catch {
471
+ return false;
472
+ }
473
+ }
474
+
475
+ /**
476
+ * tfx-multi- 접두사 psmux 세션 목록
477
+ * @returns {string[]}
478
+ */
479
+ export function listPsmuxSessions() {
480
+ try {
481
+ return parseSessionSummaries(psmuxExec(["list-sessions"]))
482
+ .map((session) => session.sessionName)
483
+ .filter((sessionName) => sessionName.startsWith("tfx-multi-"));
484
+ } catch {
485
+ return [];
486
+ }
487
+ }
488
+
489
+ /**
490
+ * pane 마지막 N줄 캡처
491
+ * @param {string} target
492
+ * @param {number} lines
493
+ * @returns {string}
494
+ */
495
+ export function capturePsmuxPane(target, lines = 5) {
496
+ try {
497
+ const full = psmuxExec(["capture-pane", "-t", target, "-p"]);
498
+ const nonEmpty = full.split("\n").filter((line) => line.trim() !== "");
499
+ return nonEmpty.slice(-lines).join("\n");
500
+ } catch {
501
+ return "";
502
+ }
503
+ }
504
+
505
+ /**
506
+ * psmux 세션 연결
507
+ * @param {string} sessionName
508
+ */
509
+ export function attachPsmuxSession(sessionName) {
510
+ const result = childProcess.spawnSync(PSMUX_BIN, ["attach-session", "-t", sessionName], {
511
+ stdio: "inherit",
512
+ timeout: 0,
513
+ windowsHide: false,
514
+ });
515
+ if ((result.status ?? 1) !== 0) {
516
+ throw new Error(`psmux attach 실패 (exit=${result.status})`);
517
+ }
518
+ }
519
+
520
+ /**
521
+ * 세션 attach client 수 조회
522
+ * @param {string} sessionName
523
+ * @returns {number|null}
524
+ */
525
+ export function getPsmuxSessionAttachedCount(sessionName) {
526
+ try {
527
+ const session = parseSessionSummaries(psmuxExec(["list-sessions"]))
528
+ .find((entry) => entry.sessionName === sessionName);
529
+ return session ? session.attachedCount : null;
530
+ } catch {
531
+ return null;
532
+ }
533
+ }
534
+
535
+ /**
536
+ * 팀메이트 조작 키 바인딩 설정
537
+ * @param {string} sessionName
538
+ * @param {object} opts
539
+ * @param {boolean} opts.inProcess
540
+ * @param {string} opts.taskListCommand
541
+ */
542
+ export function configurePsmuxKeybindings(sessionName, opts = {}) {
543
+ const { inProcess = false, taskListCommand = "" } = opts;
544
+ const cond = `#{==:#{session_name},${sessionName}}`;
545
+ const target = `${sessionName}:0`;
546
+ const bindNext = inProcess
547
+ ? "select-pane -t :.+ \\; resize-pane -Z"
548
+ : "select-pane -t :.+";
549
+ const bindPrev = inProcess
550
+ ? "select-pane -t :.- \\; resize-pane -Z"
551
+ : "select-pane -t :.-";
552
+
553
+ // psmux는 세션별 서버이므로 -t target으로 세션 컨텍스트를 전달해야 한다.
554
+ const bindSafe = (args) => {
555
+ try {
556
+ psmuxExec(["-t", target, ...args]);
557
+ } catch {
558
+ // 미지원 시 무시
559
+ }
560
+ };
561
+
562
+ bindSafe(["bind-key", "-T", "root", "-n", "S-Down", "if-shell", "-F", cond, bindNext, "send-keys S-Down"]);
563
+ bindSafe(["bind-key", "-T", "root", "-n", "S-Up", "if-shell", "-F", cond, bindPrev, "send-keys S-Up"]);
564
+ bindSafe(["bind-key", "-T", "root", "-n", "S-Right", "if-shell", "-F", cond, bindNext, "send-keys S-Right"]);
565
+ bindSafe(["bind-key", "-T", "root", "-n", "S-Left", "if-shell", "-F", cond, bindPrev, "send-keys S-Left"]);
566
+ bindSafe(["bind-key", "-T", "root", "-n", "BTab", "if-shell", "-F", cond, bindPrev, "send-keys BTab"]);
567
+ bindSafe(["bind-key", "-T", "root", "-n", "Escape", "if-shell", "-F", cond, "send-keys C-c", "send-keys Escape"]);
568
+
569
+ if (taskListCommand) {
570
+ bindSafe([
571
+ "bind-key",
572
+ "-T",
573
+ "root",
574
+ "-n",
575
+ "C-t",
576
+ "if-shell",
577
+ "-F",
578
+ cond,
579
+ `display-popup -E ${quoteArg(taskListCommand)}`,
580
+ "send-keys C-t",
581
+ ]);
582
+ }
583
+ }
584
+
585
+ // ─── steering 기능 ───
586
+
587
+ /**
588
+ * pane 출력 pipe-pane 캡처를 시작하고 즉시 snapshot을 기록한다.
589
+ * @param {string} sessionName
590
+ * @param {string} paneNameOrTarget
591
+ * @returns {{ paneId: string, paneName: string, logPath: string }}
592
+ */
593
+ export function startCapture(sessionName, paneNameOrTarget) {
594
+ ensurePsmuxInstalled();
595
+ const pane = resolvePane(sessionName, paneNameOrTarget);
596
+ const paneName = pane.title || paneNameOrTarget;
597
+ const logPath = getCaptureLogPath(sessionName, paneName);
598
+ const helperPath = ensureCaptureHelper();
599
+ mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
600
+ writeFileSync(logPath, "", "utf8");
601
+
602
+ disablePipeCapture(pane.paneId);
603
+ psmuxExec([
604
+ "pipe-pane",
605
+ "-t",
606
+ pane.paneId,
607
+ `powershell.exe -NoLogo -NoProfile -File ${quoteArg(helperPath)} ${quoteArg(logPath)}`,
608
+ ]);
609
+
610
+ refreshCaptureSnapshot(sessionName, pane.paneId);
611
+ return { paneId: pane.paneId, paneName, logPath };
612
+ }
613
+
614
+ /**
615
+ * PowerShell 명령을 pane에 비동기 전송하고 완료 토큰을 반환한다.
616
+ * @param {string} sessionName
617
+ * @param {string} paneNameOrTarget
618
+ * @param {string} commandText
619
+ * @returns {{ paneId: string, paneName: string, token: string, logPath: string }}
620
+ */
621
+ /**
622
+ * CLI 명령(codex/gemini)이 psmux pane의 PowerShell 환경에서 단축 플래그 충돌을
623
+ * 일으키는 문제를 방지하기 위해 bash -c '...' 로 감싼다.
624
+ * - codex -o flag → PS -OutVariable/OutBuffer 충돌
625
+ * - gemini -p flag → PS -ProgressAction/PipelineVariable 충돌
626
+ * @param {string} cmd
627
+ * @returns {string}
628
+ */
629
+ function wrapCliForBash(cmd) {
630
+ const trimmed = cmd.trimStart();
631
+ const isCli = /^(codex|gemini)\b/u.test(trimmed);
632
+ if (!isCli) return cmd;
633
+ // 단일 따옴표 이스케이프: ' → '\''
634
+ const escaped = trimmed.replace(/'/g, "'\\''");
635
+ return `bash -c '${escaped}'`;
636
+ }
637
+
638
+ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
639
+ ensurePsmuxInstalled();
640
+ const pane = resolvePane(sessionName, paneNameOrTarget);
641
+ const paneName = pane.title || paneNameOrTarget;
642
+ const logPath = getCaptureLogPath(sessionName, paneName);
643
+
644
+ if (!existsSync(logPath)) {
645
+ startCapture(sessionName, paneName);
646
+ }
647
+
648
+ const token = randomToken(paneName);
649
+ const safeCommand = wrapCliForBash(commandText);
650
+ const wrapped = `${safeCommand}; $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit"`;
651
+
652
+ sendLiteralToPane(pane.paneId, wrapped, true);
653
+
654
+ return { paneId: pane.paneId, paneName, token, logPath };
655
+ }
656
+
657
+ /**
658
+ * pane 캡처 로그에서 정규식 패턴을 polling으로 대기한다.
659
+ * @param {string} sessionName
660
+ * @param {string} paneNameOrTarget
661
+ * @param {string|RegExp} pattern
662
+ * @param {number} timeoutSec
663
+ * @param {object} [opts]
664
+ * @param {(snapshot: {content: string, paneId: string, paneName: string, elapsed: number}) => void} [opts.onPoll] — 각 폴링 주기마다 호출
665
+ * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null }}
666
+ */
667
+ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300, opts = {}) {
668
+ ensurePsmuxInstalled();
669
+
670
+ // E4 크래시 복구: 초기 resolvePane도 세션 사망을 감지
671
+ let pane;
672
+ try {
673
+ pane = resolvePane(sessionName, paneNameOrTarget);
674
+ } catch (resolveError) {
675
+ if (!psmuxSessionExists(sessionName)) {
676
+ return {
677
+ matched: false,
678
+ paneId: "",
679
+ paneName: String(paneNameOrTarget),
680
+ logPath: "",
681
+ match: null,
682
+ sessionDead: true,
683
+ };
684
+ }
685
+ throw resolveError; // 세션은 살아있지만 pane을 못 찾음 → 원래 에러 전파
686
+ }
687
+
688
+ const paneName = pane.title || paneNameOrTarget;
689
+ // opts.logPath: dispatch 시 확정된 캡처 로그 경로 직접 지정 (타이틀 변경 내성)
690
+ const logPath = (opts.logPath && existsSync(opts.logPath))
691
+ ? opts.logPath
692
+ : getCaptureLogPath(sessionName, paneName);
693
+ if (!existsSync(logPath)) {
694
+ throw new Error(`캡처 로그가 없습니다. 먼저 startCapture(${sessionName}, ${paneName})를 호출하세요.`);
695
+ }
696
+
697
+ const startTime = Date.now();
698
+ const deadline = startTime + Math.max(0, Math.trunc(timeoutSec * 1000));
699
+ const regex = toPatternRegExp(pattern);
700
+
701
+ while (Date.now() <= deadline) {
702
+ // E4 크래시 복구: capture 실패 시 세션 생존 체크
703
+ try {
704
+ if (opts.logPath) {
705
+ // logPath 직접 지정 시 — 셸 타이틀 변경과 무관하게 올바른 파일에 기록
706
+ const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p"]);
707
+ writeFileSync(logPath, snapshot, "utf8");
708
+ } else {
709
+ refreshCaptureSnapshot(sessionName, pane.paneId);
710
+ }
711
+ } catch {
712
+ if (!psmuxSessionExists(sessionName)) {
713
+ return {
714
+ matched: false,
715
+ paneId: pane.paneId,
716
+ paneName,
717
+ logPath,
718
+ match: null,
719
+ sessionDead: true,
720
+ };
721
+ }
722
+ // 일시적 오류 — 다음 폴링에서 재시도
723
+ }
724
+
725
+ const content = readCaptureLog(logPath);
726
+
727
+ // onPoll 콜백 각 폴링 주기마다 중간 상태 전달
728
+ if (opts.onPoll) {
729
+ try {
730
+ opts.onPoll({ content, paneId: pane.paneId, paneName, elapsed: Date.now() - startTime });
731
+ } catch { /* 콜백 예외는 삼킴 — 폴링 루프 보호 */ }
732
+ }
733
+
734
+ const match = regex.exec(content);
735
+ if (match) {
736
+ return {
737
+ matched: true,
738
+ paneId: pane.paneId,
739
+ paneName,
740
+ logPath,
741
+ match: match[0],
742
+ };
743
+ }
744
+
745
+ if (Date.now() > deadline) {
746
+ break;
747
+ }
748
+ await sleepMsAsync(POLL_INTERVAL_MS);
749
+ }
750
+
751
+ return {
752
+ matched: false,
753
+ paneId: pane.paneId,
754
+ paneName,
755
+ logPath,
756
+ match: null,
757
+ };
758
+ }
759
+
760
+ /**
761
+ * 완료 토큰이 찍힐 때까지 대기하고 exit code를 파싱한다.
762
+ * @param {string} sessionName
763
+ * @param {string} paneNameOrTarget
764
+ * @param {string} token
765
+ * @param {number} timeoutSec
766
+ * @param {object} [opts] — waitForPattern에 전달할 옵션 (onPoll 등)
767
+ * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
768
+ */
769
+ export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300, opts = {}) {
770
+ const completionRegex = new RegExp(
771
+ `${escapeRegExp(COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
772
+ "m",
773
+ );
774
+ const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec, opts);
775
+ const exitMatch = result.match ? completionRegex.exec(result.match) : null;
776
+ return {
777
+ ...result,
778
+ token,
779
+ exitCode: exitMatch ? Number.parseInt(exitMatch[1], 10) : null,
780
+ };
781
+ }
782
+
783
+ // ─── 하이브리드 모드 워커 관리 함수 ───
784
+
785
+ /**
786
+ * psmux 세션의 pane에서 워커 실행
787
+ * @param {string} sessionName - 대상 psmux 세션 이름
788
+ * @param {string} workerName - 워커 식별용 pane 타이틀
789
+ * @param {string} cmd - 실행할 커맨드
790
+ * @returns {{ paneId: string, workerName: string }}
791
+ */
792
+ export function spawnWorker(sessionName, workerName, cmd) {
793
+ if (!hasPsmux()) {
794
+ throw new Error("psmux가 설치되어 있지 않습니다. psmux를 먼저 설치하세요.");
795
+ }
796
+
797
+ // remain-on-exit: 종료된 pane이 즉시 사라지는 것 방지
798
+ try {
799
+ psmuxExec(["set-option", "-t", sessionName, "remain-on-exit", "on"]);
800
+ } catch {
801
+ // 미지원 시 무시
802
+ }
803
+
804
+ // Windows: pane 기본셸이 PowerShell → Git Bash로 래핑
805
+ // psmux가 이스케이프 시퀀스를 처리하므로 포워드 슬래시 경로를 사용한다.
806
+ const shellCmd = IS_WINDOWS
807
+ ? `& '${GIT_BASH.replace(/\\/g, "/")}' -l -c '${cmd.replace(/'/g, "'\\''")}'`
808
+ : cmd;
809
+
810
+ try {
811
+ const paneTarget = psmuxExec([
812
+ "split-window",
813
+ "-t",
814
+ sessionName,
815
+ "-P",
816
+ "-F",
817
+ "#{session_name}:#{window_index}.#{pane_index}",
818
+ shellCmd,
819
+ ]);
820
+ psmuxExec(["select-pane", "-t", paneTarget, "-T", workerName]);
821
+ return { paneId: paneTarget, workerName };
822
+ } catch (err) {
823
+ throw new Error(`워커 생성 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
824
+ }
825
+ }
826
+
827
+ /**
828
+ * 워커 pane 실행 상태 확인
829
+ * @param {string} sessionName - 대상 psmux 세션 이름
830
+ * @param {string} workerName - 워커 pane 타이틀
831
+ * @returns {{ status: "running"|"exited", exitCode: number|null, paneId: string }}
832
+ */
833
+ export function getWorkerStatus(sessionName, workerName) {
834
+ if (!hasPsmux()) {
835
+ throw new Error("psmux가 설치되어 있지 않습니다.");
836
+ }
837
+ try {
838
+ const pane = resolvePane(sessionName, workerName);
839
+ return {
840
+ status: pane.isDead ? "exited" : "running",
841
+ exitCode: pane.isDead ? pane.exitCode : null,
842
+ paneId: pane.paneId,
843
+ };
844
+ } catch (err) {
845
+ if (err.message.includes("Pane을 찾을 없습니다")) {
846
+ throw new Error(`워커를 찾을 수 없습니다: ${workerName}`);
847
+ }
848
+ throw new Error(`워커 상태 조회 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
849
+ }
850
+ }
851
+
852
+ /**
853
+ * 워커 pane 프로세스 강제 종료
854
+ * @param {string} sessionName - 대상 psmux 세션 이름
855
+ * @param {string} workerName - 워커 pane 타이틀
856
+ * @returns {{ killed: boolean }}
857
+ */
858
+ export function killWorker(sessionName, workerName) {
859
+ if (!hasPsmux()) {
860
+ throw new Error("psmux가 설치되어 있지 않습니다.");
861
+ }
862
+ try {
863
+ const { paneId, status } = getWorkerStatus(sessionName, workerName);
864
+
865
+ // 이미 종료된 워커 → pane 정리만 수행
866
+ if (status === "exited") {
867
+ try {
868
+ psmuxExec(["kill-pane", "-t", paneId]);
869
+ } catch {
870
+ // 무시
871
+ }
872
+ return { killed: true };
873
+ }
874
+
875
+ // running → C-c 우아한 종료 시도
876
+ try {
877
+ psmuxExec(["send-keys", "-t", paneId, "C-c"]);
878
+ } catch {
879
+ // send-keys 실패 무시
880
+ }
881
+
882
+ sleepMs(1000);
883
+
884
+ try {
885
+ psmuxExec(["kill-pane", "-t", paneId]);
886
+ } catch {
887
+ // 이미 종료된 pane 무시
888
+ }
889
+ return { killed: true };
890
+ } catch (err) {
891
+ if (err.message.includes("워커를 찾을 수 없습니다")) {
892
+ return { killed: true };
893
+ }
894
+ throw new Error(`워커 종료 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
895
+ }
896
+ }
897
+
898
+ /**
899
+ * 워커 pane 출력 마지막 N줄 캡처
900
+ * @param {string} sessionName - 대상 psmux 세션 이름
901
+ * @param {string} workerName - 워커 pane 타이틀
902
+ * @param {number} lines - 캡처할 줄 수 (기본 50)
903
+ * @returns {string} 캡처된 출력
904
+ */
905
+ export function captureWorkerOutput(sessionName, workerName, lines = 50) {
906
+ if (!hasPsmux()) {
907
+ throw new Error("psmux가 설치되어 있지 않습니다.");
908
+ }
909
+ try {
910
+ const { paneId } = getWorkerStatus(sessionName, workerName);
911
+ return psmuxExec(["capture-pane", "-t", paneId, "-p", "-S", `-${lines}`]);
912
+ } catch (err) {
913
+ if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
914
+ throw new Error(`출력 캡처 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
915
+ }
916
+ }
917
+
918
+ // ─── CLI 진입점 ───
919
+
920
+ if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
921
+ (async () => {
922
+ const [, , cmd, ...args] = process.argv;
923
+
924
+ // CLI 인자 파싱 헬퍼
925
+ function getArg(name) {
926
+ const idx = args.indexOf(`--${name}`);
927
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
928
+ }
929
+
930
+ try {
931
+ switch (cmd) {
932
+ case "spawn": {
933
+ const session = getArg("session");
934
+ const name = getArg("name");
935
+ const workerCmd = getArg("cmd");
936
+ if (!session || !name || !workerCmd) {
937
+ console.error("사용법: node psmux.mjs spawn --session <세션> --name <워커명> --cmd <커맨드>");
938
+ process.exit(1);
939
+ }
940
+ console.log(JSON.stringify(spawnWorker(session, name, workerCmd), null, 2));
941
+ break;
942
+ }
943
+ case "status": {
944
+ const session = getArg("session");
945
+ const name = getArg("name");
946
+ if (!session || !name) {
947
+ console.error("사용법: node psmux.mjs status --session <세션> --name <워커명>");
948
+ process.exit(1);
949
+ }
950
+ console.log(JSON.stringify(getWorkerStatus(session, name), null, 2));
951
+ break;
952
+ }
953
+ case "kill": {
954
+ const session = getArg("session");
955
+ const name = getArg("name");
956
+ if (!session || !name) {
957
+ console.error("사용법: node psmux.mjs kill --session <세션> --name <워커명>");
958
+ process.exit(1);
959
+ }
960
+ console.log(JSON.stringify(killWorker(session, name), null, 2));
961
+ break;
962
+ }
963
+ case "output": {
964
+ const session = getArg("session");
965
+ const name = getArg("name");
966
+ const lines = parseInt(getArg("lines") || "50", 10);
967
+ if (!session || !name) {
968
+ console.error("사용법: node psmux.mjs output --session <세션> --name <워커명> [--lines <줄수>]");
969
+ process.exit(1);
970
+ }
971
+ console.log(captureWorkerOutput(session, name, lines));
972
+ break;
973
+ }
974
+ case "capture-start": {
975
+ const session = getArg("session");
976
+ const name = getArg("name");
977
+ if (!session || !name) {
978
+ console.error("사용법: node psmux.mjs capture-start --session <세션> --name <pane>");
979
+ process.exit(1);
980
+ }
981
+ console.log(JSON.stringify(startCapture(session, name), null, 2));
982
+ break;
983
+ }
984
+ case "dispatch": {
985
+ const session = getArg("session");
986
+ const name = getArg("name");
987
+ const commandText = getArg("command");
988
+ if (!session || !name || !commandText) {
989
+ console.error("사용법: node psmux.mjs dispatch --session <세션> --name <pane> --command <PowerShell 명령>");
990
+ process.exit(1);
991
+ }
992
+ console.log(JSON.stringify(dispatchCommand(session, name, commandText), null, 2));
993
+ break;
994
+ }
995
+ case "wait-pattern": {
996
+ const session = getArg("session");
997
+ const name = getArg("name");
998
+ const pattern = getArg("pattern");
999
+ const timeoutSec = parseInt(getArg("timeout") || "300", 10);
1000
+ if (!session || !name || !pattern) {
1001
+ console.error("사용법: node psmux.mjs wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
1002
+ process.exit(1);
1003
+ }
1004
+ const result = await waitForPattern(session, name, pattern, timeoutSec);
1005
+ console.log(JSON.stringify(result, null, 2));
1006
+ if (!result.matched) process.exit(2);
1007
+ break;
1008
+ }
1009
+ case "wait-completion": {
1010
+ const session = getArg("session");
1011
+ const name = getArg("name");
1012
+ const token = getArg("token");
1013
+ const timeoutSec = parseInt(getArg("timeout") || "300", 10);
1014
+ if (!session || !name || !token) {
1015
+ console.error("사용법: node psmux.mjs wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
1016
+ process.exit(1);
1017
+ }
1018
+ const result = await waitForCompletion(session, name, token, timeoutSec);
1019
+ console.log(JSON.stringify(result, null, 2));
1020
+ if (!result.matched) process.exit(2);
1021
+ break;
1022
+ }
1023
+ default:
1024
+ console.error("사용법: node psmux.mjs spawn|status|kill|output|capture-start|dispatch|wait-pattern|wait-completion [args]");
1025
+ console.error("");
1026
+ console.error(" spawn --session <세션> --name <워커명> --cmd <커맨드>");
1027
+ console.error(" status --session <세션> --name <워커명>");
1028
+ console.error(" kill --session <세션> --name <워커명>");
1029
+ console.error(" output --session <세션> --name <워커명> [--lines <줄수>]");
1030
+ console.error(" capture-start --session <세션> --name <pane>");
1031
+ console.error(" dispatch --session <세션> --name <pane> --command <PowerShell 명령>");
1032
+ console.error(" wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
1033
+ console.error(" wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
1034
+ process.exit(1);
1035
+ }
1036
+ } catch (err) {
1037
+ console.error(`오류: ${err.message}`);
1038
+ process.exit(1);
1039
+ }
1040
+ })();
1041
+ }