triflux 3.2.0-dev.8 → 3.2.0-dev.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/triflux.mjs +581 -340
  2. package/hooks/keyword-rules.json +16 -0
  3. package/hub/bridge.mjs +410 -318
  4. package/hub/hitl.mjs +45 -31
  5. package/hub/pipe.mjs +457 -0
  6. package/hub/router.mjs +422 -161
  7. package/hub/server.mjs +429 -424
  8. package/hub/store.mjs +388 -314
  9. package/hub/team/cli-team-common.mjs +348 -0
  10. package/hub/team/cli-team-control.mjs +393 -0
  11. package/hub/team/cli-team-start.mjs +512 -0
  12. package/hub/team/cli-team-status.mjs +269 -0
  13. package/hub/team/cli.mjs +59 -1459
  14. package/hub/team/dashboard.mjs +1 -9
  15. package/hub/team/native.mjs +12 -80
  16. package/hub/team/nativeProxy.mjs +121 -47
  17. package/hub/team/pane.mjs +66 -43
  18. package/hub/team/psmux.mjs +297 -0
  19. package/hub/team/session.mjs +354 -291
  20. package/hub/team/shared.mjs +13 -0
  21. package/hub/team/staleState.mjs +299 -0
  22. package/hub/tools.mjs +41 -52
  23. package/hub/workers/claude-worker.mjs +446 -0
  24. package/hub/workers/codex-mcp.mjs +414 -0
  25. package/hub/workers/factory.mjs +18 -0
  26. package/hub/workers/gemini-worker.mjs +349 -0
  27. package/hub/workers/interface.mjs +41 -0
  28. package/hud/hud-qos-status.mjs +4 -2
  29. package/package.json +4 -1
  30. package/scripts/keyword-detector.mjs +15 -0
  31. package/scripts/lib/keyword-rules.mjs +4 -1
  32. package/scripts/psmux-steering-prototype.sh +368 -0
  33. package/scripts/setup.mjs +128 -70
  34. package/scripts/tfx-route-worker.mjs +161 -0
  35. package/scripts/tfx-route.sh +415 -80
  36. package/skills/tfx-auto/SKILL.md +90 -564
  37. package/skills/tfx-auto-codex/SKILL.md +1 -3
  38. package/skills/tfx-codex/SKILL.md +1 -4
  39. package/skills/tfx-doctor/SKILL.md +1 -0
  40. package/skills/tfx-gemini/SKILL.md +1 -4
  41. package/skills/tfx-setup/SKILL.md +1 -4
  42. package/skills/tfx-team/SKILL.md +53 -62
package/bin/triflux.mjs CHANGED
@@ -1,12 +1,15 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // triflux CLI — setup, doctor, version
3
- import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync } from "fs";
4
- import { join, dirname } from "path";
5
- import { homedir } from "os";
3
+ import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, rmSync, statSync } from "fs";
4
+ import { join, dirname } from "path";
5
+ import { homedir } from "os";
6
6
  import { execSync, spawn } from "child_process";
7
7
  import { fileURLToPath } from "url";
8
-
9
- const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { detectMultiplexer, getSessionAttachedCount, killSession, listSessions, tmuxExec } from "../hub/team/session.mjs";
10
+ import { cleanupStaleOmcTeams, inspectStaleOmcTeams } from "../hub/team/staleState.mjs";
11
+
12
+ const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
10
13
  const CLAUDE_DIR = join(homedir(), ".claude");
11
14
  const CODEX_DIR = join(homedir(), ".codex");
12
15
  const CODEX_CONFIG_PATH = join(CODEX_DIR, "config.toml");
@@ -46,9 +49,10 @@ const RED_BRIGHT = "\x1b[38;5;196m";
46
49
 
47
50
  // ── 브랜드 요소 ──
48
51
  const BRAND = `${AMBER}${BOLD}triflux${RESET}`;
49
- const VER = `${DIM}v${PKG.version}${RESET}`;
50
- const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
51
- const DOT = `${GRAY}·${RESET}`;
52
+ const VER = `${DIM}v${PKG.version}${RESET}`;
53
+ const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
54
+ const DOT = `${GRAY}·${RESET}`;
55
+ const STALE_TEAM_MAX_AGE_SEC = 3600;
52
56
 
53
57
  // ── 유틸리티 ──
54
58
 
@@ -68,7 +72,7 @@ function which(cmd) {
68
72
  } catch { return null; }
69
73
  }
70
74
 
71
- function whichInShell(cmd, shell) {
75
+ function whichInShell(cmd, shell) {
72
76
  const cmds = {
73
77
  bash: `bash -c "source ~/.bashrc 2>/dev/null && command -v ${cmd} 2>/dev/null"`,
74
78
  cmd: `cmd /c where ${cmd} 2>nul`,
@@ -83,12 +87,12 @@ function whichInShell(cmd, shell) {
83
87
  stdio: ["pipe", "pipe", "ignore"],
84
88
  }).trim();
85
89
  return result.split(/\r?\n/)[0] || null;
86
- } catch { return null; }
87
- }
88
-
89
- function isDevUpdateRequested(argv = process.argv) {
90
- return argv.includes("--dev") || argv.includes("@dev") || argv.includes("dev");
91
- }
90
+ } catch { return null; }
91
+ }
92
+
93
+ function isDevUpdateRequested(argv = process.argv) {
94
+ return argv.includes("--dev") || argv.includes("@dev") || argv.includes("dev");
95
+ }
92
96
 
93
97
  function checkShellAvailable(shell) {
94
98
  const cmds = { bash: "bash --version", cmd: "cmd /c echo ok", pwsh: "pwsh -NoProfile -c echo ok" };
@@ -98,13 +102,136 @@ function checkShellAvailable(shell) {
98
102
  } catch { return false; }
99
103
  }
100
104
 
101
- function getVersion(filePath) {
102
- try {
103
- const content = readFileSync(filePath, "utf8");
104
- const match = content.match(/VERSION\s*=\s*"([^"]+)"/);
105
+ function getVersion(filePath) {
106
+ try {
107
+ const content = readFileSync(filePath, "utf8");
108
+ const match = content.match(/VERSION\s*=\s*"([^"]+)"/);
105
109
  return match ? match[1] : null;
106
- } catch { return null; }
107
- }
110
+ } catch { return null; }
111
+ }
112
+
113
+ function parseSessionCreated(rawValue) {
114
+ const value = String(rawValue || "").trim();
115
+ if (!value) return null;
116
+
117
+ const numeric = Number(value);
118
+ if (Number.isFinite(numeric) && numeric > 0) {
119
+ return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric);
120
+ }
121
+
122
+ const parsed = Date.parse(value);
123
+ if (Number.isFinite(parsed)) {
124
+ return Math.floor(parsed / 1000);
125
+ }
126
+
127
+ const normalized = value.replace(/^(\d{2})-(\d{2})-(\d{2})(\s+)/, "20$1-$2-$3$4");
128
+ const reparsed = Date.parse(normalized);
129
+ if (Number.isFinite(reparsed)) {
130
+ return Math.floor(reparsed / 1000);
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ function formatElapsedAge(ageSec) {
137
+ if (!Number.isFinite(ageSec) || ageSec < 0) return "알 수 없음";
138
+ if (ageSec < 60) return `${ageSec}초`;
139
+ if (ageSec < 3600) return `${Math.floor(ageSec / 60)}분`;
140
+ if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}시간`;
141
+ return `${Math.floor(ageSec / 86400)}일`;
142
+ }
143
+
144
+ function readTeamSessionCreatedMap() {
145
+ const createdMap = new Map();
146
+
147
+ try {
148
+ const output = tmuxExec('list-sessions -F "#{session_name} #{session_created}"');
149
+ for (const line of output.split(/\r?\n/)) {
150
+ const trimmed = line.trim();
151
+ if (!trimmed) continue;
152
+
153
+ const firstSpace = trimmed.indexOf(" ");
154
+ if (firstSpace === -1) continue;
155
+
156
+ const sessionName = trimmed.slice(0, firstSpace);
157
+ const createdRaw = trimmed.slice(firstSpace + 1).trim();
158
+ const createdAt = parseSessionCreated(createdRaw);
159
+ createdMap.set(sessionName, {
160
+ createdAt,
161
+ createdRaw,
162
+ });
163
+ }
164
+ } catch {
165
+ // session_created 포맷을 읽지 못하면 stale 판정만 완화한다.
166
+ }
167
+
168
+ return createdMap;
169
+ }
170
+
171
+ function inspectTeamSessions() {
172
+ const mux = detectMultiplexer();
173
+ if (!mux) {
174
+ return { mux: null, sessions: [] };
175
+ }
176
+
177
+ const sessionNames = listSessions();
178
+ if (sessionNames.length === 0) {
179
+ return { mux, sessions: [] };
180
+ }
181
+
182
+ const createdMap = readTeamSessionCreatedMap();
183
+ const nowSec = Math.floor(Date.now() / 1000);
184
+ const sessions = sessionNames.map((sessionName) => {
185
+ const createdInfo = createdMap.get(sessionName) || { createdAt: null, createdRaw: "" };
186
+ const attachedCount = getSessionAttachedCount(sessionName);
187
+ const ageSec = createdInfo.createdAt == null ? null : Math.max(0, nowSec - createdInfo.createdAt);
188
+ const stale = ageSec != null && ageSec >= STALE_TEAM_MAX_AGE_SEC && attachedCount === 0;
189
+
190
+ return {
191
+ sessionName,
192
+ attachedCount,
193
+ ageSec,
194
+ createdAt: createdInfo.createdAt,
195
+ createdRaw: createdInfo.createdRaw,
196
+ stale,
197
+ };
198
+ });
199
+
200
+ return { mux, sessions };
201
+ }
202
+
203
+ async function cleanupStaleTeamSessions(staleSessions) {
204
+ let cleaned = 0;
205
+ let failed = 0;
206
+
207
+ for (const session of staleSessions) {
208
+ let removed = false;
209
+
210
+ for (let attempt = 1; attempt <= 3; attempt++) {
211
+ killSession(session.sessionName);
212
+ const stillAlive = listSessions().includes(session.sessionName);
213
+ if (!stillAlive) {
214
+ removed = true;
215
+ cleaned++;
216
+ ok(`stale 세션 정리: ${session.sessionName}`);
217
+ break;
218
+ }
219
+
220
+ if (attempt < 3) {
221
+ await delay(1000);
222
+ }
223
+ }
224
+
225
+ if (!removed) {
226
+ failed++;
227
+ fail(`세션 정리 실패: ${session.sessionName} — 수동 정리 필요`);
228
+ }
229
+ }
230
+
231
+ info(`${cleaned}개 stale 세션 정리 완료`);
232
+
233
+ return { cleaned, failed };
234
+ }
108
235
 
109
236
  function escapeRegExp(value) {
110
237
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -343,10 +470,10 @@ function cmdSetup() {
343
470
  console.log(`\n${DIM}설치 위치: ${CLAUDE_DIR}${RESET}\n`);
344
471
  }
345
472
 
346
- function cmdDoctor(options = {}) {
347
- const { fix = false, reset = false } = options;
348
- const modeLabel = reset ? ` ${RED}--reset${RESET}` : fix ? ` ${YELLOW}--fix${RESET}` : "";
349
- console.log(`\n ${AMBER}${BOLD}⬡ triflux doctor${RESET} ${VER}${modeLabel}\n`);
473
+ async function cmdDoctor(options = {}) {
474
+ const { fix = false, reset = false } = options;
475
+ const modeLabel = reset ? ` ${RED}--reset${RESET}` : fix ? ` ${YELLOW}--fix${RESET}` : "";
476
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux doctor${RESET} ${VER}${modeLabel}\n`);
350
477
  console.log(` ${LINE}`);
351
478
 
352
479
  // ── reset 모드: 캐시 전체 초기화 ──
@@ -431,10 +558,10 @@ function cmdDoctor(options = {}) {
431
558
  // 스킬 동기화
432
559
  const fSkillsSrc = join(PKG_ROOT, "skills");
433
560
  const fSkillsDst = join(CLAUDE_DIR, "skills");
434
- if (existsSync(fSkillsSrc)) {
435
- let sc = 0, st = 0;
436
- for (const name of readdirSync(fSkillsSrc)) {
437
- const src = join(fSkillsSrc, name, "SKILL.md");
561
+ if (existsSync(fSkillsSrc)) {
562
+ let sc = 0, st = 0;
563
+ for (const name of readdirSync(fSkillsSrc)) {
564
+ const src = join(fSkillsSrc, name, "SKILL.md");
438
565
  const dst = join(fSkillsDst, name, "SKILL.md");
439
566
  if (!existsSync(src)) continue;
440
567
  st++;
@@ -443,21 +570,21 @@ function cmdDoctor(options = {}) {
443
570
  if (!existsSync(dst)) { copyFileSync(src, dst); sc++; }
444
571
  else if (readFileSync(src, "utf8") !== readFileSync(dst, "utf8")) { copyFileSync(src, dst); sc++; }
445
572
  }
446
- if (sc > 0) ok(`스킬: ${sc}/${st}개 업데이트됨`);
447
- else ok(`스킬: ${st}개 최신 상태`);
448
- }
449
- const profileFix = ensureCodexProfiles();
450
- if (!profileFix.ok) {
451
- warn(`Codex Profiles 자동 복구 실패: ${profileFix.message}`);
452
- } else if (profileFix.added > 0) {
453
- ok(`Codex Profiles: ${profileFix.added}개 추가됨`);
454
- } else {
455
- info("Codex Profiles: 이미 최신 상태");
456
- }
457
- // 에러/스테일 캐시 정리
458
- const fCacheDir = join(CLAUDE_DIR, "cache");
459
- const staleNames = ["claude-usage-cache.json", ".claude-refresh-lock", "codex-rate-limits-cache.json"];
460
- let cleaned = 0;
573
+ if (sc > 0) ok(`스킬: ${sc}/${st}개 업데이트됨`);
574
+ else ok(`스킬: ${st}개 최신 상태`);
575
+ }
576
+ const profileFix = ensureCodexProfiles();
577
+ if (!profileFix.ok) {
578
+ warn(`Codex Profiles 자동 복구 실패: ${profileFix.message}`);
579
+ } else if (profileFix.added > 0) {
580
+ ok(`Codex Profiles: ${profileFix.added}개 추가됨`);
581
+ } else {
582
+ info("Codex Profiles: 이미 최신 상태");
583
+ }
584
+ // 에러/스테일 캐시 정리
585
+ const fCacheDir = join(CLAUDE_DIR, "cache");
586
+ const staleNames = ["claude-usage-cache.json", ".claude-refresh-lock", "codex-rate-limits-cache.json"];
587
+ let cleaned = 0;
461
588
  for (const name of staleNames) {
462
589
  const fp = join(fCacheDir, name);
463
590
  if (!existsSync(fp)) continue;
@@ -497,56 +624,56 @@ function cmdDoctor(options = {}) {
497
624
  // 3. Codex CLI
498
625
  section(`Codex CLI ${WHITE_BRIGHT}●${RESET}`);
499
626
  issues += checkCliCrossShell("codex", "npm install -g @openai/codex");
500
- if (which("codex")) {
501
- if (process.env.OPENAI_API_KEY) {
502
- ok("OPENAI_API_KEY 설정됨");
503
- } else {
504
- warn(`OPENAI_API_KEY 미설정 ${GRAY}(Pro 구독이면 불필요)${RESET}`);
505
- }
506
- }
507
-
508
- // 4. Codex Profiles
509
- section("Codex Profiles");
510
- if (existsSync(CODEX_CONFIG_PATH)) {
511
- const codexConfig = readFileSync(CODEX_CONFIG_PATH, "utf8");
512
- for (const profile of REQUIRED_CODEX_PROFILES) {
513
- if (hasProfileSection(codexConfig, profile.name)) {
514
- ok(`${profile.name}: 정상`);
515
- } else {
516
- warn(`${profile.name}: 미설정`);
517
- issues++;
518
- }
519
- }
520
- } else {
521
- warn("config.toml 미존재");
522
- issues++;
523
- }
524
-
525
- // 5. Gemini CLI
526
- section(`Gemini CLI ${BLUE}●${RESET}`);
527
- issues += checkCliCrossShell("gemini", "npm install -g @google/gemini-cli");
528
- if (which("gemini")) {
529
- if (process.env.GEMINI_API_KEY) {
530
- ok("GEMINI_API_KEY 설정됨");
627
+ if (which("codex")) {
628
+ if (process.env.OPENAI_API_KEY) {
629
+ ok("OPENAI_API_KEY 설정됨");
630
+ } else {
631
+ warn(`OPENAI_API_KEY 미설정 ${GRAY}(Pro 구독이면 불필요)${RESET}`);
632
+ }
633
+ }
634
+
635
+ // 4. Codex Profiles
636
+ section("Codex Profiles");
637
+ if (existsSync(CODEX_CONFIG_PATH)) {
638
+ const codexConfig = readFileSync(CODEX_CONFIG_PATH, "utf8");
639
+ for (const profile of REQUIRED_CODEX_PROFILES) {
640
+ if (hasProfileSection(codexConfig, profile.name)) {
641
+ ok(`${profile.name}: 정상`);
642
+ } else {
643
+ warn(`${profile.name}: 미설정`);
644
+ issues++;
645
+ }
646
+ }
647
+ } else {
648
+ warn("config.toml 미존재");
649
+ issues++;
650
+ }
651
+
652
+ // 5. Gemini CLI
653
+ section(`Gemini CLI ${BLUE}●${RESET}`);
654
+ issues += checkCliCrossShell("gemini", "npm install -g @google/gemini-cli");
655
+ if (which("gemini")) {
656
+ if (process.env.GEMINI_API_KEY) {
657
+ ok("GEMINI_API_KEY 설정됨");
531
658
  } else {
532
659
  warn(`GEMINI_API_KEY 미설정 ${GRAY}(gemini auth login)${RESET}`);
533
660
  }
534
661
  }
535
662
 
536
- // 6. Claude Code
537
- section(`Claude Code ${AMBER}●${RESET}`);
538
- const claudePath = which("claude");
539
- if (claudePath) {
540
- ok("설치됨");
541
- } else {
663
+ // 6. Claude Code
664
+ section(`Claude Code ${AMBER}●${RESET}`);
665
+ const claudePath = which("claude");
666
+ if (claudePath) {
667
+ ok("설치됨");
668
+ } else {
542
669
  fail("미설치 (필수)");
543
670
  issues++;
544
671
  }
545
672
 
546
- // 7. 스킬 설치 상태
547
- section("Skills");
548
- const skillsSrc = join(PKG_ROOT, "skills");
549
- const skillsDst = join(CLAUDE_DIR, "skills");
673
+ // 7. 스킬 설치 상태
674
+ section("Skills");
675
+ const skillsSrc = join(PKG_ROOT, "skills");
676
+ const skillsDst = join(CLAUDE_DIR, "skills");
550
677
  if (existsSync(skillsSrc)) {
551
678
  let installed = 0;
552
679
  let total = 0;
@@ -569,9 +696,9 @@ function cmdDoctor(options = {}) {
569
696
  }
570
697
  }
571
698
 
572
- // 8. 플러그인 등록
573
- section("Plugin");
574
- const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
699
+ // 8. 플러그인 등록
700
+ section("Plugin");
701
+ const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
575
702
  if (existsSync(pluginsFile)) {
576
703
  const content = readFileSync(pluginsFile, "utf8");
577
704
  if (content.includes("triflux")) {
@@ -584,9 +711,9 @@ function cmdDoctor(options = {}) {
584
711
  info("플러그인 시스템 감지 안 됨 — npm 단독 사용");
585
712
  }
586
713
 
587
- // 9. MCP 인벤토리
588
- section("MCP Inventory");
589
- const mcpCache = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
714
+ // 9. MCP 인벤토리
715
+ section("MCP Inventory");
716
+ const mcpCache = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
590
717
  if (existsSync(mcpCache)) {
591
718
  try {
592
719
  const inv = JSON.parse(readFileSync(mcpCache, "utf8"));
@@ -607,8 +734,8 @@ function cmdDoctor(options = {}) {
607
734
  info(`수동: node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`);
608
735
  }
609
736
 
610
- // 10. CLI 이슈 트래커
611
- section("CLI Issues");
737
+ // 10. CLI 이슈 트래커
738
+ section("CLI Issues");
612
739
  const issuesFile = join(CLAUDE_DIR, "cache", "cli-issues.jsonl");
613
740
  if (existsSync(issuesFile)) {
614
741
  try {
@@ -672,6 +799,120 @@ function cmdDoctor(options = {}) {
672
799
  ok("이슈 로그 없음 (정상)");
673
800
  }
674
801
 
802
+ // 11. Team Sessions
803
+ section("Team Sessions");
804
+ const teamSessionReport = inspectTeamSessions();
805
+ if (!teamSessionReport.mux) {
806
+ info("tmux/psmux 미감지 — 팀 세션 검사 건너뜀");
807
+ } else if (teamSessionReport.sessions.length === 0) {
808
+ ok(`활성 팀 세션 없음 ${DIM}(${teamSessionReport.mux})${RESET}`);
809
+ } else {
810
+ info(`multiplexer: ${teamSessionReport.mux}`);
811
+
812
+ for (const session of teamSessionReport.sessions) {
813
+ const attachedLabel = session.attachedCount == null ? "?" : `${session.attachedCount}`;
814
+ const ageLabel = formatElapsedAge(session.ageSec);
815
+
816
+ if (session.stale) {
817
+ warn(`${session.sessionName}: stale 추정 (attach=${attachedLabel}, 경과=${ageLabel})`);
818
+ } else {
819
+ ok(`${session.sessionName}: 정상 (attach=${attachedLabel}, 경과=${ageLabel})`);
820
+ }
821
+
822
+ if (session.createdAt == null) {
823
+ info(`${session.sessionName}: session_created 파싱 실패${session.createdRaw ? ` (${session.createdRaw})` : ""}`);
824
+ }
825
+ }
826
+
827
+ const staleSessions = teamSessionReport.sessions.filter((session) => session.stale);
828
+ if (staleSessions.length > 0) {
829
+ if (fix) {
830
+ const cleanupResult = await cleanupStaleTeamSessions(staleSessions);
831
+ issues += cleanupResult.failed;
832
+ } else {
833
+ info("정리: tfx doctor --fix");
834
+ issues += staleSessions.length;
835
+ }
836
+ }
837
+ }
838
+
839
+ // 12. OMC stale team 상태
840
+ section("OMC Stale Teams");
841
+ const omcTeamReport = inspectStaleOmcTeams({
842
+ startDir: process.cwd(),
843
+ maxAgeMs: STALE_TEAM_MAX_AGE_SEC * 1000,
844
+ liveSessionNames: teamSessionReport.sessions.map((session) => session.sessionName),
845
+ });
846
+ if (!omcTeamReport.stateRoot) {
847
+ info(".omc/state 없음 — 검사 건너뜀");
848
+ } else if (omcTeamReport.entries.length === 0) {
849
+ ok(`stale team 없음 ${DIM}(${omcTeamReport.stateRoot})${RESET}`);
850
+ } else {
851
+ warn(`${omcTeamReport.entries.length}개 stale team 발견`);
852
+
853
+ for (const entry of omcTeamReport.entries) {
854
+ const ageLabel = formatElapsedAge(entry.ageSec);
855
+ const scopeLabel = entry.scope === "root" ? "root-state" : entry.sessionId;
856
+ warn(`${scopeLabel}: stale team (경과=${ageLabel}, 프로세스 없음)`);
857
+ if (entry.teamName) info(`팀: ${entry.teamName}`);
858
+ info(`파일: ${entry.stateFile}`);
859
+ }
860
+
861
+ if (fix) {
862
+ const cleanupResult = cleanupStaleOmcTeams(omcTeamReport.entries);
863
+ for (const result of cleanupResult.results) {
864
+ if (result.ok) {
865
+ ok(`stale team 정리: ${result.entry.scope === "root" ? "root-state" : result.entry.sessionId}`);
866
+ } else {
867
+ fail(`stale team 정리 실패: ${result.entry.scope === "root" ? "root-state" : result.entry.sessionId} — ${result.error.message}`);
868
+ }
869
+ }
870
+ issues += cleanupResult.failed;
871
+ } else {
872
+ info("정리: tfx doctor --fix");
873
+ issues += omcTeamReport.entries.length;
874
+ }
875
+ }
876
+
877
+ // 13. Orphan Teams
878
+ section("Orphan Teams");
879
+ const teamsDir = join(CLAUDE_DIR, "teams");
880
+ const tasksDir = join(CLAUDE_DIR, "tasks");
881
+ if (existsSync(teamsDir)) {
882
+ try {
883
+ const teamDirs = readdirSync(teamsDir).filter(d => {
884
+ try { return statSync(join(teamsDir, d)).isDirectory(); } catch { return false; }
885
+ });
886
+ if (teamDirs.length === 0) {
887
+ ok("잔존 팀 없음");
888
+ } else {
889
+ warn(`${teamDirs.length}개 잔존 팀 발견: ${teamDirs.join(", ")}`);
890
+ if (fix) {
891
+ let cleaned = 0;
892
+ for (const d of teamDirs) {
893
+ try {
894
+ rmSync(join(teamsDir, d), { recursive: true, force: true });
895
+ cleaned++;
896
+ } catch {}
897
+ // 연관 tasks 디렉토리도 정리
898
+ const taskDir = join(tasksDir, d);
899
+ if (existsSync(taskDir)) {
900
+ try { rmSync(taskDir, { recursive: true, force: true }); } catch {}
901
+ }
902
+ }
903
+ ok(`${cleaned}개 잔존 팀 정리 완료`);
904
+ } else {
905
+ info("정리: /tfx-doctor --fix 또는 수동 rm -rf ~/.claude/teams/{name}/");
906
+ issues++;
907
+ }
908
+ }
909
+ } catch (e) {
910
+ warn(`teams 디렉토리 읽기 실패: ${e.message}`);
911
+ }
912
+ } else {
913
+ ok("잔존 팀 없음");
914
+ }
915
+
675
916
  // 결과
676
917
  console.log(`\n ${LINE}`);
677
918
  if (issues === 0) {
@@ -681,10 +922,10 @@ function cmdDoctor(options = {}) {
681
922
  }
682
923
  }
683
924
 
684
- function cmdUpdate() {
685
- const isDev = isDevUpdateRequested(process.argv);
686
- const tagLabel = isDev ? ` ${YELLOW}--dev${RESET}` : "";
687
- console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
925
+ function cmdUpdate() {
926
+ const isDev = isDevUpdateRequested(process.argv);
927
+ const tagLabel = isDev ? ` ${YELLOW}--dev${RESET}` : "";
928
+ console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
688
929
 
689
930
  // 1. 설치 방식 감지
690
931
  const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
@@ -736,13 +977,13 @@ function cmdUpdate() {
736
977
 
737
978
  info(`검색: ${installMode === "plugin" ? "플러그인" : installMode === "npm-global" ? "npm global" : installMode === "npm-local" ? "npm local" : installMode === "git-local" ? "git 로컬 저장소" : "알 수 없음"} 설치 감지`);
738
979
 
739
- // 2. 설치 방식에 따라 업데이트
740
- const oldVer = PKG.version;
741
- let updated = false;
742
- let stoppedHubInfo = null;
743
-
744
- try {
745
- switch (installMode) {
980
+ // 2. 설치 방식에 따라 업데이트
981
+ const oldVer = PKG.version;
982
+ let updated = false;
983
+ let stoppedHubInfo = null;
984
+
985
+ try {
986
+ switch (installMode) {
746
987
  case "plugin": {
747
988
  const gitDir = pluginPath || PKG_ROOT;
748
989
  const result = execSync("git pull", {
@@ -753,19 +994,19 @@ function cmdUpdate() {
753
994
  ok(`git pull — ${result}`);
754
995
  updated = true;
755
996
  break;
756
- }
757
- case "npm-global": {
758
- stoppedHubInfo = stopHubForUpdate();
759
- if (stoppedHubInfo?.pid) {
760
- info(`실행 중 hub 정지 (PID ${stoppedHubInfo.pid})`);
761
- }
762
- const npmCmd = isDev ? "npm install -g triflux@dev" : "npm update -g triflux";
763
- const result = execSync(npmCmd, {
764
- encoding: "utf8",
997
+ }
998
+ case "npm-global": {
999
+ stoppedHubInfo = stopHubForUpdate();
1000
+ if (stoppedHubInfo?.pid) {
1001
+ info(`실행 중 hub 정지 (PID ${stoppedHubInfo.pid})`);
1002
+ }
1003
+ const npmCmd = isDev ? "npm install -g triflux@dev" : "npm update -g triflux";
1004
+ const result = execSync(npmCmd, {
1005
+ encoding: "utf8",
765
1006
  timeout: 60000,
766
1007
  stdio: ["pipe", "pipe", "ignore"],
767
1008
  }).trim().split(/\r?\n/)[0];
768
- ok(`${isDev ? "npm install -g triflux@dev" : "npm update -g triflux"} — ${result || "완료"}`);
1009
+ ok(`${isDev ? "npm install -g triflux@dev" : "npm update -g triflux"} — ${result || "완료"}`);
769
1010
  updated = true;
770
1011
  break;
771
1012
  }
@@ -777,7 +1018,7 @@ function cmdUpdate() {
777
1018
  cwd: process.cwd(),
778
1019
  stdio: ["pipe", "pipe", "ignore"],
779
1020
  }).trim().split(/\r?\n/)[0];
780
- ok(`${isDev ? "npm install triflux@dev" : "npm update triflux"} — ${result || "완료"}`);
1021
+ ok(`${isDev ? "npm install triflux@dev" : "npm update triflux"} — ${result || "완료"}`);
781
1022
  updated = true;
782
1023
  break;
783
1024
  }
@@ -795,14 +1036,14 @@ function cmdUpdate() {
795
1036
  fail("설치 방식을 감지할 수 없음");
796
1037
  info("수동 업데이트: cd <triflux-dir> && git pull");
797
1038
  return;
798
- }
799
- } catch (e) {
800
- if (stoppedHubInfo && startHubAfterUpdate(stoppedHubInfo)) {
801
- info("업데이트 실패 후 hub 재기동 시도");
802
- }
803
- fail(`업데이트 실패: ${e.message}`);
804
- return;
805
- }
1039
+ }
1040
+ } catch (e) {
1041
+ if (stoppedHubInfo && startHubAfterUpdate(stoppedHubInfo)) {
1042
+ info("업데이트 실패 후 hub 재기동 시도");
1043
+ }
1044
+ fail(`업데이트 실패: ${e.message}`);
1045
+ return;
1046
+ }
806
1047
 
807
1048
  // 3. setup 재실행 (tfx-route.sh, HUD, 스킬 동기화)
808
1049
  if (updated) {
@@ -820,16 +1061,16 @@ function cmdUpdate() {
820
1061
  ok(`버전: v${oldVer} (이미 최신)`);
821
1062
  }
822
1063
 
823
- // setup 재실행
824
- console.log("");
825
- info("setup 재실행 중...");
826
- cmdSetup();
827
-
828
- if (stoppedHubInfo) {
829
- if (startHubAfterUpdate(stoppedHubInfo)) info("hub 재기동 완료");
830
- else warn("hub 재기동 실패 — `tfx hub start`로 수동 시작 필요");
831
- }
832
- }
1064
+ // setup 재실행
1065
+ console.log("");
1066
+ info("setup 재실행 중...");
1067
+ cmdSetup();
1068
+
1069
+ if (stoppedHubInfo) {
1070
+ if (startHubAfterUpdate(stoppedHubInfo)) info("hub 재기동 완료");
1071
+ else warn("hub 재기동 실패 — `tfx hub start`로 수동 시작 필요");
1072
+ }
1073
+ }
833
1074
 
834
1075
  console.log(`${GREEN}${BOLD}업데이트 완료${RESET}\n`);
835
1076
  }
@@ -932,8 +1173,8 @@ ${updateNotice}
932
1173
  ${WHITE_BRIGHT}tfx doctor${RESET} ${GRAY}CLI 진단 + 이슈 확인${RESET}
933
1174
  ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
934
1175
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
935
- ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
936
- ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
1176
+ ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
1177
+ ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
937
1178
  ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
938
1179
  ${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
939
1180
  ${WHITE_BRIGHT}tfx team${RESET} ${GRAY}멀티-CLI 팀 모드 (tmux + Hub)${RESET}
@@ -941,12 +1182,12 @@ ${updateNotice}
941
1182
  ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
942
1183
  ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
943
1184
 
944
- ${BOLD}Skills${RESET} ${GRAY}(Claude Code 슬래시 커맨드)${RESET}
945
-
946
- ${AMBER}/tfx-auto${RESET} ${GRAY}자동 분류 + 병렬 실행${RESET}
947
- ${WHITE_BRIGHT}/tfx-auto-codex${RESET} ${GRAY}Codex 리드 + Gemini 유지 (no-Claude-native)${RESET}
948
- ${WHITE_BRIGHT}/tfx-codex${RESET} ${GRAY}Codex 전용 모드${RESET}
949
- ${BLUE}/tfx-gemini${RESET} ${GRAY}Gemini 전용 모드${RESET}
1185
+ ${BOLD}Skills${RESET} ${GRAY}(Claude Code 슬래시 커맨드)${RESET}
1186
+
1187
+ ${AMBER}/tfx-auto${RESET} ${GRAY}자동 분류 + 병렬 실행${RESET}
1188
+ ${WHITE_BRIGHT}/tfx-auto-codex${RESET} ${GRAY}Codex 리드 + Gemini 유지 (no-Claude-native)${RESET}
1189
+ ${WHITE_BRIGHT}/tfx-codex${RESET} ${GRAY}Codex 전용 모드${RESET}
1190
+ ${BLUE}/tfx-gemini${RESET} ${GRAY}Gemini 전용 모드${RESET}
950
1191
  ${AMBER}/tfx-setup${RESET} ${GRAY}HUD 설정 + 진단${RESET}
951
1192
  ${YELLOW}/tfx-doctor${RESET} ${GRAY}진단 + 수리 + 캐시 초기화${RESET}
952
1193
 
@@ -979,88 +1220,88 @@ async function cmdCodexTeam() {
979
1220
  return;
980
1221
  }
981
1222
 
982
- const hasAgents = args.includes("--agents");
983
- const hasLead = args.includes("--lead");
984
- const hasLayout = args.includes("--layout");
985
- const isControl = passthrough.has(sub);
986
- const normalizedArgs = isControl && args.length ? [sub, ...args.slice(1)] : args;
987
- const inject = [];
988
- if (!isControl && !hasLead) inject.push("--lead", "codex");
989
- if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
990
- if (!isControl && !hasLayout) inject.push("--layout", "1xN");
991
- const forwarded = isControl ? normalizedArgs : [...inject, ...args];
992
-
993
- const prevArgv = process.argv;
994
- const prevProfile = process.env.TFX_TEAM_PROFILE;
995
- process.env.TFX_TEAM_PROFILE = "codex-team";
996
- const { pathToFileURL } = await import("node:url");
997
- const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
998
- process.argv = [prevArgv[0], prevArgv[1], "team", ...forwarded];
999
- try {
1000
- await cmdTeam();
1001
- } finally {
1002
- process.argv = prevArgv;
1003
- if (typeof prevProfile === "string") process.env.TFX_TEAM_PROFILE = prevProfile;
1004
- else delete process.env.TFX_TEAM_PROFILE;
1005
- }
1006
- }
1223
+ const hasAgents = args.includes("--agents");
1224
+ const hasLead = args.includes("--lead");
1225
+ const hasLayout = args.includes("--layout");
1226
+ const isControl = passthrough.has(sub);
1227
+ const normalizedArgs = isControl && args.length ? [sub, ...args.slice(1)] : args;
1228
+ const inject = [];
1229
+ if (!isControl && !hasLead) inject.push("--lead", "codex");
1230
+ if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
1231
+ if (!isControl && !hasLayout) inject.push("--layout", "1xN");
1232
+ const forwarded = isControl ? normalizedArgs : [...inject, ...args];
1233
+
1234
+ const prevArgv = process.argv;
1235
+ const prevProfile = process.env.TFX_TEAM_PROFILE;
1236
+ process.env.TFX_TEAM_PROFILE = "codex-team";
1237
+ const { pathToFileURL } = await import("node:url");
1238
+ const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
1239
+ process.argv = [prevArgv[0], prevArgv[1], "team", ...forwarded];
1240
+ try {
1241
+ await cmdTeam();
1242
+ } finally {
1243
+ process.argv = prevArgv;
1244
+ if (typeof prevProfile === "string") process.env.TFX_TEAM_PROFILE = prevProfile;
1245
+ else delete process.env.TFX_TEAM_PROFILE;
1246
+ }
1247
+ }
1007
1248
 
1008
1249
  // ── hub 서브커맨드 ──
1009
1250
 
1010
- const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
1011
- const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
1012
-
1013
- function sleepMs(ms) {
1014
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1015
- }
1016
-
1017
- function stopHubForUpdate() {
1018
- if (!existsSync(HUB_PID_FILE)) return null;
1019
- let info = null;
1020
- try {
1021
- info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1022
- process.kill(info.pid, 0);
1023
- } catch {
1024
- try { unlinkSync(HUB_PID_FILE); } catch {}
1025
- return null;
1026
- }
1027
-
1028
- try {
1029
- if (process.platform === "win32") {
1030
- execSync(`taskkill /PID ${info.pid} /T /F`, {
1031
- stdio: ["pipe", "pipe", "ignore"],
1032
- timeout: 10000,
1033
- });
1034
- } else {
1035
- process.kill(info.pid, "SIGTERM");
1036
- }
1037
- } catch {
1038
- try { process.kill(info.pid, "SIGKILL"); } catch {}
1039
- }
1040
-
1041
- sleepMs(300);
1042
- try { unlinkSync(HUB_PID_FILE); } catch {}
1043
- return info;
1044
- }
1045
-
1046
- function startHubAfterUpdate(info) {
1047
- if (!info) return false;
1048
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
1049
- if (!existsSync(serverPath)) return false;
1050
- const port = Number(info?.port) > 0 ? String(info.port) : String(process.env.TFX_HUB_PORT || "27888");
1051
-
1052
- try {
1053
- const child = spawn(process.execPath, [serverPath], {
1054
- env: { ...process.env, TFX_HUB_PORT: port },
1055
- stdio: "ignore",
1056
- detached: true,
1057
- });
1058
- child.unref();
1059
- return true;
1060
- } catch {
1061
- return false;
1062
- }
1063
- }
1251
+ const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
1252
+ const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
1253
+
1254
+ function sleepMs(ms) {
1255
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1256
+ }
1257
+
1258
+ function stopHubForUpdate() {
1259
+ if (!existsSync(HUB_PID_FILE)) return null;
1260
+ let info = null;
1261
+ try {
1262
+ info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1263
+ process.kill(info.pid, 0);
1264
+ } catch {
1265
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1266
+ return null;
1267
+ }
1268
+
1269
+ try {
1270
+ if (process.platform === "win32") {
1271
+ execSync(`taskkill /PID ${info.pid} /T /F`, {
1272
+ stdio: ["pipe", "pipe", "ignore"],
1273
+ timeout: 10000,
1274
+ });
1275
+ } else {
1276
+ process.kill(info.pid, "SIGTERM");
1277
+ }
1278
+ } catch {
1279
+ try { process.kill(info.pid, "SIGKILL"); } catch {}
1280
+ }
1281
+
1282
+ sleepMs(300);
1283
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1284
+ return info;
1285
+ }
1286
+
1287
+ function startHubAfterUpdate(info) {
1288
+ if (!info) return false;
1289
+ const serverPath = join(PKG_ROOT, "hub", "server.mjs");
1290
+ if (!existsSync(serverPath)) return false;
1291
+ const port = Number(info?.port) > 0 ? String(info.port) : String(process.env.TFX_HUB_PORT || "27888");
1292
+
1293
+ try {
1294
+ const child = spawn(process.execPath, [serverPath], {
1295
+ env: { ...process.env, TFX_HUB_PORT: port },
1296
+ stdio: "ignore",
1297
+ detached: true,
1298
+ });
1299
+ child.unref();
1300
+ return true;
1301
+ } catch {
1302
+ return false;
1303
+ }
1304
+ }
1064
1305
 
1065
1306
  // 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
1066
1307
  function autoRegisterMcp(mcpUrl) {
@@ -1136,40 +1377,40 @@ function autoRegisterMcp(mcpUrl) {
1136
1377
  } catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
1137
1378
  }
1138
1379
 
1139
- async function cmdHub() {
1140
- const sub = process.argv[3] || "status";
1141
- const defaultPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
1142
- const probePort = Number.isFinite(defaultPortRaw) && defaultPortRaw > 0 ? defaultPortRaw : 27888;
1143
- const formatHostForUrl = (host) => host.includes(":") ? `[${host}]` : host;
1144
- const probeHubStatus = async (host = "127.0.0.1", port = probePort, timeoutMs = 3000) => {
1145
- try {
1146
- const res = await fetch(`http://${formatHostForUrl(host)}:${port}/status`, {
1147
- signal: AbortSignal.timeout(timeoutMs),
1148
- });
1149
- if (!res.ok) return null;
1150
- const data = await res.json();
1151
- return data?.hub ? data : null;
1152
- } catch {
1153
- return null;
1154
- }
1155
- };
1156
- const recoverPidFile = (statusData, defaultHost = "127.0.0.1") => {
1157
- const pid = Number(statusData?.pid);
1158
- const port = Number(statusData?.port) || probePort;
1159
- if (!Number.isFinite(pid) || pid <= 0) return;
1160
- try {
1161
- mkdirSync(HUB_PID_DIR, { recursive: true });
1162
- writeFileSync(HUB_PID_FILE, JSON.stringify({
1163
- pid,
1164
- port,
1165
- host: defaultHost,
1166
- url: `http://${formatHostForUrl(defaultHost)}:${port}/mcp`,
1167
- started: Date.now(),
1168
- }));
1169
- } catch {}
1170
- };
1171
-
1172
- switch (sub) {
1380
+ async function cmdHub() {
1381
+ const sub = process.argv[3] || "status";
1382
+ const defaultPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
1383
+ const probePort = Number.isFinite(defaultPortRaw) && defaultPortRaw > 0 ? defaultPortRaw : 27888;
1384
+ const formatHostForUrl = (host) => host.includes(":") ? `[${host}]` : host;
1385
+ const probeHubStatus = async (host = "127.0.0.1", port = probePort, timeoutMs = 3000) => {
1386
+ try {
1387
+ const res = await fetch(`http://${formatHostForUrl(host)}:${port}/status`, {
1388
+ signal: AbortSignal.timeout(timeoutMs),
1389
+ });
1390
+ if (!res.ok) return null;
1391
+ const data = await res.json();
1392
+ return data?.hub ? data : null;
1393
+ } catch {
1394
+ return null;
1395
+ }
1396
+ };
1397
+ const recoverPidFile = (statusData, defaultHost = "127.0.0.1") => {
1398
+ const pid = Number(statusData?.pid);
1399
+ const port = Number(statusData?.port) || probePort;
1400
+ if (!Number.isFinite(pid) || pid <= 0) return;
1401
+ try {
1402
+ mkdirSync(HUB_PID_DIR, { recursive: true });
1403
+ writeFileSync(HUB_PID_FILE, JSON.stringify({
1404
+ pid,
1405
+ port,
1406
+ host: defaultHost,
1407
+ url: `http://${formatHostForUrl(defaultHost)}:${port}/mcp`,
1408
+ started: Date.now(),
1409
+ }));
1410
+ } catch {}
1411
+ };
1412
+
1413
+ switch (sub) {
1173
1414
  case "start": {
1174
1415
  // 이미 실행 중인지 확인
1175
1416
  if (existsSync(HUB_PID_FILE)) {
@@ -1200,13 +1441,13 @@ async function cmdHub() {
1200
1441
  });
1201
1442
  child.unref();
1202
1443
 
1203
- // PID 파일 확인 (최대 3초 대기, 100ms 폴링)
1204
- let started = false;
1205
- const deadline = Date.now() + 3000;
1206
- while (Date.now() < deadline) {
1207
- if (existsSync(HUB_PID_FILE)) { started = true; break; }
1208
- await new Promise((r) => setTimeout(r, 100));
1209
- }
1444
+ // PID 파일 확인 (최대 3초 대기, 100ms 폴링)
1445
+ let started = false;
1446
+ const deadline = Date.now() + 3000;
1447
+ while (Date.now() < deadline) {
1448
+ if (existsSync(HUB_PID_FILE)) { started = true; break; }
1449
+ await new Promise((r) => setTimeout(r, 100));
1450
+ }
1210
1451
 
1211
1452
  if (started) {
1212
1453
  const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
@@ -1225,20 +1466,20 @@ async function cmdHub() {
1225
1466
  break;
1226
1467
  }
1227
1468
 
1228
- case "stop": {
1229
- if (!existsSync(HUB_PID_FILE)) {
1230
- const probed = await probeHubStatus("127.0.0.1", probePort, 1500)
1231
- || (probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500));
1232
- if (probed && Number.isFinite(Number(probed.pid))) {
1233
- try {
1234
- process.kill(Number(probed.pid), "SIGTERM");
1235
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${probed.pid})${DIM} (probe)${RESET}\n`);
1236
- return;
1237
- } catch {}
1238
- }
1239
- console.log(`\n ${DIM}hub 미실행${RESET}\n`);
1240
- return;
1241
- }
1469
+ case "stop": {
1470
+ if (!existsSync(HUB_PID_FILE)) {
1471
+ const probed = await probeHubStatus("127.0.0.1", probePort, 1500)
1472
+ || (probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500));
1473
+ if (probed && Number.isFinite(Number(probed.pid))) {
1474
+ try {
1475
+ process.kill(Number(probed.pid), "SIGTERM");
1476
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${probed.pid})${DIM} (probe)${RESET}\n`);
1477
+ return;
1478
+ } catch {}
1479
+ }
1480
+ console.log(`\n ${DIM}hub 미실행${RESET}\n`);
1481
+ return;
1482
+ }
1242
1483
  try {
1243
1484
  const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1244
1485
  process.kill(info.pid, "SIGTERM");
@@ -1250,36 +1491,36 @@ async function cmdHub() {
1250
1491
  }
1251
1492
  break;
1252
1493
  }
1253
-
1254
- case "status": {
1255
- if (!existsSync(HUB_PID_FILE)) {
1256
- const probed = await probeHubStatus();
1257
- if (!probed) {
1258
- const fallback = probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500);
1259
- if (fallback) {
1260
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(default port probe 성공)${RESET}`);
1261
- console.log(` URL: http://127.0.0.1:${fallback.port || 27888}/mcp`);
1262
- if (fallback.pid !== undefined) console.log(` PID: ${fallback.pid}`);
1263
- if (fallback.hub?.state) console.log(` State: ${fallback.hub.state}`);
1264
- if (fallback.sessions !== undefined) console.log(` Sessions: ${fallback.sessions}`);
1265
- recoverPidFile(fallback, "127.0.0.1");
1266
- console.log("");
1267
- return;
1268
- }
1269
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
1270
- return;
1271
- }
1272
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(pid file 없음 / probe 성공)${RESET}`);
1273
- console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
1274
- if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
1275
- if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
1276
- if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
1277
- recoverPidFile(probed, "127.0.0.1");
1278
- console.log("");
1279
- return;
1280
- }
1281
- try {
1282
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1494
+
1495
+ case "status": {
1496
+ if (!existsSync(HUB_PID_FILE)) {
1497
+ const probed = await probeHubStatus();
1498
+ if (!probed) {
1499
+ const fallback = probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500);
1500
+ if (fallback) {
1501
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(default port probe 성공)${RESET}`);
1502
+ console.log(` URL: http://127.0.0.1:${fallback.port || 27888}/mcp`);
1503
+ if (fallback.pid !== undefined) console.log(` PID: ${fallback.pid}`);
1504
+ if (fallback.hub?.state) console.log(` State: ${fallback.hub.state}`);
1505
+ if (fallback.sessions !== undefined) console.log(` Sessions: ${fallback.sessions}`);
1506
+ recoverPidFile(fallback, "127.0.0.1");
1507
+ console.log("");
1508
+ return;
1509
+ }
1510
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
1511
+ return;
1512
+ }
1513
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(pid file 없음 / probe 성공)${RESET}`);
1514
+ console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
1515
+ if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
1516
+ if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
1517
+ if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
1518
+ recoverPidFile(probed, "127.0.0.1");
1519
+ console.log("");
1520
+ return;
1521
+ }
1522
+ try {
1523
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1283
1524
  process.kill(info.pid, 0); // 생존 확인
1284
1525
  const uptime = Date.now() - info.started;
1285
1526
  const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
@@ -1289,39 +1530,39 @@ async function cmdHub() {
1289
1530
  console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
1290
1531
  console.log(` URL: ${info.url}`);
1291
1532
  console.log(` PID: ${info.pid}`);
1292
- console.log(` Uptime: ${uptimeStr}`);
1293
-
1294
- // HTTP 상태 조회 시도
1295
- try {
1296
- const host = typeof info.host === "string" ? info.host : "127.0.0.1";
1297
- const port = Number(info.port) || probePort;
1298
- const data = await probeHubStatus(host, port, 3000);
1299
- if (data.hub) {
1300
- console.log(` State: ${data.hub.state}`);
1301
- }
1533
+ console.log(` Uptime: ${uptimeStr}`);
1534
+
1535
+ // HTTP 상태 조회 시도
1536
+ try {
1537
+ const host = typeof info.host === "string" ? info.host : "127.0.0.1";
1538
+ const port = Number(info.port) || probePort;
1539
+ const data = await probeHubStatus(host, port, 3000);
1540
+ if (data.hub) {
1541
+ console.log(` State: ${data.hub.state}`);
1542
+ }
1302
1543
  if (data.sessions !== undefined) {
1303
1544
  console.log(` Sessions: ${data.sessions}`);
1304
1545
  }
1305
1546
  } catch {}
1306
1547
 
1307
1548
  console.log("");
1308
- } catch {
1309
- try { unlinkSync(HUB_PID_FILE); } catch {}
1310
- const probed = await probeHubStatus();
1311
- if (!probed) {
1312
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
1313
- break;
1314
- }
1315
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(stale PID 정리 후 probe 성공)${RESET}`);
1316
- console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
1317
- if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
1318
- if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
1319
- if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
1320
- recoverPidFile(probed, "127.0.0.1");
1321
- console.log("");
1322
- }
1323
- break;
1324
- }
1549
+ } catch {
1550
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1551
+ const probed = await probeHubStatus();
1552
+ if (!probed) {
1553
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
1554
+ break;
1555
+ }
1556
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(stale PID 정리 후 probe 성공)${RESET}`);
1557
+ console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
1558
+ if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
1559
+ if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
1560
+ if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
1561
+ recoverPidFile(probed, "127.0.0.1");
1562
+ console.log("");
1563
+ }
1564
+ break;
1565
+ }
1325
1566
 
1326
1567
  default:
1327
1568
  console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
@@ -1338,15 +1579,15 @@ const cmd = process.argv[2] || "help";
1338
1579
 
1339
1580
  switch (cmd) {
1340
1581
  case "setup": cmdSetup(); break;
1341
- case "doctor": {
1342
- const fix = process.argv.includes("--fix");
1343
- const reset = process.argv.includes("--reset");
1344
- cmdDoctor({ fix, reset });
1345
- break;
1346
- }
1582
+ case "doctor": {
1583
+ const fix = process.argv.includes("--fix");
1584
+ const reset = process.argv.includes("--reset");
1585
+ await cmdDoctor({ fix, reset });
1586
+ break;
1587
+ }
1347
1588
  case "update": cmdUpdate(); break;
1348
1589
  case "list": case "ls": cmdList(); break;
1349
- case "hub": await cmdHub(); break;
1590
+ case "hub": await cmdHub(); break;
1350
1591
  case "team": {
1351
1592
  const { pathToFileURL } = await import("node:url");
1352
1593
  const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);