triflux 3.2.0-dev.1 → 3.2.0-dev.2
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 +185 -43
- package/hooks/hooks.json +12 -0
- package/hub/bridge.mjs +137 -51
- package/hub/server.mjs +100 -29
- package/hub/team/cli.mjs +1080 -113
- package/hub/team/native-supervisor.mjs +300 -0
- package/hub/team/native.mjs +92 -0
- package/hub/team/nativeProxy.mjs +455 -0
- package/hub/team/orchestrator.mjs +99 -35
- package/hub/team/pane.mjs +18 -9
- package/hub/team/session.mjs +359 -16
- package/hub/tools.mjs +113 -15
- package/package.json +1 -1
- package/scripts/setup.mjs +95 -0
- package/scripts/team-keyword.mjs +35 -0
- package/scripts/tfx-route.sh +13 -73
- package/skills/tfx-team/SKILL.md +204 -86
package/hub/team/session.mjs
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
|
-
// hub/team/session.mjs — tmux 세션 생명주기 관리
|
|
2
|
-
// 의존성: child_process (Node.js 내장)만 사용
|
|
3
|
-
import { execSync } from "node:child_process";
|
|
1
|
+
// hub/team/session.mjs — tmux/wt 세션 생명주기 관리
|
|
2
|
+
// 의존성: child_process (Node.js 내장)만 사용
|
|
3
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const GIT_BASH_CANDIDATES = [
|
|
6
|
+
"C:/Program Files/Git/bin/bash.exe",
|
|
7
|
+
"C:/Program Files/Git/usr/bin/bash.exe",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
function findGitBashExe() {
|
|
11
|
+
for (const p of GIT_BASH_CANDIDATES) {
|
|
12
|
+
try {
|
|
13
|
+
execSync(`"${p}" --version`, { stdio: "ignore", timeout: 3000 });
|
|
14
|
+
return p;
|
|
15
|
+
} catch {
|
|
16
|
+
// 다음 후보
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Windows Terminal 실행 파일 존재 여부 */
|
|
23
|
+
export function hasWindowsTerminal() {
|
|
24
|
+
if (process.platform !== "win32") return false;
|
|
25
|
+
try {
|
|
26
|
+
execSync("where wt.exe", { stdio: "ignore", timeout: 3000 });
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 현재 프로세스가 Windows Terminal 내에서 실행 중인지 여부 */
|
|
34
|
+
export function hasWindowsTerminalSession() {
|
|
35
|
+
return process.platform === "win32" && !!process.env.WT_SESSION;
|
|
36
|
+
}
|
|
4
37
|
|
|
5
38
|
/** tmux 실행 가능 여부 확인 */
|
|
6
39
|
function hasTmux() {
|
|
@@ -22,12 +55,29 @@ function hasWslTmux() {
|
|
|
22
55
|
}
|
|
23
56
|
}
|
|
24
57
|
|
|
58
|
+
/** Git Bash 내 tmux 사용 가능 여부 (Windows 전용) */
|
|
59
|
+
function hasGitBashTmux() {
|
|
60
|
+
const bash = findGitBashExe();
|
|
61
|
+
if (!bash) return false;
|
|
62
|
+
try {
|
|
63
|
+
const r = spawnSync(bash, ["-lc", "tmux -V"], {
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
timeout: 5000,
|
|
66
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
+
});
|
|
68
|
+
return (r.status ?? 1) === 0;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
25
74
|
/**
|
|
26
75
|
* 터미널 멀티플렉서 감지
|
|
27
|
-
* @returns {'tmux'|'wsl-tmux'|null}
|
|
76
|
+
* @returns {'tmux'|'git-bash-tmux'|'wsl-tmux'|null}
|
|
28
77
|
*/
|
|
29
78
|
export function detectMultiplexer() {
|
|
30
79
|
if (hasTmux()) return "tmux";
|
|
80
|
+
if (process.platform === "win32" && hasGitBashTmux()) return "git-bash-tmux";
|
|
31
81
|
if (process.platform === "win32" && hasWslTmux()) return "wsl-tmux";
|
|
32
82
|
return null;
|
|
33
83
|
}
|
|
@@ -53,6 +103,23 @@ function tmux(args, opts = {}) {
|
|
|
53
103
|
" 3. tfx team \"작업\" (자동으로 WSL tmux 사용)"
|
|
54
104
|
);
|
|
55
105
|
}
|
|
106
|
+
if (mux === "git-bash-tmux") {
|
|
107
|
+
const bash = findGitBashExe();
|
|
108
|
+
if (!bash) throw new Error("git-bash-tmux 감지 실패");
|
|
109
|
+
const r = spawnSync(bash, ["-lc", `tmux ${args}`], {
|
|
110
|
+
encoding: "utf8",
|
|
111
|
+
timeout: 10000,
|
|
112
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
113
|
+
...opts,
|
|
114
|
+
});
|
|
115
|
+
if ((r.status ?? 1) !== 0) {
|
|
116
|
+
const e = new Error(r.stderr || "tmux command failed");
|
|
117
|
+
e.status = r.status;
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
return (r.stdout || "").trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
56
123
|
const prefix = mux === "wsl-tmux" ? "wsl tmux" : "tmux";
|
|
57
124
|
const result = execSync(`${prefix} ${args}`, {
|
|
58
125
|
encoding: "utf8",
|
|
@@ -60,15 +127,204 @@ function tmux(args, opts = {}) {
|
|
|
60
127
|
stdio: ["pipe", "pipe", "pipe"],
|
|
61
128
|
...opts,
|
|
62
129
|
});
|
|
63
|
-
// stdio: "ignore" 시 execSync가 null 반환 — 안전 처리
|
|
64
130
|
return result != null ? result.trim() : "";
|
|
65
131
|
}
|
|
66
132
|
|
|
133
|
+
/**
|
|
134
|
+
* tmux 명령 직접 실행 (고수준 모듈에서 재사용)
|
|
135
|
+
* @param {string} args
|
|
136
|
+
* @param {object} opts
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
export function tmuxExec(args, opts = {}) {
|
|
140
|
+
return tmux(args, opts);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 현재 멀티플렉서 환경에 맞는 attach 실행 스펙 반환
|
|
145
|
+
* @param {string} sessionName
|
|
146
|
+
* @returns {{ command: string, args: string[] }}
|
|
147
|
+
*/
|
|
148
|
+
export function resolveAttachCommand(sessionName) {
|
|
149
|
+
const mux = detectMultiplexer();
|
|
150
|
+
if (!mux) {
|
|
151
|
+
throw new Error("tmux 미발견");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (mux === "git-bash-tmux") {
|
|
155
|
+
const bash = findGitBashExe();
|
|
156
|
+
if (!bash) throw new Error("git-bash-tmux 감지 실패");
|
|
157
|
+
return {
|
|
158
|
+
command: bash,
|
|
159
|
+
args: ["-lc", `tmux attach-session -t ${sessionName}`],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (mux === "wsl-tmux") {
|
|
164
|
+
return {
|
|
165
|
+
command: "wsl",
|
|
166
|
+
args: ["tmux", "attach-session", "-t", sessionName],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
command: "tmux",
|
|
172
|
+
args: ["attach-session", "-t", sessionName],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* wt.exe 커맨드 실행
|
|
178
|
+
* @param {string[]} args
|
|
179
|
+
* @param {object} opts
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
function wt(args, opts = {}) {
|
|
183
|
+
if (!hasWindowsTerminal()) {
|
|
184
|
+
throw new Error("wt.exe 미발견");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const r = spawnSync("wt.exe", args, {
|
|
188
|
+
encoding: "utf8",
|
|
189
|
+
timeout: 10000,
|
|
190
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
191
|
+
windowsHide: true,
|
|
192
|
+
...opts,
|
|
193
|
+
});
|
|
194
|
+
if ((r.status ?? 1) !== 0) {
|
|
195
|
+
const e = new Error((r.stderr || r.stdout || "wt command failed").trim());
|
|
196
|
+
e.status = r.status;
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
return (r.stdout || "").trim();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Windows Terminal pane 분할용 cmd.exe 인자 구성
|
|
204
|
+
* @param {string} command
|
|
205
|
+
* @returns {string[]}
|
|
206
|
+
*/
|
|
207
|
+
function buildWtCmdArgs(command) {
|
|
208
|
+
return ["cmd.exe", "/c", command];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Windows Terminal 독립 모드 세션 생성
|
|
213
|
+
* - 현재 pane를 앵커로 사용하고, 우측(1xN) 또는 하단(Nx1)으로만 분할
|
|
214
|
+
* - 생성한 pane에 즉시 CLI 커맨드를 실행
|
|
215
|
+
* @param {string} sessionName
|
|
216
|
+
* @param {object} opts
|
|
217
|
+
* @param {'1xN'|'Nx1'} opts.layout
|
|
218
|
+
* @param {Array<{title:string,command:string,cwd?:string}>} opts.paneCommands
|
|
219
|
+
* @returns {{ sessionName: string, panes: string[], titles: string[], layout: '1xN'|'Nx1', paneCount: number, anchorPane: string }}
|
|
220
|
+
*/
|
|
221
|
+
export function createWtSession(sessionName, opts = {}) {
|
|
222
|
+
const { layout = "1xN", paneCommands = [] } = opts;
|
|
223
|
+
|
|
224
|
+
if (!hasWindowsTerminalSession()) {
|
|
225
|
+
throw new Error("WT_SESSION 미감지");
|
|
226
|
+
}
|
|
227
|
+
if (!hasWindowsTerminal()) {
|
|
228
|
+
throw new Error("wt.exe 미발견");
|
|
229
|
+
}
|
|
230
|
+
if (!Array.isArray(paneCommands) || paneCommands.length === 0) {
|
|
231
|
+
throw new Error("paneCommands가 비어 있음");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const splitFlag = layout === "Nx1" ? "-H" : "-V";
|
|
235
|
+
const panes = [];
|
|
236
|
+
const titles = [];
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < paneCommands.length; i++) {
|
|
239
|
+
const pane = paneCommands[i] || {};
|
|
240
|
+
const title = pane.title || `${sessionName}-${i + 1}`;
|
|
241
|
+
const command = String(pane.command || "").trim();
|
|
242
|
+
const cwd = pane.cwd || process.cwd();
|
|
243
|
+
if (!command) continue;
|
|
244
|
+
|
|
245
|
+
wt(["-w", "0", "sp", splitFlag, "--title", title, "-d", cwd, ...buildWtCmdArgs(command)]);
|
|
246
|
+
panes.push(`wt:${i}`);
|
|
247
|
+
titles.push(title);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
sessionName,
|
|
252
|
+
panes,
|
|
253
|
+
titles,
|
|
254
|
+
layout: layout === "Nx1" ? "Nx1" : "1xN",
|
|
255
|
+
paneCount: panes.length,
|
|
256
|
+
anchorPane: "wt:anchor",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Windows Terminal pane 포커스 이동
|
|
262
|
+
* @param {number} paneIndex - createWtSession()에서 생성한 pane 인덱스(0 기반)
|
|
263
|
+
* @param {object} opts
|
|
264
|
+
* @param {'1xN'|'Nx1'} opts.layout
|
|
265
|
+
* @returns {boolean}
|
|
266
|
+
*/
|
|
267
|
+
export function focusWtPane(paneIndex, opts = {}) {
|
|
268
|
+
if (!hasWindowsTerminalSession() || !hasWindowsTerminal()) return false;
|
|
269
|
+
const idx = Number(paneIndex);
|
|
270
|
+
if (!Number.isInteger(idx) || idx < 0) return false;
|
|
271
|
+
|
|
272
|
+
const layout = opts.layout === "Nx1" ? "Nx1" : "1xN";
|
|
273
|
+
const backDir = layout === "Nx1" ? "up" : "left";
|
|
274
|
+
const stepDir = layout === "Nx1" ? "down" : "right";
|
|
275
|
+
|
|
276
|
+
// 앵커로 최대한 복귀
|
|
277
|
+
for (let i = 0; i < 10; i++) {
|
|
278
|
+
try { wt(["-w", "0", "move-focus", backDir]); } catch { break; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (let i = 0; i <= idx; i++) {
|
|
282
|
+
wt(["-w", "0", "move-focus", stepDir]);
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Windows Terminal에서 생성한 팀 pane 정리
|
|
289
|
+
* @param {object} opts
|
|
290
|
+
* @param {'1xN'|'Nx1'} opts.layout
|
|
291
|
+
* @param {number} opts.paneCount
|
|
292
|
+
* @returns {number} 닫힌 pane 수 (best-effort)
|
|
293
|
+
*/
|
|
294
|
+
export function closeWtSession(opts = {}) {
|
|
295
|
+
if (!hasWindowsTerminalSession() || !hasWindowsTerminal()) return 0;
|
|
296
|
+
|
|
297
|
+
const paneCount = Math.max(0, Number(opts.paneCount || 0));
|
|
298
|
+
if (paneCount === 0) return 0;
|
|
299
|
+
|
|
300
|
+
const layout = opts.layout === "Nx1" ? "Nx1" : "1xN";
|
|
301
|
+
const backDir = layout === "Nx1" ? "up" : "left";
|
|
302
|
+
const stepDir = layout === "Nx1" ? "down" : "right";
|
|
303
|
+
let closed = 0;
|
|
304
|
+
|
|
305
|
+
// 앵커(원래 tfx 실행 pane)로 최대한 복귀
|
|
306
|
+
for (let i = 0; i < 10; i++) {
|
|
307
|
+
try { wt(["-w", "0", "move-focus", backDir]); } catch { break; }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (let i = 0; i < paneCount; i++) {
|
|
311
|
+
try {
|
|
312
|
+
wt(["-w", "0", "move-focus", stepDir]);
|
|
313
|
+
wt(["-w", "0", "close-pane"]);
|
|
314
|
+
closed++;
|
|
315
|
+
} catch {
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return closed;
|
|
321
|
+
}
|
|
322
|
+
|
|
67
323
|
/**
|
|
68
324
|
* tmux 세션 생성 + 레이아웃 분할
|
|
69
325
|
* @param {string} sessionName — 세션 이름
|
|
70
326
|
* @param {object} opts
|
|
71
|
-
* @param {'2x2'|'1xN'} opts.layout — 레이아웃 (기본 2x2)
|
|
327
|
+
* @param {'2x2'|'1xN'|'Nx1'} opts.layout — 레이아웃 (기본 2x2)
|
|
72
328
|
* @param {number} opts.paneCount — pane 수 (기본 4)
|
|
73
329
|
* @returns {{ sessionName: string, panes: string[] }}
|
|
74
330
|
*/
|
|
@@ -86,23 +342,33 @@ export function createSession(sessionName, opts = {}) {
|
|
|
86
342
|
const panes = [`${sessionName}:0.0`];
|
|
87
343
|
|
|
88
344
|
if (layout === "2x2" && paneCount >= 3) {
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
tmux(`split-window -
|
|
345
|
+
// 3-pane 기본: lead 왼쪽, workers 오른쪽 상/하
|
|
346
|
+
// 4-pane: 좌/우 각각 상/하(균등 2x2)
|
|
347
|
+
tmux(`split-window -h -t ${sessionName}:0.0`);
|
|
348
|
+
tmux(`split-window -v -t ${sessionName}:0.1`);
|
|
92
349
|
if (paneCount >= 4) {
|
|
93
|
-
tmux(`split-window -v -t ${sessionName}:0.
|
|
350
|
+
tmux(`split-window -v -t ${sessionName}:0.0`);
|
|
94
351
|
}
|
|
95
352
|
// pane ID 재수집
|
|
96
353
|
panes.length = 0;
|
|
97
354
|
for (let i = 0; i < Math.min(paneCount, 4); i++) {
|
|
98
355
|
panes.push(`${sessionName}:0.${i}`);
|
|
99
356
|
}
|
|
357
|
+
} else if (layout === "1xN") {
|
|
358
|
+
// 세로 분할(좌/우 컬럼 확장)
|
|
359
|
+
for (let i = 1; i < paneCount; i++) {
|
|
360
|
+
tmux(`split-window -h -t ${sessionName}:0`);
|
|
361
|
+
}
|
|
362
|
+
tmux(`select-layout -t ${sessionName}:0 even-horizontal`);
|
|
363
|
+
panes.length = 0;
|
|
364
|
+
for (let i = 0; i < paneCount; i++) {
|
|
365
|
+
panes.push(`${sessionName}:0.${i}`);
|
|
366
|
+
}
|
|
100
367
|
} else {
|
|
101
|
-
//
|
|
368
|
+
// Nx1 가로 분할(상/하 스택)
|
|
102
369
|
for (let i = 1; i < paneCount; i++) {
|
|
103
370
|
tmux(`split-window -v -t ${sessionName}:0`);
|
|
104
371
|
}
|
|
105
|
-
// even-vertical 레이아웃 적용
|
|
106
372
|
tmux(`select-layout -t ${sessionName}:0 even-vertical`);
|
|
107
373
|
panes.length = 0;
|
|
108
374
|
for (let i = 0; i < paneCount; i++) {
|
|
@@ -113,18 +379,76 @@ export function createSession(sessionName, opts = {}) {
|
|
|
113
379
|
return { sessionName, panes };
|
|
114
380
|
}
|
|
115
381
|
|
|
382
|
+
/**
|
|
383
|
+
* pane 포커스 이동
|
|
384
|
+
* @param {string} target
|
|
385
|
+
* @param {object} opts
|
|
386
|
+
* @param {boolean} opts.zoom
|
|
387
|
+
*/
|
|
388
|
+
export function focusPane(target, opts = {}) {
|
|
389
|
+
const { zoom = false } = opts;
|
|
390
|
+
tmux(`select-pane -t ${target}`);
|
|
391
|
+
if (zoom) {
|
|
392
|
+
try { tmux(`resize-pane -t ${target} -Z`); } catch {}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 팀메이트 조작 키 바인딩 설정
|
|
398
|
+
* - Shift+Down: 다음 팀메이트
|
|
399
|
+
* - Shift+Up: 이전 팀메이트
|
|
400
|
+
* - Escape: 현재 팀메이트 인터럽트(C-c)
|
|
401
|
+
* - Ctrl+T: 태스크 목록 표시
|
|
402
|
+
* @param {string} sessionName
|
|
403
|
+
* @param {object} opts
|
|
404
|
+
* @param {boolean} opts.inProcess
|
|
405
|
+
* @param {string} opts.taskListCommand
|
|
406
|
+
*/
|
|
407
|
+
export function configureTeammateKeybindings(sessionName, opts = {}) {
|
|
408
|
+
const { inProcess = false, taskListCommand = "" } = opts;
|
|
409
|
+
const cond = `#{==:#{session_name},${sessionName}}`;
|
|
410
|
+
|
|
411
|
+
if (inProcess) {
|
|
412
|
+
// 단일 뷰(zoom) 상태에서 팀메이트 순환
|
|
413
|
+
tmux(`bind-key -T root -n S-Down if-shell -F '${cond}' 'select-pane -t :.+ \\; resize-pane -Z' 'send-keys S-Down'`);
|
|
414
|
+
tmux(`bind-key -T root -n S-Up if-shell -F '${cond}' 'select-pane -t :.- \\; resize-pane -Z' 'send-keys S-Up'`);
|
|
415
|
+
} else {
|
|
416
|
+
// 분할 뷰에서 팀메이트 순환
|
|
417
|
+
tmux(`bind-key -T root -n S-Down if-shell -F '${cond}' 'select-pane -t :.+' 'send-keys S-Down'`);
|
|
418
|
+
tmux(`bind-key -T root -n S-Up if-shell -F '${cond}' 'select-pane -t :.-' 'send-keys S-Up'`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 현재 활성 pane 인터럽트
|
|
422
|
+
tmux(`bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`);
|
|
423
|
+
|
|
424
|
+
// 태스크 목록 토글 (tmux 3.2+ popup 우선, 실패 시 안내 메시지)
|
|
425
|
+
if (taskListCommand) {
|
|
426
|
+
const escaped = taskListCommand.replace(/'/g, "'\\''");
|
|
427
|
+
try {
|
|
428
|
+
tmux(`bind-key -T root -n C-t if-shell -F '${cond}' \"display-popup -E '${escaped}'\" \"send-keys C-t\"`);
|
|
429
|
+
} catch {
|
|
430
|
+
tmux(`bind-key -T root -n C-t if-shell -F '${cond}' 'display-message "tfx team tasks 명령으로 태스크 확인"' 'send-keys C-t'`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
116
435
|
/**
|
|
117
436
|
* tmux 세션 연결 (포그라운드 전환)
|
|
118
437
|
* @param {string} sessionName
|
|
119
438
|
*/
|
|
120
439
|
export function attachSession(sessionName) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
440
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
441
|
+
throw new Error("현재 터미널은 tmux attach를 지원하지 않음 (non-TTY)");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const { command, args } = resolveAttachCommand(sessionName);
|
|
445
|
+
const r = spawnSync(command, args, {
|
|
125
446
|
stdio: "inherit",
|
|
126
447
|
timeout: 0, // 타임아웃 없음 (사용자가 detach할 때까지)
|
|
127
448
|
});
|
|
449
|
+
if ((r.status ?? 1) !== 0) {
|
|
450
|
+
throw new Error(`tmux attach 실패 (exit=${r.status})`);
|
|
451
|
+
}
|
|
128
452
|
}
|
|
129
453
|
|
|
130
454
|
/**
|
|
@@ -168,6 +492,25 @@ export function listSessions() {
|
|
|
168
492
|
}
|
|
169
493
|
}
|
|
170
494
|
|
|
495
|
+
/**
|
|
496
|
+
* 세션 attach client 수 조회
|
|
497
|
+
* @param {string} sessionName
|
|
498
|
+
* @returns {number|null}
|
|
499
|
+
*/
|
|
500
|
+
export function getSessionAttachedCount(sessionName) {
|
|
501
|
+
try {
|
|
502
|
+
const output = tmux('list-sessions -F "#{session_name} #{session_attached}"');
|
|
503
|
+
const line = output
|
|
504
|
+
.split("\n")
|
|
505
|
+
.find((l) => l.startsWith(`${sessionName} `));
|
|
506
|
+
if (!line) return null;
|
|
507
|
+
const n = parseInt(line.split(" ")[1], 10);
|
|
508
|
+
return Number.isFinite(n) ? n : null;
|
|
509
|
+
} catch {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
171
514
|
/**
|
|
172
515
|
* pane 마지막 N줄 캡처
|
|
173
516
|
* @param {string} target — 예: tfx-team-abc:0.1
|
package/hub/tools.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
// hub/tools.mjs — MCP 도구
|
|
2
|
-
// register
|
|
1
|
+
// hub/tools.mjs — MCP 도구 정의
|
|
2
|
+
// register/status/publish/ask/poll/handoff/HITL + team proxy
|
|
3
3
|
// 모든 도구 응답: { ok: boolean, error?: { code, message }, data?: ... }
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
teamInfo,
|
|
7
|
+
teamTaskList,
|
|
8
|
+
teamTaskUpdate,
|
|
9
|
+
teamSendMessage,
|
|
10
|
+
} from './team/nativeProxy.mjs';
|
|
11
|
+
|
|
5
12
|
/**
|
|
6
13
|
* MCP 도구 목록 생성
|
|
7
14
|
* @param {object} store — createStore() 반환
|
|
@@ -145,19 +152,19 @@ export function createTools(store, router, hitl) {
|
|
|
145
152
|
auto_ack: args.auto_ack,
|
|
146
153
|
});
|
|
147
154
|
|
|
148
|
-
// wait_ms > 0 이고 메시지 없으면 짧은 간격으로 반복 재시도
|
|
149
|
-
if (!messages.length && args.wait_ms > 0) {
|
|
150
|
-
const interval = Math.min(args.wait_ms, 500);
|
|
151
|
-
const deadline = Date.now() + Math.min(args.wait_ms, 30000);
|
|
152
|
-
while (!messages.length && Date.now() < deadline) {
|
|
153
|
-
await new Promise(r => setTimeout(r, interval));
|
|
154
|
-
messages = store.pollForAgent(args.agent_id, {
|
|
155
|
-
max_messages: args.max_messages,
|
|
156
|
-
include_topics: args.include_topics,
|
|
157
|
-
auto_ack: args.auto_ack,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
155
|
+
// wait_ms > 0 이고 메시지 없으면 짧은 간격으로 반복 재시도
|
|
156
|
+
if (!messages.length && args.wait_ms > 0) {
|
|
157
|
+
const interval = Math.min(args.wait_ms, 500);
|
|
158
|
+
const deadline = Date.now() + Math.min(args.wait_ms, 30000);
|
|
159
|
+
while (!messages.length && Date.now() < deadline) {
|
|
160
|
+
await new Promise(r => setTimeout(r, interval));
|
|
161
|
+
messages = store.pollForAgent(args.agent_id, {
|
|
162
|
+
max_messages: args.max_messages,
|
|
163
|
+
include_topics: args.include_topics,
|
|
164
|
+
auto_ack: args.auto_ack,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
161
168
|
|
|
162
169
|
return {
|
|
163
170
|
ok: true,
|
|
@@ -238,5 +245,96 @@ export function createTools(store, router, hitl) {
|
|
|
238
245
|
return hitl.submitHumanInput(args);
|
|
239
246
|
}),
|
|
240
247
|
},
|
|
248
|
+
|
|
249
|
+
// ── 9. team_info ──
|
|
250
|
+
{
|
|
251
|
+
name: 'team_info',
|
|
252
|
+
description: 'Claude Native Teams 메타/멤버/경로 정보를 조회합니다',
|
|
253
|
+
inputSchema: {
|
|
254
|
+
type: 'object',
|
|
255
|
+
required: ['team_name'],
|
|
256
|
+
properties: {
|
|
257
|
+
team_name: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
258
|
+
include_members: { type: 'boolean', default: true },
|
|
259
|
+
include_paths: { type: 'boolean', default: true },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
handler: wrap('TEAM_INFO_FAILED', (args) => {
|
|
263
|
+
return teamInfo(args);
|
|
264
|
+
}),
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
// ── 10. team_task_list ──
|
|
268
|
+
{
|
|
269
|
+
name: 'team_task_list',
|
|
270
|
+
description: 'Claude Native Teams task 목록을 owner/status 조건으로 조회합니다',
|
|
271
|
+
inputSchema: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
required: ['team_name'],
|
|
274
|
+
properties: {
|
|
275
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
276
|
+
owner: { type: 'string' },
|
|
277
|
+
statuses: {
|
|
278
|
+
type: 'array',
|
|
279
|
+
items: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'failed', 'deleted'] },
|
|
280
|
+
maxItems: 8,
|
|
281
|
+
},
|
|
282
|
+
include_internal: { type: 'boolean', default: false },
|
|
283
|
+
limit: { type: 'integer', minimum: 1, maximum: 1000, default: 200 },
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
handler: wrap('TEAM_TASK_LIST_FAILED', (args) => {
|
|
287
|
+
return teamTaskList(args);
|
|
288
|
+
}),
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
// ── 11. team_task_update ──
|
|
292
|
+
{
|
|
293
|
+
name: 'team_task_update',
|
|
294
|
+
description: 'Claude Native Teams task를 claim/update 합니다',
|
|
295
|
+
inputSchema: {
|
|
296
|
+
type: 'object',
|
|
297
|
+
required: ['team_name', 'task_id'],
|
|
298
|
+
properties: {
|
|
299
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
300
|
+
task_id: { type: 'string', minLength: 1, maxLength: 64 },
|
|
301
|
+
claim: { type: 'boolean', default: false },
|
|
302
|
+
owner: { type: 'string' },
|
|
303
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'failed', 'deleted'] },
|
|
304
|
+
subject: { type: 'string' },
|
|
305
|
+
description: { type: 'string' },
|
|
306
|
+
activeForm: { type: 'string' },
|
|
307
|
+
add_blocks: { type: 'array', items: { type: 'string' } },
|
|
308
|
+
add_blocked_by: { type: 'array', items: { type: 'string' } },
|
|
309
|
+
metadata_patch: { type: 'object' },
|
|
310
|
+
if_match_mtime_ms: { type: 'number' },
|
|
311
|
+
actor: { type: 'string' },
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
handler: wrap('TEAM_TASK_UPDATE_FAILED', (args) => {
|
|
315
|
+
return teamTaskUpdate(args);
|
|
316
|
+
}),
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
// ── 12. team_send_message ──
|
|
320
|
+
{
|
|
321
|
+
name: 'team_send_message',
|
|
322
|
+
description: 'Claude Native Teams inbox에 메시지를 append 합니다',
|
|
323
|
+
inputSchema: {
|
|
324
|
+
type: 'object',
|
|
325
|
+
required: ['team_name', 'from', 'text'],
|
|
326
|
+
properties: {
|
|
327
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
328
|
+
from: { type: 'string', minLength: 1, maxLength: 128 },
|
|
329
|
+
to: { type: 'string', default: 'team-lead' },
|
|
330
|
+
text: { type: 'string', minLength: 1, maxLength: 200000 },
|
|
331
|
+
summary: { type: 'string', maxLength: 1000 },
|
|
332
|
+
color: { type: 'string', default: 'blue' },
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
handler: wrap('TEAM_SEND_MESSAGE_FAILED', (args) => {
|
|
336
|
+
return teamSendMessage(args);
|
|
337
|
+
}),
|
|
338
|
+
},
|
|
241
339
|
];
|
|
242
340
|
}
|