triflux 3.2.0-dev.7 → 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 +557 -251
  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,9 +922,9 @@ 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}` : "";
925
+ function cmdUpdate() {
926
+ const isDev = isDevUpdateRequested(process.argv);
927
+ const tagLabel = isDev ? ` ${YELLOW}--dev${RESET}` : "";
687
928
  console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
688
929
 
689
930
  // 1. 설치 방식 감지
@@ -739,6 +980,7 @@ function cmdUpdate() {
739
980
  // 2. 설치 방식에 따라 업데이트
740
981
  const oldVer = PKG.version;
741
982
  let updated = false;
983
+ let stoppedHubInfo = null;
742
984
 
743
985
  try {
744
986
  switch (installMode) {
@@ -754,13 +996,17 @@ function cmdUpdate() {
754
996
  break;
755
997
  }
756
998
  case "npm-global": {
999
+ stoppedHubInfo = stopHubForUpdate();
1000
+ if (stoppedHubInfo?.pid) {
1001
+ info(`실행 중 hub 정지 (PID ${stoppedHubInfo.pid})`);
1002
+ }
757
1003
  const npmCmd = isDev ? "npm install -g triflux@dev" : "npm update -g triflux";
758
1004
  const result = execSync(npmCmd, {
759
1005
  encoding: "utf8",
760
1006
  timeout: 60000,
761
1007
  stdio: ["pipe", "pipe", "ignore"],
762
1008
  }).trim().split(/\r?\n/)[0];
763
- 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 || "완료"}`);
764
1010
  updated = true;
765
1011
  break;
766
1012
  }
@@ -772,7 +1018,7 @@ function cmdUpdate() {
772
1018
  cwd: process.cwd(),
773
1019
  stdio: ["pipe", "pipe", "ignore"],
774
1020
  }).trim().split(/\r?\n/)[0];
775
- ok(`${isDev ? "npm install triflux@dev" : "npm update triflux"} — ${result || "완료"}`);
1021
+ ok(`${isDev ? "npm install triflux@dev" : "npm update triflux"} — ${result || "완료"}`);
776
1022
  updated = true;
777
1023
  break;
778
1024
  }
@@ -792,6 +1038,9 @@ function cmdUpdate() {
792
1038
  return;
793
1039
  }
794
1040
  } catch (e) {
1041
+ if (stoppedHubInfo && startHubAfterUpdate(stoppedHubInfo)) {
1042
+ info("업데이트 실패 후 hub 재기동 시도");
1043
+ }
795
1044
  fail(`업데이트 실패: ${e.message}`);
796
1045
  return;
797
1046
  }
@@ -816,6 +1065,11 @@ function cmdUpdate() {
816
1065
  console.log("");
817
1066
  info("setup 재실행 중...");
818
1067
  cmdSetup();
1068
+
1069
+ if (stoppedHubInfo) {
1070
+ if (startHubAfterUpdate(stoppedHubInfo)) info("hub 재기동 완료");
1071
+ else warn("hub 재기동 실패 — `tfx hub start`로 수동 시작 필요");
1072
+ }
819
1073
  }
820
1074
 
821
1075
  console.log(`${GREEN}${BOLD}업데이트 완료${RESET}\n`);
@@ -919,8 +1173,8 @@ ${updateNotice}
919
1173
  ${WHITE_BRIGHT}tfx doctor${RESET} ${GRAY}CLI 진단 + 이슈 확인${RESET}
920
1174
  ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
921
1175
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
922
- ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
923
- ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
1176
+ ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
1177
+ ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
924
1178
  ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
925
1179
  ${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
926
1180
  ${WHITE_BRIGHT}tfx team${RESET} ${GRAY}멀티-CLI 팀 모드 (tmux + Hub)${RESET}
@@ -928,12 +1182,12 @@ ${updateNotice}
928
1182
  ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
929
1183
  ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
930
1184
 
931
- ${BOLD}Skills${RESET} ${GRAY}(Claude Code 슬래시 커맨드)${RESET}
932
-
933
- ${AMBER}/tfx-auto${RESET} ${GRAY}자동 분류 + 병렬 실행${RESET}
934
- ${WHITE_BRIGHT}/tfx-auto-codex${RESET} ${GRAY}Codex 리드 + Gemini 유지 (no-Claude-native)${RESET}
935
- ${WHITE_BRIGHT}/tfx-codex${RESET} ${GRAY}Codex 전용 모드${RESET}
936
- ${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}
937
1191
  ${AMBER}/tfx-setup${RESET} ${GRAY}HUD 설정 + 진단${RESET}
938
1192
  ${YELLOW}/tfx-doctor${RESET} ${GRAY}진단 + 수리 + 캐시 초기화${RESET}
939
1193
 
@@ -966,37 +1220,89 @@ async function cmdCodexTeam() {
966
1220
  return;
967
1221
  }
968
1222
 
969
- const hasAgents = args.includes("--agents");
970
- const hasLead = args.includes("--lead");
971
- const hasLayout = args.includes("--layout");
972
- const isControl = passthrough.has(sub);
973
- const normalizedArgs = isControl && args.length ? [sub, ...args.slice(1)] : args;
974
- const inject = [];
975
- if (!isControl && !hasLead) inject.push("--lead", "codex");
976
- if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
977
- if (!isControl && !hasLayout) inject.push("--layout", "1xN");
978
- const forwarded = isControl ? normalizedArgs : [...inject, ...args];
979
-
980
- const prevArgv = process.argv;
981
- const prevProfile = process.env.TFX_TEAM_PROFILE;
982
- process.env.TFX_TEAM_PROFILE = "codex-team";
983
- const { pathToFileURL } = await import("node:url");
984
- const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
985
- process.argv = [prevArgv[0], prevArgv[1], "team", ...forwarded];
986
- try {
987
- await cmdTeam();
988
- } finally {
989
- process.argv = prevArgv;
990
- if (typeof prevProfile === "string") process.env.TFX_TEAM_PROFILE = prevProfile;
991
- else delete process.env.TFX_TEAM_PROFILE;
992
- }
993
- }
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
+ }
994
1248
 
995
1249
  // ── hub 서브커맨드 ──
996
1250
 
997
1251
  const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
998
1252
  const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
999
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
+ }
1305
+
1000
1306
  // 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
1001
1307
  function autoRegisterMcp(mcpUrl) {
1002
1308
  section("MCP 자동 등록");
@@ -1071,40 +1377,40 @@ function autoRegisterMcp(mcpUrl) {
1071
1377
  } catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
1072
1378
  }
1073
1379
 
1074
- async function cmdHub() {
1075
- const sub = process.argv[3] || "status";
1076
- const defaultPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
1077
- const probePort = Number.isFinite(defaultPortRaw) && defaultPortRaw > 0 ? defaultPortRaw : 27888;
1078
- const formatHostForUrl = (host) => host.includes(":") ? `[${host}]` : host;
1079
- const probeHubStatus = async (host = "127.0.0.1", port = probePort, timeoutMs = 3000) => {
1080
- try {
1081
- const res = await fetch(`http://${formatHostForUrl(host)}:${port}/status`, {
1082
- signal: AbortSignal.timeout(timeoutMs),
1083
- });
1084
- if (!res.ok) return null;
1085
- const data = await res.json();
1086
- return data?.hub ? data : null;
1087
- } catch {
1088
- return null;
1089
- }
1090
- };
1091
- const recoverPidFile = (statusData, defaultHost = "127.0.0.1") => {
1092
- const pid = Number(statusData?.pid);
1093
- const port = Number(statusData?.port) || probePort;
1094
- if (!Number.isFinite(pid) || pid <= 0) return;
1095
- try {
1096
- mkdirSync(HUB_PID_DIR, { recursive: true });
1097
- writeFileSync(HUB_PID_FILE, JSON.stringify({
1098
- pid,
1099
- port,
1100
- host: defaultHost,
1101
- url: `http://${formatHostForUrl(defaultHost)}:${port}/mcp`,
1102
- started: Date.now(),
1103
- }));
1104
- } catch {}
1105
- };
1106
-
1107
- 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) {
1108
1414
  case "start": {
1109
1415
  // 이미 실행 중인지 확인
1110
1416
  if (existsSync(HUB_PID_FILE)) {
@@ -1135,13 +1441,13 @@ async function cmdHub() {
1135
1441
  });
1136
1442
  child.unref();
1137
1443
 
1138
- // PID 파일 확인 (최대 3초 대기, 100ms 폴링)
1139
- let started = false;
1140
- const deadline = Date.now() + 3000;
1141
- while (Date.now() < deadline) {
1142
- if (existsSync(HUB_PID_FILE)) { started = true; break; }
1143
- await new Promise((r) => setTimeout(r, 100));
1144
- }
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
+ }
1145
1451
 
1146
1452
  if (started) {
1147
1453
  const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
@@ -1160,20 +1466,20 @@ async function cmdHub() {
1160
1466
  break;
1161
1467
  }
1162
1468
 
1163
- case "stop": {
1164
- if (!existsSync(HUB_PID_FILE)) {
1165
- const probed = await probeHubStatus("127.0.0.1", probePort, 1500)
1166
- || (probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500));
1167
- if (probed && Number.isFinite(Number(probed.pid))) {
1168
- try {
1169
- process.kill(Number(probed.pid), "SIGTERM");
1170
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${probed.pid})${DIM} (probe)${RESET}\n`);
1171
- return;
1172
- } catch {}
1173
- }
1174
- console.log(`\n ${DIM}hub 미실행${RESET}\n`);
1175
- return;
1176
- }
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
+ }
1177
1483
  try {
1178
1484
  const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1179
1485
  process.kill(info.pid, "SIGTERM");
@@ -1185,36 +1491,36 @@ async function cmdHub() {
1185
1491
  }
1186
1492
  break;
1187
1493
  }
1188
-
1189
- case "status": {
1190
- if (!existsSync(HUB_PID_FILE)) {
1191
- const probed = await probeHubStatus();
1192
- if (!probed) {
1193
- const fallback = probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500);
1194
- if (fallback) {
1195
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(default port probe 성공)${RESET}`);
1196
- console.log(` URL: http://127.0.0.1:${fallback.port || 27888}/mcp`);
1197
- if (fallback.pid !== undefined) console.log(` PID: ${fallback.pid}`);
1198
- if (fallback.hub?.state) console.log(` State: ${fallback.hub.state}`);
1199
- if (fallback.sessions !== undefined) console.log(` Sessions: ${fallback.sessions}`);
1200
- recoverPidFile(fallback, "127.0.0.1");
1201
- console.log("");
1202
- return;
1203
- }
1204
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
1205
- return;
1206
- }
1207
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(pid file 없음 / probe 성공)${RESET}`);
1208
- console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
1209
- if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
1210
- if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
1211
- if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
1212
- recoverPidFile(probed, "127.0.0.1");
1213
- console.log("");
1214
- return;
1215
- }
1216
- try {
1217
- 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"));
1218
1524
  process.kill(info.pid, 0); // 생존 확인
1219
1525
  const uptime = Date.now() - info.started;
1220
1526
  const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
@@ -1224,39 +1530,39 @@ async function cmdHub() {
1224
1530
  console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
1225
1531
  console.log(` URL: ${info.url}`);
1226
1532
  console.log(` PID: ${info.pid}`);
1227
- console.log(` Uptime: ${uptimeStr}`);
1228
-
1229
- // HTTP 상태 조회 시도
1230
- try {
1231
- const host = typeof info.host === "string" ? info.host : "127.0.0.1";
1232
- const port = Number(info.port) || probePort;
1233
- const data = await probeHubStatus(host, port, 3000);
1234
- if (data.hub) {
1235
- console.log(` State: ${data.hub.state}`);
1236
- }
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
+ }
1237
1543
  if (data.sessions !== undefined) {
1238
1544
  console.log(` Sessions: ${data.sessions}`);
1239
1545
  }
1240
1546
  } catch {}
1241
1547
 
1242
1548
  console.log("");
1243
- } catch {
1244
- try { unlinkSync(HUB_PID_FILE); } catch {}
1245
- const probed = await probeHubStatus();
1246
- if (!probed) {
1247
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
1248
- break;
1249
- }
1250
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(stale PID 정리 후 probe 성공)${RESET}`);
1251
- console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
1252
- if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
1253
- if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
1254
- if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
1255
- recoverPidFile(probed, "127.0.0.1");
1256
- console.log("");
1257
- }
1258
- break;
1259
- }
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
+ }
1260
1566
 
1261
1567
  default:
1262
1568
  console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
@@ -1273,15 +1579,15 @@ const cmd = process.argv[2] || "help";
1273
1579
 
1274
1580
  switch (cmd) {
1275
1581
  case "setup": cmdSetup(); break;
1276
- case "doctor": {
1277
- const fix = process.argv.includes("--fix");
1278
- const reset = process.argv.includes("--reset");
1279
- cmdDoctor({ fix, reset });
1280
- break;
1281
- }
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
+ }
1282
1588
  case "update": cmdUpdate(); break;
1283
1589
  case "list": case "ls": cmdList(); break;
1284
- case "hub": await cmdHub(); break;
1590
+ case "hub": await cmdHub(); break;
1285
1591
  case "team": {
1286
1592
  const { pathToFileURL } = await import("node:url");
1287
1593
  const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);