triflux 3.2.0-dev.8 → 3.3.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/bin/triflux.mjs +1296 -1055
  2. package/hooks/hooks.json +17 -0
  3. package/hooks/keyword-rules.json +20 -4
  4. package/hooks/pipeline-stop.mjs +54 -0
  5. package/hub/bridge.mjs +517 -318
  6. package/hub/hitl.mjs +45 -31
  7. package/hub/pipe.mjs +457 -0
  8. package/hub/pipeline/index.mjs +121 -0
  9. package/hub/pipeline/state.mjs +164 -0
  10. package/hub/pipeline/transitions.mjs +114 -0
  11. package/hub/router.mjs +422 -161
  12. package/hub/schema.sql +14 -0
  13. package/hub/server.mjs +499 -424
  14. package/hub/store.mjs +388 -314
  15. package/hub/team/cli-team-common.mjs +348 -0
  16. package/hub/team/cli-team-control.mjs +393 -0
  17. package/hub/team/cli-team-start.mjs +516 -0
  18. package/hub/team/cli-team-status.mjs +269 -0
  19. package/hub/team/cli.mjs +75 -1475
  20. package/hub/team/dashboard.mjs +1 -9
  21. package/hub/team/native.mjs +190 -130
  22. package/hub/team/nativeProxy.mjs +165 -78
  23. package/hub/team/orchestrator.mjs +15 -20
  24. package/hub/team/pane.mjs +137 -103
  25. package/hub/team/psmux.mjs +506 -0
  26. package/hub/team/session.mjs +393 -330
  27. package/hub/team/shared.mjs +13 -0
  28. package/hub/team/staleState.mjs +299 -0
  29. package/hub/tools.mjs +105 -31
  30. package/hub/workers/claude-worker.mjs +446 -0
  31. package/hub/workers/codex-mcp.mjs +414 -0
  32. package/hub/workers/factory.mjs +18 -0
  33. package/hub/workers/gemini-worker.mjs +349 -0
  34. package/hub/workers/interface.mjs +41 -0
  35. package/hud/hud-qos-status.mjs +1790 -1788
  36. package/package.json +4 -1
  37. package/scripts/__tests__/keyword-detector.test.mjs +8 -8
  38. package/scripts/keyword-detector.mjs +15 -0
  39. package/scripts/lib/keyword-rules.mjs +4 -1
  40. package/scripts/preflight-cache.mjs +72 -0
  41. package/scripts/psmux-steering-prototype.sh +368 -0
  42. package/scripts/setup.mjs +136 -71
  43. package/scripts/tfx-route-worker.mjs +161 -0
  44. package/scripts/tfx-route.sh +485 -91
  45. package/skills/tfx-auto/SKILL.md +90 -564
  46. package/skills/tfx-auto-codex/SKILL.md +1 -3
  47. package/skills/tfx-codex/SKILL.md +1 -4
  48. package/skills/tfx-doctor/SKILL.md +1 -0
  49. package/skills/tfx-gemini/SKILL.md +1 -4
  50. package/skills/tfx-multi/SKILL.md +378 -0
  51. package/skills/tfx-setup/SKILL.md +1 -4
  52. package/skills/tfx-team/SKILL.md +0 -304
package/hub/team/cli.mjs CHANGED
@@ -1,1496 +1,96 @@
1
- // hub/team/cli.mjs — tfx team CLI 진입점
2
- // bin/triflux.mjs에서 import하여 사용
3
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
4
- import { join, dirname } from "node:path";
5
- import { homedir } from "node:os";
6
- import { spawn } from "node:child_process";
7
-
8
- import {
9
- createSession,
10
- createWtSession,
11
- attachSession,
12
- resolveAttachCommand,
13
- killSession,
14
- closeWtSession,
15
- sessionExists,
16
- getSessionAttachedCount,
17
- listSessions,
18
- capturePaneOutput,
19
- focusPane,
20
- focusWtPane,
21
- configureTeammateKeybindings,
22
- detectMultiplexer,
23
- hasWindowsTerminal,
24
- hasWindowsTerminalSession,
25
- } from "./session.mjs";
26
- import { buildCliCommand, startCliInPane, injectPrompt, sendKeys } from "./pane.mjs";
27
- import { orchestrate, decomposeTask, buildLeadPrompt, buildPrompt } from "./orchestrator.mjs";
28
-
29
- // ── 상수 ──
30
- const PKG_ROOT = dirname(dirname(dirname(new URL(import.meta.url).pathname))).replace(/^\/([A-Z]:)/, "$1");
31
- const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
32
- const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
33
- const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
34
- const TEAM_PROFILE = (() => {
35
- const raw = String(process.env.TFX_TEAM_PROFILE || "team").trim().toLowerCase();
36
- return raw === "codex-team" ? "codex-team" : "team";
37
- })();
38
- const TEAM_STATE_FILE = join(
39
- HUB_PID_DIR,
40
- TEAM_PROFILE === "codex-team" ? "team-state-codex-team.json" : "team-state.json",
41
- );
42
-
43
- const TEAM_SUBCOMMANDS = new Set([
44
- "status", "attach", "stop", "kill", "send", "list", "help", "tasks", "task", "focus", "interrupt", "control", "debug",
45
- ]);
46
-
47
- // ── 색상 ──
48
- const AMBER = "\x1b[38;5;214m";
49
- const GREEN = "\x1b[38;5;82m";
50
- const RED = "\x1b[38;5;196m";
51
- const GRAY = "\x1b[38;5;245m";
52
- const DIM = "\x1b[2m";
53
- const BOLD = "\x1b[1m";
54
- const RESET = "\x1b[0m";
55
- const WHITE = "\x1b[97m";
56
- const YELLOW = "\x1b[33m";
57
-
58
- function ok(msg) { console.log(` ${GREEN}✓${RESET} ${msg}`); }
59
- function warn(msg) { console.log(` ${YELLOW}⚠${RESET} ${msg}`); }
60
- function fail(msg) { console.log(` ${RED}✗${RESET} ${msg}`); }
61
-
62
- // ── 팀 상태 관리 ──
63
-
64
- function loadTeamState() {
65
- try {
66
- return JSON.parse(readFileSync(TEAM_STATE_FILE, "utf8"));
67
- } catch {
68
- return null;
69
- }
70
- }
71
-
72
- function saveTeamState(state) {
73
- mkdirSync(HUB_PID_DIR, { recursive: true });
74
- const nextState = { ...state, profile: TEAM_PROFILE };
75
- writeFileSync(TEAM_STATE_FILE, JSON.stringify(nextState, null, 2) + "\n");
76
- }
77
-
78
- function clearTeamState() {
79
- try { unlinkSync(TEAM_STATE_FILE); } catch {}
80
- }
81
-
82
- // ── Hub 유틸 ──
83
-
84
- function formatHostForUrl(host) {
85
- return host.includes(":") ? `[${host}]` : host;
86
- }
87
-
88
- function buildHubBaseUrl(host, port) {
89
- return `http://${formatHostForUrl(host)}:${port}`;
90
- }
91
-
92
- function getDefaultHubPort() {
93
- const envPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
94
- return Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : 27888;
95
- }
96
-
97
- function getDefaultHubUrl() {
98
- return `${buildHubBaseUrl("127.0.0.1", getDefaultHubPort())}/mcp`;
99
- }
100
-
101
- function getDefaultHubBase() {
102
- return getDefaultHubUrl().replace(/\/mcp$/, "");
103
- }
104
-
105
- function normalizeLoopbackHost(host) {
106
- if (typeof host !== "string") return "127.0.0.1";
107
- const candidate = host.trim();
108
- return LOOPBACK_HOSTS.has(candidate) ? candidate : "127.0.0.1";
109
- }
110
-
111
- async function probeHubStatus(host, port, timeoutMs = 1500) {
112
- try {
113
- const res = await fetch(`${buildHubBaseUrl(host, port)}/status`, {
114
- signal: AbortSignal.timeout(timeoutMs),
115
- });
116
- if (!res.ok) return null;
117
- const data = await res.json();
118
- return data?.hub ? data : null;
119
- } catch {
120
- return null;
121
- }
122
- }
123
-
124
- async function getHubInfo() {
125
- const probePort = getDefaultHubPort();
126
-
127
- if (existsSync(HUB_PID_FILE)) {
128
- try {
129
- const raw = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
130
- const pid = Number(raw?.pid);
131
- if (!Number.isFinite(pid) || pid <= 0) throw new Error("invalid pid");
132
- process.kill(pid, 0); // 프로세스 생존 확인
133
- const host = normalizeLoopbackHost(raw?.host);
134
- const port = Number(raw.port) || 27888;
135
- const status = await probeHubStatus(host, port, 1200);
136
- if (!status) {
137
- // transient timeout/응답 지연은 stale로 단정하지 않고 기존 PID 정보를 유지한다.
138
- return {
139
- ...raw,
140
- pid,
141
- host,
142
- port,
143
- url: `${buildHubBaseUrl(host, port)}/mcp`,
144
- degraded: true,
145
- };
146
- }
147
- return {
148
- ...raw,
149
- pid,
150
- host,
151
- port,
152
- url: `${buildHubBaseUrl(host, port)}/mcp`,
153
- };
154
- } catch {
155
- try { unlinkSync(HUB_PID_FILE); } catch {}
156
- }
157
- }
1
+ // hub/team/cli.mjs — team CLI UI/네비게이션 진입점
2
+ // bin/triflux.mjs에서 import하여 사용
3
+ import { AMBER, GRAY, DIM, BOLD, RESET, WHITE } from "./shared.mjs";
4
+ import { TEAM_SUBCOMMANDS } from "./cli-team-common.mjs";
5
+ import { teamStart } from "./cli-team-start.mjs";
6
+ import { teamStatus, teamTasks, teamTaskUpdate, teamDebug, teamList } from "./cli-team-status.mjs";
7
+ import {
8
+ teamAttach,
9
+ teamFocus,
10
+ teamInterrupt,
11
+ teamControl,
12
+ teamStop,
13
+ teamKill,
14
+ teamSend,
15
+ } from "./cli-team-control.mjs";
158
16
 
159
- // PID 파일이 없거나 stale인 경우에도 실제 Hub가 떠 있으면 재사용
160
- const candidates = Array.from(new Set([probePort, 27888]));
161
- for (const portCandidate of candidates) {
162
- const data = await probeHubStatus("127.0.0.1", portCandidate, 1200);
163
- if (!data) continue;
164
- const port = Number(data.port) || portCandidate;
165
- const pid = Number(data.pid);
166
- const recovered = {
167
- pid: Number.isFinite(pid) ? pid : null,
168
- host: "127.0.0.1",
169
- port,
170
- url: `${buildHubBaseUrl("127.0.0.1", port)}/mcp`,
171
- discovered: true,
172
- };
173
- if (Number.isFinite(recovered.pid) && recovered.pid > 0) {
174
- try {
175
- mkdirSync(HUB_PID_DIR, { recursive: true });
176
- writeFileSync(HUB_PID_FILE, JSON.stringify({
177
- pid: recovered.pid,
178
- port: recovered.port,
179
- host: recovered.host,
180
- url: recovered.url,
181
- started: Date.now(),
182
- }));
183
- } catch {}
184
- }
185
- return recovered;
186
- }
187
- return null;
188
- }
17
+ function teamHelp() {
18
+ console.log(`
19
+ ${AMBER}${BOLD}⬡ tfx multi${RESET} ${DIM}멀티-CLI 팀 모드 (Lead + Teammates)${RESET}
189
20
 
190
- async function startHubDaemon() {
191
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
192
- if (!existsSync(serverPath)) {
193
- fail("hub/server.mjs 없음 hub 모듈이 설치되지 않음");
194
- return null;
195
- }
196
-
197
- const child = spawn(process.execPath, [serverPath], {
198
- env: { ...process.env },
199
- stdio: "ignore",
200
- detached: true,
201
- });
202
- child.unref();
21
+ ${BOLD}시작${RESET}
22
+ ${WHITE}tfx multi "작업 설명"${RESET}
23
+ ${WHITE}tfx multi --agents codex,gemini --lead claude "작업"${RESET}
24
+ ${WHITE}tfx multi --teammate-mode tmux "작업"${RESET}
25
+ ${WHITE}tfx multi --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}
26
+ ${WHITE}tfx multi --layout 1xN "작업"${RESET} ${DIM}(세로 분할 컬럼)${RESET}
27
+ ${WHITE}tfx multi --layout Nx1 "작업"${RESET} ${DIM}(가로 분할 스택)${RESET}
28
+ ${WHITE}tfx multi --teammate-mode in-process "작업"${RESET} ${DIM}(tmux 불필요)${RESET}
203
29
 
204
- const expectedPort = getDefaultHubPort();
30
+ ${BOLD}제어${RESET}
31
+ ${WHITE}tfx multi status${RESET} ${GRAY}현재 팀 상태${RESET}
32
+ ${WHITE}tfx multi debug${RESET} ${DIM}[--lines 30]${RESET} ${GRAY}강화 디버그 출력(환경/세션/pane tail)${RESET}
33
+ ${WHITE}tfx multi tasks${RESET} ${GRAY}공유 태스크 목록${RESET}
34
+ ${WHITE}tfx multi task${RESET} ${DIM}<pending|progress|done> <T1>${RESET} ${GRAY}태스크 상태 갱신${RESET}
35
+ ${WHITE}tfx multi attach${RESET} ${DIM}[--wt]${RESET} ${GRAY}세션 재연결 (WT 분할은 opt-in)${RESET}
36
+ ${WHITE}tfx multi focus${RESET} ${DIM}<lead|이름|번호> [--wt]${RESET} ${GRAY}특정 팀메이트 포커스${RESET}
37
+ ${WHITE}tfx multi send${RESET} ${DIM}<lead|이름|번호> "msg"${RESET} ${GRAY}팀메이트에 메시지 주입${RESET}
38
+ ${WHITE}tfx multi interrupt${RESET} ${DIM}<대상>${RESET} ${GRAY}팀메이트 인터럽트(C-c)${RESET}
39
+ ${WHITE}tfx multi control${RESET} ${DIM}<대상> <cmd>${RESET} ${GRAY}리드 제어명령(interrupt|stop|pause|resume)${RESET}
40
+ ${WHITE}tfx multi stop${RESET} ${GRAY}graceful 종료${RESET}
41
+ ${WHITE}tfx multi kill${RESET} ${GRAY}모든 팀 세션 강제 종료${RESET}
42
+ ${WHITE}tfx multi list${RESET} ${GRAY}활성 세션 목록${RESET}
205
43
 
206
- // Hub 상태 확인 (최대 3초 대기)
207
- const deadline = Date.now() + 3000;
208
- while (Date.now() < deadline) {
209
- const info = await getHubInfo();
210
- if (info && info.port === expectedPort) return info;
211
- await new Promise((r) => setTimeout(r, 100));
212
- }
213
- return null;
214
- }
215
-
216
- // ── 인자 파싱 ──
217
-
218
- function normalizeTeammateMode(mode = "auto") {
219
- const raw = String(mode).toLowerCase();
220
- if (raw === "inline" || raw === "native") return "in-process";
221
- if (raw === "in-process" || raw === "tmux" || raw === "wt") return raw;
222
- if (raw === "windows-terminal" || raw === "windows_terminal") return "wt";
223
- if (raw === "auto") {
224
- return process.env.TMUX ? "tmux" : "in-process";
225
- }
226
- return "in-process";
227
- }
228
-
229
- function normalizeLayout(layout = "2x2") {
230
- const raw = String(layout).toLowerCase();
231
- if (raw === "2x2" || raw === "grid") return "2x2";
232
- if (raw === "1xn" || raw === "1x3" || raw === "vertical" || raw === "columns") return "1xN";
233
- if (raw === "nx1" || raw === "horizontal" || raw === "rows") return "Nx1";
234
- return "2x2";
235
- }
236
-
237
- function parseTeamArgs() {
238
- const args = process.argv.slice(3);
239
- let agents = ["codex", "gemini"]; // 기본: codex + gemini
240
- let lead = "claude"; // 기본 리드
241
- let layout = "2x2";
242
- let teammateMode = "auto";
243
- const taskParts = [];
244
-
245
- for (let i = 0; i < args.length; i++) {
246
- const cur = args[i];
247
- if (cur === "--agents" && args[i + 1]) {
248
- agents = args[++i].split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
249
- } else if (cur === "--lead" && args[i + 1]) {
250
- lead = args[++i].trim().toLowerCase();
251
- } else if (cur === "--layout" && args[i + 1]) {
252
- layout = args[++i];
253
- } else if ((cur === "--teammate-mode" || cur === "--mode") && args[i + 1]) {
254
- teammateMode = args[++i];
255
- } else if (!cur.startsWith("-")) {
256
- taskParts.push(cur);
257
- }
258
- }
259
-
260
- return {
261
- agents,
262
- lead,
263
- layout: normalizeLayout(layout),
264
- teammateMode: normalizeTeammateMode(teammateMode),
265
- task: taskParts.join(" ").trim(),
266
- };
267
- }
268
-
269
- function ensureTmuxOrExit() {
270
- const mux = detectMultiplexer();
271
- if (mux) return;
272
-
273
- console.log(`
274
- ${RED}${BOLD}tmux 미발견${RESET}
275
-
276
- 현재 선택한 모드는 tmux 기반 팀세션이 필요합니다.
277
-
278
- 설치:
279
- WSL2: ${WHITE}wsl sudo apt install tmux${RESET}
280
- macOS: ${WHITE}brew install tmux${RESET}
281
- Linux: ${WHITE}apt install tmux${RESET}
282
-
283
- Windows에서는 WSL2를 권장합니다:
284
- 1. ${WHITE}wsl --install${RESET}
285
- 2. ${WHITE}wsl sudo apt install tmux${RESET}
286
- 3. ${WHITE}tfx team "작업"${RESET}
287
- `);
288
- process.exit(1);
289
- }
290
-
291
- async function launchAttachInWindowsTerminal(sessionName) {
292
- if (!hasWindowsTerminal()) return false;
293
-
294
- let attachSpec;
295
- try {
296
- attachSpec = resolveAttachCommand(sessionName);
297
- } catch {
298
- return false;
299
- }
300
-
301
- const launch = (args) => {
302
- const child = spawn("wt", args, {
303
- detached: true,
304
- stdio: "ignore",
305
- windowsHide: false,
306
- });
307
- child.unref();
308
- };
309
-
310
- const beforeAttached = getSessionAttachedCount(sessionName);
311
-
312
- try {
313
- // 분할선이 세로(좌/우)가 되도록 -V 우선
314
- launch(["-w", "0", "split-pane", "-V", "-d", PKG_ROOT, attachSpec.command, ...attachSpec.args]);
315
- if (beforeAttached == null) {
316
- return true;
317
- }
318
-
319
- const deadline = Date.now() + 3500;
320
- while (Date.now() < deadline) {
321
- await new Promise((r) => setTimeout(r, 120));
322
- const nowAttached = getSessionAttachedCount(sessionName);
323
- if (typeof nowAttached === "number" && nowAttached > beforeAttached) {
324
- return true;
325
- }
326
- }
327
- return false;
328
- } catch {
329
- return false;
330
- }
331
- }
332
-
333
- function buildManualAttachCommand(sessionName) {
334
- try {
335
- const spec = resolveAttachCommand(sessionName);
336
- const quoted = [spec.command, ...spec.args].map((s) => {
337
- const v = String(s);
338
- return /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v;
339
- });
340
- return quoted.join(" ");
341
- } catch {
342
- return `tmux attach-session -t ${sessionName}`;
343
- }
344
- }
345
-
346
- function wantsWtAttachFallback() {
347
- return process.argv.includes("--wt")
348
- || process.argv.includes("--spawn-wt")
349
- || process.env.TFX_ATTACH_WT_AUTO === "1";
350
- }
351
-
352
- function toAgentId(cli, target) {
353
- const suffix = String(target).split(/[:.]/).pop();
354
- return `${cli}-${suffix}`;
355
- }
356
-
357
- function buildNativeCliCommand(cli) {
358
- switch (cli) {
359
- case "codex":
360
- // 비-TTY supervisor 환경에서 확인 프롬프트/alt-screen 의존을 줄임
361
- return "codex --dangerously-bypass-approvals-and-sandbox --no-alt-screen";
362
- case "gemini":
363
- return "gemini";
364
- case "claude":
365
- return "claude";
366
- default:
367
- return buildCliCommand(cli);
368
- }
369
- }
370
-
371
- function buildTasks(subtasks, workers) {
372
- return subtasks.map((subtask, i) => ({
373
- id: `T${i + 1}`,
374
- title: subtask,
375
- owner: workers[i]?.name || null,
376
- status: "pending",
377
- depends_on: i === 0 ? [] : [`T${i}`],
378
- }));
379
- }
380
-
381
- function renderTasks(tasks = []) {
382
- if (!tasks.length) {
383
- console.log(`\n ${DIM}태스크 없음${RESET}\n`);
384
- return;
385
- }
386
-
387
- console.log(`\n ${AMBER}${BOLD}⬡ Team Tasks${RESET}\n`);
388
- for (const t of tasks) {
389
- const dep = t.depends_on?.length ? ` ${DIM}(deps: ${t.depends_on.join(",")})${RESET}` : "";
390
- const owner = t.owner ? ` ${GRAY}[${t.owner}]${RESET}` : "";
391
- console.log(` ${WHITE}${t.id}${RESET} ${t.status.padEnd(11)} ${t.title}${owner}${dep}`);
392
- }
393
- console.log("");
394
- }
395
-
396
- function resolveMember(state, selector) {
397
- const members = state?.members || [];
398
- if (!selector) return null;
399
-
400
- const direct = members.find((m) => m.name === selector || m.role === selector || m.agentId === selector);
401
- if (direct) return direct;
402
-
403
- // 스킬 친화 별칭: worker-1, worker-2 ...
404
- const workerAlias = /^worker-(\d+)$/i.exec(selector);
405
- if (workerAlias) {
406
- const workerIdx = parseInt(workerAlias[1], 10) - 1;
407
- const workers = members.filter((m) => m.role === "worker");
408
- if (workerIdx >= 0 && workerIdx < workers.length) return workers[workerIdx];
409
- }
410
-
411
- const n = parseInt(selector, 10);
412
- if (!Number.isNaN(n)) {
413
- // 하위 호환: pane 번호 우선
414
- const byPane = members.find((m) => m.pane?.endsWith(`.${n}`) || m.pane?.endsWith(`:${n}`));
415
- if (byPane) return byPane;
416
-
417
- // teammate 스타일: 1-based 인덱스
418
- if (n >= 1 && n <= members.length) return members[n - 1];
419
- }
420
-
421
- return null;
422
- }
423
-
424
- async function publishLeadControl(state, targetMember, command, reason = "") {
425
- const hubBase = (state?.hubUrl || getDefaultHubUrl()).replace(/\/mcp$/, "");
426
- const leadAgent = (state?.members || []).find((m) => m.role === "lead")?.agentId || "lead";
427
-
428
- const payload = {
429
- from_agent: leadAgent,
430
- to_agent: targetMember.agentId,
431
- command,
432
- reason,
433
- payload: {
434
- issued_by: leadAgent,
435
- issued_at: Date.now(),
436
- },
437
- };
438
-
439
- try {
440
- const res = await fetch(`${hubBase}/bridge/control`, {
441
- method: "POST",
442
- headers: { "Content-Type": "application/json" },
443
- body: JSON.stringify(payload),
444
- });
445
- return !!res.ok;
446
- } catch {
447
- return false;
448
- }
449
- }
450
-
451
- function isNativeMode(state) {
452
- return state?.teammateMode === "in-process" && !!state?.native?.controlUrl;
453
- }
454
-
455
- function isWtMode(state) {
456
- return state?.teammateMode === "wt";
457
- }
458
-
459
- function isTeamAlive(state) {
460
- if (!state) return false;
461
- if (isNativeMode(state)) {
462
- try {
463
- process.kill(state.native.supervisorPid, 0);
464
- return true;
465
- } catch {
466
- return false;
467
- }
468
- }
469
- if (isWtMode(state)) {
470
- // WT pane 상태를 신뢰성 있게 조회할 API가 없어, WT_SESSION은 힌트로만 사용한다.
471
- if (!hasWindowsTerminal()) return false;
472
- if (hasWindowsTerminalSession()) return true;
473
- return Array.isArray(state.members) && state.members.length > 0;
474
- }
475
- return sessionExists(state.sessionName);
476
- }
477
-
478
- async function nativeRequest(state, path, body = {}) {
479
- if (!isNativeMode(state)) return null;
480
- try {
481
- const res = await fetch(`${state.native.controlUrl}${path}`, {
482
- method: "POST",
483
- headers: { "Content-Type": "application/json" },
484
- body: JSON.stringify(body),
485
- });
486
- return await res.json();
487
- } catch {
488
- return null;
489
- }
490
- }
491
-
492
- async function nativeGetStatus(state) {
493
- if (!isNativeMode(state)) return null;
494
- try {
495
- const res = await fetch(`${state.native.controlUrl}/status`);
496
- return await res.json();
497
- } catch {
498
- return null;
499
- }
500
- }
501
-
502
- async function startNativeSupervisor({ sessionId, task, lead, agents, subtasks, hubUrl }) {
503
- const nativeConfigPath = join(HUB_PID_DIR, `team-native-${sessionId}.config.json`);
504
- const nativeRuntimePath = join(HUB_PID_DIR, `team-native-${sessionId}.runtime.json`);
505
- const logsDir = join(HUB_PID_DIR, "team-logs", sessionId);
506
- mkdirSync(logsDir, { recursive: true });
507
-
508
- const leadMember = {
509
- role: "lead",
510
- name: "lead",
511
- cli: lead,
512
- agentId: `${lead}-lead`,
513
- command: buildNativeCliCommand(lead),
514
- };
515
-
516
- const workers = agents.map((cli, i) => ({
517
- role: "worker",
518
- name: `${cli}-${i + 1}`,
519
- cli,
520
- agentId: `${cli}-w${i + 1}`,
521
- command: buildNativeCliCommand(cli),
522
- subtask: subtasks[i],
523
- }));
524
-
525
- const leadPrompt = buildLeadPrompt(task, {
526
- agentId: leadMember.agentId,
527
- hubUrl,
528
- teammateMode: "in-process",
529
- workers: workers.map((w) => ({ agentId: w.agentId, cli: w.cli, subtask: w.subtask })),
530
- });
531
-
532
- const members = [
533
- { ...leadMember, prompt: leadPrompt },
534
- ...workers.map((w) => ({
535
- ...w,
536
- prompt: buildPrompt(w.subtask, { cli: w.cli, agentId: w.agentId, hubUrl }),
537
- })),
538
- ];
539
-
540
- const config = {
541
- sessionName: sessionId,
542
- hubUrl,
543
- startupDelayMs: 3000,
544
- logsDir,
545
- runtimeFile: nativeRuntimePath,
546
- members,
547
- };
548
- writeFileSync(nativeConfigPath, JSON.stringify(config, null, 2) + "\n");
549
-
550
- const supervisorPath = join(PKG_ROOT, "hub", "team", "native-supervisor.mjs");
551
- const child = spawn(process.execPath, [supervisorPath, "--config", nativeConfigPath], {
552
- detached: true,
553
- stdio: "ignore",
554
- env: { ...process.env },
555
- });
556
- child.unref();
557
-
558
- const deadline = Date.now() + 5000;
559
- while (Date.now() < deadline) {
560
- if (existsSync(nativeRuntimePath)) {
561
- try {
562
- const runtime = JSON.parse(readFileSync(nativeRuntimePath, "utf8"));
563
- return { runtime, members };
564
- } catch {}
565
- }
566
- await new Promise((r) => setTimeout(r, 100));
567
- }
568
-
569
- return { runtime: null, members };
570
- }
571
-
572
- // ── 서브커맨드 ──
573
-
574
- async function teamStart() {
575
- const { agents, lead, layout, teammateMode, task } = parseTeamArgs();
576
- if (!task) {
577
- console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET}\n`);
578
- console.log(` 사용법: ${WHITE}tfx team "작업 설명"${RESET}`);
579
- console.log(` ${WHITE}tfx team --agents codex,gemini --lead claude "작업"${RESET}`);
580
- console.log(` ${WHITE}tfx team --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}`);
581
- console.log(` ${WHITE}tfx team --teammate-mode in-process "작업"${RESET} ${DIM}(tmux 불필요)${RESET}\n`);
582
- return;
583
- }
584
-
585
- console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET}\n`);
586
-
587
- let hub = await getHubInfo();
588
- if (!hub) {
589
- process.stdout.write(" Hub 시작 중...");
590
- hub = await startHubDaemon();
591
- if (hub) {
592
- console.log(` ${GREEN}✓${RESET}`);
593
- } else {
594
- console.log(` ${RED}✗${RESET}`);
595
- warn("Hub 시작 실패 — 수동으로 실행: tfx hub start");
596
- }
597
- } else {
598
- ok(`Hub: ${DIM}${hub.url}${RESET}`);
599
- }
600
-
601
- const sessionId = `tfx-team-${Date.now().toString(36).slice(-4)}`;
602
- const subtasks = decomposeTask(task, agents.length);
603
- const hubUrl = hub?.url || getDefaultHubUrl();
604
- let effectiveTeammateMode = teammateMode;
605
-
606
- if (teammateMode === "wt") {
607
- if (!hasWindowsTerminal()) {
608
- warn("wt.exe 미발견 — in-process 모드로 자동 fallback");
609
- effectiveTeammateMode = "in-process";
610
- } else if (!hasWindowsTerminalSession()) {
611
- warn("WT_SESSION 미감지(Windows Terminal 외부) — in-process 모드로 자동 fallback");
612
- effectiveTeammateMode = "in-process";
613
- }
614
- }
615
-
616
- console.log(` 세션: ${WHITE}${sessionId}${RESET}`);
617
- console.log(` 모드: ${effectiveTeammateMode}`);
618
- console.log(` 리드: ${AMBER}${lead}${RESET}`);
619
- console.log(` 워커: ${agents.map((a) => `${AMBER}${a}${RESET}`).join(", ")}`);
620
-
621
- // ── in-process(네이티브): tmux 없이 supervisor가 직접 CLI 프로세스 관리 ──
622
- if (effectiveTeammateMode === "in-process") {
623
- for (let i = 0; i < subtasks.length; i++) {
624
- const preview = subtasks[i].length > 44 ? subtasks[i].slice(0, 44) + "…" : subtasks[i];
625
- console.log(` ${DIM}[${agents[i]}-${i + 1}] ${preview}${RESET}`);
626
- }
627
- console.log("");
628
-
629
- const { runtime, members } = await startNativeSupervisor({
630
- sessionId,
631
- task,
632
- lead,
633
- agents,
634
- subtasks,
635
- hubUrl,
636
- });
637
-
638
- if (!runtime?.controlUrl) {
639
- fail("in-process supervisor 시작 실패");
640
- return;
641
- }
642
-
643
- const tasks = buildTasks(subtasks, members.filter((m) => m.role === "worker"));
644
-
645
- saveTeamState({
646
- sessionName: sessionId,
647
- task,
648
- lead,
649
- agents,
650
- layout: "native",
651
- teammateMode: effectiveTeammateMode,
652
- startedAt: Date.now(),
653
- hubUrl,
654
- members: members.map((m, idx) => ({
655
- role: m.role,
656
- name: m.name,
657
- cli: m.cli,
658
- agentId: m.agentId,
659
- pane: `native:${idx}`,
660
- subtask: m.subtask || null,
661
- })),
662
- panes: {},
663
- tasks,
664
- native: {
665
- controlUrl: runtime.controlUrl,
666
- supervisorPid: runtime.supervisorPid,
667
- },
668
- });
669
-
670
- ok("네이티브 in-process 팀 시작 완료");
671
- console.log(` ${DIM}tmux 없이 실행됨 (직접 CLI 프로세스)${RESET}`);
672
- console.log(` ${DIM}제어: tfx team send/control/tasks/status${RESET}\n`);
673
- return;
674
- }
675
-
676
- // ── wt 모드(Windows Terminal 독립 split-pane) ──
677
- if (effectiveTeammateMode === "wt") {
678
- const paneCount = agents.length + 1; // lead + workers
679
- const effectiveLayout = layout === "Nx1" ? "Nx1" : "1xN";
680
- if (layout !== effectiveLayout) {
681
- warn(`wt 모드에서 ${layout} 레이아웃은 미지원 — ${effectiveLayout}로 대체`);
682
- }
683
- console.log(` 레이아웃: ${effectiveLayout} (${paneCount} panes)`);
684
-
685
- const paneCommands = [
686
- {
687
- title: `${sessionId}-lead`,
688
- command: buildCliCommand(lead),
689
- cwd: PKG_ROOT,
690
- },
691
- ...agents.map((cli, i) => ({
692
- title: `${sessionId}-${cli}-${i + 1}`,
693
- command: buildCliCommand(cli),
694
- cwd: PKG_ROOT,
695
- })),
696
- ];
697
-
698
- const session = createWtSession(sessionId, {
699
- layout: effectiveLayout,
700
- paneCommands,
701
- });
702
-
703
- const members = [
704
- {
705
- role: "lead",
706
- name: "lead",
707
- cli: lead,
708
- pane: session.panes[0] || "wt:0",
709
- agentId: toAgentId(lead, session.panes[0] || "wt:0"),
710
- },
711
- ];
712
-
713
- for (let i = 0; i < agents.length; i++) {
714
- const cli = agents[i];
715
- const target = session.panes[i + 1] || `wt:${i + 1}`;
716
- members.push({
717
- role: "worker",
718
- name: `${cli}-${i + 1}`,
719
- cli,
720
- pane: target,
721
- subtask: subtasks[i],
722
- agentId: toAgentId(cli, target),
723
- });
724
- }
725
-
726
- for (const worker of members.filter((m) => m.role === "worker")) {
727
- const preview = worker.subtask.length > 44 ? worker.subtask.slice(0, 44) + "…" : worker.subtask;
728
- console.log(` ${DIM}[${worker.name}] ${preview}${RESET}`);
729
- }
730
- console.log("");
731
-
732
- const tasks = buildTasks(subtasks, members.filter((m) => m.role === "worker"));
733
- const panes = {};
734
- for (const m of members) {
735
- panes[m.pane] = {
736
- role: m.role,
737
- name: m.name,
738
- cli: m.cli,
739
- agentId: m.agentId,
740
- subtask: m.subtask || null,
741
- };
742
- }
743
-
744
- saveTeamState({
745
- sessionName: sessionId,
746
- task,
747
- lead,
748
- agents,
749
- layout: effectiveLayout,
750
- teammateMode: effectiveTeammateMode,
751
- startedAt: Date.now(),
752
- hubUrl,
753
- members,
754
- panes,
755
- tasks,
756
- wt: {
757
- windowId: 0,
758
- layout: effectiveLayout,
759
- paneCount: session.paneCount,
760
- },
761
- });
762
-
763
- ok("Windows Terminal wt 팀 시작 완료");
764
- console.log(` ${DIM}현재 pane 기준으로 ${effectiveLayout} 분할 생성됨${RESET}`);
765
- console.log(` ${DIM}wt 모드는 자동 프롬프트 주입/Hub direct 제어(send/control)가 제한됩니다.${RESET}\n`);
766
- return;
767
- }
768
-
769
- // ── tmux 모드 ──
770
- ensureTmuxOrExit();
771
-
772
- const paneCount = agents.length + 1; // lead + workers
773
- const effectiveLayout = paneCount <= 4 ? layout : (layout === "Nx1" ? "Nx1" : "1xN");
774
- console.log(` 레이아웃: ${effectiveLayout} (${paneCount} panes)`);
775
-
776
- const session = createSession(sessionId, {
777
- layout: effectiveLayout,
778
- paneCount,
779
- });
780
-
781
- // Pane 0: lead
782
- const leadTarget = session.panes[0];
783
- startCliInPane(leadTarget, buildCliCommand(lead));
784
-
785
- // Pane 1..N: workers
786
- const assignments = [];
787
- const members = [
788
- {
789
- role: "lead",
790
- name: "lead",
791
- cli: lead,
792
- pane: leadTarget,
793
- agentId: toAgentId(lead, leadTarget),
794
- },
795
- ];
796
-
797
- for (let i = 0; i < agents.length; i++) {
798
- const cli = agents[i];
799
- const target = session.panes[i + 1];
800
- startCliInPane(target, buildCliCommand(cli));
801
-
802
- const worker = {
803
- role: "worker",
804
- name: `${cli}-${i + 1}`,
805
- cli,
806
- pane: target,
807
- subtask: subtasks[i],
808
- agentId: toAgentId(cli, target),
809
- };
810
-
811
- members.push(worker);
812
- assignments.push({ target, cli, subtask: subtasks[i] });
813
- }
814
-
815
- for (const worker of members.filter((m) => m.role === "worker")) {
816
- const preview = worker.subtask.length > 44 ? worker.subtask.slice(0, 44) + "…" : worker.subtask;
817
- console.log(` ${DIM}[${worker.name}] ${preview}${RESET}`);
818
- }
819
- console.log("");
820
-
821
- ok("CLI 초기화 대기 (3초)...");
822
- await new Promise((r) => setTimeout(r, 3000));
823
-
824
- await orchestrate(sessionId, assignments, {
825
- hubUrl,
826
- teammateMode: effectiveTeammateMode,
827
- lead: {
828
- target: leadTarget,
829
- cli: lead,
830
- task,
831
- },
832
- });
833
- ok("리드/워커 프롬프트 주입 완료");
834
-
835
- const tasks = buildTasks(subtasks, members.filter((m) => m.role === "worker"));
836
- const panes = {};
837
- for (const m of members) {
838
- panes[m.pane] = {
839
- role: m.role,
840
- name: m.name,
841
- cli: m.cli,
842
- agentId: m.agentId,
843
- subtask: m.subtask || null,
844
- };
845
- }
846
-
847
- saveTeamState({
848
- sessionName: sessionId,
849
- task,
850
- lead,
851
- agents,
852
- layout: effectiveLayout,
853
- teammateMode: effectiveTeammateMode,
854
- startedAt: Date.now(),
855
- hubUrl,
856
- members,
857
- panes,
858
- tasks,
859
- });
860
-
861
- const profilePrefix = TEAM_PROFILE === "team" ? "" : `TFX_TEAM_PROFILE=${TEAM_PROFILE} `;
862
- const taskListCommand = `${profilePrefix}${process.execPath} ${join(PKG_ROOT, "bin", "triflux.mjs")} team tasks`;
863
- configureTeammateKeybindings(sessionId, {
864
- inProcess: false,
865
- taskListCommand,
866
- });
867
-
868
- console.log(`\n ${GREEN}${BOLD}팀 세션 준비 완료${RESET}`);
869
- console.log(` ${DIM}Shift+Down: 다음 팀메이트 전환${RESET}`);
870
- console.log(` ${DIM}Shift+Tab / Shift+Left: 이전 팀메이트 전환${RESET}`);
871
- console.log(` ${DIM}Escape: 현재 팀메이트 인터럽트${RESET}`);
872
- console.log(` ${DIM}Ctrl+T: 태스크 목록${RESET}`);
873
- console.log(` ${DIM}참고: 일부 터미널/호스트에서 Shift+Up 전달이 제한될 수 있음${RESET}`);
874
- console.log(` ${DIM}Ctrl+B → D: 세션 분리 (백그라운드)${RESET}\n`);
875
-
876
- if (process.stdout.isTTY && process.stdin.isTTY) {
877
- attachSession(sessionId);
878
- } else {
879
- warn("TTY 미지원 환경이라 자동 attach를 생략함");
880
- console.log(` ${DIM}수동 연결: tfx team attach${RESET}\n`);
881
- }
882
- }
883
-
884
- async function teamStatus() {
885
- const state = loadTeamState();
886
- if (!state) {
887
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
888
- return;
889
- }
890
-
891
- const alive = isTeamAlive(state);
892
- const status = alive ? `${GREEN}● active${RESET}` : `${RED}● dead${RESET}`;
893
- const uptime = alive ? `${Math.round((Date.now() - state.startedAt) / 60000)}분` : "-";
894
-
895
- console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET} ${status}\n`);
896
- console.log(` 세션: ${state.sessionName}`);
897
- console.log(` 모드: ${state.teammateMode || "tmux"}`);
898
- console.log(` 리드: ${state.lead || "claude"}`);
899
- console.log(` 워커: ${(state.agents || []).join(", ")}`);
900
- console.log(` Uptime: ${uptime}`);
901
- console.log(` 태스크: ${(state.tasks || []).length}`);
902
- if (isWtMode(state) && !hasWindowsTerminalSession()) {
903
- console.log(` ${DIM}WT_SESSION 미감지: 생존성은 heuristics로 판정됨${RESET}`);
904
- }
905
-
906
- const members = state.members || [];
907
- if (members.length) {
908
- console.log("");
909
- for (const m of members) {
910
- const roleTag = m.role === "lead" ? "lead" : "worker";
911
- console.log(` - ${m.name} (${m.cli}) ${DIM}${roleTag}${RESET} ${DIM}${m.pane}${RESET}`);
912
- }
913
- }
914
-
915
- if (isNativeMode(state) && alive) {
916
- const native = await nativeGetStatus(state);
917
- const nativeMembers = native?.data?.members || [];
918
- if (nativeMembers.length) {
919
- console.log("");
920
- for (const m of nativeMembers) {
921
- console.log(` • ${m.name}: ${m.status}${m.lastPreview ? ` ${DIM}${m.lastPreview}${RESET}` : ""}`);
922
- }
923
- }
924
- }
925
-
926
- // Hub task-list 데이터 통합 (v2.2)
927
- if (alive) {
928
- const hubTasks = await fetchHubTaskList(state);
929
- if (hubTasks.length > 0) {
930
- const completed = hubTasks.filter((t) => t.status === "completed").length;
931
- const inProgress = hubTasks.filter((t) => t.status === "in_progress").length;
932
- const failed = hubTasks.filter((t) => t.status === "failed").length;
933
- const pending = hubTasks.filter((t) => !t.status || t.status === "pending").length;
934
-
935
- console.log(`\n ${BOLD}Hub Tasks${RESET} ${DIM}(${completed}/${hubTasks.length} done)${RESET}`);
936
- for (const t of hubTasks) {
937
- const icon = t.status === "completed" ? `${GREEN}✓${RESET}`
938
- : t.status === "in_progress" ? `${AMBER}●${RESET}`
939
- : t.status === "failed" ? `${RED}✗${RESET}`
940
- : `${GRAY}○${RESET}`;
941
- const owner = t.owner ? ` ${GRAY}[${t.owner}]${RESET}` : "";
942
- const subject = t.subject || t.description?.slice(0, 50) || "";
943
- console.log(` ${icon} ${subject}${owner}`);
944
- }
945
- if (failed > 0) console.log(` ${RED}⚠ ${failed}건 실패${RESET}`);
946
- }
947
- }
948
-
949
- console.log("");
950
- }
951
-
952
- /**
953
- * Hub bridge에서 팀 task-list 조회 (v2.2)
954
- * @param {object} state — team-state.json
955
- * @returns {Promise<Array>}
956
- */
957
- async function fetchHubTaskList(state) {
958
- const hubBase = (state?.hubUrl || getDefaultHubUrl()).replace(/\/mcp$/, "");
959
- // teamName: native 모드는 state에 저장된 팀 이름, SKILL.md 모드는 세션 이름 기반
960
- const teamName = state?.native?.teamName || state?.sessionName || null;
961
- if (!teamName) return [];
962
- try {
963
- const res = await fetch(`${hubBase}/bridge/team/task-list`, {
964
- method: "POST",
965
- headers: { "Content-Type": "application/json" },
966
- body: JSON.stringify({ team_name: teamName }),
967
- signal: AbortSignal.timeout(2000),
968
- });
969
- const data = await res.json();
970
- return data?.ok ? (data.data?.tasks || []) : [];
971
- } catch {
972
- return [];
973
- }
974
- }
975
-
976
- function teamTasks() {
977
- const state = loadTeamState();
978
- if (!state || !isTeamAlive(state)) {
979
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
980
- return;
981
- }
982
- renderTasks(state.tasks || []);
983
- }
984
-
985
- function teamTaskUpdate() {
986
- const state = loadTeamState();
987
- if (!state || !isTeamAlive(state)) {
988
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
989
- return;
990
- }
991
-
992
- const action = (process.argv[4] || "").toLowerCase();
993
- const taskId = (process.argv[5] || "").toUpperCase();
994
-
995
- const nextStatus = action === "done" || action === "complete" || action === "completed"
996
- ? "completed"
997
- : action === "progress" || action === "in-progress" || action === "in_progress"
998
- ? "in_progress"
999
- : action === "pending"
1000
- ? "pending"
1001
- : null;
1002
-
1003
- if (!nextStatus || !taskId) {
1004
- console.log(`\n 사용법: ${WHITE}tfx team task <pending|progress|done> <T1>${RESET}\n`);
1005
- return;
1006
- }
1007
-
1008
- const tasks = state.tasks || [];
1009
- const target = tasks.find((t) => String(t.id).toUpperCase() === taskId);
1010
- if (!target) {
1011
- console.log(`\n ${DIM}태스크를 찾을 수 없음: ${taskId}${RESET}\n`);
1012
- return;
1013
- }
1014
-
1015
- target.status = nextStatus;
1016
- saveTeamState(state);
1017
- ok(`${target.id} 상태 갱신: ${nextStatus}`);
1018
- console.log("");
1019
- }
1020
-
1021
- async function teamAttach() {
1022
- const state = loadTeamState();
1023
- if (!state || !isTeamAlive(state)) {
1024
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1025
- return;
1026
- }
1027
-
1028
- if (isNativeMode(state)) {
1029
- console.log(`\n ${DIM}in-process 모드는 별도 attach가 없습니다.${RESET}`);
1030
- console.log(` ${DIM}상태 확인: tfx team status${RESET}\n`);
1031
- return;
1032
- }
1033
-
1034
- if (isWtMode(state)) {
1035
- console.log(`\n ${DIM}wt 모드는 attach 개념이 없습니다 (Windows Terminal pane가 독립 실행됨).${RESET}`);
1036
- console.log(` ${DIM}재실행/정리는: tfx team stop${RESET}\n`);
1037
- return;
1038
- }
1039
-
1040
- try {
1041
- attachSession(state.sessionName);
1042
- } catch (e) {
1043
- const allowWt = wantsWtAttachFallback();
1044
- if (allowWt && await launchAttachInWindowsTerminal(state.sessionName)) {
1045
- warn(`현재 터미널에서 attach 실패: ${e.message}`);
1046
- ok("Windows Terminal split-pane로 attach 재시도 창을 열었습니다.");
1047
- console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}`);
1048
- console.log("");
1049
- return;
1050
- }
1051
- fail(`attach 실패: ${e.message}`);
1052
- if (allowWt) {
1053
- fail("WT 분할창 attach 자동 검증 실패 (session_attached 증가 없음)");
1054
- } else {
1055
- warn("자동 WT 분할은 기본 비활성입니다. 필요 시 --wt 옵션으로 실행하세요.");
1056
- }
1057
- console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}`);
1058
- console.log("");
1059
- return;
1060
- }
1061
- }
1062
-
1063
- async function teamDebug() {
1064
- const state = loadTeamState();
1065
- const linesIdx = process.argv.findIndex((a) => a === "--lines" || a === "-n");
1066
- const lines = linesIdx !== -1 ? Math.max(3, parseInt(process.argv[linesIdx + 1] || "20", 10) || 20) : 20;
1067
- const mux = detectMultiplexer() || "none";
1068
- const hub = await getHubInfo();
1069
-
1070
- console.log(`\n ${AMBER}${BOLD}⬡ Team Debug${RESET}\n`);
1071
- console.log(` platform: ${process.platform}`);
1072
- console.log(` node: ${process.version}`);
1073
- console.log(` tty: stdout=${!!process.stdout.isTTY}, stdin=${!!process.stdin.isTTY}`);
1074
- console.log(` mux: ${mux}`);
1075
- console.log(` hub-pid: ${hub ? `${hub.pid}` : "-"}`);
1076
- console.log(` hub-url: ${hub?.url || "-"}`);
1077
-
1078
- const sessions = listSessions();
1079
- console.log(` sessions: ${sessions.length ? sessions.join(", ") : "-"}`);
1080
-
1081
- if (!state) {
1082
- console.log(`\n ${DIM}team-state 없음 (활성 세션 없음)${RESET}\n`);
1083
- return;
1084
- }
1085
-
1086
- console.log(`\n ${BOLD}state${RESET}`);
1087
- console.log(` session: ${state.sessionName}`);
1088
- console.log(` profile: ${state.profile || TEAM_PROFILE}`);
1089
- console.log(` mode: ${state.teammateMode || "tmux"}`);
1090
- console.log(` lead: ${state.lead}`);
1091
- console.log(` agents: ${(state.agents || []).join(", ")}`);
1092
- console.log(` alive: ${isTeamAlive(state) ? "yes" : "no"}`);
1093
- const attached = getSessionAttachedCount(state.sessionName);
1094
- console.log(` attached: ${attached == null ? "-" : attached}`);
1095
-
1096
- if (isWtMode(state)) {
1097
- const wtState = state.wt || {};
1098
- console.log(`\n ${BOLD}wt-session${RESET}`);
1099
- console.log(` window: ${wtState.windowId ?? 0}`);
1100
- console.log(` layout: ${wtState.layout || state.layout || "-"}`);
1101
- console.log(` panes: ${wtState.paneCount ?? (state.members || []).length}`);
1102
- console.log(` wt.exe: ${hasWindowsTerminal() ? "yes" : "no"}`);
1103
- console.log(` WT_SESSION:${hasWindowsTerminalSession() ? "yes" : "no"}`);
1104
- console.log("");
1105
- return;
1106
- }
1107
-
1108
- if (isNativeMode(state)) {
1109
- const native = await nativeGetStatus(state);
1110
- const members = native?.data?.members || [];
1111
- console.log(`\n ${BOLD}native-members${RESET}`);
1112
- if (!members.length) {
1113
- console.log(` ${DIM}(no data)${RESET}`);
1114
- } else {
1115
- for (const m of members) {
1116
- console.log(` - ${m.name}: ${m.status}${m.lastPreview ? ` ${DIM}${m.lastPreview}${RESET}` : ""}`);
1117
- }
1118
- }
1119
- console.log("");
1120
- return;
1121
- }
1122
-
1123
- const members = state.members || [];
1124
- console.log(`\n ${BOLD}pane-tail${RESET} ${DIM}(last ${lines} lines)${RESET}`);
1125
- if (!members.length) {
1126
- console.log(` ${DIM}(members 없음)${RESET}`);
1127
- } else {
1128
- for (const m of members) {
1129
- const tail = capturePaneOutput(m.pane, lines) || "(empty)";
1130
- console.log(`\n [${m.name}] ${m.pane}`);
1131
- const tailLines = tail.split("\n").slice(-lines);
1132
- for (const line of tailLines) {
1133
- console.log(` ${line}`);
1134
- }
1135
- }
1136
- }
1137
- console.log("");
1138
- }
1139
-
1140
- async function teamFocus() {
1141
- const state = loadTeamState();
1142
- if (!state || !isTeamAlive(state)) {
1143
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1144
- return;
1145
- }
1146
-
1147
- if (isNativeMode(state)) {
1148
- console.log(`\n ${DIM}in-process 모드는 focus/attach 개념이 없습니다.${RESET}`);
1149
- console.log(` ${DIM}직접 지시: tfx team send <대상> \"메시지\"${RESET}\n`);
1150
- return;
1151
- }
1152
-
1153
- const selector = process.argv[4];
1154
- const member = resolveMember(state, selector);
1155
- if (!member) {
1156
- console.log(`\n 사용법: ${WHITE}tfx team focus <lead|이름|번호>${RESET}\n`);
1157
- return;
1158
- }
1159
-
1160
- if (isWtMode(state)) {
1161
- const m = /^wt:(\d+)$/.exec(member.pane || "");
1162
- const paneIndex = m ? parseInt(m[1], 10) : NaN;
1163
- if (!Number.isFinite(paneIndex)) {
1164
- warn(`wt pane 인덱스 파싱 실패: ${member.pane}`);
1165
- console.log("");
1166
- return;
1167
- }
1168
- const focused = focusWtPane(paneIndex, {
1169
- layout: state?.wt?.layout || state?.layout || "1xN",
1170
- });
1171
- if (focused) {
1172
- ok(`${member.name} pane 포커스 이동 (wt)`);
1173
- } else {
1174
- warn("wt pane 포커스 이동 실패 (WT_SESSION/wt.exe 상태 확인 필요)");
1175
- }
1176
- console.log("");
1177
- return;
1178
- }
1179
-
1180
- focusPane(member.pane, { zoom: (state.teammateMode === "in-process") });
1181
- try {
1182
- attachSession(state.sessionName);
1183
- } catch (e) {
1184
- const allowWt = wantsWtAttachFallback();
1185
- if (allowWt && await launchAttachInWindowsTerminal(state.sessionName)) {
1186
- warn(`현재 터미널에서 attach 실패: ${e.message}`);
1187
- ok("Windows Terminal split-pane로 attach 재시도 창을 열었습니다.");
1188
- console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}`);
1189
- console.log("");
1190
- return;
1191
- }
1192
- fail(`attach 실패: ${e.message}`);
1193
- if (allowWt) {
1194
- fail("WT 분할창 attach 자동 검증 실패 (session_attached 증가 없음)");
1195
- } else {
1196
- warn("자동 WT 분할은 기본 비활성입니다. 필요 시 --wt 옵션으로 실행하세요.");
1197
- }
1198
- console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}`);
1199
- console.log("");
1200
- return;
1201
- }
1202
- }
1203
-
1204
- async function teamInterrupt() {
1205
- const state = loadTeamState();
1206
- if (!state || !isTeamAlive(state)) {
1207
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1208
- return;
1209
- }
1210
-
1211
- const selector = process.argv[4] || "lead";
1212
- const member = resolveMember(state, selector);
1213
- if (!member) {
1214
- console.log(`\n 사용법: ${WHITE}tfx team interrupt <lead|이름|번호>${RESET}\n`);
1215
- return;
1216
- }
1217
-
1218
- if (isWtMode(state)) {
1219
- warn("wt 모드에서는 pane stdin 주입이 지원되지 않아 interrupt를 자동 전송할 수 없습니다.");
1220
- console.log(` ${DIM}수동으로 해당 pane에서 Ctrl+C를 입력하세요.${RESET}`);
1221
- console.log("");
1222
- return;
1223
- }
1224
-
1225
- if (isNativeMode(state)) {
1226
- const result = await nativeRequest(state, "/interrupt", { member: member.name });
1227
- if (result?.ok) {
1228
- ok(`${member.name} 인터럽트 전송`);
1229
- } else {
1230
- warn(`${member.name} 인터럽트 실패`);
1231
- }
1232
- console.log("");
1233
- return;
1234
- }
1235
-
1236
- sendKeys(member.pane, "C-c");
1237
- ok(`${member.name} 인터럽트 전송`);
1238
- console.log("");
1239
- }
1240
-
1241
- async function teamControl() {
1242
- const state = loadTeamState();
1243
- if (!state || !isTeamAlive(state)) {
1244
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1245
- return;
1246
- }
1247
-
1248
- const selector = process.argv[4];
1249
- const command = (process.argv[5] || "").toLowerCase();
1250
- const reason = process.argv.slice(6).join(" ");
1251
- const member = resolveMember(state, selector);
1252
- const allowed = new Set(["interrupt", "stop", "pause", "resume"]);
1253
-
1254
- if (!member || !allowed.has(command)) {
1255
- console.log(`\n 사용법: ${WHITE}tfx team control <lead|이름|번호> <interrupt|stop|pause|resume> [사유]${RESET}\n`);
1256
- return;
1257
- }
1258
-
1259
- if (isWtMode(state)) {
1260
- warn("wt 모드는 Hub direct/control 주입 경로가 비활성입니다.");
1261
- console.log(` ${DIM}수동 제어: 해당 pane에서 직접 명령/인터럽트를 수행하세요.${RESET}`);
1262
- console.log("");
1263
- return;
1264
- }
1265
-
1266
- // 직접 주입: MCP 유무와 무관하게 즉시 전달
1267
- let directOk = false;
1268
- if (isNativeMode(state)) {
1269
- const direct = await nativeRequest(state, "/control", {
1270
- member: member.name,
1271
- command,
1272
- reason,
1273
- });
1274
- directOk = !!direct?.ok;
1275
- } else {
1276
- const controlMsg = `[LEAD CONTROL] command=${command}${reason ? ` reason=${reason}` : ""}`;
1277
- injectPrompt(member.pane, controlMsg);
1278
- if (command === "interrupt") {
1279
- sendKeys(member.pane, "C-c");
1280
- }
1281
- directOk = true;
1282
- }
1283
-
1284
- // Hub direct mailbox에도 발행
1285
- const published = await publishLeadControl(state, member, command, reason);
1286
-
1287
- if (directOk && published) {
1288
- ok(`${member.name} 제어 전송 (${command}, direct + hub)`);
1289
- } else if (directOk) {
1290
- ok(`${member.name} 제어 전송 (${command}, direct only)`);
1291
- } else {
1292
- warn(`${member.name} 제어 전송 실패 (${command})`);
1293
- }
1294
- console.log("");
1295
- }
1296
-
1297
- async function teamStop() {
1298
- const state = loadTeamState();
1299
- if (!state) {
1300
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1301
- return;
1302
- }
1303
-
1304
- if (isNativeMode(state)) {
1305
- await nativeRequest(state, "/stop", {});
1306
- try { process.kill(state.native.supervisorPid, "SIGTERM"); } catch {}
1307
- ok(`세션 종료: ${state.sessionName}`);
1308
- } else if (isWtMode(state)) {
1309
- const closed = closeWtSession({
1310
- layout: state?.wt?.layout || state?.layout || "1xN",
1311
- paneCount: state?.wt?.paneCount ?? (state.members || []).length,
1312
- });
1313
- ok(`세션 종료: ${state.sessionName}${closed ? ` (${closed} panes closed)` : ""}`);
1314
- } else {
1315
- if (sessionExists(state.sessionName)) {
1316
- killSession(state.sessionName);
1317
- ok(`세션 종료: ${state.sessionName}`);
1318
- } else {
1319
- console.log(` ${DIM}세션 이미 종료됨${RESET}`);
1320
- }
1321
- }
1322
-
1323
- clearTeamState();
1324
- console.log("");
1325
- }
1326
-
1327
- async function teamKill() {
1328
- const state = loadTeamState();
1329
- if (state && isNativeMode(state) && isTeamAlive(state)) {
1330
- await nativeRequest(state, "/stop", {});
1331
- try { process.kill(state.native.supervisorPid, "SIGTERM"); } catch {}
1332
- clearTeamState();
1333
- ok(`종료: ${state.sessionName}`);
1334
- console.log("");
1335
- return;
1336
- }
1337
- if (state && isWtMode(state)) {
1338
- const closed = closeWtSession({
1339
- layout: state?.wt?.layout || state?.layout || "1xN",
1340
- paneCount: state?.wt?.paneCount ?? (state.members || []).length,
1341
- });
1342
- clearTeamState();
1343
- ok(`종료: ${state.sessionName}${closed ? ` (${closed} panes closed)` : ""}`);
1344
- console.log("");
1345
- return;
1346
- }
1347
-
1348
- const sessions = listSessions();
1349
- if (sessions.length === 0) {
1350
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1351
- return;
1352
- }
1353
- for (const s of sessions) {
1354
- killSession(s);
1355
- ok(`종료: ${s}`);
1356
- }
1357
- clearTeamState();
1358
- console.log("");
1359
- }
1360
-
1361
- async function teamSend() {
1362
- const state = loadTeamState();
1363
- if (!state || !isTeamAlive(state)) {
1364
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1365
- return;
1366
- }
1367
-
1368
- const selector = process.argv[4];
1369
- const message = process.argv.slice(5).join(" ");
1370
- const member = resolveMember(state, selector);
1371
- if (!member || !message) {
1372
- console.log(`\n 사용법: ${WHITE}tfx team send <lead|이름|번호> "메시지"${RESET}\n`);
1373
- return;
1374
- }
1375
-
1376
- if (isWtMode(state)) {
1377
- warn("wt 모드는 pane 프롬프트 자동 주입(send)이 지원되지 않습니다.");
1378
- console.log(` ${DIM}수동 전달: 선택한 pane에 직접 붙여넣으세요.${RESET}`);
1379
- console.log("");
1380
- return;
1381
- }
1382
-
1383
- if (isNativeMode(state)) {
1384
- const result = await nativeRequest(state, "/send", { member: member.name, text: message });
1385
- if (result?.ok) {
1386
- ok(`${member.name}에 메시지 주입 완료`);
1387
- } else {
1388
- warn(`${member.name} 메시지 주입 실패`);
1389
- }
1390
- console.log("");
1391
- return;
1392
- }
1393
-
1394
- injectPrompt(member.pane, message);
1395
- ok(`${member.name}에 메시지 주입 완료`);
1396
- console.log("");
1397
- }
1398
-
1399
- function teamList() {
1400
- const state = loadTeamState();
1401
- if (state && isNativeMode(state) && isTeamAlive(state)) {
1402
- console.log(`\n ${AMBER}${BOLD}⬡ 팀 세션 목록${RESET}\n`);
1403
- console.log(` ${GREEN}●${RESET} ${state.sessionName} ${DIM}(in-process)${RESET}`);
1404
- console.log("");
1405
- return;
1406
- }
1407
- if (state && isWtMode(state) && isTeamAlive(state)) {
1408
- console.log(`\n ${AMBER}${BOLD}⬡ 팀 세션 목록${RESET}\n`);
1409
- console.log(` ${GREEN}●${RESET} ${state.sessionName} ${DIM}(wt)${RESET}`);
1410
- console.log("");
1411
- return;
1412
- }
1413
-
1414
- const sessions = listSessions();
1415
- if (sessions.length === 0) {
1416
- console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1417
- return;
1418
- }
1419
- console.log(`\n ${AMBER}${BOLD}⬡ 팀 세션 목록${RESET}\n`);
1420
- for (const s of sessions) {
1421
- console.log(` ${GREEN}●${RESET} ${s}`);
1422
- }
1423
- console.log("");
1424
- }
1425
-
1426
- function teamHelp() {
1427
- console.log(`
1428
- ${AMBER}${BOLD}⬡ tfx team${RESET} ${DIM}멀티-CLI 팀 모드 (Lead + Teammates)${RESET}
1429
-
1430
- ${BOLD}시작${RESET}
1431
- ${WHITE}tfx team "작업 설명"${RESET}
1432
- ${WHITE}tfx team --agents codex,gemini --lead claude "작업"${RESET}
1433
- ${WHITE}tfx team --teammate-mode tmux "작업"${RESET}
1434
- ${WHITE}tfx team --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}
1435
- ${WHITE}tfx team --layout 1xN "작업"${RESET} ${DIM}(세로 분할 컬럼)${RESET}
1436
- ${WHITE}tfx team --layout Nx1 "작업"${RESET} ${DIM}(가로 분할 스택)${RESET}
1437
- ${WHITE}tfx team --teammate-mode in-process "작업"${RESET} ${DIM}(tmux 불필요)${RESET}
1438
-
1439
- ${BOLD}제어${RESET}
1440
- ${WHITE}tfx team status${RESET} ${GRAY}현재 팀 상태${RESET}
1441
- ${WHITE}tfx team debug${RESET} ${DIM}[--lines 30]${RESET} ${GRAY}강화 디버그 출력(환경/세션/pane tail)${RESET}
1442
- ${WHITE}tfx team tasks${RESET} ${GRAY}공유 태스크 목록${RESET}
1443
- ${WHITE}tfx team task${RESET} ${DIM}<pending|progress|done> <T1>${RESET} ${GRAY}태스크 상태 갱신${RESET}
1444
- ${WHITE}tfx team attach${RESET} ${DIM}[--wt]${RESET} ${GRAY}세션 재연결 (WT 분할은 opt-in)${RESET}
1445
- ${WHITE}tfx team focus${RESET} ${DIM}<lead|이름|번호> [--wt]${RESET} ${GRAY}특정 팀메이트 포커스${RESET}
1446
- ${WHITE}tfx team send${RESET} ${DIM}<lead|이름|번호> "msg"${RESET} ${GRAY}팀메이트에 메시지 주입${RESET}
1447
- ${WHITE}tfx team interrupt${RESET} ${DIM}<대상>${RESET} ${GRAY}팀메이트 인터럽트(C-c)${RESET}
1448
- ${WHITE}tfx team control${RESET} ${DIM}<대상> <cmd>${RESET} ${GRAY}리드 제어명령(interrupt|stop|pause|resume)${RESET}
1449
- ${WHITE}tfx team stop${RESET} ${GRAY}graceful 종료${RESET}
1450
- ${WHITE}tfx team kill${RESET} ${GRAY}모든 팀 세션 강제 종료${RESET}
1451
- ${WHITE}tfx team list${RESET} ${GRAY}활성 세션 목록${RESET}
1452
-
1453
44
  ${BOLD}키 조작(Claude teammate 스타일, tmux 모드)${RESET}
1454
45
  ${WHITE}Shift+Down${RESET} ${GRAY}다음 팀메이트${RESET}
1455
46
  ${WHITE}Shift+Tab${RESET} ${GRAY}이전 팀메이트 (권장)${RESET}
1456
47
  ${WHITE}Shift+Left${RESET} ${GRAY}이전 팀메이트 (대체)${RESET}
1457
- ${WHITE}Shift+Up${RESET} ${GRAY}이전 팀메이트 (환경 따라 미동작 가능)${RESET}
48
+ ${WHITE}Shift+Up${RESET} ${GRAY}미지원 (Claude Code가 캡처 불가, scroll-up 충돌)${RESET}
1458
49
  ${WHITE}Escape${RESET} ${GRAY}현재 팀메이트 인터럽트${RESET}
1459
50
  ${WHITE}Ctrl+T${RESET} ${GRAY}태스크 목록 토글${RESET}
1460
51
  `);
1461
52
  }
1462
-
1463
- // ── 메인 진입점 ──
1464
-
1465
- /**
1466
- * tfx team 서브커맨드 라우터
1467
- * bin/triflux.mjs에서 호출
1468
- */
53
+
54
+ /**
55
+ * tfx multi 서브커맨드 라우터
56
+ * bin/triflux.mjs에서 호출
57
+ */
1469
58
  export async function cmdTeam() {
1470
59
  const rawSub = process.argv[3];
1471
60
  const sub = typeof rawSub === "string" ? rawSub.toLowerCase() : rawSub;
1472
-
1473
- switch (sub) {
1474
- case "status": return teamStatus();
1475
- case "debug": return teamDebug();
1476
- case "tasks": return teamTasks();
1477
- case "task": return teamTaskUpdate();
1478
- case "attach": return teamAttach();
1479
- case "focus": return teamFocus();
1480
- case "interrupt": return teamInterrupt();
1481
- case "control": return teamControl();
1482
- case "stop": return teamStop();
1483
- case "kill": return teamKill();
1484
- case "send": return teamSend();
1485
- case "list": return teamList();
1486
- case "help":
1487
- case "--help":
1488
- case "-h":
1489
- return teamHelp();
61
+
62
+ switch (sub) {
63
+ case "status":
64
+ return teamStatus();
65
+ case "debug":
66
+ return teamDebug();
67
+ case "tasks":
68
+ return teamTasks();
69
+ case "task":
70
+ return teamTaskUpdate();
71
+ case "attach":
72
+ return teamAttach();
73
+ case "focus":
74
+ return teamFocus();
75
+ case "interrupt":
76
+ return teamInterrupt();
77
+ case "control":
78
+ return teamControl();
79
+ case "stop":
80
+ return teamStop();
81
+ case "kill":
82
+ return teamKill();
83
+ case "send":
84
+ return teamSend();
85
+ case "list":
86
+ return teamList();
87
+ case "help":
88
+ case "--help":
89
+ case "-h":
90
+ return teamHelp();
1490
91
  case undefined:
1491
92
  return teamHelp();
1492
93
  default:
1493
- // 서브커맨드가 아니면 작업 문자열로 간주
1494
94
  if (typeof sub === "string" && !sub.startsWith("-") && TEAM_SUBCOMMANDS.has(sub)) {
1495
95
  return teamHelp();
1496
96
  }