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