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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/bin/triflux.mjs +1296 -1055
  2. package/hooks/hooks.json +17 -0
  3. package/hooks/keyword-rules.json +20 -4
  4. package/hooks/pipeline-stop.mjs +54 -0
  5. package/hub/bridge.mjs +517 -318
  6. package/hub/hitl.mjs +45 -31
  7. package/hub/pipe.mjs +457 -0
  8. package/hub/pipeline/index.mjs +121 -0
  9. package/hub/pipeline/state.mjs +164 -0
  10. package/hub/pipeline/transitions.mjs +114 -0
  11. package/hub/router.mjs +422 -161
  12. package/hub/schema.sql +14 -0
  13. package/hub/server.mjs +499 -424
  14. package/hub/store.mjs +388 -314
  15. package/hub/team/cli-team-common.mjs +348 -0
  16. package/hub/team/cli-team-control.mjs +393 -0
  17. package/hub/team/cli-team-start.mjs +516 -0
  18. package/hub/team/cli-team-status.mjs +269 -0
  19. package/hub/team/cli.mjs +75 -1475
  20. package/hub/team/dashboard.mjs +1 -9
  21. package/hub/team/native.mjs +190 -130
  22. package/hub/team/nativeProxy.mjs +165 -78
  23. package/hub/team/orchestrator.mjs +15 -20
  24. package/hub/team/pane.mjs +137 -103
  25. package/hub/team/psmux.mjs +506 -0
  26. package/hub/team/session.mjs +393 -330
  27. package/hub/team/shared.mjs +13 -0
  28. package/hub/team/staleState.mjs +299 -0
  29. package/hub/tools.mjs +105 -31
  30. package/hub/workers/claude-worker.mjs +446 -0
  31. package/hub/workers/codex-mcp.mjs +414 -0
  32. package/hub/workers/factory.mjs +18 -0
  33. package/hub/workers/gemini-worker.mjs +349 -0
  34. package/hub/workers/interface.mjs +41 -0
  35. package/hud/hud-qos-status.mjs +1790 -1788
  36. package/package.json +4 -1
  37. package/scripts/__tests__/keyword-detector.test.mjs +8 -8
  38. package/scripts/keyword-detector.mjs +15 -0
  39. package/scripts/lib/keyword-rules.mjs +4 -1
  40. package/scripts/preflight-cache.mjs +72 -0
  41. package/scripts/psmux-steering-prototype.sh +368 -0
  42. package/scripts/setup.mjs +136 -71
  43. package/scripts/tfx-route-worker.mjs +161 -0
  44. package/scripts/tfx-route.sh +485 -91
  45. package/skills/tfx-auto/SKILL.md +90 -564
  46. package/skills/tfx-auto-codex/SKILL.md +1 -3
  47. package/skills/tfx-codex/SKILL.md +1 -4
  48. package/skills/tfx-doctor/SKILL.md +1 -0
  49. package/skills/tfx-gemini/SKILL.md +1 -4
  50. package/skills/tfx-multi/SKILL.md +378 -0
  51. package/skills/tfx-setup/SKILL.md +1 -4
  52. package/skills/tfx-team/SKILL.md +0 -304
package/bin/triflux.mjs CHANGED
@@ -1,448 +1,575 @@
1
- #!/usr/bin/env node
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";
1
+ #!/usr/bin/env node
2
+ // triflux CLI — setup, doctor, version
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
+ 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";
8
11
 
9
12
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
10
- const CLAUDE_DIR = join(homedir(), ".claude");
11
- const CODEX_DIR = join(homedir(), ".codex");
12
- const CODEX_CONFIG_PATH = join(CODEX_DIR, "config.toml");
13
- const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
14
-
15
- const REQUIRED_CODEX_PROFILES = [
16
- {
17
- name: "xhigh",
18
- lines: [
19
- 'model = "gpt-5.3-codex"',
20
- 'model_reasoning_effort = "xhigh"',
21
- ],
22
- },
23
- {
24
- name: "spark_fast",
25
- lines: [
26
- 'model = "gpt-5.1-codex-mini"',
27
- 'model_reasoning_effort = "low"',
28
- ],
29
- },
30
- ];
31
-
32
- // ── 색상 체계 (triflux brand: amber/orange accent) ──
33
- const CYAN = "\x1b[36m";
34
- const GREEN = "\x1b[32m";
35
- const RED = "\x1b[31m";
36
- const YELLOW = "\x1b[33m";
37
- const DIM = "\x1b[2m";
38
- const BOLD = "\x1b[1m";
39
- const RESET = "\x1b[0m";
40
- const AMBER = "\x1b[38;5;214m";
41
- const BLUE = "\x1b[38;5;39m";
42
- const WHITE_BRIGHT = "\x1b[97m";
43
- const GRAY = "\x1b[38;5;245m";
44
- const GREEN_BRIGHT = "\x1b[38;5;82m";
45
- const RED_BRIGHT = "\x1b[38;5;196m";
46
-
47
- // ── 브랜드 요소 ──
48
- 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
-
53
- // ── 유틸리티 ──
54
-
55
- function ok(msg) { console.log(` ${GREEN_BRIGHT}✓${RESET} ${msg}`); }
56
- function warn(msg) { console.log(` ${YELLOW}⚠${RESET} ${msg}`); }
57
- function fail(msg) { console.log(` ${RED_BRIGHT}✗${RESET} ${msg}`); }
58
- function info(msg) { console.log(` ${GRAY}${msg}${RESET}`); }
59
- function section(title) { console.log(`\n ${AMBER}▸${RESET} ${BOLD}${title}${RESET}`); }
60
-
61
- function which(cmd) {
62
- try {
63
- const result = execSync(
64
- process.platform === "win32" ? `where ${cmd} 2>nul` : `which ${cmd} 2>/dev/null`,
65
- { encoding: "utf8", timeout: 5000 }
66
- ).trim();
67
- return result.split(/\r?\n/)[0] || null;
68
- } catch { return null; }
69
- }
70
-
13
+ const CLAUDE_DIR = join(homedir(), ".claude");
14
+ const CODEX_DIR = join(homedir(), ".codex");
15
+ const CODEX_CONFIG_PATH = join(CODEX_DIR, "config.toml");
16
+ const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
17
+
18
+ const REQUIRED_CODEX_PROFILES = [
19
+ {
20
+ name: "xhigh",
21
+ lines: [
22
+ 'model = "gpt-5.3-codex"',
23
+ 'model_reasoning_effort = "xhigh"',
24
+ ],
25
+ },
26
+ {
27
+ name: "spark_fast",
28
+ lines: [
29
+ 'model = "gpt-5.1-codex-mini"',
30
+ 'model_reasoning_effort = "low"',
31
+ ],
32
+ },
33
+ ];
34
+
35
+ // ── 색상 체계 (triflux brand: amber/orange accent) ──
36
+ const CYAN = "\x1b[36m";
37
+ const GREEN = "\x1b[32m";
38
+ const RED = "\x1b[31m";
39
+ const YELLOW = "\x1b[33m";
40
+ const DIM = "\x1b[2m";
41
+ const BOLD = "\x1b[1m";
42
+ const RESET = "\x1b[0m";
43
+ const AMBER = "\x1b[38;5;214m";
44
+ const BLUE = "\x1b[38;5;39m";
45
+ const WHITE_BRIGHT = "\x1b[97m";
46
+ const GRAY = "\x1b[38;5;245m";
47
+ const GREEN_BRIGHT = "\x1b[38;5;82m";
48
+ const RED_BRIGHT = "\x1b[38;5;196m";
49
+
50
+ // ── 브랜드 요소 ──
51
+ const BRAND = `${AMBER}${BOLD}triflux${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;
56
+
57
+ // ── 유틸리티 ──
58
+
59
+ function ok(msg) { console.log(` ${GREEN_BRIGHT}✓${RESET} ${msg}`); }
60
+ function warn(msg) { console.log(` ${YELLOW}⚠${RESET} ${msg}`); }
61
+ function fail(msg) { console.log(` ${RED_BRIGHT}✗${RESET} ${msg}`); }
62
+ function info(msg) { console.log(` ${GRAY}${msg}${RESET}`); }
63
+ function section(title) { console.log(`\n ${AMBER}▸${RESET} ${BOLD}${title}${RESET}`); }
64
+
65
+ function which(cmd) {
66
+ try {
67
+ const result = execSync(
68
+ process.platform === "win32" ? `where ${cmd} 2>nul` : `which ${cmd} 2>/dev/null`,
69
+ { encoding: "utf8", timeout: 5000 }
70
+ ).trim();
71
+ return result.split(/\r?\n/)[0] || null;
72
+ } catch { return null; }
73
+ }
74
+
71
75
  function whichInShell(cmd, shell) {
72
- const cmds = {
73
- bash: `bash -c "source ~/.bashrc 2>/dev/null && command -v ${cmd} 2>/dev/null"`,
74
- cmd: `cmd /c where ${cmd} 2>nul`,
75
- pwsh: `pwsh -NoProfile -c "(Get-Command ${cmd} -EA SilentlyContinue).Source"`,
76
- };
77
- const command = cmds[shell];
78
- if (!command) return null;
79
- try {
80
- const result = execSync(command, {
81
- encoding: "utf8",
82
- timeout: 8000,
83
- stdio: ["pipe", "pipe", "ignore"],
84
- }).trim();
85
- return result.split(/\r?\n/)[0] || null;
76
+ const cmds = {
77
+ bash: `bash -c "source ~/.bashrc 2>/dev/null && command -v ${cmd} 2>/dev/null"`,
78
+ cmd: `cmd /c where ${cmd} 2>nul`,
79
+ pwsh: `pwsh -NoProfile -c "(Get-Command ${cmd} -EA SilentlyContinue).Source"`,
80
+ };
81
+ const command = cmds[shell];
82
+ if (!command) return null;
83
+ try {
84
+ const result = execSync(command, {
85
+ encoding: "utf8",
86
+ timeout: 8000,
87
+ stdio: ["pipe", "pipe", "ignore"],
88
+ }).trim();
89
+ return result.split(/\r?\n/)[0] || null;
86
90
  } catch { return null; }
87
91
  }
88
92
 
89
93
  function isDevUpdateRequested(argv = process.argv) {
90
94
  return argv.includes("--dev") || argv.includes("@dev") || argv.includes("dev");
91
95
  }
92
-
93
- function checkShellAvailable(shell) {
94
- const cmds = { bash: "bash --version", cmd: "cmd /c echo ok", pwsh: "pwsh -NoProfile -c echo ok" };
95
- try {
96
- execSync(cmds[shell], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
97
- return true;
98
- } catch { return false; }
99
- }
100
-
101
- function getVersion(filePath) {
102
- try {
103
- const content = readFileSync(filePath, "utf8");
104
- const match = content.match(/VERSION\s*=\s*"([^"]+)"/);
105
- return match ? match[1] : null;
106
- } catch { return null; }
107
- }
108
-
109
- function escapeRegExp(value) {
110
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
111
- }
112
-
113
- function hasProfileSection(tomlContent, profileName) {
114
- const section = `^\\[profiles\\.${escapeRegExp(profileName)}\\]\\s*$`;
115
- return new RegExp(section, "m").test(tomlContent);
116
- }
117
-
118
- function ensureCodexProfiles() {
119
- try {
120
- if (!existsSync(CODEX_DIR)) mkdirSync(CODEX_DIR, { recursive: true });
121
-
122
- const original = existsSync(CODEX_CONFIG_PATH)
123
- ? readFileSync(CODEX_CONFIG_PATH, "utf8")
124
- : "";
125
-
126
- let updated = original;
127
- let added = 0;
128
-
129
- for (const profile of REQUIRED_CODEX_PROFILES) {
130
- if (hasProfileSection(updated, profile.name)) continue;
131
-
132
- if (updated.length > 0 && !updated.endsWith("\n")) updated += "\n";
133
- if (updated.trim().length > 0) updated += "\n";
134
- updated += `[profiles.${profile.name}]\n${profile.lines.join("\n")}\n`;
135
- added++;
136
- }
137
-
138
- if (added > 0) {
139
- writeFileSync(CODEX_CONFIG_PATH, updated, "utf8");
140
- }
141
-
142
- return { ok: true, added };
143
- } catch (e) {
144
- return { ok: false, added: 0, message: e.message };
145
- }
146
- }
147
-
148
- function syncFile(src, dst, label) {
149
- const dstDir = dirname(dst);
150
- if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
151
-
152
- if (!existsSync(src)) {
153
- fail(`${label}: 소스 파일 없음 (${src})`);
154
- return false;
155
- }
156
-
157
- const srcVer = getVersion(src);
158
- const dstVer = existsSync(dst) ? getVersion(dst) : null;
159
-
160
- if (!existsSync(dst)) {
161
- copyFileSync(src, dst);
162
- try { chmodSync(dst, 0o755); } catch {}
163
- ok(`${label}: 설치됨 ${srcVer ? `(v${srcVer})` : ""}`);
164
- return true;
165
- }
166
-
167
- const srcContent = readFileSync(src, "utf8");
168
- const dstContent = readFileSync(dst, "utf8");
169
- if (srcContent !== dstContent) {
170
- copyFileSync(src, dst);
171
- try { chmodSync(dst, 0o755); } catch {}
172
- const verInfo = (srcVer && dstVer && srcVer !== dstVer)
173
- ? `(v${dstVer} v${srcVer})`
174
- : srcVer ? `(v${srcVer}, 내용 변경)` : "(내용 변경)";
175
- ok(`${label}: 업데이트됨 ${verInfo}`);
176
- return true;
177
- }
178
-
179
- ok(`${label}: 최신 상태 ${srcVer ? `(v${srcVer})` : ""}`);
180
- return false;
181
- }
182
-
183
- // ── 크로스 진단 ──
184
-
185
- function checkCliCrossShell(cmd, installHint) {
186
- const shells = process.platform === "win32" ? ["bash", "cmd", "pwsh"] : ["bash"];
187
- let anyFound = false;
188
- let bashMissing = false;
189
-
190
- for (const shell of shells) {
191
- if (!checkShellAvailable(shell)) {
192
- info(`${shell}: ${DIM}셸 없음 (건너뜀)${RESET}`);
193
- continue;
194
- }
195
- const p = whichInShell(cmd, shell);
196
- if (p) {
197
- ok(`${shell}: ${p}`);
198
- anyFound = true;
199
- } else {
200
- fail(`${shell}: 미발견`);
201
- if (shell === "bash") bashMissing = true;
202
- }
203
- }
204
-
205
- if (!anyFound) {
206
- info(`미설치 (선택사항) — ${installHint}`);
207
- info("없으면 Claude 네이티브 에이전트로 fallback");
208
- return 1;
209
- }
210
- if (bashMissing) {
211
- warn("bash에서 미발견 — tfx-route.sh 실행 불가");
212
- info('→ ~/.bashrc에 추가: export PATH="$PATH:$APPDATA/npm"');
213
- return 1;
214
- }
215
- return 0;
216
- }
217
-
218
- // ── 명령어 ──
219
-
220
- function cmdSetup() {
221
- console.log(`\n${BOLD}triflux setup${RESET}\n`);
222
-
223
- syncFile(
224
- join(PKG_ROOT, "scripts", "tfx-route.sh"),
225
- join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
226
- "tfx-route.sh"
227
- );
228
-
229
- syncFile(
230
- join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
231
- join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
232
- "hud-qos-status.mjs"
233
- );
234
-
235
- syncFile(
236
- join(PKG_ROOT, "scripts", "notion-read.mjs"),
237
- join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
238
- "notion-read.mjs"
239
- );
240
-
241
- syncFile(
242
- join(PKG_ROOT, "scripts", "tfx-route-post.mjs"),
243
- join(CLAUDE_DIR, "scripts", "tfx-route-post.mjs"),
244
- "tfx-route-post.mjs"
245
- );
246
-
247
- syncFile(
248
- join(PKG_ROOT, "scripts", "tfx-batch-stats.mjs"),
249
- join(CLAUDE_DIR, "scripts", "tfx-batch-stats.mjs"),
250
- "tfx-batch-stats.mjs"
251
- );
252
-
253
- // 스킬 동기화 (~/.claude/skills/{name}/SKILL.md)
254
- const skillsSrc = join(PKG_ROOT, "skills");
255
- const skillsDst = join(CLAUDE_DIR, "skills");
256
- if (existsSync(skillsSrc)) {
257
- let skillCount = 0;
258
- let skillTotal = 0;
259
- for (const name of readdirSync(skillsSrc)) {
260
- const src = join(skillsSrc, name, "SKILL.md");
261
- const dst = join(skillsDst, name, "SKILL.md");
262
- if (!existsSync(src)) continue;
263
- skillTotal++;
264
-
265
- const dstDir = dirname(dst);
266
- if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
267
-
268
- if (!existsSync(dst)) {
269
- copyFileSync(src, dst);
270
- skillCount++;
271
- } else {
272
- const srcContent = readFileSync(src, "utf8");
273
- const dstContent = readFileSync(dst, "utf8");
274
- if (srcContent !== dstContent) {
275
- copyFileSync(src, dst);
276
- skillCount++;
277
- }
278
- }
279
- }
280
- if (skillCount > 0) {
281
- ok(`스킬: ${skillCount}/${skillTotal}개 업데이트됨`);
282
- } else {
283
- ok(`스킬: ${skillTotal}개 최신 상태`);
284
- }
285
- }
286
-
287
- const codexProfileResult = ensureCodexProfiles();
288
- if (!codexProfileResult.ok) {
289
- warn(`Codex profiles 설정 실패: ${codexProfileResult.message}`);
290
- } else if (codexProfileResult.added > 0) {
291
- ok(`Codex profiles: ${codexProfileResult.added}개 추가됨 (~/.codex/config.toml)`);
292
- } else {
293
- ok("Codex profiles: 이미 준비됨");
294
- }
295
-
296
- // hub MCP 사전 등록 (서버 미실행이어도 설정만 등록 — hub start 시 즉시 사용 가능)
297
- if (existsSync(join(PKG_ROOT, "hub", "server.mjs"))) {
298
- const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
299
- autoRegisterMcp(defaultHubUrl);
300
- console.log("");
301
- }
302
-
303
- // HUD statusLine 설정
304
- console.log(`${CYAN}[HUD 설정]${RESET}`);
305
- const settingsPath = join(CLAUDE_DIR, "settings.json");
306
- const hudPath = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
307
-
308
- if (existsSync(hudPath)) {
309
- try {
310
- let settings = {};
311
- if (existsSync(settingsPath)) {
312
- settings = JSON.parse(readFileSync(settingsPath, "utf8"));
313
- }
314
-
315
- const currentCmd = settings.statusLine?.command || "";
316
- if (currentCmd.includes("hud-qos-status.mjs")) {
317
- ok("statusLine 이미 설정됨");
318
- } else {
319
- const nodePath = process.execPath.replace(/\\/g, "/");
320
- const hudForward = hudPath.replace(/\\/g, "/");
321
- const nodeRef = nodePath.includes(" ") ? `"${nodePath}"` : nodePath;
322
- const hudRef = hudForward.includes(" ") ? `"${hudForward}"` : hudForward;
323
-
324
- if (currentCmd) {
325
- warn(`기존 statusLine 덮어쓰기: ${currentCmd}`);
326
- }
327
-
328
- settings.statusLine = {
329
- type: "command",
330
- command: `${nodeRef} ${hudRef}`,
331
- };
332
-
333
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
334
- ok("statusLine 설정 완료 세션 재시작 후 HUD 표시");
335
- }
336
- } catch (e) {
337
- fail(`settings.json 처리 실패: ${e.message}`);
338
- }
339
- } else {
340
- warn("HUD 파일 없음 — 먼저 파일 동기화 필요");
341
- }
342
-
343
- console.log(`\n${DIM}설치 위치: ${CLAUDE_DIR}${RESET}\n`);
344
- }
345
-
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`);
350
- console.log(` ${LINE}`);
351
-
352
- // ── reset 모드: 캐시 전체 초기화 ──
353
- if (reset) {
354
- section("Cache Reset");
355
- const cacheDir = join(CLAUDE_DIR, "cache");
356
- const resetFiles = [
357
- "claude-usage-cache.json",
358
- ".claude-refresh-lock",
359
- "codex-rate-limits-cache.json",
360
- "gemini-quota-cache.json",
361
- "gemini-project-id.json",
362
- "gemini-session-cache.json",
363
- "gemini-rpm-tracker.json",
364
- "sv-accumulator.json",
365
- "mcp-inventory.json",
366
- "cli-issues.jsonl",
367
- "triflux-update-check.json",
368
- ];
369
- let cleared = 0;
370
- for (const name of resetFiles) {
371
- const fp = join(cacheDir, name);
372
- if (existsSync(fp)) {
373
- try { unlinkSync(fp); cleared++; ok(`삭제됨: ${name}`); }
374
- catch (e) { fail(`삭제 실패: ${name} — ${e.message}`); }
375
- }
376
- }
377
- if (cleared === 0) {
378
- ok("삭제할 캐시 파일 없음 (이미 깨끗함)");
379
- } else {
380
- console.log("");
381
- ok(`${BOLD}${cleared}개${RESET} 캐시 파일 초기화 완료`);
382
- }
383
- // 캐시 즉시 재생성
384
- console.log("");
385
- section("Cache Rebuild");
386
- const mcpCheck = join(PKG_ROOT, "scripts", "mcp-check.mjs");
387
- if (existsSync(mcpCheck)) {
388
- try {
389
- execSync(`"${process.execPath}" "${mcpCheck}"`, { timeout: 15000, stdio: "ignore" });
390
- ok("MCP 인벤토리 재생성됨");
391
- } catch { warn("MCP 인벤토리 재생성 실패 — 다음 세션에서 자동 재시도"); }
392
- }
393
- const hudScript = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
394
- if (existsSync(hudScript)) {
395
- try {
396
- execSync(`"${process.execPath}" "${hudScript}" --refresh-claude-usage`, { timeout: 20000, stdio: "ignore" });
397
- ok("Claude 사용량 캐시 재생성됨");
398
- } catch { warn("Claude 사용량 캐시 재생성 실패 — 다음 API 호출 시 자동 생성"); }
399
- try {
400
- execSync(`"${process.execPath}" "${hudScript}" --refresh-codex-rate-limits`, { timeout: 15000, stdio: "ignore" });
401
- ok("Codex 레이트 리밋 캐시 재생성됨");
402
- } catch { warn("Codex 레이트 리밋 캐시 재생성 실패"); }
403
- try {
404
- execSync(`"${process.execPath}" "${hudScript}" --refresh-gemini-quota`, { timeout: 15000, stdio: "ignore" });
405
- ok("Gemini 쿼터 캐시 재생성됨");
406
- } catch { warn("Gemini 쿼터 캐시 재생성 실패"); }
407
- }
408
- console.log(`\n ${LINE}`);
409
- console.log(` ${GREEN_BRIGHT}${BOLD}✓ 캐시 초기화 + 재생성 완료${RESET}\n`);
410
- return;
411
- }
412
-
413
- // ── fix 모드: 파일 동기화 + 캐시 정리 후 진단 ──
414
- if (fix) {
415
- section("Auto Fix");
416
- syncFile(
417
- join(PKG_ROOT, "scripts", "tfx-route.sh"),
418
- join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
419
- "tfx-route.sh"
420
- );
421
- syncFile(
422
- join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
423
- join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
424
- "hud-qos-status.mjs"
425
- );
426
- syncFile(
427
- join(PKG_ROOT, "scripts", "notion-read.mjs"),
428
- join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
429
- "notion-read.mjs"
430
- );
431
- // 스킬 동기화
432
- const fSkillsSrc = join(PKG_ROOT, "skills");
433
- const fSkillsDst = join(CLAUDE_DIR, "skills");
96
+
97
+ function checkShellAvailable(shell) {
98
+ const cmds = { bash: "bash --version", cmd: "cmd /c echo ok", pwsh: "pwsh -NoProfile -c echo ok" };
99
+ try {
100
+ execSync(cmds[shell], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
101
+ return true;
102
+ } catch { return false; }
103
+ }
104
+
105
+ function getVersion(filePath) {
106
+ try {
107
+ const content = readFileSync(filePath, "utf8");
108
+ const match = content.match(/VERSION\s*=\s*"([^"]+)"/);
109
+ return match ? match[1] : null;
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
+ }
235
+
236
+ function escapeRegExp(value) {
237
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
238
+ }
239
+
240
+ function hasProfileSection(tomlContent, profileName) {
241
+ const section = `^\\[profiles\\.${escapeRegExp(profileName)}\\]\\s*$`;
242
+ return new RegExp(section, "m").test(tomlContent);
243
+ }
244
+
245
+ function ensureCodexProfiles() {
246
+ try {
247
+ if (!existsSync(CODEX_DIR)) mkdirSync(CODEX_DIR, { recursive: true });
248
+
249
+ const original = existsSync(CODEX_CONFIG_PATH)
250
+ ? readFileSync(CODEX_CONFIG_PATH, "utf8")
251
+ : "";
252
+
253
+ let updated = original;
254
+ let added = 0;
255
+
256
+ for (const profile of REQUIRED_CODEX_PROFILES) {
257
+ if (hasProfileSection(updated, profile.name)) continue;
258
+
259
+ if (updated.length > 0 && !updated.endsWith("\n")) updated += "\n";
260
+ if (updated.trim().length > 0) updated += "\n";
261
+ updated += `[profiles.${profile.name}]\n${profile.lines.join("\n")}\n`;
262
+ added++;
263
+ }
264
+
265
+ if (added > 0) {
266
+ writeFileSync(CODEX_CONFIG_PATH, updated, "utf8");
267
+ }
268
+
269
+ return { ok: true, added };
270
+ } catch (e) {
271
+ return { ok: false, added: 0, message: e.message };
272
+ }
273
+ }
274
+
275
+ function syncFile(src, dst, label) {
276
+ const dstDir = dirname(dst);
277
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
278
+
279
+ if (!existsSync(src)) {
280
+ fail(`${label}: 소스 파일 없음 (${src})`);
281
+ return false;
282
+ }
283
+
284
+ const srcVer = getVersion(src);
285
+ const dstVer = existsSync(dst) ? getVersion(dst) : null;
286
+
287
+ if (!existsSync(dst)) {
288
+ copyFileSync(src, dst);
289
+ try { chmodSync(dst, 0o755); } catch {}
290
+ ok(`${label}: 설치됨 ${srcVer ? `(v${srcVer})` : ""}`);
291
+ return true;
292
+ }
293
+
294
+ const srcContent = readFileSync(src, "utf8");
295
+ const dstContent = readFileSync(dst, "utf8");
296
+ if (srcContent !== dstContent) {
297
+ copyFileSync(src, dst);
298
+ try { chmodSync(dst, 0o755); } catch {}
299
+ const verInfo = (srcVer && dstVer && srcVer !== dstVer)
300
+ ? `(v${dstVer} v${srcVer})`
301
+ : srcVer ? `(v${srcVer}, 내용 변경)` : "(내용 변경)";
302
+ ok(`${label}: 업데이트됨 ${verInfo}`);
303
+ return true;
304
+ }
305
+
306
+ ok(`${label}: 최신 상태 ${srcVer ? `(v${srcVer})` : ""}`);
307
+ return false;
308
+ }
309
+
310
+ // ── 크로스 진단 ──
311
+
312
+ function checkCliCrossShell(cmd, installHint) {
313
+ const shells = process.platform === "win32" ? ["bash", "cmd", "pwsh"] : ["bash"];
314
+ let anyFound = false;
315
+ let bashMissing = false;
316
+
317
+ for (const shell of shells) {
318
+ if (!checkShellAvailable(shell)) {
319
+ info(`${shell}: ${DIM}셸 없음 (건너뜀)${RESET}`);
320
+ continue;
321
+ }
322
+ const p = whichInShell(cmd, shell);
323
+ if (p) {
324
+ ok(`${shell}: ${p}`);
325
+ anyFound = true;
326
+ } else {
327
+ fail(`${shell}: 미발견`);
328
+ if (shell === "bash") bashMissing = true;
329
+ }
330
+ }
331
+
332
+ if (!anyFound) {
333
+ info(`미설치 (선택사항) — ${installHint}`);
334
+ info("없으면 Claude 네이티브 에이전트로 fallback");
335
+ return 1;
336
+ }
337
+ if (bashMissing) {
338
+ warn("bash에서 미발견tfx-route.sh 실행 불가");
339
+ info('→ ~/.bashrc에 추가: export PATH="$PATH:$APPDATA/npm"');
340
+ return 1;
341
+ }
342
+ return 0;
343
+ }
344
+
345
+ // ── 명령어 ──
346
+
347
+ function cmdSetup() {
348
+ console.log(`\n${BOLD}triflux setup${RESET}\n`);
349
+
350
+ syncFile(
351
+ join(PKG_ROOT, "scripts", "tfx-route.sh"),
352
+ join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
353
+ "tfx-route.sh"
354
+ );
355
+
356
+ syncFile(
357
+ join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
358
+ join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
359
+ "hud-qos-status.mjs"
360
+ );
361
+
362
+ syncFile(
363
+ join(PKG_ROOT, "scripts", "notion-read.mjs"),
364
+ join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
365
+ "notion-read.mjs"
366
+ );
367
+
368
+ syncFile(
369
+ join(PKG_ROOT, "scripts", "tfx-route-post.mjs"),
370
+ join(CLAUDE_DIR, "scripts", "tfx-route-post.mjs"),
371
+ "tfx-route-post.mjs"
372
+ );
373
+
374
+ syncFile(
375
+ join(PKG_ROOT, "scripts", "tfx-batch-stats.mjs"),
376
+ join(CLAUDE_DIR, "scripts", "tfx-batch-stats.mjs"),
377
+ "tfx-batch-stats.mjs"
378
+ );
379
+
380
+ // 스킬 동기화 (~/.claude/skills/{name}/SKILL.md)
381
+ const skillsSrc = join(PKG_ROOT, "skills");
382
+ const skillsDst = join(CLAUDE_DIR, "skills");
383
+ if (existsSync(skillsSrc)) {
384
+ let skillCount = 0;
385
+ let skillTotal = 0;
386
+ for (const name of readdirSync(skillsSrc)) {
387
+ const src = join(skillsSrc, name, "SKILL.md");
388
+ const dst = join(skillsDst, name, "SKILL.md");
389
+ if (!existsSync(src)) continue;
390
+ skillTotal++;
391
+
392
+ const dstDir = dirname(dst);
393
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
394
+
395
+ if (!existsSync(dst)) {
396
+ copyFileSync(src, dst);
397
+ skillCount++;
398
+ } else {
399
+ const srcContent = readFileSync(src, "utf8");
400
+ const dstContent = readFileSync(dst, "utf8");
401
+ if (srcContent !== dstContent) {
402
+ copyFileSync(src, dst);
403
+ skillCount++;
404
+ }
405
+ }
406
+ }
407
+ if (skillCount > 0) {
408
+ ok(`스킬: ${skillCount}/${skillTotal} 업데이트됨`);
409
+ } else {
410
+ ok(`스킬: ${skillTotal}개 최신 상태`);
411
+ }
412
+ }
413
+
414
+ const codexProfileResult = ensureCodexProfiles();
415
+ if (!codexProfileResult.ok) {
416
+ warn(`Codex profiles 설정 실패: ${codexProfileResult.message}`);
417
+ } else if (codexProfileResult.added > 0) {
418
+ ok(`Codex profiles: ${codexProfileResult.added}개 추가됨 (~/.codex/config.toml)`);
419
+ } else {
420
+ ok("Codex profiles: 이미 준비됨");
421
+ }
422
+
423
+ // hub MCP 사전 등록 (서버 미실행이어도 설정만 등록 — hub start 시 즉시 사용 가능)
424
+ if (existsSync(join(PKG_ROOT, "hub", "server.mjs"))) {
425
+ const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
426
+ autoRegisterMcp(defaultHubUrl);
427
+ console.log("");
428
+ }
429
+
430
+ // HUD statusLine 설정
431
+ console.log(`${CYAN}[HUD 설정]${RESET}`);
432
+ const settingsPath = join(CLAUDE_DIR, "settings.json");
433
+ const hudPath = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
434
+
435
+ if (existsSync(hudPath)) {
436
+ try {
437
+ let settings = {};
438
+ if (existsSync(settingsPath)) {
439
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
440
+ }
441
+
442
+ const currentCmd = settings.statusLine?.command || "";
443
+ if (currentCmd.includes("hud-qos-status.mjs")) {
444
+ ok("statusLine 이미 설정됨");
445
+ } else {
446
+ const nodePath = process.execPath.replace(/\\/g, "/");
447
+ const hudForward = hudPath.replace(/\\/g, "/");
448
+ const nodeRef = nodePath.includes(" ") ? `"${nodePath}"` : nodePath;
449
+ const hudRef = hudForward.includes(" ") ? `"${hudForward}"` : hudForward;
450
+
451
+ if (currentCmd) {
452
+ warn(`기존 statusLine 덮어쓰기: ${currentCmd}`);
453
+ }
454
+
455
+ settings.statusLine = {
456
+ type: "command",
457
+ command: `${nodeRef} ${hudRef}`,
458
+ };
459
+
460
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
461
+ ok("statusLine 설정 완료 — 세션 재시작 후 HUD 표시");
462
+ }
463
+ } catch (e) {
464
+ fail(`settings.json 처리 실패: ${e.message}`);
465
+ }
466
+ } else {
467
+ warn("HUD 파일 없음 — 먼저 파일 동기화 필요");
468
+ }
469
+
470
+ console.log(`\n${DIM}설치 위치: ${CLAUDE_DIR}${RESET}\n`);
471
+ }
472
+
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`);
477
+ console.log(` ${LINE}`);
478
+
479
+ // ── reset 모드: 캐시 전체 초기화 ──
480
+ if (reset) {
481
+ section("Cache Reset");
482
+ const cacheDir = join(CLAUDE_DIR, "cache");
483
+ const resetFiles = [
484
+ "claude-usage-cache.json",
485
+ ".claude-refresh-lock",
486
+ "codex-rate-limits-cache.json",
487
+ "gemini-quota-cache.json",
488
+ "gemini-project-id.json",
489
+ "gemini-session-cache.json",
490
+ "gemini-rpm-tracker.json",
491
+ "sv-accumulator.json",
492
+ "mcp-inventory.json",
493
+ "cli-issues.jsonl",
494
+ "triflux-update-check.json",
495
+ ];
496
+ let cleared = 0;
497
+ for (const name of resetFiles) {
498
+ const fp = join(cacheDir, name);
499
+ if (existsSync(fp)) {
500
+ try { unlinkSync(fp); cleared++; ok(`삭제됨: ${name}`); }
501
+ catch (e) { fail(`삭제 실패: ${name} — ${e.message}`); }
502
+ }
503
+ }
504
+ if (cleared === 0) {
505
+ ok("삭제할 캐시 파일 없음 (이미 깨끗함)");
506
+ } else {
507
+ console.log("");
508
+ ok(`${BOLD}${cleared}개${RESET} 캐시 파일 초기화 완료`);
509
+ }
510
+ // 캐시 즉시 재생성
511
+ console.log("");
512
+ section("Cache Rebuild");
513
+ const mcpCheck = join(PKG_ROOT, "scripts", "mcp-check.mjs");
514
+ if (existsSync(mcpCheck)) {
515
+ try {
516
+ execSync(`"${process.execPath}" "${mcpCheck}"`, { timeout: 15000, stdio: "ignore" });
517
+ ok("MCP 인벤토리 재생성됨");
518
+ } catch { warn("MCP 인벤토리 재생성 실패 — 다음 세션에서 자동 재시도"); }
519
+ }
520
+ const hudScript = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
521
+ if (existsSync(hudScript)) {
522
+ try {
523
+ execSync(`"${process.execPath}" "${hudScript}" --refresh-claude-usage`, { timeout: 20000, stdio: "ignore" });
524
+ ok("Claude 사용량 캐시 재생성됨");
525
+ } catch { warn("Claude 사용량 캐시 재생성 실패 — 다음 API 호출 시 자동 생성"); }
526
+ try {
527
+ execSync(`"${process.execPath}" "${hudScript}" --refresh-codex-rate-limits`, { timeout: 15000, stdio: "ignore" });
528
+ ok("Codex 레이트 리밋 캐시 재생성됨");
529
+ } catch { warn("Codex 레이트 리밋 캐시 재생성 실패"); }
530
+ try {
531
+ execSync(`"${process.execPath}" "${hudScript}" --refresh-gemini-quota`, { timeout: 15000, stdio: "ignore" });
532
+ ok("Gemini 쿼터 캐시 재생성됨");
533
+ } catch { warn("Gemini 쿼터 캐시 재생성 실패"); }
534
+ }
535
+ console.log(`\n ${LINE}`);
536
+ console.log(` ${GREEN_BRIGHT}${BOLD}✓ 캐시 초기화 + 재생성 완료${RESET}\n`);
537
+ return;
538
+ }
539
+
540
+ // ── fix 모드: 파일 동기화 + 캐시 정리 후 진단 ──
541
+ if (fix) {
542
+ section("Auto Fix");
543
+ syncFile(
544
+ join(PKG_ROOT, "scripts", "tfx-route.sh"),
545
+ join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
546
+ "tfx-route.sh"
547
+ );
548
+ syncFile(
549
+ join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
550
+ join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
551
+ "hud-qos-status.mjs"
552
+ );
553
+ syncFile(
554
+ join(PKG_ROOT, "scripts", "notion-read.mjs"),
555
+ join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
556
+ "notion-read.mjs"
557
+ );
558
+ // 스킬 동기화
559
+ const fSkillsSrc = join(PKG_ROOT, "skills");
560
+ const fSkillsDst = join(CLAUDE_DIR, "skills");
434
561
  if (existsSync(fSkillsSrc)) {
435
562
  let sc = 0, st = 0;
436
563
  for (const name of readdirSync(fSkillsSrc)) {
437
564
  const src = join(fSkillsSrc, name, "SKILL.md");
438
- const dst = join(fSkillsDst, name, "SKILL.md");
439
- if (!existsSync(src)) continue;
440
- st++;
441
- const dstDir = dirname(dst);
442
- if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
443
- if (!existsSync(dst)) { copyFileSync(src, dst); sc++; }
444
- else if (readFileSync(src, "utf8") !== readFileSync(dst, "utf8")) { copyFileSync(src, dst); sc++; }
445
- }
565
+ const dst = join(fSkillsDst, name, "SKILL.md");
566
+ if (!existsSync(src)) continue;
567
+ st++;
568
+ const dstDir = dirname(dst);
569
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
570
+ if (!existsSync(dst)) { copyFileSync(src, dst); sc++; }
571
+ else if (readFileSync(src, "utf8") !== readFileSync(dst, "utf8")) { copyFileSync(src, dst); sc++; }
572
+ }
446
573
  if (sc > 0) ok(`스킬: ${sc}/${st}개 업데이트됨`);
447
574
  else ok(`스킬: ${st}개 최신 상태`);
448
575
  }
@@ -458,45 +585,45 @@ function cmdDoctor(options = {}) {
458
585
  const fCacheDir = join(CLAUDE_DIR, "cache");
459
586
  const staleNames = ["claude-usage-cache.json", ".claude-refresh-lock", "codex-rate-limits-cache.json"];
460
587
  let cleaned = 0;
461
- for (const name of staleNames) {
462
- const fp = join(fCacheDir, name);
463
- if (!existsSync(fp)) continue;
464
- try {
465
- const parsed = JSON.parse(readFileSync(fp, "utf8"));
466
- if (parsed.error || name.startsWith(".")) { unlinkSync(fp); cleaned++; ok(`에러 캐시 정리: ${name}`); }
467
- } catch { try { unlinkSync(fp); cleaned++; ok(`손상된 캐시 정리: ${name}`); } catch {} }
468
- }
469
- if (cleaned === 0) info("에러 캐시 없음");
470
- console.log(`\n ${LINE}`);
471
- info("수정 완료 — 아래 진단 결과를 확인하세요");
472
- console.log("");
473
- }
474
-
475
- let issues = 0;
476
-
477
- // 1. tfx-route.sh
478
- section("tfx-route.sh");
479
- const routeSh = join(CLAUDE_DIR, "scripts", "tfx-route.sh");
480
- if (existsSync(routeSh)) {
481
- const ver = getVersion(routeSh);
482
- ok(`설치됨 ${ver ? `${DIM}v${ver}${RESET}` : ""}`);
483
- } else {
484
- fail("미설치 — tfx setup 실행 필요");
485
- issues++;
486
- }
487
-
488
- // 2. HUD
489
- section("HUD");
490
- const hud = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
491
- if (existsSync(hud)) {
492
- ok("설치됨");
493
- } else {
494
- warn("미설치 ${GRAY}(선택사항)${RESET}");
495
- }
496
-
497
- // 3. Codex CLI
498
- section(`Codex CLI ${WHITE_BRIGHT}●${RESET}`);
499
- issues += checkCliCrossShell("codex", "npm install -g @openai/codex");
588
+ for (const name of staleNames) {
589
+ const fp = join(fCacheDir, name);
590
+ if (!existsSync(fp)) continue;
591
+ try {
592
+ const parsed = JSON.parse(readFileSync(fp, "utf8"));
593
+ if (parsed.error || name.startsWith(".")) { unlinkSync(fp); cleaned++; ok(`에러 캐시 정리: ${name}`); }
594
+ } catch { try { unlinkSync(fp); cleaned++; ok(`손상된 캐시 정리: ${name}`); } catch {} }
595
+ }
596
+ if (cleaned === 0) info("에러 캐시 없음");
597
+ console.log(`\n ${LINE}`);
598
+ info("수정 완료 — 아래 진단 결과를 확인하세요");
599
+ console.log("");
600
+ }
601
+
602
+ let issues = 0;
603
+
604
+ // 1. tfx-route.sh
605
+ section("tfx-route.sh");
606
+ const routeSh = join(CLAUDE_DIR, "scripts", "tfx-route.sh");
607
+ if (existsSync(routeSh)) {
608
+ const ver = getVersion(routeSh);
609
+ ok(`설치됨 ${ver ? `${DIM}v${ver}${RESET}` : ""}`);
610
+ } else {
611
+ fail("미설치 — tfx setup 실행 필요");
612
+ issues++;
613
+ }
614
+
615
+ // 2. HUD
616
+ section("HUD");
617
+ const hud = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
618
+ if (existsSync(hud)) {
619
+ ok("설치됨");
620
+ } else {
621
+ warn("미설치 ${GRAY}(선택사항)${RESET}");
622
+ }
623
+
624
+ // 3. Codex CLI
625
+ section(`Codex CLI ${WHITE_BRIGHT}●${RESET}`);
626
+ issues += checkCliCrossShell("codex", "npm install -g @openai/codex");
500
627
  if (which("codex")) {
501
628
  if (process.env.OPENAI_API_KEY) {
502
629
  ok("OPENAI_API_KEY 설정됨");
@@ -528,214 +655,328 @@ function cmdDoctor(options = {}) {
528
655
  if (which("gemini")) {
529
656
  if (process.env.GEMINI_API_KEY) {
530
657
  ok("GEMINI_API_KEY 설정됨");
531
- } else {
532
- warn(`GEMINI_API_KEY 미설정 ${GRAY}(gemini auth login)${RESET}`);
533
- }
534
- }
535
-
658
+ } else {
659
+ warn(`GEMINI_API_KEY 미설정 ${GRAY}(gemini auth login)${RESET}`);
660
+ }
661
+ }
662
+
536
663
  // 6. Claude Code
537
664
  section(`Claude Code ${AMBER}●${RESET}`);
538
665
  const claudePath = which("claude");
539
666
  if (claudePath) {
540
667
  ok("설치됨");
541
668
  } else {
542
- fail("미설치 (필수)");
543
- issues++;
544
- }
545
-
669
+ fail("미설치 (필수)");
670
+ issues++;
671
+ }
672
+
546
673
  // 7. 스킬 설치 상태
547
674
  section("Skills");
548
675
  const skillsSrc = join(PKG_ROOT, "skills");
549
676
  const skillsDst = join(CLAUDE_DIR, "skills");
550
- if (existsSync(skillsSrc)) {
551
- let installed = 0;
552
- let total = 0;
553
- const missing = [];
554
- for (const name of readdirSync(skillsSrc)) {
555
- if (!existsSync(join(skillsSrc, name, "SKILL.md"))) continue;
556
- total++;
557
- if (existsSync(join(skillsDst, name, "SKILL.md"))) {
558
- installed++;
559
- } else {
560
- missing.push(name);
561
- }
562
- }
563
- if (installed === total) {
564
- ok(`${installed}/${total}개 설치됨`);
565
- } else {
566
- warn(`${installed}/${total}개 설치됨 — 미설치: ${missing.join(", ")}`);
567
- info("triflux setup으로 동기화 가능");
568
- issues++;
569
- }
570
- }
571
-
677
+ if (existsSync(skillsSrc)) {
678
+ let installed = 0;
679
+ let total = 0;
680
+ const missing = [];
681
+ for (const name of readdirSync(skillsSrc)) {
682
+ if (!existsSync(join(skillsSrc, name, "SKILL.md"))) continue;
683
+ total++;
684
+ if (existsSync(join(skillsDst, name, "SKILL.md"))) {
685
+ installed++;
686
+ } else {
687
+ missing.push(name);
688
+ }
689
+ }
690
+ if (installed === total) {
691
+ ok(`${installed}/${total}개 설치됨`);
692
+ } else {
693
+ warn(`${installed}/${total}개 설치됨 — 미설치: ${missing.join(", ")}`);
694
+ info("triflux setup으로 동기화 가능");
695
+ issues++;
696
+ }
697
+ }
698
+
572
699
  // 8. 플러그인 등록
573
700
  section("Plugin");
574
701
  const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
575
- if (existsSync(pluginsFile)) {
576
- const content = readFileSync(pluginsFile, "utf8");
577
- if (content.includes("triflux")) {
578
- ok("triflux 플러그인 등록됨");
579
- } else {
580
- warn("triflux 플러그인 미등록 — npm 단독 사용 중");
581
- info("플러그인 등록: /plugin marketplace add <repo-url>");
582
- }
583
- } else {
584
- info("플러그인 시스템 감지 안 됨 — npm 단독 사용");
585
- }
586
-
702
+ if (existsSync(pluginsFile)) {
703
+ const content = readFileSync(pluginsFile, "utf8");
704
+ if (content.includes("triflux")) {
705
+ ok("triflux 플러그인 등록됨");
706
+ } else {
707
+ warn("triflux 플러그인 미등록 — npm 단독 사용 중");
708
+ info("플러그인 등록: /plugin marketplace add <repo-url>");
709
+ }
710
+ } else {
711
+ info("플러그인 시스템 감지 안 됨 — npm 단독 사용");
712
+ }
713
+
587
714
  // 9. MCP 인벤토리
588
715
  section("MCP Inventory");
589
716
  const mcpCache = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
590
- if (existsSync(mcpCache)) {
591
- try {
592
- const inv = JSON.parse(readFileSync(mcpCache, "utf8"));
593
- ok(`캐시 존재 (${inv.timestamp})`);
594
- if (inv.codex?.servers?.length) {
595
- const names = inv.codex.servers.map(s => s.name).join(", ");
596
- info(`Codex: ${inv.codex.servers.length}개 서버 (${names})`);
597
- }
598
- if (inv.gemini?.servers?.length) {
599
- const names = inv.gemini.servers.map(s => s.name).join(", ");
600
- info(`Gemini: ${inv.gemini.servers.length}개 서버 (${names})`);
601
- }
602
- } catch {
603
- warn("캐시 파일 파싱 실패");
604
- }
605
- } else {
606
- warn("캐시 없음 — 다음 세션 시작 시 자동 생성");
607
- info(`수동: node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`);
608
- }
609
-
717
+ if (existsSync(mcpCache)) {
718
+ try {
719
+ const inv = JSON.parse(readFileSync(mcpCache, "utf8"));
720
+ ok(`캐시 존재 (${inv.timestamp})`);
721
+ if (inv.codex?.servers?.length) {
722
+ const names = inv.codex.servers.map(s => s.name).join(", ");
723
+ info(`Codex: ${inv.codex.servers.length}개 서버 (${names})`);
724
+ }
725
+ if (inv.gemini?.servers?.length) {
726
+ const names = inv.gemini.servers.map(s => s.name).join(", ");
727
+ info(`Gemini: ${inv.gemini.servers.length}개 서버 (${names})`);
728
+ }
729
+ } catch {
730
+ warn("캐시 파일 파싱 실패");
731
+ }
732
+ } else {
733
+ warn("캐시 없음 — 다음 세션 시작 시 자동 생성");
734
+ info(`수동: node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`);
735
+ }
736
+
610
737
  // 10. CLI 이슈 트래커
611
738
  section("CLI Issues");
612
- const issuesFile = join(CLAUDE_DIR, "cache", "cli-issues.jsonl");
613
- if (existsSync(issuesFile)) {
614
- try {
615
- const lines = readFileSync(issuesFile, "utf8").trim().split("\n").filter(Boolean);
616
- const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
617
- const unresolved = entries.filter(e => !e.resolved);
618
-
619
- if (unresolved.length === 0) {
620
- ok("미해결 이슈 없음");
621
- } else {
622
- // 패턴별 그룹핑
623
- const groups = {};
624
- for (const e of unresolved) {
625
- const key = `${e.cli}:${e.pattern}`;
626
- if (!groups[key]) groups[key] = { ...e, count: 0 };
627
- groups[key].count++;
628
- if (e.ts > groups[key].ts) { groups[key].ts = e.ts; groups[key].snippet = e.snippet; }
629
- }
630
-
631
- // 알려진 해결 버전 (패턴별 수정된 triflux 버전)
632
- const KNOWN_FIXES = {
633
- "gemini:deprecated_flag": "1.8.9", // -p → --prompt
634
- };
635
-
636
- const currentVer = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
637
- let cleaned = 0;
638
-
639
- for (const [key, g] of Object.entries(groups)) {
640
- const fixVer = KNOWN_FIXES[key];
641
- if (fixVer && currentVer >= fixVer) {
642
- // 해결된 이슈 — 자동 정리
643
- cleaned += g.count;
644
- continue;
645
- }
646
- const age = Date.now() - g.ts;
647
- const ago = age < 3600000 ? `${Math.round(age / 60000)}분 전` :
648
- age < 86400000 ? `${Math.round(age / 3600000)}시간 전` :
649
- `${Math.round(age / 86400000)}일 전`;
650
- const sev = g.severity === "error" ? `${RED}ERROR${RESET}` : `${YELLOW}WARN${RESET}`;
651
- warn(`[${sev}] ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`);
652
- if (g.snippet) info(` ${g.snippet.substring(0, 120)}`);
653
- if (fixVer) info(` 해결: triflux >= v${fixVer} (npm update -g triflux)`);
654
- issues++;
655
- }
656
-
657
- // 해결된 이슈 자동 정리
658
- if (cleaned > 0) {
659
- const remaining = entries.filter(e => {
660
- const key = `${e.cli}:${e.pattern}`;
661
- const fixVer = KNOWN_FIXES[key];
662
- return !(fixVer && currentVer >= fixVer);
663
- });
664
- writeFileSync(issuesFile, remaining.map(e => JSON.stringify(e)).join("\n") + (remaining.length ? "\n" : ""));
665
- ok(`${cleaned}개 해결된 이슈 자동 정리됨`);
666
- }
667
- }
668
- } catch (e) {
669
- warn(`이슈 파일 읽기 실패: ${e.message}`);
670
- }
671
- } else {
672
- ok("이슈 로그 없음 (정상)");
673
- }
674
-
675
- // 결과
676
- console.log(`\n ${LINE}`);
677
- if (issues === 0) {
678
- console.log(` ${GREEN_BRIGHT}${BOLD}✓ 모든 검사 통과${RESET}\n`);
679
- } else {
680
- console.log(` ${YELLOW}${BOLD}⚠ ${issues}개 항목 확인 필요${RESET}\n`);
681
- }
682
- }
683
-
739
+ const issuesFile = join(CLAUDE_DIR, "cache", "cli-issues.jsonl");
740
+ if (existsSync(issuesFile)) {
741
+ try {
742
+ const lines = readFileSync(issuesFile, "utf8").trim().split("\n").filter(Boolean);
743
+ const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
744
+ const unresolved = entries.filter(e => !e.resolved);
745
+
746
+ if (unresolved.length === 0) {
747
+ ok("미해결 이슈 없음");
748
+ } else {
749
+ // 패턴별 그룹핑
750
+ const groups = {};
751
+ for (const e of unresolved) {
752
+ const key = `${e.cli}:${e.pattern}`;
753
+ if (!groups[key]) groups[key] = { ...e, count: 0 };
754
+ groups[key].count++;
755
+ if (e.ts > groups[key].ts) { groups[key].ts = e.ts; groups[key].snippet = e.snippet; }
756
+ }
757
+
758
+ // 알려진 해결 버전 (패턴별 수정된 triflux 버전)
759
+ const KNOWN_FIXES = {
760
+ "gemini:deprecated_flag": "1.8.9", // -p → --prompt
761
+ };
762
+
763
+ const currentVer = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
764
+ let cleaned = 0;
765
+
766
+ for (const [key, g] of Object.entries(groups)) {
767
+ const fixVer = KNOWN_FIXES[key];
768
+ if (fixVer && currentVer >= fixVer) {
769
+ // 해결된 이슈 — 자동 정리
770
+ cleaned += g.count;
771
+ continue;
772
+ }
773
+ const age = Date.now() - g.ts;
774
+ const ago = age < 3600000 ? `${Math.round(age / 60000)}분 전` :
775
+ age < 86400000 ? `${Math.round(age / 3600000)}시간 전` :
776
+ `${Math.round(age / 86400000)}일 전`;
777
+ const sev = g.severity === "error" ? `${RED}ERROR${RESET}` : `${YELLOW}WARN${RESET}`;
778
+ warn(`[${sev}] ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`);
779
+ if (g.snippet) info(` ${g.snippet.substring(0, 120)}`);
780
+ if (fixVer) info(` 해결: triflux >= v${fixVer} (npm update -g triflux)`);
781
+ issues++;
782
+ }
783
+
784
+ // 해결된 이슈 자동 정리
785
+ if (cleaned > 0) {
786
+ const remaining = entries.filter(e => {
787
+ const key = `${e.cli}:${e.pattern}`;
788
+ const fixVer = KNOWN_FIXES[key];
789
+ return !(fixVer && currentVer >= fixVer);
790
+ });
791
+ writeFileSync(issuesFile, remaining.map(e => JSON.stringify(e)).join("\n") + (remaining.length ? "\n" : ""));
792
+ ok(`${cleaned}개 해결된 이슈 자동 정리됨`);
793
+ }
794
+ }
795
+ } catch (e) {
796
+ warn(`이슈 파일 읽기 실패: ${e.message}`);
797
+ }
798
+ } else {
799
+ ok("이슈 로그 없음 (정상)");
800
+ }
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
+
916
+ // 결과
917
+ console.log(`\n ${LINE}`);
918
+ if (issues === 0) {
919
+ console.log(` ${GREEN_BRIGHT}${BOLD}✓ 모든 검사 통과${RESET}\n`);
920
+ } else {
921
+ console.log(` ${YELLOW}${BOLD}⚠ ${issues}개 항목 확인 필요${RESET}\n`);
922
+ }
923
+ }
924
+
684
925
  function cmdUpdate() {
685
926
  const isDev = isDevUpdateRequested(process.argv);
686
927
  const tagLabel = isDev ? ` ${YELLOW}--dev${RESET}` : "";
687
928
  console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
688
-
689
- // 1. 설치 방식 감지
690
- const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
691
- let installMode = "unknown";
692
- let pluginPath = null;
693
-
694
- // 플러그인 모드 감지
695
- if (existsSync(pluginsFile)) {
696
- try {
697
- const plugins = JSON.parse(readFileSync(pluginsFile, "utf8"));
698
- for (const [key, entries] of Object.entries(plugins.plugins || {})) {
699
- if (key.startsWith("triflux")) {
700
- pluginPath = entries[0]?.installPath;
701
- installMode = "plugin";
702
- break;
703
- }
704
- }
705
- } catch {}
706
- }
707
-
708
- // PKG_ROOT가 플러그인 캐시 내에 있으면 플러그인 모드
709
- if (installMode === "unknown" && PKG_ROOT.includes(join(".claude", "plugins"))) {
710
- installMode = "plugin";
711
- pluginPath = PKG_ROOT;
712
- }
713
-
714
- // npm global 감지
715
- if (installMode === "unknown") {
716
- try {
717
- const npmList = execSync("npm list -g triflux --depth=0", {
718
- encoding: "utf8",
719
- timeout: 10000,
720
- stdio: ["pipe", "pipe", "ignore"],
721
- });
722
- if (npmList.includes("triflux")) installMode = "npm-global";
723
- } catch {}
724
- }
725
-
726
- // npm local 감지
727
- if (installMode === "unknown") {
728
- const localPkg = join(process.cwd(), "node_modules", "triflux");
729
- if (existsSync(localPkg)) installMode = "npm-local";
730
- }
731
-
732
- // git 저장소 직접 사용
733
- if (installMode === "unknown" && existsSync(join(PKG_ROOT, ".git"))) {
734
- installMode = "git-local";
735
- }
736
-
737
- info(`검색: ${installMode === "plugin" ? "플러그인" : installMode === "npm-global" ? "npm global" : installMode === "npm-local" ? "npm local" : installMode === "git-local" ? "git 로컬 저장소" : "알 수 없음"} 설치 감지`);
738
-
929
+
930
+ // 1. 설치 방식 감지
931
+ const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
932
+ let installMode = "unknown";
933
+ let pluginPath = null;
934
+
935
+ // 플러그인 모드 감지
936
+ if (existsSync(pluginsFile)) {
937
+ try {
938
+ const plugins = JSON.parse(readFileSync(pluginsFile, "utf8"));
939
+ for (const [key, entries] of Object.entries(plugins.plugins || {})) {
940
+ if (key.startsWith("triflux")) {
941
+ pluginPath = entries[0]?.installPath;
942
+ installMode = "plugin";
943
+ break;
944
+ }
945
+ }
946
+ } catch {}
947
+ }
948
+
949
+ // PKG_ROOT가 플러그인 캐시 내에 있으면 플러그인 모드
950
+ if (installMode === "unknown" && PKG_ROOT.includes(join(".claude", "plugins"))) {
951
+ installMode = "plugin";
952
+ pluginPath = PKG_ROOT;
953
+ }
954
+
955
+ // npm global 감지
956
+ if (installMode === "unknown") {
957
+ try {
958
+ const npmList = execSync("npm list -g triflux --depth=0", {
959
+ encoding: "utf8",
960
+ timeout: 10000,
961
+ stdio: ["pipe", "pipe", "ignore"],
962
+ });
963
+ if (npmList.includes("triflux")) installMode = "npm-global";
964
+ } catch {}
965
+ }
966
+
967
+ // npm local 감지
968
+ if (installMode === "unknown") {
969
+ const localPkg = join(process.cwd(), "node_modules", "triflux");
970
+ if (existsSync(localPkg)) installMode = "npm-local";
971
+ }
972
+
973
+ // git 저장소 직접 사용
974
+ if (installMode === "unknown" && existsSync(join(PKG_ROOT, ".git"))) {
975
+ installMode = "git-local";
976
+ }
977
+
978
+ info(`검색: ${installMode === "plugin" ? "플러그인" : installMode === "npm-global" ? "npm global" : installMode === "npm-local" ? "npm local" : installMode === "git-local" ? "git 로컬 저장소" : "알 수 없음"} 설치 감지`);
979
+
739
980
  // 2. 설치 방식에 따라 업데이트
740
981
  const oldVer = PKG.version;
741
982
  let updated = false;
@@ -743,16 +984,16 @@ function cmdUpdate() {
743
984
 
744
985
  try {
745
986
  switch (installMode) {
746
- case "plugin": {
747
- const gitDir = pluginPath || PKG_ROOT;
748
- const result = execSync("git pull", {
749
- encoding: "utf8",
750
- timeout: 30000,
751
- cwd: gitDir,
752
- }).trim();
753
- ok(`git pull — ${result}`);
754
- updated = true;
755
- break;
987
+ case "plugin": {
988
+ const gitDir = pluginPath || PKG_ROOT;
989
+ const result = execSync("git pull", {
990
+ encoding: "utf8",
991
+ timeout: 30000,
992
+ cwd: gitDir,
993
+ }).trim();
994
+ ok(`git pull — ${result}`);
995
+ updated = true;
996
+ break;
756
997
  }
757
998
  case "npm-global": {
758
999
  stoppedHubInfo = stopHubForUpdate();
@@ -762,39 +1003,39 @@ function cmdUpdate() {
762
1003
  const npmCmd = isDev ? "npm install -g triflux@dev" : "npm update -g triflux";
763
1004
  const result = execSync(npmCmd, {
764
1005
  encoding: "utf8",
765
- timeout: 60000,
766
- stdio: ["pipe", "pipe", "ignore"],
767
- }).trim().split(/\r?\n/)[0];
1006
+ timeout: 60000,
1007
+ stdio: ["pipe", "pipe", "ignore"],
1008
+ }).trim().split(/\r?\n/)[0];
768
1009
  ok(`${isDev ? "npm install -g triflux@dev" : "npm update -g triflux"} — ${result || "완료"}`);
769
- updated = true;
770
- break;
771
- }
772
- case "npm-local": {
773
- const npmLocalCmd = isDev ? "npm install triflux@dev" : "npm update triflux";
774
- const result = execSync(npmLocalCmd, {
775
- encoding: "utf8",
776
- timeout: 60000,
777
- cwd: process.cwd(),
778
- stdio: ["pipe", "pipe", "ignore"],
779
- }).trim().split(/\r?\n/)[0];
1010
+ updated = true;
1011
+ break;
1012
+ }
1013
+ case "npm-local": {
1014
+ const npmLocalCmd = isDev ? "npm install triflux@dev" : "npm update triflux";
1015
+ const result = execSync(npmLocalCmd, {
1016
+ encoding: "utf8",
1017
+ timeout: 60000,
1018
+ cwd: process.cwd(),
1019
+ stdio: ["pipe", "pipe", "ignore"],
1020
+ }).trim().split(/\r?\n/)[0];
780
1021
  ok(`${isDev ? "npm install triflux@dev" : "npm update triflux"} — ${result || "완료"}`);
781
- updated = true;
782
- break;
783
- }
784
- case "git-local": {
785
- const result = execSync("git pull", {
786
- encoding: "utf8",
787
- timeout: 30000,
788
- cwd: PKG_ROOT,
789
- }).trim();
790
- ok(`git pull — ${result}`);
791
- updated = true;
792
- break;
793
- }
794
- default:
795
- fail("설치 방식을 감지할 수 없음");
796
- info("수동 업데이트: cd <triflux-dir> && git pull");
797
- return;
1022
+ updated = true;
1023
+ break;
1024
+ }
1025
+ case "git-local": {
1026
+ const result = execSync("git pull", {
1027
+ encoding: "utf8",
1028
+ timeout: 30000,
1029
+ cwd: PKG_ROOT,
1030
+ }).trim();
1031
+ ok(`git pull — ${result}`);
1032
+ updated = true;
1033
+ break;
1034
+ }
1035
+ default:
1036
+ fail("설치 방식을 감지할 수 없음");
1037
+ info("수동 업데이트: cd <triflux-dir> && git pull");
1038
+ return;
798
1039
  }
799
1040
  } catch (e) {
800
1041
  if (stoppedHubInfo && startHubAfterUpdate(stoppedHubInfo)) {
@@ -803,23 +1044,23 @@ function cmdUpdate() {
803
1044
  fail(`업데이트 실패: ${e.message}`);
804
1045
  return;
805
1046
  }
806
-
807
- // 3. setup 재실행 (tfx-route.sh, HUD, 스킬 동기화)
808
- if (updated) {
809
- console.log("");
810
- // 업데이트 후 새 버전 읽기
811
- let newVer = oldVer;
812
- try {
813
- const newPkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
814
- newVer = newPkg.version;
815
- } catch {}
816
-
817
- if (newVer !== oldVer) {
818
- ok(`버전: v${oldVer} → v${newVer}`);
819
- } else {
820
- ok(`버전: v${oldVer} (이미 최신)`);
821
- }
822
-
1047
+
1048
+ // 3. setup 재실행 (tfx-route.sh, HUD, 스킬 동기화)
1049
+ if (updated) {
1050
+ console.log("");
1051
+ // 업데이트 후 새 버전 읽기
1052
+ let newVer = oldVer;
1053
+ try {
1054
+ const newPkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
1055
+ newVer = newPkg.version;
1056
+ } catch {}
1057
+
1058
+ if (newVer !== oldVer) {
1059
+ ok(`버전: v${oldVer} → v${newVer}`);
1060
+ } else {
1061
+ ok(`버전: v${oldVer} (이미 최신)`);
1062
+ }
1063
+
823
1064
  // setup 재실행
824
1065
  console.log("");
825
1066
  info("setup 재실행 중...");
@@ -830,155 +1071,155 @@ function cmdUpdate() {
830
1071
  else warn("hub 재기동 실패 — `tfx hub start`로 수동 시작 필요");
831
1072
  }
832
1073
  }
833
-
834
- console.log(`${GREEN}${BOLD}업데이트 완료${RESET}\n`);
835
- }
836
-
837
- function cmdList() {
838
- console.log(`\n ${AMBER}${BOLD}⬡ triflux list${RESET} ${VER}\n`);
839
- console.log(` ${LINE}`);
840
-
841
- const pluginSkills = join(PKG_ROOT, "skills");
842
- const installedSkills = join(CLAUDE_DIR, "skills");
843
-
844
- section("패키지 스킬");
845
- if (existsSync(pluginSkills)) {
846
- for (const name of readdirSync(pluginSkills).sort()) {
847
- const src = join(pluginSkills, name, "SKILL.md");
848
- if (!existsSync(src)) continue;
849
- const dst = join(installedSkills, name, "SKILL.md");
850
- const installed = existsSync(dst);
851
- if (installed) {
852
- console.log(` ${GREEN_BRIGHT}✓${RESET} ${BOLD}${name}${RESET}`);
853
- } else {
854
- console.log(` ${RED_BRIGHT}✗${RESET} ${DIM}${name}${RESET} ${GRAY}(미설치)${RESET}`);
855
- }
856
- }
857
- }
858
-
859
- section("사용자 스킬");
860
- const pkgNames = new Set(existsSync(pluginSkills) ? readdirSync(pluginSkills) : []);
861
- let userCount = 0;
862
- if (existsSync(installedSkills)) {
863
- for (const name of readdirSync(installedSkills).sort()) {
864
- if (pkgNames.has(name)) continue;
865
- const skill = join(installedSkills, name, "SKILL.md");
866
- if (!existsSync(skill)) continue;
867
- console.log(` ${AMBER}◆${RESET} ${name}`);
868
- userCount++;
869
- }
870
- }
871
- if (userCount === 0) console.log(` ${GRAY}없음${RESET}`);
872
-
873
- console.log(`\n ${LINE}`);
874
- console.log(` ${GRAY}${installedSkills}${RESET}\n`);
875
- }
876
-
877
- function cmdVersion() {
878
- const routeVer = getVersion(join(CLAUDE_DIR, "scripts", "tfx-route.sh"));
879
- const hudVer = getVersion(join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"));
880
- console.log(`\n ${AMBER}${BOLD}⬡ triflux${RESET} ${WHITE_BRIGHT}v${PKG.version}${RESET}`);
881
- if (routeVer) console.log(` ${GRAY}tfx-route${RESET} v${routeVer}`);
882
- if (hudVer) console.log(` ${GRAY}hud${RESET} v${hudVer}`);
883
- console.log("");
884
- }
885
-
886
- function checkForUpdate() {
887
- const cacheFile = join(CLAUDE_DIR, "cache", "triflux-update-check.json");
888
- const cacheDir = dirname(cacheFile);
889
-
890
- // 캐시 확인 (1시간 이내면 캐시 사용)
891
- try {
892
- if (existsSync(cacheFile)) {
893
- const cache = JSON.parse(readFileSync(cacheFile, "utf8"));
894
- if (Date.now() - cache.timestamp < 3600000) {
895
- return cache.latest !== PKG.version ? cache.latest : null;
896
- }
897
- }
898
- } catch {}
899
-
900
- // npm registry 조회
901
- try {
902
- const result = execSync("npm view triflux version", {
903
- encoding: "utf8",
904
- timeout: 5000,
905
- stdio: ["pipe", "pipe", "ignore"],
906
- }).trim();
907
-
908
- if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
909
- writeFileSync(cacheFile, JSON.stringify({ latest: result, timestamp: Date.now() }));
910
-
911
- return result !== PKG.version ? result : null;
912
- } catch {
913
- return null;
914
- }
915
- }
916
-
917
- function cmdHelp() {
918
- const latestVer = checkForUpdate();
919
- const updateNotice = latestVer
920
- ? `\n ${YELLOW}${BOLD}↑ v${latestVer} 사용 가능${RESET} ${GRAY}npm update -g triflux${RESET}\n`
921
- : "";
922
-
923
- console.log(`
924
- ${AMBER}${BOLD}⬡ triflux${RESET} ${DIM}v${PKG.version}${RESET}
925
- ${GRAY}CLI-first multi-model orchestrator for Claude Code${RESET}
926
- ${updateNotice}
927
- ${LINE}
928
-
929
- ${BOLD}Commands${RESET}
930
-
931
- ${WHITE_BRIGHT}tfx setup${RESET} ${GRAY}파일 동기화 + HUD 설정${RESET}
932
- ${WHITE_BRIGHT}tfx doctor${RESET} ${GRAY}CLI 진단 + 이슈 확인${RESET}
933
- ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
934
- ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
1074
+
1075
+ console.log(`${GREEN}${BOLD}업데이트 완료${RESET}\n`);
1076
+ }
1077
+
1078
+ function cmdList() {
1079
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux list${RESET} ${VER}\n`);
1080
+ console.log(` ${LINE}`);
1081
+
1082
+ const pluginSkills = join(PKG_ROOT, "skills");
1083
+ const installedSkills = join(CLAUDE_DIR, "skills");
1084
+
1085
+ section("패키지 스킬");
1086
+ if (existsSync(pluginSkills)) {
1087
+ for (const name of readdirSync(pluginSkills).sort()) {
1088
+ const src = join(pluginSkills, name, "SKILL.md");
1089
+ if (!existsSync(src)) continue;
1090
+ const dst = join(installedSkills, name, "SKILL.md");
1091
+ const installed = existsSync(dst);
1092
+ if (installed) {
1093
+ console.log(` ${GREEN_BRIGHT}✓${RESET} ${BOLD}${name}${RESET}`);
1094
+ } else {
1095
+ console.log(` ${RED_BRIGHT}✗${RESET} ${DIM}${name}${RESET} ${GRAY}(미설치)${RESET}`);
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ section("사용자 스킬");
1101
+ const pkgNames = new Set(existsSync(pluginSkills) ? readdirSync(pluginSkills) : []);
1102
+ let userCount = 0;
1103
+ if (existsSync(installedSkills)) {
1104
+ for (const name of readdirSync(installedSkills).sort()) {
1105
+ if (pkgNames.has(name)) continue;
1106
+ const skill = join(installedSkills, name, "SKILL.md");
1107
+ if (!existsSync(skill)) continue;
1108
+ console.log(` ${AMBER}◆${RESET} ${name}`);
1109
+ userCount++;
1110
+ }
1111
+ }
1112
+ if (userCount === 0) console.log(` ${GRAY}없음${RESET}`);
1113
+
1114
+ console.log(`\n ${LINE}`);
1115
+ console.log(` ${GRAY}${installedSkills}${RESET}\n`);
1116
+ }
1117
+
1118
+ function cmdVersion() {
1119
+ const routeVer = getVersion(join(CLAUDE_DIR, "scripts", "tfx-route.sh"));
1120
+ const hudVer = getVersion(join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"));
1121
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux${RESET} ${WHITE_BRIGHT}v${PKG.version}${RESET}`);
1122
+ if (routeVer) console.log(` ${GRAY}tfx-route${RESET} v${routeVer}`);
1123
+ if (hudVer) console.log(` ${GRAY}hud${RESET} v${hudVer}`);
1124
+ console.log("");
1125
+ }
1126
+
1127
+ function checkForUpdate() {
1128
+ const cacheFile = join(CLAUDE_DIR, "cache", "triflux-update-check.json");
1129
+ const cacheDir = dirname(cacheFile);
1130
+
1131
+ // 캐시 확인 (1시간 이내면 캐시 사용)
1132
+ try {
1133
+ if (existsSync(cacheFile)) {
1134
+ const cache = JSON.parse(readFileSync(cacheFile, "utf8"));
1135
+ if (Date.now() - cache.timestamp < 3600000) {
1136
+ return cache.latest !== PKG.version ? cache.latest : null;
1137
+ }
1138
+ }
1139
+ } catch {}
1140
+
1141
+ // npm registry 조회
1142
+ try {
1143
+ const result = execSync("npm view triflux version", {
1144
+ encoding: "utf8",
1145
+ timeout: 5000,
1146
+ stdio: ["pipe", "pipe", "ignore"],
1147
+ }).trim();
1148
+
1149
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
1150
+ writeFileSync(cacheFile, JSON.stringify({ latest: result, timestamp: Date.now() }));
1151
+
1152
+ return result !== PKG.version ? result : null;
1153
+ } catch {
1154
+ return null;
1155
+ }
1156
+ }
1157
+
1158
+ function cmdHelp() {
1159
+ const latestVer = checkForUpdate();
1160
+ const updateNotice = latestVer
1161
+ ? `\n ${YELLOW}${BOLD}↑ v${latestVer} 사용 가능${RESET} ${GRAY}npm update -g triflux${RESET}\n`
1162
+ : "";
1163
+
1164
+ console.log(`
1165
+ ${AMBER}${BOLD}⬡ triflux${RESET} ${DIM}v${PKG.version}${RESET}
1166
+ ${GRAY}CLI-first multi-model orchestrator for Claude Code${RESET}
1167
+ ${updateNotice}
1168
+ ${LINE}
1169
+
1170
+ ${BOLD}Commands${RESET}
1171
+
1172
+ ${WHITE_BRIGHT}tfx setup${RESET} ${GRAY}파일 동기화 + HUD 설정${RESET}
1173
+ ${WHITE_BRIGHT}tfx doctor${RESET} ${GRAY}CLI 진단 + 이슈 확인${RESET}
1174
+ ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
1175
+ ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
935
1176
  ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
936
1177
  ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
937
- ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
938
- ${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
939
- ${WHITE_BRIGHT}tfx team${RESET} ${GRAY}멀티-CLI 팀 모드 (tmux + Hub)${RESET}
940
- ${WHITE_BRIGHT}tfx codex-team${RESET} ${GRAY}Codex 전용 팀 모드 (기본 lead/agents: codex)${RESET}
941
- ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
942
- ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
943
-
1178
+ ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
1179
+ ${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
1180
+ ${WHITE_BRIGHT}tfx multi${RESET} ${GRAY}멀티-CLI 팀 모드 (tmux + Hub)${RESET}
1181
+ ${WHITE_BRIGHT}tfx codex-team${RESET} ${GRAY}Codex 전용 팀 모드 (기본 lead/agents: codex)${RESET}
1182
+ ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
1183
+ ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
1184
+
944
1185
  ${BOLD}Skills${RESET} ${GRAY}(Claude Code 슬래시 커맨드)${RESET}
945
1186
 
946
1187
  ${AMBER}/tfx-auto${RESET} ${GRAY}자동 분류 + 병렬 실행${RESET}
947
1188
  ${WHITE_BRIGHT}/tfx-auto-codex${RESET} ${GRAY}Codex 리드 + Gemini 유지 (no-Claude-native)${RESET}
948
1189
  ${WHITE_BRIGHT}/tfx-codex${RESET} ${GRAY}Codex 전용 모드${RESET}
949
1190
  ${BLUE}/tfx-gemini${RESET} ${GRAY}Gemini 전용 모드${RESET}
950
- ${AMBER}/tfx-setup${RESET} ${GRAY}HUD 설정 + 진단${RESET}
951
- ${YELLOW}/tfx-doctor${RESET} ${GRAY}진단 + 수리 + 캐시 초기화${RESET}
952
-
953
- ${LINE}
954
- ${GRAY}github.com/tellang/triflux${RESET}
955
- `);
956
- }
957
-
958
- async function cmdCodexTeam() {
959
- const args = process.argv.slice(3);
960
- const sub = String(args[0] || "").toLowerCase();
961
- const passthrough = new Set([
962
- "status", "attach", "stop", "kill", "send", "list", "help", "--help", "-h",
963
- "tasks", "task", "focus", "interrupt", "control", "debug",
964
- ]);
965
-
966
- if (sub === "help" || sub === "--help" || sub === "-h") {
967
- console.log(`
968
- ${AMBER}${BOLD}⬡ tfx codex-team${RESET}
969
-
970
- ${WHITE_BRIGHT}tfx codex-team "작업"${RESET} ${GRAY}Codex 리드 + 워커 2개로 팀 시작${RESET}
971
- ${WHITE_BRIGHT}tfx codex-team --layout 1xN "작업"${RESET} ${GRAY}(세로 분할 컬럼)${RESET}
972
- ${WHITE_BRIGHT}tfx codex-team --layout Nx1 "작업"${RESET} ${GRAY}(가로 분할 스택)${RESET}
973
- ${WHITE_BRIGHT}tfx codex-team status${RESET}
974
- ${WHITE_BRIGHT}tfx codex-team debug --lines 30${RESET}
975
- ${WHITE_BRIGHT}tfx codex-team send N "msg"${RESET}
976
-
977
- ${DIM}내부적으로 tfx team을 호출하며, 시작 시 --lead codex --agents codex,codex를 기본 주입합니다.${RESET}
978
- `);
979
- return;
980
- }
981
-
1191
+ ${AMBER}/tfx-setup${RESET} ${GRAY}HUD 설정 + 진단${RESET}
1192
+ ${YELLOW}/tfx-doctor${RESET} ${GRAY}진단 + 수리 + 캐시 초기화${RESET}
1193
+
1194
+ ${LINE}
1195
+ ${GRAY}github.com/tellang/triflux${RESET}
1196
+ `);
1197
+ }
1198
+
1199
+ async function cmdCodexTeam() {
1200
+ const args = process.argv.slice(3);
1201
+ const sub = String(args[0] || "").toLowerCase();
1202
+ const passthrough = new Set([
1203
+ "status", "attach", "stop", "kill", "send", "list", "help", "--help", "-h",
1204
+ "tasks", "task", "focus", "interrupt", "control", "debug",
1205
+ ]);
1206
+
1207
+ if (sub === "help" || sub === "--help" || sub === "-h") {
1208
+ console.log(`
1209
+ ${AMBER}${BOLD}⬡ tfx codex-team${RESET}
1210
+
1211
+ ${WHITE_BRIGHT}tfx codex-team "작업"${RESET} ${GRAY}Codex 리드 + 워커 2개로 팀 시작${RESET}
1212
+ ${WHITE_BRIGHT}tfx codex-team --layout 1xN "작업"${RESET} ${GRAY}(세로 분할 컬럼)${RESET}
1213
+ ${WHITE_BRIGHT}tfx codex-team --layout Nx1 "작업"${RESET} ${GRAY}(가로 분할 스택)${RESET}
1214
+ ${WHITE_BRIGHT}tfx codex-team status${RESET}
1215
+ ${WHITE_BRIGHT}tfx codex-team debug --lines 30${RESET}
1216
+ ${WHITE_BRIGHT}tfx codex-team send N "msg"${RESET}
1217
+
1218
+ ${DIM}내부적으로 tfx multi을 호출하며, 시작 시 --lead codex --agents codex,codex를 기본 주입합니다.${RESET}
1219
+ `);
1220
+ return;
1221
+ }
1222
+
982
1223
  const hasAgents = args.includes("--agents");
983
1224
  const hasLead = args.includes("--lead");
984
1225
  const hasLayout = args.includes("--layout");
@@ -989,7 +1230,7 @@ async function cmdCodexTeam() {
989
1230
  if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
990
1231
  if (!isControl && !hasLayout) inject.push("--layout", "1xN");
991
1232
  const forwarded = isControl ? normalizedArgs : [...inject, ...args];
992
-
1233
+
993
1234
  const prevArgv = process.argv;
994
1235
  const prevProfile = process.env.TFX_TEAM_PROFILE;
995
1236
  process.env.TFX_TEAM_PROFILE = "codex-team";
@@ -1004,9 +1245,9 @@ async function cmdCodexTeam() {
1004
1245
  else delete process.env.TFX_TEAM_PROFILE;
1005
1246
  }
1006
1247
  }
1007
-
1008
- // ── hub 서브커맨드 ──
1009
-
1248
+
1249
+ // ── hub 서브커맨드 ──
1250
+
1010
1251
  const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
1011
1252
  const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
1012
1253
 
@@ -1061,81 +1302,81 @@ function startHubAfterUpdate(info) {
1061
1302
  return false;
1062
1303
  }
1063
1304
  }
1064
-
1065
- // 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
1066
- function autoRegisterMcp(mcpUrl) {
1067
- section("MCP 자동 등록");
1068
-
1069
- // Codex — codex mcp add
1070
- if (which("codex")) {
1071
- try {
1072
- // 이미 등록됐는지 확인
1073
- const list = execSync("codex mcp list 2>&1", { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
1074
- if (list.includes("tfx-hub")) {
1075
- ok("Codex: 이미 등록됨");
1076
- } else {
1077
- execSync(`codex mcp add tfx-hub --url ${mcpUrl}`, { timeout: 10000, stdio: "ignore" });
1078
- ok("Codex: MCP 등록 완료");
1079
- }
1080
- } catch {
1081
- // mcp list/add 미지원 → 설정 파일 직접 수정
1082
- try {
1083
- const codexDir = join(homedir(), ".codex");
1084
- const configFile = join(codexDir, "config.json");
1085
- let config = {};
1086
- if (existsSync(configFile)) config = JSON.parse(readFileSync(configFile, "utf8"));
1087
- if (!config.mcpServers) config.mcpServers = {};
1088
- if (!config.mcpServers["tfx-hub"]) {
1089
- config.mcpServers["tfx-hub"] = { url: mcpUrl };
1090
- if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
1091
- writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
1092
- ok("Codex: config.json에 등록 완료");
1093
- } else {
1094
- ok("Codex: 이미 등록됨");
1095
- }
1096
- } catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
1097
- }
1098
- } else {
1099
- info("Codex: 미설치 (건너뜀)");
1100
- }
1101
-
1102
- // Gemini — settings.json 직접 수정
1103
- if (which("gemini")) {
1104
- try {
1105
- const geminiDir = join(homedir(), ".gemini");
1106
- const settingsFile = join(geminiDir, "settings.json");
1107
- let settings = {};
1108
- if (existsSync(settingsFile)) settings = JSON.parse(readFileSync(settingsFile, "utf8"));
1109
- if (!settings.mcpServers) settings.mcpServers = {};
1110
- if (!settings.mcpServers["tfx-hub"]) {
1111
- settings.mcpServers["tfx-hub"] = { url: mcpUrl };
1112
- if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
1113
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
1114
- ok("Gemini: settings.json에 등록 완료");
1115
- } else {
1116
- ok("Gemini: 이미 등록됨");
1117
- }
1118
- } catch (e) { warn(`Gemini 등록 실패: ${e.message}`); }
1119
- } else {
1120
- info("Gemini: 미설치 (건너뜀)");
1121
- }
1122
-
1123
- // Claude — 프로젝트 .mcp.json에 등록 (오케스트레이터용)
1124
- try {
1125
- const mcpJsonPath = join(PKG_ROOT, ".mcp.json");
1126
- let mcpJson = {};
1127
- if (existsSync(mcpJsonPath)) mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
1128
- if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
1129
- if (!mcpJson.mcpServers["tfx-hub"]) {
1130
- mcpJson.mcpServers["tfx-hub"] = { type: "url", url: mcpUrl };
1131
- writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
1132
- ok("Claude: .mcp.json에 등록 완료");
1133
- } else {
1134
- ok("Claude: 이미 등록됨");
1135
- }
1136
- } catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
1137
- }
1138
-
1305
+
1306
+ // 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
1307
+ function autoRegisterMcp(mcpUrl) {
1308
+ section("MCP 자동 등록");
1309
+
1310
+ // Codex — codex mcp add
1311
+ if (which("codex")) {
1312
+ try {
1313
+ // 이미 등록됐는지 확인
1314
+ const list = execSync("codex mcp list 2>&1", { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
1315
+ if (list.includes("tfx-hub")) {
1316
+ ok("Codex: 이미 등록됨");
1317
+ } else {
1318
+ execSync(`codex mcp add tfx-hub --url ${mcpUrl}`, { timeout: 10000, stdio: "ignore" });
1319
+ ok("Codex: MCP 등록 완료");
1320
+ }
1321
+ } catch {
1322
+ // mcp list/add 미지원 → 설정 파일 직접 수정
1323
+ try {
1324
+ const codexDir = join(homedir(), ".codex");
1325
+ const configFile = join(codexDir, "config.json");
1326
+ let config = {};
1327
+ if (existsSync(configFile)) config = JSON.parse(readFileSync(configFile, "utf8"));
1328
+ if (!config.mcpServers) config.mcpServers = {};
1329
+ if (!config.mcpServers["tfx-hub"]) {
1330
+ config.mcpServers["tfx-hub"] = { url: mcpUrl };
1331
+ if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
1332
+ writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
1333
+ ok("Codex: config.json에 등록 완료");
1334
+ } else {
1335
+ ok("Codex: 이미 등록됨");
1336
+ }
1337
+ } catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
1338
+ }
1339
+ } else {
1340
+ info("Codex: 미설치 (건너뜀)");
1341
+ }
1342
+
1343
+ // Gemini — settings.json 직접 수정
1344
+ if (which("gemini")) {
1345
+ try {
1346
+ const geminiDir = join(homedir(), ".gemini");
1347
+ const settingsFile = join(geminiDir, "settings.json");
1348
+ let settings = {};
1349
+ if (existsSync(settingsFile)) settings = JSON.parse(readFileSync(settingsFile, "utf8"));
1350
+ if (!settings.mcpServers) settings.mcpServers = {};
1351
+ if (!settings.mcpServers["tfx-hub"]) {
1352
+ settings.mcpServers["tfx-hub"] = { url: mcpUrl };
1353
+ if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
1354
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
1355
+ ok("Gemini: settings.json에 등록 완료");
1356
+ } else {
1357
+ ok("Gemini: 이미 등록됨");
1358
+ }
1359
+ } catch (e) { warn(`Gemini 등록 실패: ${e.message}`); }
1360
+ } else {
1361
+ info("Gemini: 미설치 (건너뜀)");
1362
+ }
1363
+
1364
+ // Claude — 프로젝트 .mcp.json에 등록 (오케스트레이터용)
1365
+ try {
1366
+ const mcpJsonPath = join(PKG_ROOT, ".mcp.json");
1367
+ let mcpJson = {};
1368
+ if (existsSync(mcpJsonPath)) mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
1369
+ if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
1370
+ if (!mcpJson.mcpServers["tfx-hub"]) {
1371
+ mcpJson.mcpServers["tfx-hub"] = { type: "url", url: mcpUrl };
1372
+ writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
1373
+ ok("Claude: .mcp.json에 등록 완료");
1374
+ } else {
1375
+ ok("Claude: 이미 등록됨");
1376
+ }
1377
+ } catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
1378
+ }
1379
+
1139
1380
  async function cmdHub() {
1140
1381
  const sub = process.argv[3] || "status";
1141
1382
  const defaultPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
@@ -1170,36 +1411,36 @@ async function cmdHub() {
1170
1411
  };
1171
1412
 
1172
1413
  switch (sub) {
1173
- case "start": {
1174
- // 이미 실행 중인지 확인
1175
- if (existsSync(HUB_PID_FILE)) {
1176
- try {
1177
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1178
- process.kill(info.pid, 0); // 프로세스 존재 확인
1179
- console.log(`\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`);
1180
- return;
1181
- } catch {
1182
- // PID 파일 있지만 프로세스 없음 — 정리
1183
- try { unlinkSync(HUB_PID_FILE); } catch {}
1184
- }
1185
- }
1186
-
1187
- const portArg = process.argv.indexOf("--port");
1188
- const port = portArg !== -1 ? process.argv[portArg + 1] : "27888";
1189
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
1190
-
1191
- if (!existsSync(serverPath)) {
1192
- fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
1193
- return;
1194
- }
1195
-
1196
- const child = spawn(process.execPath, [serverPath], {
1197
- env: { ...process.env, TFX_HUB_PORT: port },
1198
- stdio: "ignore",
1199
- detached: true,
1200
- });
1201
- child.unref();
1202
-
1414
+ case "start": {
1415
+ // 이미 실행 중인지 확인
1416
+ if (existsSync(HUB_PID_FILE)) {
1417
+ try {
1418
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1419
+ process.kill(info.pid, 0); // 프로세스 존재 확인
1420
+ console.log(`\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`);
1421
+ return;
1422
+ } catch {
1423
+ // PID 파일 있지만 프로세스 없음 — 정리
1424
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1425
+ }
1426
+ }
1427
+
1428
+ const portArg = process.argv.indexOf("--port");
1429
+ const port = portArg !== -1 ? process.argv[portArg + 1] : "27888";
1430
+ const serverPath = join(PKG_ROOT, "hub", "server.mjs");
1431
+
1432
+ if (!existsSync(serverPath)) {
1433
+ fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
1434
+ return;
1435
+ }
1436
+
1437
+ const child = spawn(process.execPath, [serverPath], {
1438
+ env: { ...process.env, TFX_HUB_PORT: port },
1439
+ stdio: "ignore",
1440
+ detached: true,
1441
+ });
1442
+ child.unref();
1443
+
1203
1444
  // PID 파일 확인 (최대 3초 대기, 100ms 폴링)
1204
1445
  let started = false;
1205
1446
  const deadline = Date.now() + 3000;
@@ -1207,24 +1448,24 @@ async function cmdHub() {
1207
1448
  if (existsSync(HUB_PID_FILE)) { started = true; break; }
1208
1449
  await new Promise((r) => setTimeout(r, 100));
1209
1450
  }
1210
-
1211
- if (started) {
1212
- const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1213
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} ${BOLD}tfx-hub 시작${RESET}`);
1214
- console.log(` URL: ${AMBER}${hubInfo.url}${RESET}`);
1215
- console.log(` PID: ${hubInfo.pid}`);
1216
- console.log(` DB: ${DIM}${HUB_PID_DIR}/state.db${RESET}`);
1217
- console.log("");
1218
- autoRegisterMcp(hubInfo.url);
1219
- console.log("");
1220
- } else {
1221
- // 직접 포그라운드 모드로 안내
1222
- console.log(`\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패 — 포그라운드로 실행:`);
1223
- console.log(` ${DIM}TFX_HUB_PORT=${port} node ${serverPath}${RESET}\n`);
1224
- }
1225
- break;
1226
- }
1227
-
1451
+
1452
+ if (started) {
1453
+ const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1454
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} ${BOLD}tfx-hub 시작${RESET}`);
1455
+ console.log(` URL: ${AMBER}${hubInfo.url}${RESET}`);
1456
+ console.log(` PID: ${hubInfo.pid}`);
1457
+ console.log(` DB: ${DIM}${HUB_PID_DIR}/state.db${RESET}`);
1458
+ console.log("");
1459
+ autoRegisterMcp(hubInfo.url);
1460
+ console.log("");
1461
+ } else {
1462
+ // 직접 포그라운드 모드로 안내
1463
+ console.log(`\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패 — 포그라운드로 실행:`);
1464
+ console.log(` ${DIM}TFX_HUB_PORT=${port} node ${serverPath}${RESET}\n`);
1465
+ }
1466
+ break;
1467
+ }
1468
+
1228
1469
  case "stop": {
1229
1470
  if (!existsSync(HUB_PID_FILE)) {
1230
1471
  const probed = await probeHubStatus("127.0.0.1", probePort, 1500)
@@ -1239,17 +1480,17 @@ async function cmdHub() {
1239
1480
  console.log(`\n ${DIM}hub 미실행${RESET}\n`);
1240
1481
  return;
1241
1482
  }
1242
- try {
1243
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1244
- process.kill(info.pid, "SIGTERM");
1245
- try { unlinkSync(HUB_PID_FILE); } catch {}
1246
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${info.pid})\n`);
1247
- } catch (e) {
1248
- try { unlinkSync(HUB_PID_FILE); } catch {}
1249
- console.log(`\n ${DIM}hub 프로세스 없음 — PID 파일 정리됨${RESET}\n`);
1250
- }
1251
- break;
1252
- }
1483
+ try {
1484
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1485
+ process.kill(info.pid, "SIGTERM");
1486
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1487
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${info.pid})\n`);
1488
+ } catch (e) {
1489
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1490
+ console.log(`\n ${DIM}hub 프로세스 없음 — PID 파일 정리됨${RESET}\n`);
1491
+ }
1492
+ break;
1493
+ }
1253
1494
 
1254
1495
  case "status": {
1255
1496
  if (!existsSync(HUB_PID_FILE)) {
@@ -1280,15 +1521,15 @@ async function cmdHub() {
1280
1521
  }
1281
1522
  try {
1282
1523
  const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1283
- process.kill(info.pid, 0); // 생존 확인
1284
- const uptime = Date.now() - info.started;
1285
- const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
1286
- : uptime < 3600000 ? `${Math.round(uptime / 60000)}분`
1287
- : `${Math.round(uptime / 3600000)}시간`;
1288
-
1289
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
1290
- console.log(` URL: ${info.url}`);
1291
- console.log(` PID: ${info.pid}`);
1524
+ process.kill(info.pid, 0); // 생존 확인
1525
+ const uptime = Date.now() - info.started;
1526
+ const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
1527
+ : uptime < 3600000 ? `${Math.round(uptime / 60000)}분`
1528
+ : `${Math.round(uptime / 3600000)}시간`;
1529
+
1530
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
1531
+ console.log(` URL: ${info.url}`);
1532
+ console.log(` PID: ${info.pid}`);
1292
1533
  console.log(` Uptime: ${uptimeStr}`);
1293
1534
 
1294
1535
  // HTTP 상태 조회 시도
@@ -1299,12 +1540,12 @@ async function cmdHub() {
1299
1540
  if (data.hub) {
1300
1541
  console.log(` State: ${data.hub.state}`);
1301
1542
  }
1302
- if (data.sessions !== undefined) {
1303
- console.log(` Sessions: ${data.sessions}`);
1304
- }
1305
- } catch {}
1306
-
1307
- console.log("");
1543
+ if (data.sessions !== undefined) {
1544
+ console.log(` Sessions: ${data.sessions}`);
1545
+ }
1546
+ } catch {}
1547
+
1548
+ console.log("");
1308
1549
  } catch {
1309
1550
  try { unlinkSync(HUB_PID_FILE); } catch {}
1310
1551
  const probed = await probeHubStatus();
@@ -1322,52 +1563,52 @@ async function cmdHub() {
1322
1563
  }
1323
1564
  break;
1324
1565
  }
1325
-
1326
- default:
1327
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
1328
- console.log(` ${WHITE_BRIGHT}tfx hub start${RESET} ${GRAY}허브 데몬 시작${RESET}`);
1329
- console.log(` ${DIM} --port N${RESET} ${GRAY}포트 지정 (기본 27888)${RESET}`);
1330
- console.log(` ${WHITE_BRIGHT}tfx hub stop${RESET} ${GRAY}허브 중지${RESET}`);
1331
- console.log(` ${WHITE_BRIGHT}tfx hub status${RESET} ${GRAY}상태 확인${RESET}\n`);
1332
- }
1333
- }
1334
-
1335
- // ── 메인 ──
1336
-
1337
- const cmd = process.argv[2] || "help";
1338
-
1339
- switch (cmd) {
1340
- 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
- }
1347
- case "update": cmdUpdate(); break;
1348
- case "list": case "ls": cmdList(); break;
1566
+
1567
+ default:
1568
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
1569
+ console.log(` ${WHITE_BRIGHT}tfx hub start${RESET} ${GRAY}허브 데몬 시작${RESET}`);
1570
+ console.log(` ${DIM} --port N${RESET} ${GRAY}포트 지정 (기본 27888)${RESET}`);
1571
+ console.log(` ${WHITE_BRIGHT}tfx hub stop${RESET} ${GRAY}허브 중지${RESET}`);
1572
+ console.log(` ${WHITE_BRIGHT}tfx hub status${RESET} ${GRAY}상태 확인${RESET}\n`);
1573
+ }
1574
+ }
1575
+
1576
+ // ── 메인 ──
1577
+
1578
+ const cmd = process.argv[2] || "help";
1579
+
1580
+ switch (cmd) {
1581
+ case "setup": cmdSetup(); break;
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
+ }
1588
+ case "update": cmdUpdate(); break;
1589
+ case "list": case "ls": cmdList(); break;
1349
1590
  case "hub": await cmdHub(); break;
1350
- case "team": {
1351
- const { pathToFileURL } = await import("node:url");
1352
- const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
1353
- await cmdTeam();
1354
- break;
1355
- }
1356
- case "codex-team":
1357
- await cmdCodexTeam();
1358
- break;
1359
- case "notion-read": case "nr": {
1360
- const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
1361
- const nrArgs = process.argv.slice(3).map(a => `"${a}"`).join(" ");
1362
- try {
1363
- execSync(`"${process.execPath}" "${scriptPath}" ${nrArgs}`, { stdio: "inherit", timeout: 660000 });
1364
- } catch (e) { process.exit(e.status || 1); }
1365
- break;
1366
- }
1367
- case "version": case "--version": case "-v": cmdVersion(); break;
1368
- case "help": case "--help": case "-h": cmdHelp(); break;
1369
- default:
1370
- console.error(`알 수 없는 명령: ${cmd}`);
1371
- cmdHelp();
1372
- process.exit(1);
1373
- }
1591
+ case "multi": {
1592
+ const { pathToFileURL } = await import("node:url");
1593
+ const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
1594
+ await cmdTeam();
1595
+ break;
1596
+ }
1597
+ case "codex-team":
1598
+ await cmdCodexTeam();
1599
+ break;
1600
+ case "notion-read": case "nr": {
1601
+ const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
1602
+ const nrArgs = process.argv.slice(3).map(a => `"${a}"`).join(" ");
1603
+ try {
1604
+ execSync(`"${process.execPath}" "${scriptPath}" ${nrArgs}`, { stdio: "inherit", timeout: 660000 });
1605
+ } catch (e) { process.exit(e.status || 1); }
1606
+ break;
1607
+ }
1608
+ case "version": case "--version": case "-v": cmdVersion(); break;
1609
+ case "help": case "--help": case "-h": cmdHelp(); break;
1610
+ default:
1611
+ console.error(`알 수 없는 명령: ${cmd}`);
1612
+ cmdHelp();
1613
+ process.exit(1);
1614
+ }