triflux 3.2.0-dev.1 → 3.2.0-dev.10

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 (53) hide show
  1. package/README.ko.md +26 -18
  2. package/README.md +26 -18
  3. package/bin/triflux.mjs +1614 -1084
  4. package/hooks/hooks.json +12 -0
  5. package/hooks/keyword-rules.json +354 -0
  6. package/hub/bridge.mjs +371 -193
  7. package/hub/hitl.mjs +45 -31
  8. package/hub/pipe.mjs +457 -0
  9. package/hub/router.mjs +422 -161
  10. package/hub/server.mjs +429 -344
  11. package/hub/store.mjs +388 -314
  12. package/hub/team/cli-team-common.mjs +348 -0
  13. package/hub/team/cli-team-control.mjs +393 -0
  14. package/hub/team/cli-team-start.mjs +516 -0
  15. package/hub/team/cli-team-status.mjs +269 -0
  16. package/hub/team/cli.mjs +99 -368
  17. package/hub/team/dashboard.mjs +165 -64
  18. package/hub/team/native-supervisor.mjs +300 -0
  19. package/hub/team/native.mjs +62 -0
  20. package/hub/team/nativeProxy.mjs +534 -0
  21. package/hub/team/orchestrator.mjs +99 -35
  22. package/hub/team/pane.mjs +138 -101
  23. package/hub/team/psmux.mjs +297 -0
  24. package/hub/team/session.mjs +608 -186
  25. package/hub/team/shared.mjs +13 -0
  26. package/hub/team/staleState.mjs +299 -0
  27. package/hub/tools.mjs +140 -53
  28. package/hub/workers/claude-worker.mjs +446 -0
  29. package/hub/workers/codex-mcp.mjs +414 -0
  30. package/hub/workers/factory.mjs +18 -0
  31. package/hub/workers/gemini-worker.mjs +349 -0
  32. package/hub/workers/interface.mjs +41 -0
  33. package/hud/hud-qos-status.mjs +1789 -1732
  34. package/package.json +6 -2
  35. package/scripts/__tests__/keyword-detector.test.mjs +234 -0
  36. package/scripts/hub-ensure.mjs +83 -0
  37. package/scripts/keyword-detector.mjs +272 -0
  38. package/scripts/keyword-rules-expander.mjs +521 -0
  39. package/scripts/lib/keyword-rules.mjs +168 -0
  40. package/scripts/psmux-steering-prototype.sh +368 -0
  41. package/scripts/run.cjs +62 -0
  42. package/scripts/setup.mjs +189 -7
  43. package/scripts/test-tfx-route-no-claude-native.mjs +49 -0
  44. package/scripts/tfx-route-worker.mjs +161 -0
  45. package/scripts/tfx-route.sh +943 -508
  46. package/skills/tfx-auto/SKILL.md +90 -564
  47. package/skills/tfx-auto-codex/SKILL.md +77 -0
  48. package/skills/tfx-codex/SKILL.md +1 -4
  49. package/skills/tfx-doctor/SKILL.md +1 -0
  50. package/skills/tfx-gemini/SKILL.md +1 -4
  51. package/skills/tfx-multi/SKILL.md +296 -0
  52. package/skills/tfx-setup/SKILL.md +1 -4
  53. package/skills/tfx-team/SKILL.md +0 -172
@@ -1,186 +1,608 @@
1
- // hub/team/session.mjs — tmux 세션 생명주기 관리
2
- // 의존성: child_process (Node.js 내장)만 사용
3
- import { execSync } from "node:child_process";
4
-
5
- /** tmux 실행 가능 여부 확인 */
6
- function hasTmux() {
7
- try {
8
- execSync("tmux -V", { stdio: "ignore", timeout: 3000 });
9
- return true;
10
- } catch {
11
- return false;
12
- }
13
- }
14
-
15
- /** WSL2 내 tmux 사용 가능 여부 (Windows 전용) */
16
- function hasWslTmux() {
17
- try {
18
- execSync("wsl tmux -V", { stdio: "ignore", timeout: 5000 });
19
- return true;
20
- } catch {
21
- return false;
22
- }
23
- }
24
-
25
- /**
26
- * 터미널 멀티플렉서 감지
27
- * @returns {'tmux'|'wsl-tmux'|null}
28
- */
29
- export function detectMultiplexer() {
30
- if (hasTmux()) return "tmux";
31
- if (process.platform === "win32" && hasWslTmux()) return "wsl-tmux";
32
- return null;
33
- }
34
-
35
- /**
36
- * tmux 커맨드 실행 (wsl-tmux 투명 지원)
37
- * @param {string} args — tmux 서브커맨드 + 인자
38
- * @param {object} opts execSync 옵션
39
- * @returns {string} stdout
40
- */
41
- function tmux(args, opts = {}) {
42
- const mux = detectMultiplexer();
43
- if (!mux) {
44
- throw new Error(
45
- "tmux 미발견.\n\n" +
46
- "tfx team은 tmux가 필요합니다:\n" +
47
- " WSL2: wsl sudo apt install tmux\n" +
48
- " macOS: brew install tmux\n" +
49
- " Linux: apt install tmux\n\n" +
50
- "Windows에서는 WSL2를 권장합니다:\n" +
51
- " 1. wsl --install\n" +
52
- " 2. wsl sudo apt install tmux\n" +
53
- " 3. tfx team \"작업\" (자동으로 WSL tmux 사용)"
54
- );
55
- }
56
- const prefix = mux === "wsl-tmux" ? "wsl tmux" : "tmux";
57
- const result = execSync(`${prefix} ${args}`, {
58
- encoding: "utf8",
59
- timeout: 10000,
60
- stdio: ["pipe", "pipe", "pipe"],
61
- ...opts,
62
- });
63
- // stdio: "ignore" execSync가 null 반환 — 안전 처리
64
- return result != null ? result.trim() : "";
65
- }
66
-
67
- /**
68
- * tmux 세션 생성 + 레이아웃 분할
69
- * @param {string} sessionName — 세션 이름
70
- * @param {object} opts
71
- * @param {'2x2'|'1xN'} opts.layout — 레이아웃 (기본 2x2)
72
- * @param {number} opts.paneCount — pane 수 (기본 4)
73
- * @returns {{ sessionName: string, panes: string[] }}
74
- */
75
- export function createSession(sessionName, opts = {}) {
76
- const { layout = "2x2", paneCount = 4 } = opts;
77
-
78
- // 기존 세션 정리
79
- if (sessionExists(sessionName)) {
80
- killSession(sessionName);
81
- }
82
-
83
- // 새 세션 생성 (detached)
84
- tmux(`new-session -d -s ${sessionName} -x 220 -y 55`);
85
-
86
- const panes = [`${sessionName}:0.0`];
87
-
88
- if (layout === "2x2" && paneCount >= 3) {
89
- // 2x2 그리드: 좌|우 → 좌상/좌하 → 우상/우하
90
- tmux(`split-window -h -t ${sessionName}:0`);
91
- tmux(`split-window -v -t ${sessionName}:0.0`);
92
- if (paneCount >= 4) {
93
- tmux(`split-window -v -t ${sessionName}:0.2`);
94
- }
95
- // pane ID 재수집
96
- panes.length = 0;
97
- for (let i = 0; i < Math.min(paneCount, 4); i++) {
98
- panes.push(`${sessionName}:0.${i}`);
99
- }
100
- } else {
101
- // 1xN 수직 분할
102
- for (let i = 1; i < paneCount; i++) {
103
- tmux(`split-window -v -t ${sessionName}:0`);
104
- }
105
- // even-vertical 레이아웃 적용
106
- tmux(`select-layout -t ${sessionName}:0 even-vertical`);
107
- panes.length = 0;
108
- for (let i = 0; i < paneCount; i++) {
109
- panes.push(`${sessionName}:0.${i}`);
110
- }
111
- }
112
-
113
- return { sessionName, panes };
114
- }
115
-
116
- /**
117
- * tmux 세션 연결 (포그라운드 전환)
118
- * @param {string} sessionName
119
- */
120
- export function attachSession(sessionName) {
121
- const mux = detectMultiplexer();
122
- const prefix = mux === "wsl-tmux" ? "wsl tmux" : "tmux";
123
- // stdio: inherit로 사용자에게 제어권 반환
124
- execSync(`${prefix} attach-session -t ${sessionName}`, {
125
- stdio: "inherit",
126
- timeout: 0, // 타임아웃 없음 (사용자가 detach할 때까지)
127
- });
128
- }
129
-
130
- /**
131
- * tmux 세션 존재 확인
132
- * @param {string} sessionName
133
- * @returns {boolean}
134
- */
135
- export function sessionExists(sessionName) {
136
- try {
137
- tmux(`has-session -t ${sessionName}`, { stdio: "ignore" });
138
- return true;
139
- } catch {
140
- return false;
141
- }
142
- }
143
-
144
- /**
145
- * tmux 세션 종료
146
- * @param {string} sessionName
147
- */
148
- export function killSession(sessionName) {
149
- try {
150
- tmux(`kill-session -t ${sessionName}`, { stdio: "ignore" });
151
- } catch {
152
- // 이미 종료된 세션 — 무시
153
- }
154
- }
155
-
156
- /**
157
- * tfx-team- 접두사 세션 목록
158
- * @returns {string[]}
159
- */
160
- export function listSessions() {
161
- try {
162
- const output = tmux('list-sessions -F "#{session_name}"');
163
- return output
164
- .split("\n")
165
- .filter((s) => s.startsWith("tfx-team-"));
166
- } catch {
167
- return [];
168
- }
169
- }
170
-
171
- /**
172
- * pane 마지막 N줄 캡처
173
- * @param {string} target — 예: tfx-team-abc:0.1
174
- * @param {number} lines — 캡처할 줄 수 (기본 5)
175
- * @returns {string}
176
- */
177
- export function capturePaneOutput(target, lines = 5) {
178
- try {
179
- // -l 플래그는 일부 tmux 빌드(MSYS2)에서 미지원 → 전체 캡처 후 JS에서 절삭
180
- const full = tmux(`capture-pane -t ${target} -p`);
181
- const nonEmpty = full.split("\n").filter((l) => l.trim() !== "");
182
- return nonEmpty.slice(-lines).join("\n");
183
- } catch {
184
- return "";
185
- }
186
- }
1
+ // hub/team/session.mjs — tmux/psmux/wt 세션 생명주기 관리
2
+ // 의존성: child_process (Node.js 내장)만 사용
3
+ import { execSync, spawnSync } from "node:child_process";
4
+ import {
5
+ attachPsmuxSession,
6
+ capturePsmuxPane,
7
+ configurePsmuxKeybindings,
8
+ createPsmuxSession,
9
+ getPsmuxSessionAttachedCount,
10
+ hasPsmux,
11
+ killPsmuxSession,
12
+ listPsmuxSessions,
13
+ psmuxExec,
14
+ psmuxSessionExists,
15
+ } from "./psmux.mjs";
16
+
17
+ const GIT_BASH_CANDIDATES = [
18
+ "C:/Program Files/Git/bin/bash.exe",
19
+ "C:/Program Files/Git/usr/bin/bash.exe",
20
+ ];
21
+
22
+ function findGitBashExe() {
23
+ for (const p of GIT_BASH_CANDIDATES) {
24
+ try {
25
+ execSync(`"${p}" --version`, { stdio: "ignore", timeout: 3000 });
26
+ return p;
27
+ } catch {
28
+ // 다음 후보
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /** Windows Terminal 실행 파일 존재 여부 */
35
+ export function hasWindowsTerminal() {
36
+ if (process.platform !== "win32") return false;
37
+ try {
38
+ execSync("where wt.exe", { stdio: "ignore", timeout: 3000 });
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** 현재 프로세스가 Windows Terminal 내에서 실행 중인지 여부 */
46
+ export function hasWindowsTerminalSession() {
47
+ return process.platform === "win32" && !!process.env.WT_SESSION;
48
+ }
49
+
50
+ /** tmux 실행 가능 여부 확인 */
51
+ function hasTmux() {
52
+ try {
53
+ execSync("tmux -V", { stdio: "ignore", timeout: 3000 });
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** WSL2 tmux 사용 가능 여부 (Windows 전용) */
61
+ function hasWslTmux() {
62
+ try {
63
+ execSync("wsl tmux -V", { stdio: "ignore", timeout: 5000 });
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /** Git Bash tmux 사용 가능 여부 (Windows 전용) */
71
+ function hasGitBashTmux() {
72
+ const bash = findGitBashExe();
73
+ if (!bash) return false;
74
+ try {
75
+ const r = spawnSync(bash, ["-lc", "tmux -V"], {
76
+ encoding: "utf8",
77
+ timeout: 5000,
78
+ stdio: ["ignore", "pipe", "pipe"],
79
+ });
80
+ return (r.status ?? 1) === 0;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * 터미널 멀티플렉서 감지 (결과 캐싱 — 프로세스 수명 동안 불변)
88
+ * @returns {'tmux'|'git-bash-tmux'|'wsl-tmux'|'psmux'|null}
89
+ */
90
+ let _cachedMux;
91
+ export function detectMultiplexer() {
92
+ if (_cachedMux !== undefined) return _cachedMux;
93
+ if (hasPsmux()) { _cachedMux = "psmux"; return _cachedMux; }
94
+ if (hasTmux()) { _cachedMux = "tmux"; return _cachedMux; }
95
+ if (process.platform === "win32" && hasGitBashTmux()) { _cachedMux = "git-bash-tmux"; return _cachedMux; }
96
+ if (process.platform === "win32" && hasWslTmux()) { _cachedMux = "wsl-tmux"; return _cachedMux; }
97
+ _cachedMux = null;
98
+ return _cachedMux;
99
+ }
100
+
101
+ /**
102
+ * tmux/psmux 커맨드 실행 (wsl-tmux 투명 지원)
103
+ * @param {string} args — tmux 서브커맨드 + 인자
104
+ * @param {object} opts — execSync 옵션
105
+ * @returns {string} stdout
106
+ */
107
+ function tmux(args, opts = {}) {
108
+ const mux = detectMultiplexer();
109
+ if (!mux) {
110
+ throw new Error(
111
+ "tmux/psmux 미발견.\n\n" +
112
+ "tfx multi은 tmux 계열 멀티플렉서가 필요합니다:\n" +
113
+ " Windows: psmux 설치 또는 WSL2 tmux 사용\n" +
114
+ " WSL2: wsl sudo apt install tmux\n" +
115
+ " macOS: brew install tmux\n" +
116
+ " Linux: apt install tmux\n\n" +
117
+ "Windows에서는 WSL2를 권장합니다:\n" +
118
+ " 1. wsl --install\n" +
119
+ " 2. wsl sudo apt install tmux\n" +
120
+ " 3. tfx multi \"작업\" (자동으로 WSL tmux 사용)"
121
+ );
122
+ }
123
+ if (mux === "psmux") {
124
+ return psmuxExec(args, opts);
125
+ }
126
+ if (mux === "git-bash-tmux") {
127
+ const bash = findGitBashExe();
128
+ if (!bash) throw new Error("git-bash-tmux 감지 실패");
129
+ const r = spawnSync(bash, ["-lc", `tmux ${args}`], {
130
+ encoding: "utf8",
131
+ timeout: 10000,
132
+ stdio: ["pipe", "pipe", "pipe"],
133
+ ...opts,
134
+ });
135
+ if ((r.status ?? 1) !== 0) {
136
+ const e = new Error(r.stderr || "tmux command failed");
137
+ e.status = r.status;
138
+ throw e;
139
+ }
140
+ return (r.stdout || "").trim();
141
+ }
142
+
143
+ const prefix = mux === "wsl-tmux" ? "wsl tmux" : "tmux";
144
+ const result = execSync(`${prefix} ${args}`, {
145
+ encoding: "utf8",
146
+ timeout: 10000,
147
+ stdio: ["pipe", "pipe", "pipe"],
148
+ ...opts,
149
+ });
150
+ return result != null ? result.trim() : "";
151
+ }
152
+
153
+ /**
154
+ * tmux 명령 직접 실행 (고수준 모듈에서 재사용)
155
+ * @param {string} args
156
+ * @param {object} opts
157
+ * @returns {string}
158
+ */
159
+ export function tmuxExec(args, opts = {}) {
160
+ return tmux(args, opts);
161
+ }
162
+
163
+ /**
164
+ * 현재 멀티플렉서 환경에 맞는 attach 실행 스펙 반환
165
+ * @param {string} sessionName
166
+ * @returns {{ command: string, args: string[] }}
167
+ */
168
+ export function resolveAttachCommand(sessionName) {
169
+ const mux = detectMultiplexer();
170
+ if (!mux) {
171
+ throw new Error("tmux/psmux 미발견");
172
+ }
173
+
174
+ if (mux === "psmux") {
175
+ return {
176
+ command: process.env.PSMUX_BIN || "psmux",
177
+ args: ["attach-session", "-t", sessionName],
178
+ };
179
+ }
180
+
181
+ if (mux === "git-bash-tmux") {
182
+ const bash = findGitBashExe();
183
+ if (!bash) throw new Error("git-bash-tmux 감지 실패");
184
+ return {
185
+ command: bash,
186
+ args: ["-lc", `tmux attach-session -t ${sessionName}`],
187
+ };
188
+ }
189
+
190
+ if (mux === "wsl-tmux") {
191
+ return {
192
+ command: "wsl",
193
+ args: ["tmux", "attach-session", "-t", sessionName],
194
+ };
195
+ }
196
+
197
+ return {
198
+ command: "tmux",
199
+ args: ["attach-session", "-t", sessionName],
200
+ };
201
+ }
202
+
203
+ /**
204
+ * wt.exe 커맨드 실행
205
+ * @param {string[]} args
206
+ * @param {object} opts
207
+ * @returns {string}
208
+ */
209
+ function wt(args, opts = {}) {
210
+ if (!hasWindowsTerminal()) {
211
+ throw new Error("wt.exe 미발견");
212
+ }
213
+
214
+ const r = spawnSync("wt.exe", args, {
215
+ encoding: "utf8",
216
+ timeout: 10000,
217
+ stdio: ["ignore", "pipe", "pipe"],
218
+ windowsHide: true,
219
+ ...opts,
220
+ });
221
+ if ((r.status ?? 1) !== 0) {
222
+ const e = new Error((r.stderr || r.stdout || "wt command failed").trim());
223
+ e.status = r.status;
224
+ throw e;
225
+ }
226
+ return (r.stdout || "").trim();
227
+ }
228
+
229
+ /**
230
+ * Windows Terminal pane 분할용 cmd.exe 인자 구성
231
+ * @param {string} command
232
+ * @returns {string[]}
233
+ */
234
+ function buildWtCmdArgs(command) {
235
+ return ["cmd.exe", "/c", command];
236
+ }
237
+
238
+ /**
239
+ * Windows Terminal 독립 모드 세션 생성
240
+ * - 현재 pane를 앵커로 사용하고, 우측(1xN) 또는 하단(Nx1)으로만 분할
241
+ * - 생성한 pane에 즉시 CLI 커맨드를 실행
242
+ * @param {string} sessionName
243
+ * @param {object} opts
244
+ * @param {'1xN'|'Nx1'} opts.layout
245
+ * @param {Array<{title:string,command:string,cwd?:string}>} opts.paneCommands
246
+ * @returns {{ sessionName: string, panes: string[], titles: string[], layout: '1xN'|'Nx1', paneCount: number, anchorPane: string }}
247
+ */
248
+ export function createWtSession(sessionName, opts = {}) {
249
+ const { layout = "1xN", paneCommands = [] } = opts;
250
+
251
+ if (!hasWindowsTerminalSession()) {
252
+ throw new Error("WT_SESSION 미감지");
253
+ }
254
+ if (!hasWindowsTerminal()) {
255
+ throw new Error("wt.exe 미발견");
256
+ }
257
+ if (!Array.isArray(paneCommands) || paneCommands.length === 0) {
258
+ throw new Error("paneCommands가 비어 있음");
259
+ }
260
+
261
+ const splitFlag = layout === "Nx1" ? "-H" : "-V";
262
+ const panes = [];
263
+ const titles = [];
264
+
265
+ for (let i = 0; i < paneCommands.length; i++) {
266
+ const pane = paneCommands[i] || {};
267
+ const title = pane.title || `${sessionName}-${i + 1}`;
268
+ const command = String(pane.command || "").trim();
269
+ const cwd = pane.cwd || process.cwd();
270
+ if (!command) continue;
271
+
272
+ wt(["-w", "0", "sp", splitFlag, "--title", title, "-d", cwd, ...buildWtCmdArgs(command)]);
273
+ panes.push(`wt:${i}`);
274
+ titles.push(title);
275
+ }
276
+
277
+ return {
278
+ sessionName,
279
+ panes,
280
+ titles,
281
+ layout: layout === "Nx1" ? "Nx1" : "1xN",
282
+ paneCount: panes.length,
283
+ anchorPane: "wt:anchor",
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Windows Terminal pane 포커스 이동
289
+ * @param {number} paneIndex - createWtSession()에서 생성한 pane 인덱스(0 기반)
290
+ * @param {object} opts
291
+ * @param {'1xN'|'Nx1'} opts.layout
292
+ * @returns {boolean}
293
+ */
294
+ export function focusWtPane(paneIndex, opts = {}) {
295
+ if (!hasWindowsTerminalSession() || !hasWindowsTerminal()) return false;
296
+ const idx = Number(paneIndex);
297
+ if (!Number.isInteger(idx) || idx < 0) return false;
298
+
299
+ const layout = opts.layout === "Nx1" ? "Nx1" : "1xN";
300
+ const backDir = layout === "Nx1" ? "up" : "left";
301
+ const stepDir = layout === "Nx1" ? "down" : "right";
302
+
303
+ // 앵커로 최대한 복귀
304
+ for (let i = 0; i < 10; i++) {
305
+ try { wt(["-w", "0", "move-focus", backDir]); } catch { break; }
306
+ }
307
+
308
+ for (let i = 0; i <= idx; i++) {
309
+ wt(["-w", "0", "move-focus", stepDir]);
310
+ }
311
+ return true;
312
+ }
313
+
314
+ /**
315
+ * Windows Terminal에서 생성한 팀 pane 정리
316
+ * @param {object} opts
317
+ * @param {'1xN'|'Nx1'} opts.layout
318
+ * @param {number} opts.paneCount
319
+ * @returns {number} 닫힌 pane 수 (best-effort)
320
+ */
321
+ export function closeWtSession(opts = {}) {
322
+ if (!hasWindowsTerminalSession() || !hasWindowsTerminal()) return 0;
323
+
324
+ const paneCount = Math.max(0, Number(opts.paneCount || 0));
325
+ if (paneCount === 0) return 0;
326
+
327
+ const layout = opts.layout === "Nx1" ? "Nx1" : "1xN";
328
+ const backDir = layout === "Nx1" ? "up" : "left";
329
+ const stepDir = layout === "Nx1" ? "down" : "right";
330
+ let closed = 0;
331
+
332
+ // 앵커(원래 tfx 실행 pane)로 최대한 복귀
333
+ for (let i = 0; i < 10; i++) {
334
+ try { wt(["-w", "0", "move-focus", backDir]); } catch { break; }
335
+ }
336
+
337
+ for (let i = 0; i < paneCount; i++) {
338
+ try {
339
+ wt(["-w", "0", "move-focus", stepDir]);
340
+ wt(["-w", "0", "close-pane"]);
341
+ closed++;
342
+ } catch {
343
+ break;
344
+ }
345
+ }
346
+
347
+ return closed;
348
+ }
349
+
350
+ /**
351
+ * tmux 세션 생성 + 레이아웃 분할
352
+ * @param {string} sessionName — 세션 이름
353
+ * @param {object} opts
354
+ * @param {'2x2'|'1xN'|'Nx1'} opts.layout — 레이아웃 (기본 2x2)
355
+ * @param {number} opts.paneCount — pane 수 (기본 4)
356
+ * @returns {{ sessionName: string, panes: string[] }}
357
+ */
358
+ export function createSession(sessionName, opts = {}) {
359
+ const { layout = "2x2", paneCount = 4 } = opts;
360
+ const mux = detectMultiplexer();
361
+
362
+ // 기존 세션 정리
363
+ if (sessionExists(sessionName)) {
364
+ killSession(sessionName);
365
+ }
366
+
367
+ if (mux === "psmux") {
368
+ return createPsmuxSession(sessionName, { layout, paneCount });
369
+ }
370
+
371
+ // 새 세션 생성 (detached)
372
+ tmux(`new-session -d -s ${sessionName} -x 220 -y 55`);
373
+
374
+ const panes = [`${sessionName}:0.0`];
375
+
376
+ if (layout === "2x2" && paneCount >= 3) {
377
+ // 3-pane 기본: lead 왼쪽, workers 오른쪽 상/하
378
+ // 4-pane: 좌/우 각각 상/하(균등 2x2)
379
+ tmux(`split-window -h -t ${sessionName}:0.0`);
380
+ tmux(`split-window -v -t ${sessionName}:0.1`);
381
+ if (paneCount >= 4) {
382
+ tmux(`split-window -v -t ${sessionName}:0.0`);
383
+ }
384
+ // pane ID 재수집
385
+ panes.length = 0;
386
+ for (let i = 0; i < Math.min(paneCount, 4); i++) {
387
+ panes.push(`${sessionName}:0.${i}`);
388
+ }
389
+ } else if (layout === "1xN") {
390
+ // 세로 분할(좌/우 컬럼 확장)
391
+ for (let i = 1; i < paneCount; i++) {
392
+ tmux(`split-window -h -t ${sessionName}:0`);
393
+ }
394
+ tmux(`select-layout -t ${sessionName}:0 even-horizontal`);
395
+ panes.length = 0;
396
+ for (let i = 0; i < paneCount; i++) {
397
+ panes.push(`${sessionName}:0.${i}`);
398
+ }
399
+ } else {
400
+ // Nx1 가로 분할(상/하 스택)
401
+ for (let i = 1; i < paneCount; i++) {
402
+ tmux(`split-window -v -t ${sessionName}:0`);
403
+ }
404
+ tmux(`select-layout -t ${sessionName}:0 even-vertical`);
405
+ panes.length = 0;
406
+ for (let i = 0; i < paneCount; i++) {
407
+ panes.push(`${sessionName}:0.${i}`);
408
+ }
409
+ }
410
+
411
+ return { sessionName, panes };
412
+ }
413
+
414
+ /**
415
+ * pane 포커스 이동
416
+ * @param {string} target
417
+ * @param {object} opts
418
+ * @param {boolean} opts.zoom
419
+ */
420
+ export function focusPane(target, opts = {}) {
421
+ const { zoom = false } = opts;
422
+ tmux(`select-pane -t ${target}`);
423
+ if (zoom) {
424
+ try { tmux(`resize-pane -t ${target} -Z`); } catch {}
425
+ }
426
+ }
427
+
428
+ /**
429
+ * 팀메이트 조작 키 바인딩 설정
430
+ * - Shift+Down: 다음 팀메이트
431
+ * - Shift+Up: 이전 팀메이트
432
+ * - Shift+Left / Shift+Tab: 이전 팀메이트 대체 키
433
+ * - Shift+Right: 다음 팀메이트 대체 키
434
+ * - Escape: 현재 팀메이트 인터럽트(C-c)
435
+ * - Ctrl+T: 태스크 목록 표시
436
+ * @param {string} sessionName
437
+ * @param {object} opts
438
+ * @param {boolean} opts.inProcess
439
+ * @param {string} opts.taskListCommand
440
+ */
441
+ export function configureTeammateKeybindings(sessionName, opts = {}) {
442
+ if (detectMultiplexer() === "psmux") {
443
+ configurePsmuxKeybindings(sessionName, opts);
444
+ return;
445
+ }
446
+
447
+ const { inProcess = false, taskListCommand = "" } = opts;
448
+ const cond = `#{==:#{session_name},${sessionName}}`;
449
+
450
+ // Shift+Up이 터미널/호스트 조합에 따라 전달되지 않는 경우가 있어
451
+ // 좌/우/Shift+Tab 대체 키를 함께 바인딩한다.
452
+ const bindNext = inProcess
453
+ ? `'select-pane -t :.+ \\; resize-pane -Z'`
454
+ : `'select-pane -t :.+'`;
455
+ const bindPrev = inProcess
456
+ ? `'select-pane -t :.- \\; resize-pane -Z'`
457
+ : `'select-pane -t :.-'`;
458
+
459
+ if (inProcess) {
460
+ // 단일 뷰(zoom) 상태에서 팀메이트 순환
461
+ tmux(`bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`);
462
+ tmux(`bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`);
463
+ } else {
464
+ // 분할 뷰에서 팀메이트 순환
465
+ tmux(`bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`);
466
+ tmux(`bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`);
467
+ }
468
+
469
+ // 대체 키: 일부 환경에서 S-Up이 누락될 때 사용
470
+ tmux(`bind-key -T root -n S-Right if-shell -F '${cond}' ${bindNext} 'send-keys S-Right'`);
471
+ tmux(`bind-key -T root -n S-Left if-shell -F '${cond}' ${bindPrev} 'send-keys S-Left'`);
472
+ tmux(`bind-key -T root -n BTab if-shell -F '${cond}' ${bindPrev} 'send-keys BTab'`);
473
+
474
+ // 현재 활성 pane 인터럽트
475
+ tmux(`bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`);
476
+
477
+ // 태스크 목록 토글 (tmux 3.2+ popup 우선, 실패 시 안내 메시지)
478
+ if (taskListCommand) {
479
+ const escaped = taskListCommand.replace(/'/g, "'\\''");
480
+ try {
481
+ tmux(`bind-key -T root -n C-t if-shell -F '${cond}' \"display-popup -E '${escaped}'\" \"send-keys C-t\"`);
482
+ } catch {
483
+ tmux(`bind-key -T root -n C-t if-shell -F '${cond}' 'display-message "tfx multi tasks 명령으로 태스크 확인"' 'send-keys C-t'`);
484
+ }
485
+ }
486
+ }
487
+
488
+ /**
489
+ * tmux 세션 연결 (포그라운드 전환)
490
+ * @param {string} sessionName
491
+ */
492
+ export function attachSession(sessionName) {
493
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
494
+ throw new Error("현재 터미널은 tmux attach를 지원하지 않음 (non-TTY)");
495
+ }
496
+
497
+ if (detectMultiplexer() === "psmux") {
498
+ attachPsmuxSession(sessionName);
499
+ return;
500
+ }
501
+
502
+ const { command, args } = resolveAttachCommand(sessionName);
503
+ const r = spawnSync(command, args, {
504
+ stdio: "inherit",
505
+ timeout: 0, // 타임아웃 없음 (사용자가 detach할 때까지)
506
+ });
507
+ if ((r.status ?? 1) !== 0) {
508
+ throw new Error(`tmux attach 실패 (exit=${r.status})`);
509
+ }
510
+ }
511
+
512
+ /**
513
+ * tmux 세션 존재 확인
514
+ * @param {string} sessionName
515
+ * @returns {boolean}
516
+ */
517
+ export function sessionExists(sessionName) {
518
+ if (detectMultiplexer() === "psmux") {
519
+ return psmuxSessionExists(sessionName);
520
+ }
521
+
522
+ try {
523
+ tmux(`has-session -t ${sessionName}`, { stdio: "ignore" });
524
+ return true;
525
+ } catch {
526
+ return false;
527
+ }
528
+ }
529
+
530
+ /**
531
+ * tmux 세션 종료
532
+ * @param {string} sessionName
533
+ */
534
+ export function killSession(sessionName) {
535
+ if (detectMultiplexer() === "psmux") {
536
+ killPsmuxSession(sessionName);
537
+ return;
538
+ }
539
+
540
+ try {
541
+ tmux(`kill-session -t ${sessionName}`, { stdio: "ignore" });
542
+ } catch {
543
+ // 이미 종료된 세션 — 무시
544
+ }
545
+ }
546
+
547
+ /**
548
+ * tfx-multi- 접두사 세션 목록
549
+ * @returns {string[]}
550
+ */
551
+ export function listSessions() {
552
+ if (detectMultiplexer() === "psmux") {
553
+ return listPsmuxSessions();
554
+ }
555
+
556
+ try {
557
+ const output = tmux('list-sessions -F "#{session_name}"');
558
+ return output
559
+ .split("\n")
560
+ .filter((s) => s.startsWith("tfx-multi-"));
561
+ } catch {
562
+ return [];
563
+ }
564
+ }
565
+
566
+ /**
567
+ * 세션 attach client 수 조회
568
+ * @param {string} sessionName
569
+ * @returns {number|null}
570
+ */
571
+ export function getSessionAttachedCount(sessionName) {
572
+ if (detectMultiplexer() === "psmux") {
573
+ return getPsmuxSessionAttachedCount(sessionName);
574
+ }
575
+
576
+ try {
577
+ const output = tmux('list-sessions -F "#{session_name} #{session_attached}"');
578
+ const line = output
579
+ .split("\n")
580
+ .find((l) => l.startsWith(`${sessionName} `));
581
+ if (!line) return null;
582
+ const n = parseInt(line.split(" ")[1], 10);
583
+ return Number.isFinite(n) ? n : null;
584
+ } catch {
585
+ return null;
586
+ }
587
+ }
588
+
589
+ /**
590
+ * pane 마지막 N줄 캡처
591
+ * @param {string} target — 예: tfx-multi-abc:0.1
592
+ * @param {number} lines — 캡처할 줄 수 (기본 5)
593
+ * @returns {string}
594
+ */
595
+ export function capturePaneOutput(target, lines = 5) {
596
+ if (detectMultiplexer() === "psmux") {
597
+ return capturePsmuxPane(target, lines);
598
+ }
599
+
600
+ try {
601
+ // -l 플래그는 일부 tmux 빌드(MSYS2)에서 미지원 → 전체 캡처 후 JS에서 절삭
602
+ const full = tmux(`capture-pane -t ${target} -p`);
603
+ const nonEmpty = full.split("\n").filter((l) => l.trim() !== "");
604
+ return nonEmpty.slice(-lines).join("\n");
605
+ } catch {
606
+ return "";
607
+ }
608
+ }