triflux 3.2.0-dev.3 → 3.2.0-dev.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hub/team/cli.mjs CHANGED
@@ -1,36 +1,44 @@
1
1
  // hub/team/cli.mjs — tfx team CLI 진입점
2
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 { execSync, 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";
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
26
  import { buildCliCommand, startCliInPane, injectPrompt, sendKeys } from "./pane.mjs";
27
27
  import { orchestrate, decomposeTask, buildLeadPrompt, buildPrompt } from "./orchestrator.mjs";
28
28
 
29
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 TEAM_STATE_FILE = join(HUB_PID_DIR, "team-state.json");
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
+ );
34
42
 
35
43
  const TEAM_SUBCOMMANDS = new Set([
36
44
  "status", "attach", "stop", "kill", "send", "list", "help", "tasks", "task", "focus", "interrupt", "control", "debug",
@@ -61,66 +69,163 @@ function loadTeamState() {
61
69
  }
62
70
  }
63
71
 
64
- function saveTeamState(state) {
65
- mkdirSync(HUB_PID_DIR, { recursive: true });
66
- writeFileSync(TEAM_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
67
- }
68
-
69
- function clearTeamState() {
70
- try { unlinkSync(TEAM_STATE_FILE); } catch {}
71
- }
72
-
73
- // ── Hub 유틸 ──
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
+ }
74
77
 
75
- function getHubInfo() {
76
- if (!existsSync(HUB_PID_FILE)) return null;
77
- try {
78
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
79
- process.kill(info.pid, 0); // 프로세스 생존 확인
80
- return info;
81
- } catch {
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
+ }
158
+
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
+ }
189
+
190
+ async function startHubDaemon() {
191
+ const serverPath = join(PKG_ROOT, "hub", "server.mjs");
192
+ if (!existsSync(serverPath)) {
193
+ fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
82
194
  return null;
83
195
  }
84
- }
85
196
 
86
- function startHubDaemon() {
87
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
88
- if (!existsSync(serverPath)) {
89
- fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
90
- return null;
91
- }
197
+ const child = spawn(process.execPath, [serverPath], {
198
+ env: { ...process.env },
199
+ stdio: "ignore",
200
+ detached: true,
201
+ });
202
+ child.unref();
203
+
204
+ const expectedPort = getDefaultHubPort();
205
+
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
+ }
92
215
 
93
- const child = spawn(process.execPath, [serverPath], {
94
- env: { ...process.env },
95
- stdio: "ignore",
96
- detached: true,
97
- });
98
- child.unref();
216
+ // ── 인자 파싱 ──
99
217
 
100
- // PID 파일 확인 (최대 3초 대기)
101
- const deadline = Date.now() + 3000;
102
- while (Date.now() < deadline) {
103
- if (existsSync(HUB_PID_FILE)) {
104
- return JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
105
- }
106
- execSync('node -e "setTimeout(()=>{},100)"', { stdio: "ignore", timeout: 500 });
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";
107
225
  }
108
- return null;
226
+ return "in-process";
109
227
  }
110
228
 
111
- // ── 인자 파싱 ──
112
-
113
- function normalizeTeammateMode(mode = "auto") {
114
- const raw = String(mode).toLowerCase();
115
- if (raw === "inline" || raw === "native") return "in-process";
116
- if (raw === "in-process" || raw === "tmux" || raw === "wt") return raw;
117
- if (raw === "windows-terminal" || raw === "windows_terminal") return "wt";
118
- if (raw === "auto") {
119
- return process.env.TMUX ? "tmux" : "in-process";
120
- }
121
- return "in-process";
122
- }
123
-
124
229
  function normalizeLayout(layout = "2x2") {
125
230
  const raw = String(layout).toLowerCase();
126
231
  if (raw === "2x2" || raw === "grid") return "2x2";
@@ -183,8 +288,8 @@ function ensureTmuxOrExit() {
183
288
  process.exit(1);
184
289
  }
185
290
 
186
- async function launchAttachInWindowsTerminal(sessionName) {
187
- if (!hasWindowsTerminal()) return false;
291
+ async function launchAttachInWindowsTerminal(sessionName) {
292
+ if (!hasWindowsTerminal()) return false;
188
293
 
189
294
  let attachSpec;
190
295
  try {
@@ -244,10 +349,10 @@ function wantsWtAttachFallback() {
244
349
  || process.env.TFX_ATTACH_WT_AUTO === "1";
245
350
  }
246
351
 
247
- function toAgentId(cli, target) {
248
- const suffix = String(target).split(/[:.]/).pop();
249
- return `${cli}-${suffix}`;
250
- }
352
+ function toAgentId(cli, target) {
353
+ const suffix = String(target).split(/[:.]/).pop();
354
+ return `${cli}-${suffix}`;
355
+ }
251
356
 
252
357
  function buildNativeCliCommand(cli) {
253
358
  switch (cli) {
@@ -303,11 +408,11 @@ function resolveMember(state, selector) {
303
408
  if (workerIdx >= 0 && workerIdx < workers.length) return workers[workerIdx];
304
409
  }
305
410
 
306
- const n = parseInt(selector, 10);
307
- if (!Number.isNaN(n)) {
308
- // 하위 호환: pane 번호 우선
309
- const byPane = members.find((m) => m.pane?.endsWith(`.${n}`) || m.pane?.endsWith(`:${n}`));
310
- if (byPane) return byPane;
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;
311
416
 
312
417
  // teammate 스타일: 1-based 인덱스
313
418
  if (n >= 1 && n <= members.length) return members[n - 1];
@@ -316,8 +421,8 @@ function resolveMember(state, selector) {
316
421
  return null;
317
422
  }
318
423
 
319
- async function publishLeadControl(state, targetMember, command, reason = "") {
320
- const hubBase = (state?.hubUrl || "http://127.0.0.1:27888/mcp").replace(/\/mcp$/, "");
424
+ async function publishLeadControl(state, targetMember, command, reason = "") {
425
+ const hubBase = (state?.hubUrl || getDefaultHubUrl()).replace(/\/mcp$/, "");
321
426
  const leadAgent = (state?.members || []).find((m) => m.role === "lead")?.agentId || "lead";
322
427
 
323
428
  const payload = {
@@ -343,27 +448,29 @@ async function publishLeadControl(state, targetMember, command, reason = "") {
343
448
  }
344
449
  }
345
450
 
346
- function isNativeMode(state) {
347
- return state?.teammateMode === "in-process" && !!state?.native?.controlUrl;
348
- }
349
-
350
- function isWtMode(state) {
351
- return state?.teammateMode === "wt";
352
- }
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
+ }
353
458
 
354
459
  function isTeamAlive(state) {
355
460
  if (!state) return false;
356
- if (isNativeMode(state)) {
357
- try {
358
- process.kill(state.native.supervisorPid, 0);
359
- return true;
360
- } catch {
361
- return false;
362
- }
363
- }
461
+ if (isNativeMode(state)) {
462
+ try {
463
+ process.kill(state.native.supervisorPid, 0);
464
+ return true;
465
+ } catch {
466
+ return false;
467
+ }
468
+ }
364
469
  if (isWtMode(state)) {
365
- // WT pane 상태를 신뢰성 있게 조회할 API가 없어 세션 환경/실행기 존재 여부로 판정
366
- return hasWindowsTerminal() && hasWindowsTerminalSession();
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;
367
474
  }
368
475
  return sessionExists(state.sessionName);
369
476
  }
@@ -464,23 +571,23 @@ async function startNativeSupervisor({ sessionId, task, lead, agents, subtasks,
464
571
 
465
572
  // ── 서브커맨드 ──
466
573
 
467
- async function teamStart() {
574
+ async function teamStart() {
468
575
  const { agents, lead, layout, teammateMode, task } = parseTeamArgs();
469
- if (!task) {
470
- console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET}\n`);
471
- console.log(` 사용법: ${WHITE}tfx team "작업 설명"${RESET}`);
472
- console.log(` ${WHITE}tfx team --agents codex,gemini --lead claude "작업"${RESET}`);
473
- console.log(` ${WHITE}tfx team --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}`);
474
- console.log(` ${WHITE}tfx team --teammate-mode in-process "작업"${RESET} ${DIM}(tmux 불필요)${RESET}\n`);
475
- return;
476
- }
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
+ }
477
584
 
478
585
  console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET}\n`);
479
586
 
480
- let hub = getHubInfo();
481
- if (!hub) {
482
- process.stdout.write(" Hub 시작 중...");
483
- hub = startHubDaemon();
587
+ let hub = await getHubInfo();
588
+ if (!hub) {
589
+ process.stdout.write(" Hub 시작 중...");
590
+ hub = await startHubDaemon();
484
591
  if (hub) {
485
592
  console.log(` ${GREEN}✓${RESET}`);
486
593
  } else {
@@ -491,28 +598,28 @@ async function teamStart() {
491
598
  ok(`Hub: ${DIM}${hub.url}${RESET}`);
492
599
  }
493
600
 
494
- const sessionId = `tfx-team-${Date.now().toString(36).slice(-4)}`;
495
- const subtasks = decomposeTask(task, agents.length);
496
- const hubUrl = hub?.url || "http://127.0.0.1:27888/mcp";
497
- let effectiveTeammateMode = teammateMode;
498
-
499
- if (teammateMode === "wt") {
500
- if (!hasWindowsTerminal()) {
501
- warn("wt.exe 미발견 — in-process 모드로 자동 fallback");
502
- effectiveTeammateMode = "in-process";
503
- } else if (!hasWindowsTerminalSession()) {
504
- warn("WT_SESSION 미감지(Windows Terminal 외부) — in-process 모드로 자동 fallback");
505
- effectiveTeammateMode = "in-process";
506
- }
507
- }
508
-
509
- console.log(` 세션: ${WHITE}${sessionId}${RESET}`);
510
- console.log(` 모드: ${effectiveTeammateMode}`);
511
- console.log(` 리드: ${AMBER}${lead}${RESET}`);
512
- console.log(` 워커: ${agents.map((a) => `${AMBER}${a}${RESET}`).join(", ")}`);
513
-
514
- // ── in-process(네이티브): tmux 없이 supervisor가 직접 CLI 프로세스 관리 ──
515
- if (effectiveTeammateMode === "in-process") {
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") {
516
623
  for (let i = 0; i < subtasks.length; i++) {
517
624
  const preview = subtasks[i].length > 44 ? subtasks[i].slice(0, 44) + "…" : subtasks[i];
518
625
  console.log(` ${DIM}[${agents[i]}-${i + 1}] ${preview}${RESET}`);
@@ -540,10 +647,10 @@ async function teamStart() {
540
647
  task,
541
648
  lead,
542
649
  agents,
543
- layout: "native",
544
- teammateMode: effectiveTeammateMode,
545
- startedAt: Date.now(),
546
- hubUrl,
650
+ layout: "native",
651
+ teammateMode: effectiveTeammateMode,
652
+ startedAt: Date.now(),
653
+ hubUrl,
547
654
  members: members.map((m, idx) => ({
548
655
  role: m.role,
549
656
  name: m.name,
@@ -564,103 +671,103 @@ async function teamStart() {
564
671
  console.log(` ${DIM}tmux 없이 실행됨 (직접 CLI 프로세스)${RESET}`);
565
672
  console.log(` ${DIM}제어: tfx team send/control/tasks/status${RESET}\n`);
566
673
  return;
567
- }
568
-
569
- // ── wt 모드(Windows Terminal 독립 split-pane) ──
570
- if (effectiveTeammateMode === "wt") {
571
- const paneCount = agents.length + 1; // lead + workers
572
- const effectiveLayout = layout === "Nx1" ? "Nx1" : "1xN";
573
- if (layout !== effectiveLayout) {
574
- warn(`wt 모드에서 ${layout} 레이아웃은 미지원 — ${effectiveLayout}로 대체`);
575
- }
576
- console.log(` 레이아웃: ${effectiveLayout} (${paneCount} panes)`);
577
-
578
- const paneCommands = [
579
- {
580
- title: `${sessionId}-lead`,
581
- command: buildCliCommand(lead),
582
- cwd: PKG_ROOT,
583
- },
584
- ...agents.map((cli, i) => ({
585
- title: `${sessionId}-${cli}-${i + 1}`,
586
- command: buildCliCommand(cli),
587
- cwd: PKG_ROOT,
588
- })),
589
- ];
590
-
591
- const session = createWtSession(sessionId, {
592
- layout: effectiveLayout,
593
- paneCommands,
594
- });
595
-
596
- const members = [
597
- {
598
- role: "lead",
599
- name: "lead",
600
- cli: lead,
601
- pane: session.panes[0] || "wt:0",
602
- agentId: toAgentId(lead, session.panes[0] || "wt:0"),
603
- },
604
- ];
605
-
606
- for (let i = 0; i < agents.length; i++) {
607
- const cli = agents[i];
608
- const target = session.panes[i + 1] || `wt:${i + 1}`;
609
- members.push({
610
- role: "worker",
611
- name: `${cli}-${i + 1}`,
612
- cli,
613
- pane: target,
614
- subtask: subtasks[i],
615
- agentId: toAgentId(cli, target),
616
- });
617
- }
618
-
619
- for (const worker of members.filter((m) => m.role === "worker")) {
620
- const preview = worker.subtask.length > 44 ? worker.subtask.slice(0, 44) + "…" : worker.subtask;
621
- console.log(` ${DIM}[${worker.name}] ${preview}${RESET}`);
622
- }
623
- console.log("");
624
-
625
- const tasks = buildTasks(subtasks, members.filter((m) => m.role === "worker"));
626
- const panes = {};
627
- for (const m of members) {
628
- panes[m.pane] = {
629
- role: m.role,
630
- name: m.name,
631
- cli: m.cli,
632
- agentId: m.agentId,
633
- subtask: m.subtask || null,
634
- };
635
- }
636
-
637
- saveTeamState({
638
- sessionName: sessionId,
639
- task,
640
- lead,
641
- agents,
642
- layout: effectiveLayout,
643
- teammateMode: effectiveTeammateMode,
644
- startedAt: Date.now(),
645
- hubUrl,
646
- members,
647
- panes,
648
- tasks,
649
- wt: {
650
- windowId: 0,
651
- layout: effectiveLayout,
652
- paneCount: session.paneCount,
653
- },
654
- });
655
-
656
- ok("Windows Terminal wt 팀 시작 완료");
657
- console.log(` ${DIM}현재 pane 기준으로 ${effectiveLayout} 분할 생성됨${RESET}`);
658
- console.log(` ${DIM}wt 모드는 자동 프롬프트 주입/Hub direct 제어(send/control)가 제한됩니다.${RESET}\n`);
659
- return;
660
- }
661
-
662
- // ── tmux 모드 ──
663
- ensureTmuxOrExit();
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();
664
771
 
665
772
  const paneCount = agents.length + 1; // lead + workers
666
773
  const effectiveLayout = paneCount <= 4 ? layout : (layout === "Nx1" ? "Nx1" : "1xN");
@@ -714,12 +821,12 @@ async function teamStart() {
714
821
  ok("CLI 초기화 대기 (3초)...");
715
822
  await new Promise((r) => setTimeout(r, 3000));
716
823
 
717
- await orchestrate(sessionId, assignments, {
718
- hubUrl,
719
- teammateMode: effectiveTeammateMode,
720
- lead: {
721
- target: leadTarget,
722
- cli: lead,
824
+ await orchestrate(sessionId, assignments, {
825
+ hubUrl,
826
+ teammateMode: effectiveTeammateMode,
827
+ lead: {
828
+ target: leadTarget,
829
+ cli: lead,
723
830
  task,
724
831
  },
725
832
  });
@@ -742,26 +849,29 @@ async function teamStart() {
742
849
  task,
743
850
  lead,
744
851
  agents,
745
- layout: effectiveLayout,
746
- teammateMode: effectiveTeammateMode,
747
- startedAt: Date.now(),
852
+ layout: effectiveLayout,
853
+ teammateMode: effectiveTeammateMode,
854
+ startedAt: Date.now(),
748
855
  hubUrl,
749
856
  members,
750
857
  panes,
751
858
  tasks,
752
859
  });
753
860
 
754
- const taskListCommand = `${process.execPath} ${join(PKG_ROOT, "bin", "triflux.mjs")} team tasks`;
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`;
755
863
  configureTeammateKeybindings(sessionId, {
756
864
  inProcess: false,
757
865
  taskListCommand,
758
866
  });
759
867
 
760
- console.log(`\n ${GREEN}${BOLD}팀 세션 준비 완료${RESET}`);
761
- console.log(` ${DIM}Shift+Down: 다음 팀메이트 전환${RESET}`);
762
- console.log(` ${DIM}Escape: 현재 팀메이트 인터럽트${RESET}`);
763
- console.log(` ${DIM}Ctrl+T: 태스크 목록${RESET}`);
764
- console.log(` ${DIM}Ctrl+B → D: 세션 분리 (백그라운드)${RESET}\n`);
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`);
765
875
 
766
876
  if (process.stdout.isTTY && process.stdin.isTTY) {
767
877
  attachSession(sessionId);
@@ -785,10 +895,13 @@ async function teamStatus() {
785
895
  console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET} ${status}\n`);
786
896
  console.log(` 세션: ${state.sessionName}`);
787
897
  console.log(` 모드: ${state.teammateMode || "tmux"}`);
788
- console.log(` 리드: ${state.lead || "claude"}`);
789
- console.log(` 워커: ${(state.agents || []).join(", ")}`);
790
- console.log(` Uptime: ${uptime}`);
791
- console.log(` 태스크: ${(state.tasks || []).length}`);
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
+ }
792
905
 
793
906
  const members = state.members || [];
794
907
  if (members.length) {
@@ -810,9 +923,56 @@ async function teamStatus() {
810
923
  }
811
924
  }
812
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
+
813
949
  console.log("");
814
950
  }
815
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
+
816
976
  function teamTasks() {
817
977
  const state = loadTeamState();
818
978
  if (!state || !isTeamAlive(state)) {
@@ -865,19 +1025,19 @@ async function teamAttach() {
865
1025
  return;
866
1026
  }
867
1027
 
868
- if (isNativeMode(state)) {
869
- console.log(`\n ${DIM}in-process 모드는 별도 attach가 없습니다.${RESET}`);
870
- console.log(` ${DIM}상태 확인: tfx team status${RESET}\n`);
871
- return;
872
- }
873
-
874
- if (isWtMode(state)) {
875
- console.log(`\n ${DIM}wt 모드는 attach 개념이 없습니다 (Windows Terminal pane가 독립 실행됨).${RESET}`);
876
- console.log(` ${DIM}재실행/정리는: tfx team stop${RESET}\n`);
877
- return;
878
- }
879
-
880
- try {
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 {
881
1041
  attachSession(state.sessionName);
882
1042
  } catch (e) {
883
1043
  const allowWt = wantsWtAttachFallback();
@@ -905,7 +1065,7 @@ async function teamDebug() {
905
1065
  const linesIdx = process.argv.findIndex((a) => a === "--lines" || a === "-n");
906
1066
  const lines = linesIdx !== -1 ? Math.max(3, parseInt(process.argv[linesIdx + 1] || "20", 10) || 20) : 20;
907
1067
  const mux = detectMultiplexer() || "none";
908
- const hub = getHubInfo();
1068
+ const hub = await getHubInfo();
909
1069
 
910
1070
  console.log(`\n ${AMBER}${BOLD}⬡ Team Debug${RESET}\n`);
911
1071
  console.log(` platform: ${process.platform}`);
@@ -923,28 +1083,29 @@ async function teamDebug() {
923
1083
  return;
924
1084
  }
925
1085
 
926
- console.log(`\n ${BOLD}state${RESET}`);
927
- console.log(` session: ${state.sessionName}`);
928
- console.log(` mode: ${state.teammateMode || "tmux"}`);
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"}`);
929
1090
  console.log(` lead: ${state.lead}`);
930
1091
  console.log(` agents: ${(state.agents || []).join(", ")}`);
931
1092
  console.log(` alive: ${isTeamAlive(state) ? "yes" : "no"}`);
932
- const attached = getSessionAttachedCount(state.sessionName);
933
- console.log(` attached: ${attached == null ? "-" : attached}`);
934
-
935
- if (isWtMode(state)) {
936
- const wtState = state.wt || {};
937
- console.log(`\n ${BOLD}wt-session${RESET}`);
938
- console.log(` window: ${wtState.windowId ?? 0}`);
939
- console.log(` layout: ${wtState.layout || state.layout || "-"}`);
940
- console.log(` panes: ${wtState.paneCount ?? (state.members || []).length}`);
941
- console.log(` wt.exe: ${hasWindowsTerminal() ? "yes" : "no"}`);
942
- console.log(` WT_SESSION:${hasWindowsTerminalSession() ? "yes" : "no"}`);
943
- console.log("");
944
- return;
945
- }
946
-
947
- if (isNativeMode(state)) {
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)) {
948
1109
  const native = await nativeGetStatus(state);
949
1110
  const members = native?.data?.members || [];
950
1111
  console.log(`\n ${BOLD}native-members${RESET}`);
@@ -990,34 +1151,34 @@ async function teamFocus() {
990
1151
  }
991
1152
 
992
1153
  const selector = process.argv[4];
993
- const member = resolveMember(state, selector);
994
- if (!member) {
995
- console.log(`\n 사용법: ${WHITE}tfx team focus <lead|이름|번호>${RESET}\n`);
996
- return;
997
- }
998
-
999
- if (isWtMode(state)) {
1000
- const m = /^wt:(\d+)$/.exec(member.pane || "");
1001
- const paneIndex = m ? parseInt(m[1], 10) : NaN;
1002
- if (!Number.isFinite(paneIndex)) {
1003
- warn(`wt pane 인덱스 파싱 실패: ${member.pane}`);
1004
- console.log("");
1005
- return;
1006
- }
1007
- const focused = focusWtPane(paneIndex, {
1008
- layout: state?.wt?.layout || state?.layout || "1xN",
1009
- });
1010
- if (focused) {
1011
- ok(`${member.name} pane 포커스 이동 (wt)`);
1012
- } else {
1013
- warn("wt pane 포커스 이동 실패 (WT_SESSION/wt.exe 상태 확인 필요)");
1014
- }
1015
- console.log("");
1016
- return;
1017
- }
1018
-
1019
- focusPane(member.pane, { zoom: (state.teammateMode === "in-process") });
1020
- try {
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 {
1021
1182
  attachSession(state.sessionName);
1022
1183
  } catch (e) {
1023
1184
  const allowWt = wantsWtAttachFallback();
@@ -1049,19 +1210,19 @@ async function teamInterrupt() {
1049
1210
 
1050
1211
  const selector = process.argv[4] || "lead";
1051
1212
  const member = resolveMember(state, selector);
1052
- if (!member) {
1053
- console.log(`\n 사용법: ${WHITE}tfx team interrupt <lead|이름|번호>${RESET}\n`);
1054
- return;
1055
- }
1056
-
1057
- if (isWtMode(state)) {
1058
- warn("wt 모드에서는 pane stdin 주입이 지원되지 않아 interrupt를 자동 전송할 수 없습니다.");
1059
- console.log(` ${DIM}수동으로 해당 pane에서 Ctrl+C를 입력하세요.${RESET}`);
1060
- console.log("");
1061
- return;
1062
- }
1063
-
1064
- if (isNativeMode(state)) {
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)) {
1065
1226
  const result = await nativeRequest(state, "/interrupt", { member: member.name });
1066
1227
  if (result?.ok) {
1067
1228
  ok(`${member.name} 인터럽트 전송`);
@@ -1090,20 +1251,20 @@ async function teamControl() {
1090
1251
  const member = resolveMember(state, selector);
1091
1252
  const allowed = new Set(["interrupt", "stop", "pause", "resume"]);
1092
1253
 
1093
- if (!member || !allowed.has(command)) {
1094
- console.log(`\n 사용법: ${WHITE}tfx team control <lead|이름|번호> <interrupt|stop|pause|resume> [사유]${RESET}\n`);
1095
- return;
1096
- }
1097
-
1098
- if (isWtMode(state)) {
1099
- warn("wt 모드는 Hub direct/control 주입 경로가 비활성입니다.");
1100
- console.log(` ${DIM}수동 제어: 해당 pane에서 직접 명령/인터럽트를 수행하세요.${RESET}`);
1101
- console.log("");
1102
- return;
1103
- }
1104
-
1105
- // 직접 주입: MCP 유무와 무관하게 즉시 전달
1106
- let directOk = false;
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;
1107
1268
  if (isNativeMode(state)) {
1108
1269
  const direct = await nativeRequest(state, "/control", {
1109
1270
  member: member.name,
@@ -1140,19 +1301,19 @@ async function teamStop() {
1140
1301
  return;
1141
1302
  }
1142
1303
 
1143
- if (isNativeMode(state)) {
1144
- await nativeRequest(state, "/stop", {});
1145
- try { process.kill(state.native.supervisorPid, "SIGTERM"); } catch {}
1146
- ok(`세션 종료: ${state.sessionName}`);
1147
- } else if (isWtMode(state)) {
1148
- const closed = closeWtSession({
1149
- layout: state?.wt?.layout || state?.layout || "1xN",
1150
- paneCount: state?.wt?.paneCount ?? (state.members || []).length,
1151
- });
1152
- ok(`세션 종료: ${state.sessionName}${closed ? ` (${closed} panes closed)` : ""}`);
1153
- } else {
1154
- if (sessionExists(state.sessionName)) {
1155
- killSession(state.sessionName);
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);
1156
1317
  ok(`세션 종료: ${state.sessionName}`);
1157
1318
  } else {
1158
1319
  console.log(` ${DIM}세션 이미 종료됨${RESET}`);
@@ -1163,28 +1324,28 @@ async function teamStop() {
1163
1324
  console.log("");
1164
1325
  }
1165
1326
 
1166
- async function teamKill() {
1167
- const state = loadTeamState();
1168
- if (state && isNativeMode(state) && isTeamAlive(state)) {
1169
- await nativeRequest(state, "/stop", {});
1170
- try { process.kill(state.native.supervisorPid, "SIGTERM"); } catch {}
1171
- clearTeamState();
1172
- ok(`종료: ${state.sessionName}`);
1173
- console.log("");
1174
- return;
1175
- }
1176
- if (state && isWtMode(state)) {
1177
- const closed = closeWtSession({
1178
- layout: state?.wt?.layout || state?.layout || "1xN",
1179
- paneCount: state?.wt?.paneCount ?? (state.members || []).length,
1180
- });
1181
- clearTeamState();
1182
- ok(`종료: ${state.sessionName}${closed ? ` (${closed} panes closed)` : ""}`);
1183
- console.log("");
1184
- return;
1185
- }
1186
-
1187
- const sessions = listSessions();
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();
1188
1349
  if (sessions.length === 0) {
1189
1350
  console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1190
1351
  return;
@@ -1207,19 +1368,19 @@ async function teamSend() {
1207
1368
  const selector = process.argv[4];
1208
1369
  const message = process.argv.slice(5).join(" ");
1209
1370
  const member = resolveMember(state, selector);
1210
- if (!member || !message) {
1211
- console.log(`\n 사용법: ${WHITE}tfx team send <lead|이름|번호> "메시지"${RESET}\n`);
1212
- return;
1213
- }
1214
-
1215
- if (isWtMode(state)) {
1216
- warn("wt 모드는 pane 프롬프트 자동 주입(send)이 지원되지 않습니다.");
1217
- console.log(` ${DIM}수동 전달: 선택한 pane에 직접 붙여넣으세요.${RESET}`);
1218
- console.log("");
1219
- return;
1220
- }
1221
-
1222
- if (isNativeMode(state)) {
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)) {
1223
1384
  const result = await nativeRequest(state, "/send", { member: member.name, text: message });
1224
1385
  if (result?.ok) {
1225
1386
  ok(`${member.name}에 메시지 주입 완료`);
@@ -1235,22 +1396,22 @@ async function teamSend() {
1235
1396
  console.log("");
1236
1397
  }
1237
1398
 
1238
- function teamList() {
1239
- const state = loadTeamState();
1240
- if (state && isNativeMode(state) && isTeamAlive(state)) {
1241
- console.log(`\n ${AMBER}${BOLD}⬡ 팀 세션 목록${RESET}\n`);
1242
- console.log(` ${GREEN}●${RESET} ${state.sessionName} ${DIM}(in-process)${RESET}`);
1243
- console.log("");
1244
- return;
1245
- }
1246
- if (state && isWtMode(state) && isTeamAlive(state)) {
1247
- console.log(`\n ${AMBER}${BOLD}⬡ 팀 세션 목록${RESET}\n`);
1248
- console.log(` ${GREEN}●${RESET} ${state.sessionName} ${DIM}(wt)${RESET}`);
1249
- console.log("");
1250
- return;
1251
- }
1252
-
1253
- const sessions = listSessions();
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();
1254
1415
  if (sessions.length === 0) {
1255
1416
  console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1256
1417
  return;
@@ -1266,14 +1427,14 @@ function teamHelp() {
1266
1427
  console.log(`
1267
1428
  ${AMBER}${BOLD}⬡ tfx team${RESET} ${DIM}멀티-CLI 팀 모드 (Lead + Teammates)${RESET}
1268
1429
 
1269
- ${BOLD}시작${RESET}
1270
- ${WHITE}tfx team "작업 설명"${RESET}
1271
- ${WHITE}tfx team --agents codex,gemini --lead claude "작업"${RESET}
1272
- ${WHITE}tfx team --teammate-mode tmux "작업"${RESET}
1273
- ${WHITE}tfx team --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}
1274
- ${WHITE}tfx team --layout 1xN "작업"${RESET} ${DIM}(세로 분할 컬럼)${RESET}
1275
- ${WHITE}tfx team --layout Nx1 "작업"${RESET} ${DIM}(가로 분할 스택)${RESET}
1276
- ${WHITE}tfx team --teammate-mode in-process "작업"${RESET} ${DIM}(tmux 불필요)${RESET}
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}
1277
1438
 
1278
1439
  ${BOLD}제어${RESET}
1279
1440
  ${WHITE}tfx team status${RESET} ${GRAY}현재 팀 상태${RESET}
@@ -1289,13 +1450,15 @@ function teamHelp() {
1289
1450
  ${WHITE}tfx team kill${RESET} ${GRAY}모든 팀 세션 강제 종료${RESET}
1290
1451
  ${WHITE}tfx team list${RESET} ${GRAY}활성 세션 목록${RESET}
1291
1452
 
1292
- ${BOLD}키 조작(Claude teammate 스타일, tmux 모드)${RESET}
1293
- ${WHITE}Shift+Down${RESET} ${GRAY}다음 팀메이트${RESET}
1294
- ${WHITE}Shift+Up${RESET} ${GRAY}이전 팀메이트${RESET}
1295
- ${WHITE}Escape${RESET} ${GRAY}현재 팀메이트 인터럽트${RESET}
1296
- ${WHITE}Ctrl+T${RESET} ${GRAY}태스크 목록 토글${RESET}
1297
- `);
1298
- }
1453
+ ${BOLD}키 조작(Claude teammate 스타일, tmux 모드)${RESET}
1454
+ ${WHITE}Shift+Down${RESET} ${GRAY}다음 팀메이트${RESET}
1455
+ ${WHITE}Shift+Tab${RESET} ${GRAY}이전 팀메이트 (권장)${RESET}
1456
+ ${WHITE}Shift+Left${RESET} ${GRAY}이전 팀메이트 (대체)${RESET}
1457
+ ${WHITE}Shift+Up${RESET} ${GRAY}이전 팀메이트 (환경 따라 미동작 가능)${RESET}
1458
+ ${WHITE}Escape${RESET} ${GRAY}현재 팀메이트 인터럽트${RESET}
1459
+ ${WHITE}Ctrl+T${RESET} ${GRAY}태스크 목록 토글${RESET}
1460
+ `);
1461
+ }
1299
1462
 
1300
1463
  // ── 메인 진입점 ──
1301
1464
 
@@ -1303,8 +1466,9 @@ function teamHelp() {
1303
1466
  * tfx team 서브커맨드 라우터
1304
1467
  * bin/triflux.mjs에서 호출
1305
1468
  */
1306
- export async function cmdTeam() {
1307
- const sub = process.argv[3];
1469
+ export async function cmdTeam() {
1470
+ const rawSub = process.argv[3];
1471
+ const sub = typeof rawSub === "string" ? rawSub.toLowerCase() : rawSub;
1308
1472
 
1309
1473
  switch (sub) {
1310
1474
  case "status": return teamStatus();
@@ -1323,13 +1487,13 @@ export async function cmdTeam() {
1323
1487
  case "--help":
1324
1488
  case "-h":
1325
1489
  return teamHelp();
1326
- case undefined:
1327
- return teamHelp();
1328
- default:
1329
- // 서브커맨드가 아니면 작업 문자열로 간주
1330
- if (!sub.startsWith("-") && TEAM_SUBCOMMANDS.has(sub)) {
1331
- return teamHelp();
1332
- }
1333
- return teamStart();
1334
- }
1335
- }
1490
+ case undefined:
1491
+ return teamHelp();
1492
+ default:
1493
+ // 서브커맨드가 아니면 작업 문자열로 간주
1494
+ if (typeof sub === "string" && !sub.startsWith("-") && TEAM_SUBCOMMANDS.has(sub)) {
1495
+ return teamHelp();
1496
+ }
1497
+ return teamStart();
1498
+ }
1499
+ }