triflux 3.2.0-dev.1 → 3.2.0-dev.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hub/team/cli.mjs CHANGED
@@ -5,10 +5,26 @@ import { join, dirname } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { execSync, spawn } from "node:child_process";
7
7
 
8
- import { createSession, attachSession, killSession, sessionExists, listSessions, capturePaneOutput } from "./session.mjs";
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";
9
26
  import { buildCliCommand, startCliInPane, injectPrompt, sendKeys } from "./pane.mjs";
10
- import { orchestrate, decomposeTask } from "./orchestrator.mjs";
11
- import { detectMultiplexer } from "./session.mjs";
27
+ import { orchestrate, decomposeTask, buildLeadPrompt, buildPrompt } from "./orchestrator.mjs";
12
28
 
13
29
  // ── 상수 ──
14
30
  const PKG_ROOT = dirname(dirname(dirname(new URL(import.meta.url).pathname))).replace(/^\/([A-Z]:)/, "$1");
@@ -16,6 +32,10 @@ const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
16
32
  const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
17
33
  const TEAM_STATE_FILE = join(HUB_PID_DIR, "team-state.json");
18
34
 
35
+ const TEAM_SUBCOMMANDS = new Set([
36
+ "status", "attach", "stop", "kill", "send", "list", "help", "tasks", "task", "focus", "interrupt", "control", "debug",
37
+ ]);
38
+
19
39
  // ── 색상 ──
20
40
  const AMBER = "\x1b[38;5;214m";
21
41
  const GREEN = "\x1b[38;5;82m";
@@ -90,35 +110,67 @@ function startHubDaemon() {
90
110
 
91
111
  // ── 인자 파싱 ──
92
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
+ function normalizeLayout(layout = "2x2") {
125
+ const raw = String(layout).toLowerCase();
126
+ if (raw === "2x2" || raw === "grid") return "2x2";
127
+ if (raw === "1xn" || raw === "1x3" || raw === "vertical" || raw === "columns") return "1xN";
128
+ if (raw === "nx1" || raw === "horizontal" || raw === "rows") return "Nx1";
129
+ return "2x2";
130
+ }
131
+
93
132
  function parseTeamArgs() {
94
133
  const args = process.argv.slice(3);
95
- let agents = ["codex", "codex", "gemini"]; // 기본: codex x2 + gemini
134
+ let agents = ["codex", "gemini"]; // 기본: codex + gemini
135
+ let lead = "claude"; // 기본 리드
96
136
  let layout = "2x2";
97
- let task = "";
137
+ let teammateMode = "auto";
138
+ const taskParts = [];
98
139
 
99
140
  for (let i = 0; i < args.length; i++) {
100
- if (args[i] === "--agents" && args[i + 1]) {
101
- agents = args[++i].split(",").map((s) => s.trim().toLowerCase());
102
- } else if (args[i] === "--layout" && args[i + 1]) {
141
+ const cur = args[i];
142
+ if (cur === "--agents" && args[i + 1]) {
143
+ agents = args[++i].split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
144
+ } else if (cur === "--lead" && args[i + 1]) {
145
+ lead = args[++i].trim().toLowerCase();
146
+ } else if (cur === "--layout" && args[i + 1]) {
103
147
  layout = args[++i];
104
- } else if (!args[i].startsWith("-")) {
105
- task = args[i];
148
+ } else if ((cur === "--teammate-mode" || cur === "--mode") && args[i + 1]) {
149
+ teammateMode = args[++i];
150
+ } else if (!cur.startsWith("-")) {
151
+ taskParts.push(cur);
106
152
  }
107
153
  }
108
154
 
109
- return { agents, layout, task };
155
+ return {
156
+ agents,
157
+ lead,
158
+ layout: normalizeLayout(layout),
159
+ teammateMode: normalizeTeammateMode(teammateMode),
160
+ task: taskParts.join(" ").trim(),
161
+ };
110
162
  }
111
163
 
112
- // ── 서브커맨드 ──
113
-
114
- async function teamStart() {
115
- // 1. tmux 확인
164
+ function ensureTmuxOrExit() {
116
165
  const mux = detectMultiplexer();
117
- if (!mux) {
118
- console.log(`
166
+ if (mux) return;
167
+
168
+ console.log(`
119
169
  ${RED}${BOLD}tmux 미발견${RESET}
120
170
 
121
- tfx team은 tmux 필요합니다:
171
+ 현재 선택한 모드는 tmux 기반 팀세션이 필요합니다.
172
+
173
+ 설치:
122
174
  WSL2: ${WHITE}wsl sudo apt install tmux${RESET}
123
175
  macOS: ${WHITE}brew install tmux${RESET}
124
176
  Linux: ${WHITE}apt install tmux${RESET}
@@ -128,156 +180,1011 @@ async function teamStart() {
128
180
  2. ${WHITE}wsl sudo apt install tmux${RESET}
129
181
  3. ${WHITE}tfx team "작업"${RESET}
130
182
  `);
131
- process.exit(1);
183
+ process.exit(1);
184
+ }
185
+
186
+ async function launchAttachInWindowsTerminal(sessionName) {
187
+ if (!hasWindowsTerminal()) return false;
188
+
189
+ let attachSpec;
190
+ try {
191
+ attachSpec = resolveAttachCommand(sessionName);
192
+ } catch {
193
+ return false;
132
194
  }
133
195
 
134
- // 2. 인자 파싱
135
- const { agents, layout, task } = parseTeamArgs();
136
- if (!task) {
137
- console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET}\n`);
138
- console.log(` 사용법: ${WHITE}tfx team "작업 설명"${RESET}`);
139
- console.log(` ${WHITE}tfx team --agents codex,gemini "작업"${RESET}`);
140
- console.log(` ${WHITE}tfx team --layout 1x3 "작업"${RESET}\n`);
196
+ const launch = (args) => {
197
+ const child = spawn("wt", args, {
198
+ detached: true,
199
+ stdio: "ignore",
200
+ windowsHide: false,
201
+ });
202
+ child.unref();
203
+ };
204
+
205
+ const beforeAttached = getSessionAttachedCount(sessionName);
206
+
207
+ try {
208
+ // 분할선이 세로(좌/우)가 되도록 -V 우선
209
+ launch(["-w", "0", "split-pane", "-V", "-d", PKG_ROOT, attachSpec.command, ...attachSpec.args]);
210
+ if (beforeAttached == null) {
211
+ return true;
212
+ }
213
+
214
+ const deadline = Date.now() + 3500;
215
+ while (Date.now() < deadline) {
216
+ await new Promise((r) => setTimeout(r, 120));
217
+ const nowAttached = getSessionAttachedCount(sessionName);
218
+ if (typeof nowAttached === "number" && nowAttached > beforeAttached) {
219
+ return true;
220
+ }
221
+ }
222
+ return false;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ function buildManualAttachCommand(sessionName) {
229
+ try {
230
+ const spec = resolveAttachCommand(sessionName);
231
+ const quoted = [spec.command, ...spec.args].map((s) => {
232
+ const v = String(s);
233
+ return /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v;
234
+ });
235
+ return quoted.join(" ");
236
+ } catch {
237
+ return `tmux attach-session -t ${sessionName}`;
238
+ }
239
+ }
240
+
241
+ function wantsWtAttachFallback() {
242
+ return process.argv.includes("--wt")
243
+ || process.argv.includes("--spawn-wt")
244
+ || process.env.TFX_ATTACH_WT_AUTO === "1";
245
+ }
246
+
247
+ function toAgentId(cli, target) {
248
+ const suffix = String(target).split(/[:.]/).pop();
249
+ return `${cli}-${suffix}`;
250
+ }
251
+
252
+ function buildNativeCliCommand(cli) {
253
+ switch (cli) {
254
+ case "codex":
255
+ // 비-TTY supervisor 환경에서 확인 프롬프트/alt-screen 의존을 줄임
256
+ return "codex --dangerously-bypass-approvals-and-sandbox --no-alt-screen";
257
+ case "gemini":
258
+ return "gemini";
259
+ case "claude":
260
+ return "claude";
261
+ default:
262
+ return buildCliCommand(cli);
263
+ }
264
+ }
265
+
266
+ function buildTasks(subtasks, workers) {
267
+ return subtasks.map((subtask, i) => ({
268
+ id: `T${i + 1}`,
269
+ title: subtask,
270
+ owner: workers[i]?.name || null,
271
+ status: "pending",
272
+ depends_on: i === 0 ? [] : [`T${i}`],
273
+ }));
274
+ }
275
+
276
+ function renderTasks(tasks = []) {
277
+ if (!tasks.length) {
278
+ console.log(`\n ${DIM}태스크 없음${RESET}\n`);
141
279
  return;
142
280
  }
143
281
 
144
- // 3. Hub 확인 + lazy-start
282
+ console.log(`\n ${AMBER}${BOLD}⬡ Team Tasks${RESET}\n`);
283
+ for (const t of tasks) {
284
+ const dep = t.depends_on?.length ? ` ${DIM}(deps: ${t.depends_on.join(",")})${RESET}` : "";
285
+ const owner = t.owner ? ` ${GRAY}[${t.owner}]${RESET}` : "";
286
+ console.log(` ${WHITE}${t.id}${RESET} ${t.status.padEnd(11)} ${t.title}${owner}${dep}`);
287
+ }
288
+ console.log("");
289
+ }
290
+
291
+ function resolveMember(state, selector) {
292
+ const members = state?.members || [];
293
+ if (!selector) return null;
294
+
295
+ const direct = members.find((m) => m.name === selector || m.role === selector || m.agentId === selector);
296
+ if (direct) return direct;
297
+
298
+ // 스킬 친화 별칭: worker-1, worker-2 ...
299
+ const workerAlias = /^worker-(\d+)$/i.exec(selector);
300
+ if (workerAlias) {
301
+ const workerIdx = parseInt(workerAlias[1], 10) - 1;
302
+ const workers = members.filter((m) => m.role === "worker");
303
+ if (workerIdx >= 0 && workerIdx < workers.length) return workers[workerIdx];
304
+ }
305
+
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;
311
+
312
+ // teammate 스타일: 1-based 인덱스
313
+ if (n >= 1 && n <= members.length) return members[n - 1];
314
+ }
315
+
316
+ return null;
317
+ }
318
+
319
+ async function publishLeadControl(state, targetMember, command, reason = "") {
320
+ const hubBase = (state?.hubUrl || "http://127.0.0.1:27888/mcp").replace(/\/mcp$/, "");
321
+ const leadAgent = (state?.members || []).find((m) => m.role === "lead")?.agentId || "lead";
322
+
323
+ const payload = {
324
+ from_agent: leadAgent,
325
+ to_agent: targetMember.agentId,
326
+ command,
327
+ reason,
328
+ payload: {
329
+ issued_by: leadAgent,
330
+ issued_at: Date.now(),
331
+ },
332
+ };
333
+
334
+ try {
335
+ const res = await fetch(`${hubBase}/bridge/control`, {
336
+ method: "POST",
337
+ headers: { "Content-Type": "application/json" },
338
+ body: JSON.stringify(payload),
339
+ });
340
+ return !!res.ok;
341
+ } catch {
342
+ return false;
343
+ }
344
+ }
345
+
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
+ }
353
+
354
+ function isTeamAlive(state) {
355
+ 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
+ }
364
+ if (isWtMode(state)) {
365
+ // WT pane 상태를 신뢰성 있게 조회할 API가 없어 세션 환경/실행기 존재 여부로 판정
366
+ return hasWindowsTerminal() && hasWindowsTerminalSession();
367
+ }
368
+ return sessionExists(state.sessionName);
369
+ }
370
+
371
+ async function nativeRequest(state, path, body = {}) {
372
+ if (!isNativeMode(state)) return null;
373
+ try {
374
+ const res = await fetch(`${state.native.controlUrl}${path}`, {
375
+ method: "POST",
376
+ headers: { "Content-Type": "application/json" },
377
+ body: JSON.stringify(body),
378
+ });
379
+ return await res.json();
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ async function nativeGetStatus(state) {
386
+ if (!isNativeMode(state)) return null;
387
+ try {
388
+ const res = await fetch(`${state.native.controlUrl}/status`);
389
+ return await res.json();
390
+ } catch {
391
+ return null;
392
+ }
393
+ }
394
+
395
+ async function startNativeSupervisor({ sessionId, task, lead, agents, subtasks, hubUrl }) {
396
+ const nativeConfigPath = join(HUB_PID_DIR, `team-native-${sessionId}.config.json`);
397
+ const nativeRuntimePath = join(HUB_PID_DIR, `team-native-${sessionId}.runtime.json`);
398
+ const logsDir = join(HUB_PID_DIR, "team-logs", sessionId);
399
+ mkdirSync(logsDir, { recursive: true });
400
+
401
+ const leadMember = {
402
+ role: "lead",
403
+ name: "lead",
404
+ cli: lead,
405
+ agentId: `${lead}-lead`,
406
+ command: buildNativeCliCommand(lead),
407
+ };
408
+
409
+ const workers = agents.map((cli, i) => ({
410
+ role: "worker",
411
+ name: `${cli}-${i + 1}`,
412
+ cli,
413
+ agentId: `${cli}-w${i + 1}`,
414
+ command: buildNativeCliCommand(cli),
415
+ subtask: subtasks[i],
416
+ }));
417
+
418
+ const leadPrompt = buildLeadPrompt(task, {
419
+ agentId: leadMember.agentId,
420
+ hubUrl,
421
+ teammateMode: "in-process",
422
+ workers: workers.map((w) => ({ agentId: w.agentId, cli: w.cli, subtask: w.subtask })),
423
+ });
424
+
425
+ const members = [
426
+ { ...leadMember, prompt: leadPrompt },
427
+ ...workers.map((w) => ({
428
+ ...w,
429
+ prompt: buildPrompt(w.subtask, { cli: w.cli, agentId: w.agentId, hubUrl }),
430
+ })),
431
+ ];
432
+
433
+ const config = {
434
+ sessionName: sessionId,
435
+ hubUrl,
436
+ startupDelayMs: 3000,
437
+ logsDir,
438
+ runtimeFile: nativeRuntimePath,
439
+ members,
440
+ };
441
+ writeFileSync(nativeConfigPath, JSON.stringify(config, null, 2) + "\n");
442
+
443
+ const supervisorPath = join(PKG_ROOT, "hub", "team", "native-supervisor.mjs");
444
+ const child = spawn(process.execPath, [supervisorPath, "--config", nativeConfigPath], {
445
+ detached: true,
446
+ stdio: "ignore",
447
+ env: { ...process.env },
448
+ });
449
+ child.unref();
450
+
451
+ const deadline = Date.now() + 5000;
452
+ while (Date.now() < deadline) {
453
+ if (existsSync(nativeRuntimePath)) {
454
+ try {
455
+ const runtime = JSON.parse(readFileSync(nativeRuntimePath, "utf8"));
456
+ return { runtime, members };
457
+ } catch {}
458
+ }
459
+ await new Promise((r) => setTimeout(r, 100));
460
+ }
461
+
462
+ return { runtime: null, members };
463
+ }
464
+
465
+ // ── 서브커맨드 ──
466
+
467
+ async function teamStart() {
468
+ 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
+ }
477
+
145
478
  console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET}\n`);
479
+
146
480
  let hub = getHubInfo();
147
481
  if (!hub) {
148
- process.stdout.write(` Hub 시작 중...`);
482
+ process.stdout.write(" Hub 시작 중...");
149
483
  hub = startHubDaemon();
150
484
  if (hub) {
151
485
  console.log(` ${GREEN}✓${RESET}`);
152
486
  } else {
153
487
  console.log(` ${RED}✗${RESET}`);
154
488
  warn("Hub 시작 실패 — 수동으로 실행: tfx hub start");
155
- // Hub 없이도 계속 진행 (통신만 불가)
156
489
  }
157
490
  } else {
158
491
  ok(`Hub: ${DIM}${hub.url}${RESET}`);
159
492
  }
160
493
 
161
- // 4. 세션 ID 생성
162
- const sessionId = `tfx-team-${Date.now().toString(36).slice(-4)}`;
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") {
516
+ for (let i = 0; i < subtasks.length; i++) {
517
+ const preview = subtasks[i].length > 44 ? subtasks[i].slice(0, 44) + "…" : subtasks[i];
518
+ console.log(` ${DIM}[${agents[i]}-${i + 1}] ${preview}${RESET}`);
519
+ }
520
+ console.log("");
163
521
 
164
- // 5. 작업 분해
165
- const subtasks = decomposeTask(task, agents.length);
522
+ const { runtime, members } = await startNativeSupervisor({
523
+ sessionId,
524
+ task,
525
+ lead,
526
+ agents,
527
+ subtasks,
528
+ hubUrl,
529
+ });
166
530
 
167
- console.log(` 세션: ${WHITE}${sessionId}${RESET}`);
168
- console.log(` 레이아웃: ${layout} (${agents.length + 1} panes)`);
169
- console.log(` 에이전트: ${agents.map((a) => `${AMBER}${a}${RESET}`).join(", ")}`);
170
- for (let i = 0; i < subtasks.length; i++) {
171
- const preview = subtasks[i].length > 40 ? subtasks[i].slice(0, 40) + "…" : subtasks[i];
172
- console.log(` ${DIM}[${agents[i]}] ${preview}${RESET}`);
173
- }
174
- console.log("");
531
+ if (!runtime?.controlUrl) {
532
+ fail("in-process supervisor 시작 실패");
533
+ return;
534
+ }
535
+
536
+ const tasks = buildTasks(subtasks, members.filter((m) => m.role === "worker"));
537
+
538
+ saveTeamState({
539
+ sessionName: sessionId,
540
+ task,
541
+ lead,
542
+ agents,
543
+ layout: "native",
544
+ teammateMode: effectiveTeammateMode,
545
+ startedAt: Date.now(),
546
+ hubUrl,
547
+ members: members.map((m, idx) => ({
548
+ role: m.role,
549
+ name: m.name,
550
+ cli: m.cli,
551
+ agentId: m.agentId,
552
+ pane: `native:${idx}`,
553
+ subtask: m.subtask || null,
554
+ })),
555
+ panes: {},
556
+ tasks,
557
+ native: {
558
+ controlUrl: runtime.controlUrl,
559
+ supervisorPid: runtime.supervisorPid,
560
+ },
561
+ });
562
+
563
+ ok("네이티브 in-process 팀 시작 완료");
564
+ console.log(` ${DIM}tmux 없이 실행됨 (직접 CLI 프로세스)${RESET}`);
565
+ console.log(` ${DIM}제어: tfx team send/control/tasks/status${RESET}\n`);
566
+ 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();
664
+
665
+ const paneCount = agents.length + 1; // lead + workers
666
+ const effectiveLayout = paneCount <= 4 ? layout : (layout === "Nx1" ? "Nx1" : "1xN");
667
+ console.log(` 레이아웃: ${effectiveLayout} (${paneCount} panes)`);
175
668
 
176
- // 6. tmux 세션 생성
177
669
  const session = createSession(sessionId, {
178
- layout,
179
- paneCount: agents.length + 1, // +1 for dashboard
670
+ layout: effectiveLayout,
671
+ paneCount,
180
672
  });
181
673
 
182
- // 7. Dashboard 시작 (Pane 0)
183
- const dashCmd = `node ${PKG_ROOT}/hub/team/dashboard.mjs --session ${sessionId} --interval 2`;
184
- startCliInPane(session.panes[0], dashCmd);
674
+ // Pane 0: lead
675
+ const leadTarget = session.panes[0];
676
+ startCliInPane(leadTarget, buildCliCommand(lead));
185
677
 
186
- // 8. CLI 에이전트 시작 (Pane 1~N)
678
+ // Pane 1..N: workers
187
679
  const assignments = [];
680
+ const members = [
681
+ {
682
+ role: "lead",
683
+ name: "lead",
684
+ cli: lead,
685
+ pane: leadTarget,
686
+ agentId: toAgentId(lead, leadTarget),
687
+ },
688
+ ];
689
+
188
690
  for (let i = 0; i < agents.length; i++) {
189
691
  const cli = agents[i];
190
692
  const target = session.panes[i + 1];
191
- const command = buildCliCommand(cli);
192
- startCliInPane(target, command);
693
+ startCliInPane(target, buildCliCommand(cli));
694
+
695
+ const worker = {
696
+ role: "worker",
697
+ name: `${cli}-${i + 1}`,
698
+ cli,
699
+ pane: target,
700
+ subtask: subtasks[i],
701
+ agentId: toAgentId(cli, target),
702
+ };
703
+
704
+ members.push(worker);
193
705
  assignments.push({ target, cli, subtask: subtasks[i] });
194
706
  }
195
707
 
196
- // 9. CLI 초기화 대기 (3초 interactive 모드 진입 시간)
708
+ for (const worker of members.filter((m) => m.role === "worker")) {
709
+ const preview = worker.subtask.length > 44 ? worker.subtask.slice(0, 44) + "…" : worker.subtask;
710
+ console.log(` ${DIM}[${worker.name}] ${preview}${RESET}`);
711
+ }
712
+ console.log("");
713
+
197
714
  ok("CLI 초기화 대기 (3초)...");
198
715
  await new Promise((r) => setTimeout(r, 3000));
199
716
 
200
- // 10. 프롬프트 주입
201
- const hubUrl = hub?.url || "http://127.0.0.1:27888/mcp";
202
- await orchestrate(sessionId, assignments, { hubUrl });
203
- ok("프롬프트 주입 완료");
717
+ await orchestrate(sessionId, assignments, {
718
+ hubUrl,
719
+ teammateMode: effectiveTeammateMode,
720
+ lead: {
721
+ target: leadTarget,
722
+ cli: lead,
723
+ task,
724
+ },
725
+ });
726
+ ok("리드/워커 프롬프트 주입 완료");
204
727
 
205
- // 11. 상태 저장
206
- const panes = { [session.panes[0]]: { role: "dashboard" } };
207
- for (let i = 0; i < agents.length; i++) {
208
- panes[session.panes[i + 1]] = {
209
- cli: agents[i],
210
- agentId: `${agents[i]}-${session.panes[i + 1].split(".").pop()}`,
211
- subtask: subtasks[i],
728
+ const tasks = buildTasks(subtasks, members.filter((m) => m.role === "worker"));
729
+ const panes = {};
730
+ for (const m of members) {
731
+ panes[m.pane] = {
732
+ role: m.role,
733
+ name: m.name,
734
+ cli: m.cli,
735
+ agentId: m.agentId,
736
+ subtask: m.subtask || null,
212
737
  };
213
738
  }
739
+
214
740
  saveTeamState({
215
741
  sessionName: sessionId,
216
- agents,
217
742
  task,
218
- layout,
219
- startedAt: Date.now(),
743
+ lead,
744
+ agents,
745
+ layout: effectiveLayout,
746
+ teammateMode: effectiveTeammateMode,
747
+ startedAt: Date.now(),
220
748
  hubUrl,
749
+ members,
221
750
  panes,
751
+ tasks,
752
+ });
753
+
754
+ const taskListCommand = `${process.execPath} ${join(PKG_ROOT, "bin", "triflux.mjs")} team tasks`;
755
+ configureTeammateKeybindings(sessionId, {
756
+ inProcess: false,
757
+ taskListCommand,
222
758
  });
223
759
 
224
- // 12. tmux attach
225
760
  console.log(`\n ${GREEN}${BOLD}팀 세션 준비 완료${RESET}`);
226
- console.log(` ${DIM}Ctrl+B 방향키로 pane 전환${RESET}`);
227
- console.log(` ${DIM}Ctrl+B D로 세션 분리 (백그라운드)${RESET}\n`);
228
- attachSession(sessionId);
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`);
765
+
766
+ if (process.stdout.isTTY && process.stdin.isTTY) {
767
+ attachSession(sessionId);
768
+ } else {
769
+ warn("TTY 미지원 환경이라 자동 attach를 생략함");
770
+ console.log(` ${DIM}수동 연결: tfx team attach${RESET}\n`);
771
+ }
229
772
  }
230
773
 
231
- function teamStatus() {
774
+ async function teamStatus() {
232
775
  const state = loadTeamState();
233
776
  if (!state) {
234
777
  console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
235
778
  return;
236
779
  }
237
780
 
238
- const alive = sessionExists(state.sessionName);
781
+ const alive = isTeamAlive(state);
239
782
  const status = alive ? `${GREEN}● active${RESET}` : `${RED}● dead${RESET}`;
240
783
  const uptime = alive ? `${Math.round((Date.now() - state.startedAt) / 60000)}분` : "-";
241
784
 
242
785
  console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET} ${status}\n`);
243
786
  console.log(` 세션: ${state.sessionName}`);
244
- console.log(` 작업: ${state.task}`);
245
- console.log(` 에이전트: ${state.agents.join(", ")}`);
787
+ console.log(` 모드: ${state.teammateMode || "tmux"}`);
788
+ console.log(` 리드: ${state.lead || "claude"}`);
789
+ console.log(` 워커: ${(state.agents || []).join(", ")}`);
246
790
  console.log(` Uptime: ${uptime}`);
791
+ console.log(` 태스크: ${(state.tasks || []).length}`);
792
+
793
+ const members = state.members || [];
794
+ if (members.length) {
795
+ console.log("");
796
+ for (const m of members) {
797
+ const roleTag = m.role === "lead" ? "lead" : "worker";
798
+ console.log(` - ${m.name} (${m.cli}) ${DIM}${roleTag}${RESET} ${DIM}${m.pane}${RESET}`);
799
+ }
800
+ }
801
+
802
+ if (isNativeMode(state) && alive) {
803
+ const native = await nativeGetStatus(state);
804
+ const nativeMembers = native?.data?.members || [];
805
+ if (nativeMembers.length) {
806
+ console.log("");
807
+ for (const m of nativeMembers) {
808
+ console.log(` • ${m.name}: ${m.status}${m.lastPreview ? ` ${DIM}${m.lastPreview}${RESET}` : ""}`);
809
+ }
810
+ }
811
+ }
812
+
247
813
  console.log("");
248
814
  }
249
815
 
250
- function teamAttach() {
816
+ function teamTasks() {
251
817
  const state = loadTeamState();
252
- if (!state || !sessionExists(state.sessionName)) {
818
+ if (!state || !isTeamAlive(state)) {
253
819
  console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
254
820
  return;
255
821
  }
256
- attachSession(state.sessionName);
822
+ renderTasks(state.tasks || []);
257
823
  }
258
824
 
259
- function teamStop() {
825
+ function teamTaskUpdate() {
260
826
  const state = loadTeamState();
827
+ if (!state || !isTeamAlive(state)) {
828
+ console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
829
+ return;
830
+ }
831
+
832
+ const action = (process.argv[4] || "").toLowerCase();
833
+ const taskId = (process.argv[5] || "").toUpperCase();
834
+
835
+ const nextStatus = action === "done" || action === "complete" || action === "completed"
836
+ ? "completed"
837
+ : action === "progress" || action === "in-progress" || action === "in_progress"
838
+ ? "in_progress"
839
+ : action === "pending"
840
+ ? "pending"
841
+ : null;
842
+
843
+ if (!nextStatus || !taskId) {
844
+ console.log(`\n 사용법: ${WHITE}tfx team task <pending|progress|done> <T1>${RESET}\n`);
845
+ return;
846
+ }
847
+
848
+ const tasks = state.tasks || [];
849
+ const target = tasks.find((t) => String(t.id).toUpperCase() === taskId);
850
+ if (!target) {
851
+ console.log(`\n ${DIM}태스크를 찾을 수 없음: ${taskId}${RESET}\n`);
852
+ return;
853
+ }
854
+
855
+ target.status = nextStatus;
856
+ saveTeamState(state);
857
+ ok(`${target.id} 상태 갱신: ${nextStatus}`);
858
+ console.log("");
859
+ }
860
+
861
+ async function teamAttach() {
862
+ const state = loadTeamState();
863
+ if (!state || !isTeamAlive(state)) {
864
+ console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
865
+ return;
866
+ }
867
+
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 {
881
+ attachSession(state.sessionName);
882
+ } catch (e) {
883
+ const allowWt = wantsWtAttachFallback();
884
+ if (allowWt && await launchAttachInWindowsTerminal(state.sessionName)) {
885
+ warn(`현재 터미널에서 attach 실패: ${e.message}`);
886
+ ok("Windows Terminal split-pane로 attach 재시도 창을 열었습니다.");
887
+ console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}`);
888
+ console.log("");
889
+ return;
890
+ }
891
+ fail(`attach 실패: ${e.message}`);
892
+ if (allowWt) {
893
+ fail("WT 분할창 attach 자동 검증 실패 (session_attached 증가 없음)");
894
+ } else {
895
+ warn("자동 WT 분할은 기본 비활성입니다. 필요 시 --wt 옵션으로 실행하세요.");
896
+ }
897
+ console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}`);
898
+ console.log("");
899
+ return;
900
+ }
901
+ }
902
+
903
+ async function teamDebug() {
904
+ const state = loadTeamState();
905
+ const linesIdx = process.argv.findIndex((a) => a === "--lines" || a === "-n");
906
+ const lines = linesIdx !== -1 ? Math.max(3, parseInt(process.argv[linesIdx + 1] || "20", 10) || 20) : 20;
907
+ const mux = detectMultiplexer() || "none";
908
+ const hub = getHubInfo();
909
+
910
+ console.log(`\n ${AMBER}${BOLD}⬡ Team Debug${RESET}\n`);
911
+ console.log(` platform: ${process.platform}`);
912
+ console.log(` node: ${process.version}`);
913
+ console.log(` tty: stdout=${!!process.stdout.isTTY}, stdin=${!!process.stdin.isTTY}`);
914
+ console.log(` mux: ${mux}`);
915
+ console.log(` hub-pid: ${hub ? `${hub.pid}` : "-"}`);
916
+ console.log(` hub-url: ${hub?.url || "-"}`);
917
+
918
+ const sessions = listSessions();
919
+ console.log(` sessions: ${sessions.length ? sessions.join(", ") : "-"}`);
920
+
261
921
  if (!state) {
922
+ console.log(`\n ${DIM}team-state 없음 (활성 세션 없음)${RESET}\n`);
923
+ return;
924
+ }
925
+
926
+ console.log(`\n ${BOLD}state${RESET}`);
927
+ console.log(` session: ${state.sessionName}`);
928
+ console.log(` mode: ${state.teammateMode || "tmux"}`);
929
+ console.log(` lead: ${state.lead}`);
930
+ console.log(` agents: ${(state.agents || []).join(", ")}`);
931
+ 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)) {
948
+ const native = await nativeGetStatus(state);
949
+ const members = native?.data?.members || [];
950
+ console.log(`\n ${BOLD}native-members${RESET}`);
951
+ if (!members.length) {
952
+ console.log(` ${DIM}(no data)${RESET}`);
953
+ } else {
954
+ for (const m of members) {
955
+ console.log(` - ${m.name}: ${m.status}${m.lastPreview ? ` ${DIM}${m.lastPreview}${RESET}` : ""}`);
956
+ }
957
+ }
958
+ console.log("");
959
+ return;
960
+ }
961
+
962
+ const members = state.members || [];
963
+ console.log(`\n ${BOLD}pane-tail${RESET} ${DIM}(last ${lines} lines)${RESET}`);
964
+ if (!members.length) {
965
+ console.log(` ${DIM}(members 없음)${RESET}`);
966
+ } else {
967
+ for (const m of members) {
968
+ const tail = capturePaneOutput(m.pane, lines) || "(empty)";
969
+ console.log(`\n [${m.name}] ${m.pane}`);
970
+ const tailLines = tail.split("\n").slice(-lines);
971
+ for (const line of tailLines) {
972
+ console.log(` ${line}`);
973
+ }
974
+ }
975
+ }
976
+ console.log("");
977
+ }
978
+
979
+ async function teamFocus() {
980
+ const state = loadTeamState();
981
+ if (!state || !isTeamAlive(state)) {
982
+ console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
983
+ return;
984
+ }
985
+
986
+ if (isNativeMode(state)) {
987
+ console.log(`\n ${DIM}in-process 모드는 focus/attach 개념이 없습니다.${RESET}`);
988
+ console.log(` ${DIM}직접 지시: tfx team send <대상> \"메시지\"${RESET}\n`);
989
+ return;
990
+ }
991
+
992
+ 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 {
1021
+ attachSession(state.sessionName);
1022
+ } catch (e) {
1023
+ const allowWt = wantsWtAttachFallback();
1024
+ if (allowWt && await launchAttachInWindowsTerminal(state.sessionName)) {
1025
+ warn(`현재 터미널에서 attach 실패: ${e.message}`);
1026
+ ok("Windows Terminal split-pane로 attach 재시도 창을 열었습니다.");
1027
+ console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}`);
1028
+ console.log("");
1029
+ return;
1030
+ }
1031
+ fail(`attach 실패: ${e.message}`);
1032
+ if (allowWt) {
1033
+ fail("WT 분할창 attach 자동 검증 실패 (session_attached 증가 없음)");
1034
+ } else {
1035
+ warn("자동 WT 분할은 기본 비활성입니다. 필요 시 --wt 옵션으로 실행하세요.");
1036
+ }
1037
+ console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}`);
1038
+ console.log("");
1039
+ return;
1040
+ }
1041
+ }
1042
+
1043
+ async function teamInterrupt() {
1044
+ const state = loadTeamState();
1045
+ if (!state || !isTeamAlive(state)) {
1046
+ console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1047
+ return;
1048
+ }
1049
+
1050
+ const selector = process.argv[4] || "lead";
1051
+ 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)) {
1065
+ const result = await nativeRequest(state, "/interrupt", { member: member.name });
1066
+ if (result?.ok) {
1067
+ ok(`${member.name} 인터럽트 전송`);
1068
+ } else {
1069
+ warn(`${member.name} 인터럽트 실패`);
1070
+ }
1071
+ console.log("");
1072
+ return;
1073
+ }
1074
+
1075
+ sendKeys(member.pane, "C-c");
1076
+ ok(`${member.name} 인터럽트 전송`);
1077
+ console.log("");
1078
+ }
1079
+
1080
+ async function teamControl() {
1081
+ const state = loadTeamState();
1082
+ if (!state || !isTeamAlive(state)) {
262
1083
  console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
263
1084
  return;
264
1085
  }
265
1086
 
266
- if (sessionExists(state.sessionName)) {
267
- killSession(state.sessionName);
268
- ok(`세션 종료: ${state.sessionName}`);
1087
+ const selector = process.argv[4];
1088
+ const command = (process.argv[5] || "").toLowerCase();
1089
+ const reason = process.argv.slice(6).join(" ");
1090
+ const member = resolveMember(state, selector);
1091
+ const allowed = new Set(["interrupt", "stop", "pause", "resume"]);
1092
+
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;
1107
+ if (isNativeMode(state)) {
1108
+ const direct = await nativeRequest(state, "/control", {
1109
+ member: member.name,
1110
+ command,
1111
+ reason,
1112
+ });
1113
+ directOk = !!direct?.ok;
269
1114
  } else {
270
- console.log(` ${DIM}세션 이미 종료됨${RESET}`);
1115
+ const controlMsg = `[LEAD CONTROL] command=${command}${reason ? ` reason=${reason}` : ""}`;
1116
+ injectPrompt(member.pane, controlMsg);
1117
+ if (command === "interrupt") {
1118
+ sendKeys(member.pane, "C-c");
1119
+ }
1120
+ directOk = true;
1121
+ }
1122
+
1123
+ // Hub direct mailbox에도 발행
1124
+ const published = await publishLeadControl(state, member, command, reason);
1125
+
1126
+ if (directOk && published) {
1127
+ ok(`${member.name} 제어 전송 (${command}, direct + hub)`);
1128
+ } else if (directOk) {
1129
+ ok(`${member.name} 제어 전송 (${command}, direct only)`);
1130
+ } else {
1131
+ warn(`${member.name} 제어 전송 실패 (${command})`);
1132
+ }
1133
+ console.log("");
1134
+ }
1135
+
1136
+ async function teamStop() {
1137
+ const state = loadTeamState();
1138
+ if (!state) {
1139
+ console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
1140
+ return;
1141
+ }
1142
+
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);
1156
+ ok(`세션 종료: ${state.sessionName}`);
1157
+ } else {
1158
+ console.log(` ${DIM}세션 이미 종료됨${RESET}`);
1159
+ }
271
1160
  }
272
1161
 
273
- // 상태 파일 정리
274
1162
  clearTeamState();
275
1163
  console.log("");
276
1164
  }
277
1165
 
278
- function teamKill() {
279
- // 모든 tfx-team- 세션 강제 종료
280
- const sessions = listSessions();
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();
281
1188
  if (sessions.length === 0) {
282
1189
  console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
283
1190
  return;
@@ -290,28 +1197,60 @@ function teamKill() {
290
1197
  console.log("");
291
1198
  }
292
1199
 
293
- function teamSend() {
1200
+ async function teamSend() {
294
1201
  const state = loadTeamState();
295
- if (!state || !sessionExists(state.sessionName)) {
1202
+ if (!state || !isTeamAlive(state)) {
296
1203
  console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
297
1204
  return;
298
1205
  }
299
1206
 
300
- const paneIdx = parseInt(process.argv[4], 10);
1207
+ const selector = process.argv[4];
301
1208
  const message = process.argv.slice(5).join(" ");
302
- if (isNaN(paneIdx) || !message) {
303
- console.log(`\n 사용법: ${WHITE}tfx team send <pane번호> "메시지"${RESET}\n`);
1209
+ 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)) {
1223
+ const result = await nativeRequest(state, "/send", { member: member.name, text: message });
1224
+ if (result?.ok) {
1225
+ ok(`${member.name}에 메시지 주입 완료`);
1226
+ } else {
1227
+ warn(`${member.name} 메시지 주입 실패`);
1228
+ }
1229
+ console.log("");
304
1230
  return;
305
1231
  }
306
1232
 
307
- const target = `${state.sessionName}:0.${paneIdx}`;
308
- injectPrompt(target, message);
309
- ok(`Pane ${paneIdx}에 메시지 주입 완료`);
1233
+ injectPrompt(member.pane, message);
1234
+ ok(`${member.name}에 메시지 주입 완료`);
310
1235
  console.log("");
311
1236
  }
312
1237
 
313
- function teamList() {
314
- const sessions = listSessions();
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();
315
1254
  if (sessions.length === 0) {
316
1255
  console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
317
1256
  return;
@@ -325,20 +1264,36 @@ function teamList() {
325
1264
 
326
1265
  function teamHelp() {
327
1266
  console.log(`
328
- ${AMBER}${BOLD}⬡ tfx team${RESET} ${DIM}멀티-CLI 팀 모드 (tmux + Hub)${RESET}
1267
+ ${AMBER}${BOLD}⬡ tfx team${RESET} ${DIM}멀티-CLI 팀 모드 (Lead + Teammates)${RESET}
329
1268
 
330
- ${BOLD}시작${RESET}
331
- ${WHITE}tfx team "작업 설명"${RESET} ${GRAY}기본 (codex x2 + gemini)${RESET}
332
- ${WHITE}tfx team --agents codex,gemini "작업"${RESET} ${GRAY}에이전트 지정${RESET}
333
- ${WHITE}tfx team --layout 1x3 "작업"${RESET} ${GRAY}레이아웃 지정${RESET}
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}
334
1277
 
335
1278
  ${BOLD}제어${RESET}
336
- ${WHITE}tfx team status${RESET} ${GRAY}현재 팀 상태${RESET}
337
- ${WHITE}tfx team attach${RESET} ${GRAY}tmux 세션 연결${RESET}
338
- ${WHITE}tfx team send${RESET} ${DIM}N "msg"${RESET} ${GRAY}Pane N에 입력${RESET}
339
- ${WHITE}tfx team stop${RESET} ${GRAY}graceful 종료${RESET}
340
- ${WHITE}tfx team kill${RESET} ${GRAY}모든 세션 강제 종료${RESET}
341
- ${WHITE}tfx team list${RESET} ${GRAY}활성 세션 목록${RESET}
1279
+ ${WHITE}tfx team status${RESET} ${GRAY}현재 팀 상태${RESET}
1280
+ ${WHITE}tfx team debug${RESET} ${DIM}[--lines 30]${RESET} ${GRAY}강화 디버그 출력(환경/세션/pane tail)${RESET}
1281
+ ${WHITE}tfx team tasks${RESET} ${GRAY}공유 태스크 목록${RESET}
1282
+ ${WHITE}tfx team task${RESET} ${DIM}<pending|progress|done> <T1>${RESET} ${GRAY}태스크 상태 갱신${RESET}
1283
+ ${WHITE}tfx team attach${RESET} ${DIM}[--wt]${RESET} ${GRAY}세션 재연결 (WT 분할은 opt-in)${RESET}
1284
+ ${WHITE}tfx team focus${RESET} ${DIM}<lead|이름|번호> [--wt]${RESET} ${GRAY}특정 팀메이트 포커스${RESET}
1285
+ ${WHITE}tfx team send${RESET} ${DIM}<lead|이름|번호> "msg"${RESET} ${GRAY}팀메이트에 메시지 주입${RESET}
1286
+ ${WHITE}tfx team interrupt${RESET} ${DIM}<대상>${RESET} ${GRAY}팀메이트 인터럽트(C-c)${RESET}
1287
+ ${WHITE}tfx team control${RESET} ${DIM}<대상> <cmd>${RESET} ${GRAY}리드 제어명령(interrupt|stop|pause|resume)${RESET}
1288
+ ${WHITE}tfx team stop${RESET} ${GRAY}graceful 종료${RESET}
1289
+ ${WHITE}tfx team kill${RESET} ${GRAY}모든 팀 세션 강제 종료${RESET}
1290
+ ${WHITE}tfx team list${RESET} ${GRAY}활성 세션 목록${RESET}
1291
+
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}
342
1297
  `);
343
1298
  }
344
1299
 
@@ -352,17 +1307,29 @@ export async function cmdTeam() {
352
1307
  const sub = process.argv[3];
353
1308
 
354
1309
  switch (sub) {
355
- case "status": return teamStatus();
356
- case "attach": return teamAttach();
357
- case "stop": return teamStop();
358
- case "kill": return teamKill();
359
- case "send": return teamSend();
360
- case "list": return teamList();
361
- case "help": case "--help": case "-h":
1310
+ case "status": return teamStatus();
1311
+ case "debug": return teamDebug();
1312
+ case "tasks": return teamTasks();
1313
+ case "task": return teamTaskUpdate();
1314
+ case "attach": return teamAttach();
1315
+ case "focus": return teamFocus();
1316
+ case "interrupt": return teamInterrupt();
1317
+ case "control": return teamControl();
1318
+ case "stop": return teamStop();
1319
+ case "kill": return teamKill();
1320
+ case "send": return teamSend();
1321
+ case "list": return teamList();
1322
+ case "help":
1323
+ case "--help":
1324
+ case "-h":
362
1325
  return teamHelp();
363
1326
  case undefined:
364
1327
  return teamHelp();
365
1328
  default:
1329
+ // 서브커맨드가 아니면 작업 문자열로 간주
1330
+ if (!sub.startsWith("-") && TEAM_SUBCOMMANDS.has(sub)) {
1331
+ return teamHelp();
1332
+ }
366
1333
  return teamStart();
367
1334
  }
368
1335
  }