triflux 10.18.1 → 10.19.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.
@@ -206,10 +206,11 @@
206
206
  {
207
207
  "id": "ext-session-vault-start",
208
208
  "source": "session-vault",
209
+ "requires": "$HOME/Desktop/Projects/tools/session-vault",
209
210
  "matcher": "*",
210
211
  "command": "bash \"${HOME}/Desktop/Projects/tools/session-vault/scripts/start_hook.sh\"",
211
212
  "priority": 100,
212
- "enabled": true,
213
+ "enabled": false,
213
214
  "timeout": 10,
214
215
  "blocking": false,
215
216
  "description": "세션 볼트 로깅 시작"
@@ -230,10 +231,11 @@
230
231
  {
231
232
  "id": "ext-session-vault-export",
232
233
  "source": "session-vault",
234
+ "requires": "$HOME/Desktop/Projects/tools/session-vault",
233
235
  "matcher": "*",
234
236
  "command": "bash \"${HOME}/Desktop/Projects/tools/session-vault/scripts/export_hook.sh\"",
235
237
  "priority": 100,
236
- "enabled": true,
238
+ "enabled": false,
237
239
  "timeout": 30,
238
240
  "blocking": false,
239
241
  "description": "세션 트랜스크립트 내보내기"
@@ -66,9 +66,17 @@ const TIER_PRIORITY = { pro: 0, plus: 1, unknown: 2, free: 3 };
66
66
  const LEASE_TTL_MS = 30 * 60 * 1000; // 30 minutes
67
67
  const CIRCUIT_WINDOW_MS = 10 * 60_000; // 10 minutes
68
68
  const CIRCUIT_MAX_FAILURES = 3;
69
- const AUTH_BASE_PATH = join(homedir(), ".claude", "cache", "tfx-hub");
70
- const CODEX_AUTH_SOURCE_PATH = join(homedir(), ".codex", "auth.json");
71
- const STATE_PERSIST_PATH = join(AUTH_BASE_PATH, "broker-state.json");
69
+ function getAuthBasePath() {
70
+ return join(homedir(), ".claude", "cache", "tfx-hub");
71
+ }
72
+
73
+ function getCodexAuthSourcePath() {
74
+ return join(homedir(), ".codex", "auth.json");
75
+ }
76
+
77
+ function getStatePersistPath() {
78
+ return join(getAuthBasePath(), "broker-state.json");
79
+ }
72
80
  const AUTH_SYNC_LOCK_TIMEOUT_MS = 5_000;
73
81
  const AUTH_SYNC_LOCK_RETRY_MS = 25;
74
82
  const AUTH_SYNC_LOCK_STALE_MS = 30_000;
@@ -78,6 +86,8 @@ const AUTH_SYNC_SAB = new Int32Array(new SharedArrayBuffer(4));
78
86
 
79
87
  function persistState(stateMap) {
80
88
  try {
89
+ const authBasePath = getAuthBasePath();
90
+ const statePersistPath = getStatePersistPath();
81
91
  const now = Date.now();
82
92
  const entries = {};
83
93
  for (const [id, acct] of stateMap) {
@@ -96,8 +106,8 @@ function persistState(stateMap) {
96
106
  };
97
107
  }
98
108
  }
99
- mkdirSync(AUTH_BASE_PATH, { recursive: true });
100
- writeFileSync(STATE_PERSIST_PATH, JSON.stringify({ ts: now, entries }));
109
+ mkdirSync(authBasePath, { recursive: true });
110
+ writeFileSync(statePersistPath, JSON.stringify({ ts: now, entries }));
101
111
  } catch (err) {
102
112
  try {
103
113
  console.error("[account-broker] persistState failed:", err.message);
@@ -107,8 +117,9 @@ function persistState(stateMap) {
107
117
 
108
118
  function loadPersistedState() {
109
119
  try {
110
- if (!existsSync(STATE_PERSIST_PATH)) return null;
111
- return JSON.parse(readFileSync(STATE_PERSIST_PATH, "utf8"));
120
+ const statePersistPath = getStatePersistPath();
121
+ if (!existsSync(statePersistPath)) return null;
122
+ return JSON.parse(readFileSync(statePersistPath, "utf8"));
112
123
  } catch {
113
124
  return null;
114
125
  }
@@ -239,6 +250,19 @@ function withLockFile(lockPath, opts, task) {
239
250
 
240
251
  // ── AccountBroker ────────────────────────────────────────────────
241
252
 
253
+ /**
254
+ * AccountBroker는 codex/gemini 계정 풀 + lease/cooldown/circuit breaker 를 관리한다.
255
+ *
256
+ * **Resolved-at-construction 정책 (lazy/eager mix 주의)**
257
+ *
258
+ * `getAuthBasePath()` / `getCodexAuthSourcePath()` 는 호출 시점에 `homedir()` 를
259
+ * 다시 평가하는 lazy getter 다. 그러나 default 파라미터 (`_authBasePath = getAuthBasePath()`)
260
+ * 는 생성자 호출 시점에 한 번만 평가되고 결과가 `#authBasePath` 등 private field 에 박힌다.
261
+ *
262
+ * 결과: 생성자 이후 process.env.HOME 변경은 broker 가 보는 경로에 전파되지 않는다.
263
+ * 테스트에서 HOME 을 갈아끼우려면 `new AccountBroker(config, { _authBasePath, _codexAuthSourcePath })`
264
+ * 로 오버라이드를 직접 주입하거나, broker 자체를 재생성해야 한다.
265
+ */
242
266
  class AccountBroker extends EventEmitter {
243
267
  #config;
244
268
  #state; // Map<accountId, accountState>
@@ -253,8 +277,8 @@ class AccountBroker extends EventEmitter {
253
277
  config,
254
278
  {
255
279
  _skipPersistence = false,
256
- _authBasePath = AUTH_BASE_PATH,
257
- _codexAuthSourcePath = CODEX_AUTH_SOURCE_PATH,
280
+ _authBasePath = getAuthBasePath(),
281
+ _codexAuthSourcePath = getCodexAuthSourcePath(),
258
282
  _authSyncLockTimeoutMs = AUTH_SYNC_LOCK_TIMEOUT_MS,
259
283
  _authSyncLockRetryMs = AUTH_SYNC_LOCK_RETRY_MS,
260
284
  _authSyncLockStaleMs = AUTH_SYNC_LOCK_STALE_MS,
@@ -2,7 +2,7 @@
2
2
  // Phase 2: codex-adapter.mjs에서 추출한 재사용 가능 유틸리티
3
3
 
4
4
  import { execSync, spawn } from "node:child_process";
5
- import { existsSync, readFileSync } from "node:fs";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
6
 
7
7
  import { IS_WINDOWS, killProcess } from "./platform.mjs";
8
8
 
@@ -391,7 +391,18 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
391
391
  });
392
392
  }
393
393
 
394
+ const resultFileSignature = () => {
395
+ if (!resultFile) return "";
396
+ try {
397
+ const info = statSync(resultFile);
398
+ return `${info.size}:${info.mtimeMs}`;
399
+ } catch {
400
+ return "";
401
+ }
402
+ };
403
+
394
404
  let lastBytes = 0;
405
+ let lastResultFileSignature = resultFileSignature();
395
406
  let lastChange = Date.now();
396
407
  const touch = () => {
397
408
  lastChange = Date.now();
@@ -420,8 +431,14 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
420
431
  }, timeout);
421
432
  const stallTimer = setInterval(() => {
422
433
  const size = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
423
- if (size !== lastBytes) {
434
+ const currentResultFileSignature = resultFileSignature();
435
+ if (
436
+ size !== lastBytes ||
437
+ currentResultFileSignature !== lastResultFileSignature
438
+ ) {
424
439
  lastBytes = size;
440
+ lastResultFileSignature = currentResultFileSignature;
441
+ touch();
425
442
  return;
426
443
  }
427
444
  if (Date.now() - lastChange >= stallThresholdMs)
@@ -1,25 +1,9 @@
1
- import { psmuxExec } from "./psmux.mjs";
2
- import { detectMultiplexer, hasWindowsTerminal, tmuxExec } from "./session.mjs";
3
- import { createWtManager } from "./wt-manager.mjs";
4
-
5
- function sanitizeWindowTitle(value, fallback = "triflux") {
6
- const text = String(value || "")
7
- .replace(/[\r\n]+/g, " ")
8
- .trim();
9
- return text || fallback;
10
- }
1
+ import { createTerminalOpener } from "./terminal-opener.mjs";
11
2
 
12
3
  function sanitizeSessionName(value) {
13
4
  return String(value || "").replace(/[^a-zA-Z0-9_-]/g, "") || "tfx-session";
14
5
  }
15
6
 
16
- function sanitizeWorkingDirectory(value) {
17
- const text = String(value || "")
18
- .replace(/[\r\n\x00-\x1f]/g, "")
19
- .trim();
20
- return text || process.cwd();
21
- }
22
-
23
7
  export function parseWorkerNumber(value) {
24
8
  const text = String(value || "").trim();
25
9
  const workerMatch = text.match(/^worker-(\d+)$/i);
@@ -29,118 +13,43 @@ export function parseWorkerNumber(value) {
29
13
  return null;
30
14
  }
31
15
 
32
- export function decideDashboardOpenMode({
33
- openAll = false,
34
- hasWtSession = !!process.env.WT_SESSION,
35
- } = {}) {
36
- if (openAll) return hasWtSession ? "tab" : "window";
37
- return hasWtSession ? "split" : "window";
16
+ function ignoreAsyncFailure(value) {
17
+ if (value && typeof value.then === "function") void value.catch(() => {});
38
18
  }
39
19
 
40
- async function spawnWindowsTerminal(spec, opts = {}) {
41
- if (!hasWindowsTerminal()) return false;
42
-
43
- const wt = createWtManager();
44
- const {
45
- mode = "window",
46
- title = "triflux",
47
- cwd = process.cwd(),
48
- split = { orientation: "H", size: 0.5 },
49
- } = opts;
50
-
51
- const safeTitle = sanitizeWindowTitle(title);
52
- const safeCwd = sanitizeWorkingDirectory(cwd);
53
-
54
- try {
55
- if (mode === "split") {
56
- await wt.splitPane({
57
- direction: split?.orientation === "V" ? "V" : "H",
58
- size: (split?.size || 0.5) * 100,
59
- title: safeTitle,
60
- cwd: safeCwd,
61
- command: spec.args
62
- ? `${spec.command} ${spec.args.join(" ")}`
63
- : spec.command,
64
- profile: "triflux",
65
- });
66
- } else {
67
- await wt.createTab({
68
- title: safeTitle,
69
- cwd: safeCwd,
70
- command: spec.args
71
- ? `${spec.command} ${spec.args.join(" ")}`
72
- : spec.command,
73
- profile: "triflux",
74
- });
75
- }
76
- return true;
77
- } catch {
78
- return false;
79
- }
80
- }
20
+ export async function openHeadlessDashboardTarget(sessionName, opts = {}) {
21
+ const { openAll = false, cwd = process.cwd(), title } = opts;
22
+ const safeSession = sanitizeSessionName(sessionName);
23
+ const workerNumber =
24
+ opts.workerNumber ??
25
+ (opts.worker == null ? null : parseWorkerNumber(opts.worker));
81
26
 
82
- async function spawnMacTerminal(spec, opts = {}) {
83
- const mux = detectMultiplexer();
84
- if (mux === "tmux") {
85
- try {
86
- const title = sanitizeWindowTitle(opts.title);
87
- const command = spec.args
88
- ? `${spec.command} ${spec.args.join(" ")}`
89
- : spec.command;
90
- tmuxExec(`new-window -n "${title}" "${command}"`);
91
- return true;
92
- } catch {
93
- return false;
94
- }
95
- }
96
- // tmux 없으면 기본 터미널
27
+ let opener;
97
28
  try {
98
- const { exec } = await import("node:child_process");
99
- exec(`open -a Terminal`, { timeout: 5000 });
100
- return true;
29
+ const deps = opts._deps ?? {};
30
+ const openerFactory = deps.createTerminalOpener ?? createTerminalOpener;
31
+ opener = openerFactory(deps);
101
32
  } catch {
102
- return false;
33
+ return !openAll && workerNumber != null;
103
34
  }
104
- }
105
-
106
- export function openHeadlessDashboardTarget(sessionName, opts = {}) {
107
- const { worker = null, openAll = false, cwd = process.cwd(), title } = opts;
108
-
109
- const safeSession = sanitizeSessionName(sessionName);
110
- const workerNumber = worker == null ? null : parseWorkerNumber(worker);
111
35
 
112
36
  // 선택 워커 → pane focus만 (새 창 열지 않음)
113
37
  if (!openAll && workerNumber != null) {
114
38
  try {
115
- const mux = detectMultiplexer();
116
- if (mux === "psmux") {
117
- psmuxExec(["select-pane", "-t", `${safeSession}:0.${workerNumber}`]);
118
- } else if (
119
- mux === "tmux" ||
120
- mux === "wsl-tmux" ||
121
- mux === "git-bash-tmux"
122
- ) {
123
- tmuxExec(`select-pane -t ${safeSession}:0.${workerNumber}`);
124
- }
39
+ ignoreAsyncFailure(opener.focusPane(safeSession, workerNumber));
125
40
  } catch {}
126
41
  return true;
127
42
  }
128
43
 
129
44
  // 전체 열기 (Shift+Enter) → 새 창으로 세션 attach
130
- if (process.platform === "win32") {
131
- void spawnWindowsTerminal(
132
- { command: "psmux", args: ["attach-session", "-t", safeSession] },
133
- {
134
- mode: decideDashboardOpenMode({ openAll }),
135
- title: title || `▲ ${safeSession}`,
136
- cwd,
137
- },
138
- );
139
- } else {
140
- void spawnMacTerminal(
141
- { command: "tmux", args: ["attach-session", "-t", safeSession] },
142
- { title: title || `▲ ${safeSession}`, cwd },
143
- );
45
+ try {
46
+ const opened = opener.openSession(safeSession, {
47
+ title: title || `▲ ${safeSession}`,
48
+ cwd,
49
+ profile: opts.profile ?? "triflux",
50
+ });
51
+ return await opened;
52
+ } catch {
53
+ return false;
144
54
  }
145
- return true;
146
55
  }
@@ -1370,6 +1370,7 @@ export async function autoAttachTerminal(
1370
1370
  opts = {},
1371
1371
  workerCount = 2,
1372
1372
  ) {
1373
+ if (process.platform !== "win32") return false;
1373
1374
  if (!process.env.WT_SESSION) return false;
1374
1375
 
1375
1376
  const wt = createWtManager();
@@ -18,6 +18,13 @@ function escapePowerShellSingleQuoted(value) {
18
18
  return String(value ?? "").replaceAll("'", "''");
19
19
  }
20
20
 
21
+ function escapeAppleScriptString(value) {
22
+ return String(value ?? "")
23
+ .replaceAll("\\", "\\\\")
24
+ .replaceAll('"', '\\"')
25
+ .replace(/\r?\n/g, " ");
26
+ }
27
+
21
28
  function normalizeTimestamp(value) {
22
29
  if (value instanceof Date) return value.toISOString();
23
30
  if (value == null || value === "") return new Date().toISOString();
@@ -215,8 +222,8 @@ async function sendToast(event, config, deps) {
215
222
  if ((deps.platform || process.platform) === "darwin") {
216
223
  const title = formatEventTitle(event);
217
224
  const body = formatEventBody(event);
218
- const safeTitle = title.replace(/\\/g, "\\\\").replace(/'/g, "'\"'\"'");
219
- const safeBody = body.replace(/\\/g, "\\\\").replace(/'/g, "'\"'\"'");
225
+ const safeTitle = escapeAppleScriptString(title);
226
+ const safeBody = escapeAppleScriptString(body);
220
227
  try {
221
228
  await execFileAsync(
222
229
  "osascript",
@@ -60,9 +60,25 @@ export function createPsmuxRuntime(adapter = defaultPsmuxAdapter) {
60
60
  // tmux 어댑터
61
61
  // ---------------------------------------------------------------------------
62
62
 
63
+ // tmux 세션 이름은 tmuxExec 명령 문자열에 직접 보간되므로 shell 메타문자가 들어오면 위험.
64
+ // 현 호출처는 모두 safe identifier (slug/uuid 류) 이지만 어댑터 export 가 contract 를
65
+ // 강제하지 않아 외부 호출자가 임의 문자열을 주입할 수 있다 → 보수적으로 검증.
66
+ const TMUX_SAFE_SESSION_NAME = /^[A-Za-z0-9_.:-]+$/;
67
+
68
+ function assertSafeTmuxSessionName(sessionName) {
69
+ const value = String(sessionName ?? "");
70
+ if (!TMUX_SAFE_SESSION_NAME.test(value)) {
71
+ throw new Error(
72
+ `Unsafe tmux session name: ${JSON.stringify(value)} (allowed: A-Za-z0-9_.:-)`,
73
+ );
74
+ }
75
+ return value;
76
+ }
77
+
63
78
  function tmuxSessionExists(sessionName) {
79
+ const safe = assertSafeTmuxSessionName(sessionName);
64
80
  try {
65
- tmuxExec(`has-session -t ${sessionName}`);
81
+ tmuxExec(`has-session -t ${safe}`);
66
82
  return true;
67
83
  } catch {
68
84
  return false;
@@ -70,12 +86,25 @@ function tmuxSessionExists(sessionName) {
70
86
  }
71
87
 
72
88
  function createTmuxSession(sessionName, opts = {}) {
73
- tmuxExec(`new-session -d -s ${sessionName} -x 220 -y 55`);
89
+ const safe = assertSafeTmuxSessionName(sessionName);
90
+ tmuxExec(`new-session -d -s ${safe} -x 220 -y 55`);
91
+ }
92
+
93
+ function sendPromptToTmuxSession(sessionName, prompt) {
94
+ const safe = assertSafeTmuxSessionName(sessionName);
95
+ const quotedPrompt = String(prompt).replace(/'/g, "'\\''");
96
+ tmuxExec(`send-keys -t ${safe}:0.0 '${quotedPrompt}' Enter`);
74
97
  }
75
98
 
76
99
  function killTmuxSessionByName(sessionName) {
100
+ let safe;
77
101
  try {
78
- tmuxExec(`kill-session -t ${sessionName}`);
102
+ safe = assertSafeTmuxSessionName(sessionName);
103
+ } catch {
104
+ return;
105
+ }
106
+ try {
107
+ tmuxExec(`kill-session -t ${safe}`);
79
108
  } catch {
80
109
  // 이미 종료된 세션 — 무시
81
110
  }
@@ -83,6 +112,7 @@ function killTmuxSessionByName(sessionName) {
83
112
 
84
113
  const defaultTmuxAdapter = {
85
114
  createSession: createTmuxSession,
115
+ sendPrompt: sendPromptToTmuxSession,
86
116
  killSession: killTmuxSessionByName,
87
117
  hasSession: tmuxSessionExists,
88
118
  };
@@ -90,28 +120,55 @@ const defaultTmuxAdapter = {
90
120
  /**
91
121
  * @param {{
92
122
  * createSession: typeof createTmuxSession,
123
+ * sendPrompt: typeof sendPromptToTmuxSession,
93
124
  * killSession: typeof killTmuxSessionByName,
94
125
  * hasSession: typeof tmuxSessionExists,
95
- * }} [adapter]
96
- * @returns {TeamRuntime & { name: "tmux" }}
126
+ * }} [opts.adapter]
127
+ * @param {string} [opts.sessionName]
128
+ * @returns {{
129
+ * name: "tmux",
130
+ * kind: "tmux",
131
+ * sessionName: string,
132
+ * createSession: (opts?: object) => unknown,
133
+ * sendPrompt: (prompt: string) => unknown,
134
+ * start: (sessionName: string, opts?: object) => unknown,
135
+ * stop: (sessionName: string) => void,
136
+ * isAlive: (sessionName: string) => boolean,
137
+ * getStatus: (sessionName: string) => RuntimeStatus,
138
+ * }}
97
139
  */
98
- export function createTmuxRuntime(adapter = defaultTmuxAdapter) {
140
+ export function createTmuxRuntime(opts = {}) {
141
+ const { adapter = defaultTmuxAdapter, sessionName = "" } = opts;
142
+
99
143
  return {
100
144
  name: "tmux",
101
- start(sessionName, opts = {}) {
102
- return adapter.createSession(sessionName, opts);
145
+ kind: "tmux",
146
+ sessionName,
147
+ // start() 와 동일하게 첫 인자로 sessionName override 를 받는다.
148
+ // 첫 인자가 string 이 아니면 (옵션 객체 등) closure sessionName 을 사용하는 legacy 형태로 폴백.
149
+ createSession(sessionNameArg, opts) {
150
+ if (typeof sessionNameArg === "string") {
151
+ return adapter.createSession(sessionNameArg, opts ?? {});
152
+ }
153
+ return adapter.createSession(sessionName, sessionNameArg ?? {});
103
154
  },
104
- stop(sessionName) {
105
- adapter.killSession(sessionName);
155
+ sendPrompt(prompt, sessionNameArg = sessionName) {
156
+ return adapter.sendPrompt(sessionNameArg, prompt);
106
157
  },
107
- isAlive(sessionName) {
108
- return adapter.hasSession(sessionName);
158
+ start(sessionNameArg = sessionName, opts = {}) {
159
+ return adapter.createSession(sessionNameArg, opts);
109
160
  },
110
- getStatus(sessionName) {
161
+ stop(sessionNameArg = sessionName) {
162
+ adapter.killSession(sessionNameArg);
163
+ },
164
+ isAlive(sessionNameArg = sessionName) {
165
+ return adapter.hasSession(sessionNameArg);
166
+ },
167
+ getStatus(sessionNameArg = sessionName) {
111
168
  return {
112
169
  name: "tmux",
113
- sessionName,
114
- alive: adapter.hasSession(sessionName),
170
+ sessionName: sessionNameArg,
171
+ alive: adapter.hasSession(sessionNameArg),
115
172
  };
116
173
  },
117
174
  };
@@ -119,9 +176,10 @@ export function createTmuxRuntime(adapter = defaultTmuxAdapter) {
119
176
 
120
177
  /**
121
178
  * @param {string} mode
179
+ * @param {object} [opts]
122
180
  * @returns {TeamRuntime & { name: string }}
123
181
  */
124
- export function createRuntime(mode) {
182
+ export function createRuntime(mode, opts = {}) {
125
183
  const normalizedMode = String(mode || "")
126
184
  .trim()
127
185
  .toLowerCase();
@@ -131,7 +189,7 @@ export function createRuntime(mode) {
131
189
  }
132
190
 
133
191
  if (normalizedMode === "tmux") {
134
- return createTmuxRuntime();
192
+ return createTmuxRuntime(opts);
135
193
  }
136
194
 
137
195
  if (normalizedMode === "native" || normalizedMode === "wt") {
@@ -0,0 +1,178 @@
1
+ import { exec as defaultExec } from "node:child_process";
2
+ import { platform as osPlatform } from "node:os";
3
+ import { psmuxExec as defaultPsmuxExec } from "./psmux.mjs";
4
+ import { tmuxExec as defaultTmuxExec, detectMultiplexer } from "./session.mjs";
5
+ import { createWtManager as defaultCreateWtManager } from "./wt-manager.mjs";
6
+
7
+ const TMUX_LIKE_MUXES = new Set(["tmux", "wsl-tmux", "git-bash-tmux"]);
8
+
9
+ export function sanitizeTerminalTitle(value, fallback = "triflux") {
10
+ const title = String(value ?? "")
11
+ .replace(/\s+/gu, " ")
12
+ .trim();
13
+ return title || fallback;
14
+ }
15
+
16
+ export function shellQuote(value) {
17
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
18
+ }
19
+
20
+ export function powershellSingleQuote(value) {
21
+ return `'${String(value).replace(/'/g, "''")}'`;
22
+ }
23
+
24
+ export function buildCommandString({ cwd, command }) {
25
+ const commandString = String(command ?? "");
26
+ if (!cwd) return commandString;
27
+ return `cd ${shellQuote(cwd)} && ${commandString}`;
28
+ }
29
+
30
+ function resolvePlatform(deps) {
31
+ if (typeof deps.platform === "function") return deps.platform();
32
+ return deps.platform || osPlatform();
33
+ }
34
+
35
+ function resolveMux(deps) {
36
+ if (Object.hasOwn(deps, "mux")) return deps.mux;
37
+ if (typeof deps.detectMultiplexer === "function")
38
+ return deps.detectMultiplexer();
39
+ return detectMultiplexer();
40
+ }
41
+
42
+ function createTabSpec(spec, title) {
43
+ return {
44
+ title,
45
+ command: String(spec.command ?? ""),
46
+ cwd: spec.cwd,
47
+ profile: spec.profile ?? "triflux",
48
+ };
49
+ }
50
+
51
+ function execOpenTerminal(execFn) {
52
+ return new Promise((resolve) => {
53
+ execFn("open -a Terminal", { timeout: 5000 }, (error) => {
54
+ resolve(!error);
55
+ });
56
+ });
57
+ }
58
+
59
+ // psmux는 Windows에서 wt-manager 경유 (createTab/splitPane 등) — tmux 호환 unix 명령군과 다른 표면.
60
+ // 비-Windows(macOS/Linux) 환경에서는 psmux가 tmux-compatible new-window/select-pane 명령을 받아주므로
61
+ // tmux 어댑터와 동일하게 취급한다.
62
+ function isTmuxLikeMux(mux, platform) {
63
+ return TMUX_LIKE_MUXES.has(mux) || (platform !== "win32" && mux === "psmux");
64
+ }
65
+
66
+ function shellCommandName(value) {
67
+ const command = String(value);
68
+ return /^[A-Za-z0-9_./:-]+$/u.test(command) ? command : shellQuote(command);
69
+ }
70
+
71
+ function buildAttachCommand(mux, sessionName) {
72
+ if (mux === "psmux") {
73
+ return `${shellCommandName(process.env.PSMUX_BIN || "psmux")} attach-session -t ${shellQuote(sessionName)}`;
74
+ }
75
+ return `tmux attach-session -t ${shellQuote(sessionName)}`;
76
+ }
77
+
78
+ // wt-manager.createTab은 (a) undefined/void 반환 — legacy success 의미 (b) {success:true|false, ...} 객체 반환의 두 형태가 공존.
79
+ // `result?.success !== false`는 undefined/null/{}을 모두 success로 취급하고 명시적 {success:false}만 실패로 본다.
80
+ function wtResultSucceeded(result) {
81
+ return result?.success !== false;
82
+ }
83
+
84
+ export function createTerminalOpener(deps = {}) {
85
+ const platform = resolvePlatform(deps);
86
+ const tmuxExec = deps.tmuxExec || defaultTmuxExec;
87
+ const psmuxExec = deps.psmuxExec || defaultPsmuxExec;
88
+ const exec = deps.exec || defaultExec;
89
+ const createWtManager = deps.createWtManager || defaultCreateWtManager;
90
+
91
+ async function openCommand(spec = {}) {
92
+ const title = sanitizeTerminalTitle(spec.title);
93
+
94
+ if (platform === "win32") {
95
+ const wt = createWtManager();
96
+ try {
97
+ return wtResultSucceeded(
98
+ await wt.createTab(createTabSpec(spec, title)),
99
+ );
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ const mux = resolveMux(deps);
106
+ if (isTmuxLikeMux(mux, platform)) {
107
+ tmuxExec(
108
+ `new-window -n ${shellQuote(title)} ${shellQuote(
109
+ buildCommandString(spec),
110
+ )}`,
111
+ );
112
+ return true;
113
+ }
114
+
115
+ if (platform === "darwin") {
116
+ return execOpenTerminal(exec);
117
+ }
118
+
119
+ return false;
120
+ }
121
+
122
+ async function openSession(sessionName, opts = {}) {
123
+ if (platform === "win32") {
124
+ const wt = createWtManager();
125
+ try {
126
+ return wtResultSucceeded(
127
+ await wt.createTab({
128
+ title: sanitizeTerminalTitle(opts.title ?? sessionName),
129
+ command: `psmux attach-session -t ${powershellSingleQuote(sessionName)}`,
130
+ cwd: opts.cwd,
131
+ profile: opts.profile ?? "triflux",
132
+ }),
133
+ );
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ const mux = resolveMux(deps);
140
+ if (isTmuxLikeMux(mux, platform)) {
141
+ tmuxExec(
142
+ `new-window -n ${shellQuote(opts.title ?? sessionName)} ${shellQuote(
143
+ buildAttachCommand(mux, sessionName),
144
+ )}`,
145
+ );
146
+ return true;
147
+ }
148
+
149
+ return false;
150
+ }
151
+
152
+ function focusPane(sessionName, workerNumber) {
153
+ const target = `${sessionName}:0.${workerNumber}`;
154
+ const mux = resolveMux(deps);
155
+
156
+ if (mux === "psmux") {
157
+ psmuxExec(["select-pane", "-t", target]);
158
+ return true;
159
+ }
160
+
161
+ if (isTmuxLikeMux(mux, platform)) {
162
+ tmuxExec(`select-pane -t ${shellQuote(target)}`);
163
+ return true;
164
+ }
165
+
166
+ return false;
167
+ }
168
+
169
+ return { openCommand, openSession, focusPane };
170
+ }
171
+
172
+ export function openSessionTarget(sessionName, opts = {}) {
173
+ return createTerminalOpener(opts._deps).openSession(sessionName, opts);
174
+ }
175
+
176
+ export function focusSessionPane(sessionName, workerNumber, opts = {}) {
177
+ return createTerminalOpener(opts._deps).focusPane(sessionName, workerNumber);
178
+ }
@@ -4,7 +4,7 @@
4
4
  // Remote support: host option → SSH-based git operations via remote-session.mjs.
5
5
 
6
6
  import { execFile } from "node:child_process";
7
- import { access, mkdir, readdir, rm } from "node:fs/promises";
7
+ import { access, mkdir, readdir, realpath, rm } from "node:fs/promises";
8
8
  import { join, normalize, relative, resolve } from "node:path";
9
9
  import { remoteGit, validateHost } from "./remote-session.mjs";
10
10
 
@@ -117,6 +117,14 @@ function normPath(p) {
117
117
  return normalize(p).replace(/\\/g, "/");
118
118
  }
119
119
 
120
+ async function normExistingPath(p) {
121
+ try {
122
+ return normPath(await realpath(p));
123
+ } catch {
124
+ return normPath(p);
125
+ }
126
+ }
127
+
120
128
  function resolveCleanupTarget(worktreePath, rootDir) {
121
129
  const resolvedRoot = resolve(rootDir);
122
130
  const resolvedWorktree = resolve(worktreePath);
@@ -562,10 +570,12 @@ export async function pruneOrphanWorktrees({ rootDir = process.cwd() } = {}) {
562
570
  try {
563
571
  const raw = await git(["worktree", "list", "--porcelain"], rootDir);
564
572
  registeredPaths = new Set(
565
- raw
566
- .split("\n")
567
- .filter((l) => l.startsWith("worktree "))
568
- .map((l) => normPath(l.slice("worktree ".length))),
573
+ await Promise.all(
574
+ raw
575
+ .split("\n")
576
+ .filter((l) => l.startsWith("worktree "))
577
+ .map((l) => normExistingPath(l.slice("worktree ".length))),
578
+ ),
569
579
  );
570
580
  } catch {
571
581
  return removed; // git worktree list failed → don't remove anything
@@ -573,7 +583,7 @@ export async function pruneOrphanWorktrees({ rootDir = process.cwd() } = {}) {
573
583
 
574
584
  for (const dir of wtDirs) {
575
585
  const fullPath = resolve(swarmDir, dir);
576
- const normalized = normPath(fullPath);
586
+ const normalized = await normExistingPath(fullPath);
577
587
  if (!registeredPaths.has(normalized)) {
578
588
  try {
579
589
  await stopFsmonitorDaemon(fullPath).catch(() => null);
@@ -170,12 +170,34 @@ function atomicWriteSync(filePath, data) {
170
170
  * @param {number} [opts.tabCreateDelayMs=500]
171
171
  * @param {object} [opts.deps] — 테스트용 의존성 주입
172
172
  */
173
+ function createNonWindowsStubManager() {
174
+ const asyncFalse = async () => false;
175
+ return Object.freeze({
176
+ ensureWtProfile: () => {},
177
+ createTab: asyncFalse,
178
+ renameTab: asyncFalse,
179
+ closeTab: asyncFalse,
180
+ listTabs: async () => [],
181
+ closeStale: async () => 0,
182
+ createSession: asyncFalse,
183
+ splitPane: asyncFalse,
184
+ applySplitLayout: asyncFalse,
185
+ getEnvironmentInfo: () => ({
186
+ platform: process.platform,
187
+ hasWindowsTerminal: false,
188
+ hasWt: false,
189
+ isWindowsTerminalSession: false,
190
+ }),
191
+ getTabCount: () => 0,
192
+ });
193
+ }
194
+
173
195
  export function createWtManager(opts = {}) {
174
196
  const deps = opts.deps || {};
175
197
  const platform = deps.platform || osPlatform;
176
198
 
177
199
  if (platform() !== "win32") {
178
- throw new Error("wt-manager.mjs is Windows-only");
200
+ return createNonWindowsStubManager();
179
201
  }
180
202
 
181
203
  const now = deps.now || Date.now;
@@ -16,6 +16,7 @@ const REQUIRED_TOOLS = ["codex", "codex-reply"];
16
16
  export { CODEX_MCP_EXECUTION_EXIT_CODE, CODEX_MCP_TRANSPORT_EXIT_CODE };
17
17
  export const DEFAULT_CODEX_MCP_TIMEOUT_MS = 10 * 60 * 1000;
18
18
  export const DEFAULT_CODEX_MCP_BOOTSTRAP_TIMEOUT_MS = 120 * 1000;
19
+ export const DEFAULT_CODEX_MCP_SHUTDOWN_TIMEOUT_MS = 2_000;
19
20
 
20
21
  /**
21
22
  * Codex MCP transport/bootstrap 계층 오류
@@ -40,6 +41,34 @@ function cloneEnv(env = process.env) {
40
41
  );
41
42
  }
42
43
 
44
+ async function closeWithin(label, closeFn, timeoutMs) {
45
+ let timer = null;
46
+ try {
47
+ const timeout = new Promise((resolve) => {
48
+ timer = setTimeout(() => {
49
+ resolve({ timedOut: true });
50
+ }, timeoutMs);
51
+ });
52
+ const closed = Promise.resolve()
53
+ .then(closeFn)
54
+ .then(() => ({ timedOut: false }));
55
+ const result = await Promise.race([closed, timeout]);
56
+ if (result.timedOut) {
57
+ console.error(
58
+ `[codex-mcp] WARNING: ${label} did not close within ${timeoutMs}ms; continuing shutdown.`,
59
+ );
60
+ }
61
+ return result;
62
+ } catch (error) {
63
+ console.error(
64
+ `[codex-mcp] WARNING: ${label} close failed: ${error instanceof Error ? error.message : String(error)}`,
65
+ );
66
+ return { timedOut: false };
67
+ } finally {
68
+ if (timer) clearTimeout(timer);
69
+ }
70
+ }
71
+
43
72
  function collectTextContent(content = []) {
44
73
  return content
45
74
  .filter((item) => item?.type === "text" && typeof item.text === "string")
@@ -388,7 +417,7 @@ export class CodexMcpWorker {
388
417
  this.ready = true;
389
418
  }
390
419
 
391
- async stop() {
420
+ async stop(options = {}) {
392
421
  this.ready = false;
393
422
  this.availableTools.clear();
394
423
 
@@ -397,10 +426,26 @@ export class CodexMcpWorker {
397
426
  this.transport = null;
398
427
  this.client = null;
399
428
 
429
+ const shutdownTimeoutMs = Number.isFinite(options.shutdownTimeoutMs)
430
+ ? options.shutdownTimeoutMs
431
+ : DEFAULT_CODEX_MCP_SHUTDOWN_TIMEOUT_MS;
432
+
433
+ let clientCloseTimedOut = false;
400
434
  if (client) {
401
- await client.close().catch(() => {});
402
- } else if (transport) {
403
- await transport.close().catch(() => {});
435
+ const result = await closeWithin(
436
+ "client.close",
437
+ () => client.close(),
438
+ shutdownTimeoutMs,
439
+ );
440
+ clientCloseTimedOut = Boolean(result.timedOut);
441
+ }
442
+
443
+ if (transport && (!client || clientCloseTimedOut)) {
444
+ await closeWithin(
445
+ "transport.close",
446
+ () => transport.close(),
447
+ shutdownTimeoutMs,
448
+ );
404
449
  }
405
450
 
406
451
  transport?.stderr?.destroy?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.18.1",
3
+ "version": "10.19.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/setup.mjs CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  writeFileSync,
19
19
  } from "fs";
20
20
  import { homedir } from "os";
21
- import { dirname, join, relative } from "path";
21
+ import { dirname, join, relative, resolve } from "path";
22
22
  import { fileURLToPath } from "url";
23
23
  import {
24
24
  ensureGlobalClaudeRoutingSection,
@@ -498,10 +498,22 @@ function extractManagedHookFilename(command) {
498
498
  return match ? match[1] : null;
499
499
  }
500
500
 
501
+ function expandRequiresPath(value) {
502
+ if (typeof value !== "string" || value.trim() === "") return null;
503
+ return resolve(
504
+ value.replace(/\$\{HOME\}/g, _TFX_HOME).replace(/\$HOME\b/g, _TFX_HOME),
505
+ );
506
+ }
507
+
508
+ function isRequiredPathAvailable(value) {
509
+ const expanded = expandRequiresPath(value);
510
+ return expanded ? existsSync(expanded) : true;
511
+ }
512
+
501
513
  /**
502
514
  * hook-registry.json에서 관리 대상 훅 목록을 플랫 배열로 반환한다.
503
515
  * @param {string} registryPath - hook-registry.json 경로
504
- * @returns {Array<{ event: string, id: string, fileName: string, matcher: string, command: string, priority: number, enabled: boolean }>}
516
+ * @returns {Array<{ event: string, id: string, fileName: string, matcher: string, command: string, priority: number, enabled: boolean, requires?: string }>}
505
517
  */
506
518
  function getManagedRegistryHooks(registryPath) {
507
519
  if (!existsSync(registryPath)) return [];
@@ -513,6 +525,7 @@ function getManagedRegistryHooks(registryPath) {
513
525
  if (!Array.isArray(hooks)) continue;
514
526
  for (const hook of hooks) {
515
527
  if (!hook.enabled) continue;
528
+ if (!isRequiredPathAvailable(hook.requires)) continue;
516
529
  const fileName = extractManagedHookFilename(hook.command);
517
530
  result.push({
518
531
  event,
@@ -522,6 +535,7 @@ function getManagedRegistryHooks(registryPath) {
522
535
  command: hook.command || "",
523
536
  priority: hook.priority ?? 100,
524
537
  enabled: hook.enabled,
538
+ requires: hook.requires,
525
539
  });
526
540
  }
527
541
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // tfx-route-worker.mjs — tfx-route.sh용 subprocess worker 러너
3
3
 
4
- import { existsSync, readFileSync } from "node:fs";
4
+ import { existsSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
 
@@ -133,8 +133,18 @@ function parseJsonArray(raw, label) {
133
133
  }
134
134
  }
135
135
 
136
- function readPromptFromStdin() {
137
- return readFileSync(0, "utf8");
136
+ async function readPromptFromStdin() {
137
+ // Stream-based async read avoids EAGAIN race on Node v25 + background pipe stdin.
138
+ // readFileSync(0, "utf8") raised EAGAIN when stdin pipe was non-blocking and
139
+ // data hadn't fully arrived (Node v25 timing change vs v22), causing the
140
+ // tfx-route.sh wrapper to fall back to claude-native silently.
141
+ if (process.stdin.isTTY) return "";
142
+ process.stdin.setEncoding("utf8");
143
+ let data = "";
144
+ for await (const chunk of process.stdin) {
145
+ data += chunk;
146
+ }
147
+ return data;
138
148
  }
139
149
 
140
150
  function resolveDefaultMcpConfig(cwd) {
@@ -197,7 +207,7 @@ async function runWorker(worker, type, prompt) {
197
207
  }
198
208
 
199
209
  const args = parseArgs(process.argv.slice(2));
200
- const prompt = readPromptFromStdin();
210
+ const prompt = await readPromptFromStdin();
201
211
 
202
212
  const worker = await createWorker(args.type, {
203
213
  command: args.command,
@@ -365,7 +365,8 @@ mkdir -p "$TFX_PROBE_DIR" 2>/dev/null || true
365
365
 
366
366
  estimate_expected_duration_sec() {
367
367
  local agent="${1:-}" profile="${2:-}" prompt="${3:-}"
368
- local text="${prompt,,}"
368
+ local text
369
+ text=$(printf '%s' "$prompt" | tr '[:upper:]' '[:lower:]')
369
370
  local expected=30
370
371
 
371
372
  case "$agent" in
@@ -939,9 +940,9 @@ resolve_gemini_profile() {
939
940
  }
940
941
  }
941
942
 
942
- process.stdout.write(defaults[name] || defaults.pro31);
943
+ process.stdout.write(defaults[name] || defaults[process.env.TFX_GEMINI_DEFAULT_PROFILE] || defaults.pro25);
943
944
  " "$profile" "$_GEMINI_PROFILE_CACHE" "$settings_cache" 2>/dev/null)
944
- echo "${result:-gemini-3.1-pro-preview}"
945
+ echo "${result:-gemini-2.5-pro}"
945
946
  }
946
947
 
947
948
  # ── 라우팅 테이블 ──
@@ -0,0 +1,212 @@
1
+ ---
2
+ name: tfx-wt
3
+ description: >
4
+ Windows Terminal 탭/패인 자연어 조작. 사용자가 "새 탭 열어줘", "패인 분할",
5
+ "탭 목록", "탭 닫아" 같은 한국어/영어 표현을 쓰면 wt-cli.mjs 경유로 wt-manager API 호출.
6
+ safety-guard가 wt.exe 직접 호출을 차단하므로 이 스킬이 유일한 경로다.
7
+ Use when: 새 탭, tab open, 패인, pane split, 탭 목록, 탭 닫아, wt 탭, wt 패인
8
+ triggers:
9
+ - tfx-wt
10
+ - wt-tab-route
11
+ - wt-tab-rename
12
+ - wt-tab-list
13
+ - wt-tab-close
14
+ argument-hint: "<create-tab|split-pane|layout|list|close|close-stale|rename> [json-opts]"
15
+ ---
16
+
17
+ # tfx-wt — Windows Terminal 자연어 조작
18
+
19
+ > **ARGUMENTS 처리**: 이 스킬이 `ARGUMENTS: <값>`과 함께 호출되면, 해당 값을 사용자 입력으로 취급한다.
20
+ > ARGUMENTS가 비어있거나 없으면 사용자에게 의도 확인 후 적절한 action 으로 라우팅한다.
21
+
22
+ > **인프라**: keyword-rules.json 의 `wt-tab-*` 4 규칙이 이 스킬로 라우팅한다.
23
+ > 사용자는 보통 자연어로 호출 ("탭 열어줘") 하므로 의도 → action 매핑이 첫 단계.
24
+
25
+ ## OS 정책
26
+
27
+ 이 스킬은 **Windows 전용**. macOS/Linux 의 탭/패인 자연어 라우팅은 본 스킬 범위 외다.
28
+
29
+ | OS | 동작 | 라우팅 |
30
+ |----|------|--------|
31
+ | Windows | `wt.exe` 실제 호출 (wt-manager 경유). safety-guard 차단 우회 | **tfx-wt 가 담당** |
32
+ | macOS / Linux | `createWtManager()` 가 stub 반환 (PR #241). 모든 action no-op | **`terminal-opener.mjs` 가 tmux 경로로 담당** (PR #236). tfx-wt 는 사용자에게 안내만 |
33
+
34
+ ### 분리 정신 (PR #236, #241)
35
+
36
+ | 레이어 | 역할 |
37
+ |--------|------|
38
+ | `hub/team/wt-manager.mjs` | Windows-only (non-Windows 에서 stub). tfx-wt 가 사용 |
39
+ | `hub/team/terminal-opener.mjs` | Cross-platform abstraction. Windows→wt-manager, macOS/Linux→tmux |
40
+ | `hub/team/runtime-strategy.mjs` | `psmux` (Windows) 와 `tmux` (macOS/Linux) runtime identity 분리 유지 |
41
+
42
+ macOS 사용자가 "탭 열어"라고 했는데 본 스킬로 들어오면 잘못된 라우팅. 사용자에게 다음을 안내한 뒤 종료:
43
+ - "Windows Terminal 은 macOS 에 없습니다."
44
+ - "macOS 에서 새 탭/패인 자동 생성이 필요하면 `terminal-opener.mjs` 의 tmux 경로를 사용하세요."
45
+ - "오케스트레이션 (swarm worker 배치, dashboard) 은 자동으로 OS 분기됩니다."
46
+
47
+ `wt-manager.mjs` 자체가 OS-aware 이므로 스킬 코드에서 platform 분기 불필요.
48
+
49
+ ## 의도 → action 매핑
50
+
51
+ | 자연어 입력 | action | 예시 |
52
+ |------------|--------|------|
53
+ | 새 탭, tab open, 탭 추가/생성/열어/띄워, 터미널 탭 | `create-tab` | "새 탭 열어줘" |
54
+ | 패인 분할, pane split, 화면 나눠 | `split-pane` | "패인 가로로 분할" |
55
+ | 탭 + 여러 개 동시 배치, layout, dashboard | `layout` | "워커 3개 가로로 배치" |
56
+ | 탭 목록, 탭 리스트, 열린 탭, 현재 탭 | `list` | "지금 탭 뭐있어" |
57
+ | 탭 닫아, 탭 종료, tab close, 탭 정리 | `close` | "worker 탭 닫아" |
58
+ | 오래된 탭 정리, stale 탭, idle 탭 정리 | `close-stale` | "1시간 넘은 탭 정리해" |
59
+ | 탭 이름 변경, 탭 제목 바꿔, rename | `rename` | "이 탭 이름 backend 로 바꿔" |
60
+
61
+ ## wt + psmux 통합 패턴 (Windows 기본 사용 흐름)
62
+
63
+ triflux 의 WT `triflux` 프로파일은 **commandline = psmux** (wt-manager:319 의 `ensureWtProfile`). 즉 wt 의 새 탭/패인 = wt 가 컨테이너, **그 안에서 psmux 가 멀티플렉서로 도는 구조**. swarm worker, dashboard, 장기 세션 모두 이 형태.
64
+
65
+ | 레이어 | 역할 |
66
+ |--------|------|
67
+ | wt | 윈도우 / 탭 / 패인 컨테이너 |
68
+ | psmux (탭 내부 default 셸) | 세션 호스팅. detach → wt 닫혀도 살아있음 → 재첨부 가능 |
69
+
70
+ 전형적인 `wt sp` 한 줄 (`.claude/rules/tfx-psmux.md` RULE 5-3 인용):
71
+
72
+ ```bash
73
+ wt.exe -w 0 sp -H -p triflux --title "worker" psmux attach-session -t SESSION
74
+ ```
75
+
76
+ 이걸 본 스킬에서 호출하려면 `split-pane` 또는 `create-tab` 의 `command` 에 `psmux attach-session -t <session>` 을 넣는다. 다중 worker 동시 배치는 `layout`.
77
+
78
+ ### Swarm worker 다중 배치 (전형)
79
+
80
+ ```bash
81
+ node scripts/wt-cli.mjs layout '[
82
+ {"title":"w1","command":"psmux attach-session -t s1","direction":"H"},
83
+ {"title":"w2","command":"psmux attach-session -t s2","direction":"V"},
84
+ {"title":"w3","command":"psmux attach-session -t s3","direction":"V"}
85
+ ]'
86
+ ```
87
+
88
+ 각 패인이 미리 띄워둔 psmux session (s1/s2/s3) 에 attach. wt 패인 닫혀도 psmux session 은 살아있어 재시작 후 reattach 가능.
89
+
90
+ ### 일반 명령 실행 (psmux 통합 불필요한 경우)
91
+
92
+ 장기 세션이 아니고 단발 명령이면 psmux 우회 가능:
93
+
94
+ ```bash
95
+ node scripts/wt-cli.mjs create-tab '{"title":"build","command":"npm run build","profile":"triflux"}'
96
+ ```
97
+
98
+ `profile: "triflux"` 가 자동으로 psmux 를 셸로 띄우긴 하지만, `command` 가 즉시 종료되면 `closeOnExit: "always"` (wt-manager:331) 로 패인도 닫힘.
99
+
100
+ ## 실행
101
+
102
+ `scripts/wt-cli.mjs` 가 `wt-manager` 의 thin wrapper. json-opts 는 단일 인자로 전달.
103
+
104
+ ```bash
105
+ node scripts/wt-cli.mjs <action> '<json-opts>'
106
+ ```
107
+
108
+ ### create-tab — 새 탭 생성
109
+
110
+ ```bash
111
+ node scripts/wt-cli.mjs create-tab '{"title":"backend","command":"npm run dev","profile":"triflux","cwd":"C:\\path"}'
112
+ ```
113
+
114
+ | 옵션 | 필수 | 기본 |
115
+ |------|------|------|
116
+ | `title` | 권장 | 자동 |
117
+ | `command` | 권장 | shell |
118
+ | `profile` | 선택 | "triflux" |
119
+ | `cwd` | 선택 | 현재 |
120
+
121
+ 반환: `{ success, title, id? }`
122
+
123
+ ### split-pane — 패인 분할
124
+
125
+ ```bash
126
+ node scripts/wt-cli.mjs split-pane '{"direction":"H","title":"logs","command":"tail -f log"}'
127
+ ```
128
+
129
+ | 옵션 | 값 | 의미 |
130
+ |------|-----|------|
131
+ | `direction` | `"H"` / `"V"` | 가로 / 세로 |
132
+ | `title` | string | 패인 제목 |
133
+ | `command` | string | 실행할 명령 |
134
+
135
+ ### layout — 다중 패인 배치
136
+
137
+ ```bash
138
+ node scripts/wt-cli.mjs layout '[{"title":"w1","command":"...","direction":"H"},{"title":"w2","command":"...","direction":"V"}]'
139
+ ```
140
+
141
+ 또는 객체 형태: `'{"panes":[...]}'`. dashboard / swarm worker 배치에 사용.
142
+
143
+ ### list — 탭 목록
144
+
145
+ ```bash
146
+ node scripts/wt-cli.mjs list
147
+ ```
148
+
149
+ 반환: `[{ title, id, ... }]`. macOS/Linux 에서는 `[]`.
150
+
151
+ ### close — 탭 닫기
152
+
153
+ ```bash
154
+ node scripts/wt-cli.mjs close '{"title":"worker-1"}'
155
+ ```
156
+
157
+ `title` 은 정확 일치 또는 prefix. **단일 탭** 닫기.
158
+
159
+ ### close-stale — 오래된 탭 정리
160
+
161
+ ```bash
162
+ node scripts/wt-cli.mjs close-stale '{"olderThanMs":3600000,"titlePattern":"worker-"}'
163
+ ```
164
+
165
+ | 옵션 | 의미 |
166
+ |------|------|
167
+ | `olderThanMs` | 이 ms 이상 idle 탭만 |
168
+ | `titlePattern` | title prefix 또는 regex 매칭만 |
169
+ | `dryRun` | true 시 닫을 후보만 반환 |
170
+
171
+ 반환: `{ success, closed: ["title-1", ...] }`
172
+
173
+ ### rename — 탭 이름 변경
174
+
175
+ ```bash
176
+ node scripts/wt-cli.mjs rename '{"title":"old","newTitle":"backend"}'
177
+ ```
178
+
179
+ ## CLAUDE.md 규칙 준수
180
+
181
+ CLAUDE.md `psmux-wt > wt.exe → wt-manager 경유` 섹션:
182
+
183
+ | 차단되는 직접 호출 | 이 스킬 경유 |
184
+ |-------------------|-------------|
185
+ | `wt.exe new-tab ...` | `create-tab` |
186
+ | `wt.exe split-pane ...` | `split-pane` |
187
+ | `wt.exe -w 0 sp -H ...` | `layout` (다중) 또는 `split-pane` (단일) |
188
+ | `Start-Process wt.exe ...` (PowerShell) | `create-tab` |
189
+
190
+ safety-guard 가 위 4 패턴을 모두 차단하므로 자연어 라우팅 → `tfx-wt` → `wt-cli.mjs` 가 유일한 안전 경로.
191
+
192
+ ## 안티패턴
193
+
194
+ | 패턴 | 문제 | 대체 |
195
+ |------|------|------|
196
+ | `Bash("wt.exe new-tab ...")` | safety-guard 차단 | `node scripts/wt-cli.mjs create-tab '{...}'` |
197
+ | macOS 에서 "탭 열어" 받고 강제 실행 시도 | wt-manager stub 반환 → 효과 없음 + 혼란 | "Windows Terminal 미설치 환경 — no-op" 명시 후 종료 |
198
+ | `wt.exe -w 0 nt` (새 창) | CLAUDE.md tfx-psmux.md RULE 5-3 금지 | `sp -H` / `sp -V` (split) 사용 |
199
+ | 다중 패인을 `create-tab` N회 호출 | 새 창 N개 띄우기 | `layout` 한 번 호출 |
200
+
201
+ ## 관련
202
+
203
+ - `scripts/wt-cli.mjs` — CLI wrapper (이 스킬이 호출)
204
+ - `hub/team/wt-manager.mjs` — 실제 구현체 (`createTab`, `splitPane`, `applySplitLayout`, `closeTab`, `closeStale`, `renameTab`, `listTabs`)
205
+ - CLAUDE.md `psmux-wt` 섹션 — wt-manager API 가이드
206
+ - `.claude/rules/tfx-psmux.md` — psmux/WT 정책 RULE 5/6
207
+ - `hooks/keyword-rules.json` `wt-tab-*` 4 규칙 — 자연어 라우팅 진입점
208
+
209
+ ## 메모
210
+
211
+ - PR #241 (5/8) 이후 macOS/Linux 에서도 안전. wt-manager 가 stub 반환하므로 crash 없이 no-op.
212
+ - 4/11 b313c648 커밋이 keyword-rules + wt-cli.mjs 만 추가하고 SKILL.md 를 빠뜨려 한 달간 dead 라우팅. issue #248 로 발견 후 본 스킬 추가.