triflux 3.2.0-dev.8 → 3.2.0-dev.9

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