triflux 10.18.2 → 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.
- package/hub/account-broker.mjs +33 -9
- package/hub/cli-adapter-base.mjs +19 -2
- package/hub/team/dashboard-open.mjs +24 -115
- package/hub/team/headless.mjs +1 -0
- package/hub/team/notify.mjs +9 -2
- package/hub/team/runtime-strategy.mjs +75 -17
- package/hub/team/terminal-opener.mjs +178 -0
- package/hub/team/worktree-lifecycle.mjs +16 -6
- package/hub/team/wt-manager.mjs +23 -1
- package/hub/workers/codex-mcp.mjs +49 -4
- package/package.json +1 -1
- package/scripts/tfx-route-worker.mjs +14 -4
- package/scripts/tfx-route.sh +2 -2
- package/skills/tfx-wt/SKILL.md +212 -0
package/hub/account-broker.mjs
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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(
|
|
100
|
-
writeFileSync(
|
|
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
|
-
|
|
111
|
-
|
|
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 =
|
|
257
|
-
_codexAuthSourcePath =
|
|
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,
|
package/hub/cli-adapter-base.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
29
|
+
const deps = opts._deps ?? {};
|
|
30
|
+
const openerFactory = deps.createTerminalOpener ?? createTerminalOpener;
|
|
31
|
+
opener = openerFactory(deps);
|
|
101
32
|
} catch {
|
|
102
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
}
|
package/hub/team/headless.mjs
CHANGED
package/hub/team/notify.mjs
CHANGED
|
@@ -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
|
|
219
|
-
const safeBody = body
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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(
|
|
140
|
+
export function createTmuxRuntime(opts = {}) {
|
|
141
|
+
const { adapter = defaultTmuxAdapter, sessionName = "" } = opts;
|
|
142
|
+
|
|
99
143
|
return {
|
|
100
144
|
name: "tmux",
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
adapter.
|
|
155
|
+
sendPrompt(prompt, sessionNameArg = sessionName) {
|
|
156
|
+
return adapter.sendPrompt(sessionNameArg, prompt);
|
|
106
157
|
},
|
|
107
|
-
|
|
108
|
-
return adapter.
|
|
158
|
+
start(sessionNameArg = sessionName, opts = {}) {
|
|
159
|
+
return adapter.createSession(sessionNameArg, opts);
|
|
109
160
|
},
|
|
110
|
-
|
|
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(
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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 =
|
|
586
|
+
const normalized = await normExistingPath(fullPath);
|
|
577
587
|
if (!registeredPaths.has(normalized)) {
|
|
578
588
|
try {
|
|
579
589
|
await stopFsmonitorDaemon(fullPath).catch(() => null);
|
package/hub/team/wt-manager.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
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,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
|
|
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
|
-
|
|
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,
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -940,9 +940,9 @@ resolve_gemini_profile() {
|
|
|
940
940
|
}
|
|
941
941
|
}
|
|
942
942
|
|
|
943
|
-
process.stdout.write(defaults[name] || defaults.
|
|
943
|
+
process.stdout.write(defaults[name] || defaults[process.env.TFX_GEMINI_DEFAULT_PROFILE] || defaults.pro25);
|
|
944
944
|
" "$profile" "$_GEMINI_PROFILE_CACHE" "$settings_cache" 2>/dev/null)
|
|
945
|
-
echo "${result:-gemini-
|
|
945
|
+
echo "${result:-gemini-2.5-pro}"
|
|
946
946
|
}
|
|
947
947
|
|
|
948
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 로 발견 후 본 스킬 추가.
|