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.
- package/bin/triflux.mjs +557 -251
- package/hooks/keyword-rules.json +16 -0
- package/hub/bridge.mjs +410 -318
- package/hub/hitl.mjs +45 -31
- package/hub/pipe.mjs +457 -0
- package/hub/router.mjs +422 -161
- package/hub/server.mjs +429 -424
- package/hub/store.mjs +388 -314
- package/hub/team/cli-team-common.mjs +348 -0
- package/hub/team/cli-team-control.mjs +393 -0
- package/hub/team/cli-team-start.mjs +512 -0
- package/hub/team/cli-team-status.mjs +269 -0
- package/hub/team/cli.mjs +59 -1459
- package/hub/team/dashboard.mjs +1 -9
- package/hub/team/native.mjs +12 -80
- package/hub/team/nativeProxy.mjs +121 -47
- package/hub/team/pane.mjs +66 -43
- package/hub/team/psmux.mjs +297 -0
- package/hub/team/session.mjs +354 -291
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +299 -0
- package/hub/tools.mjs +41 -52
- package/hub/workers/claude-worker.mjs +446 -0
- package/hub/workers/codex-mcp.mjs +414 -0
- package/hub/workers/factory.mjs +18 -0
- package/hub/workers/gemini-worker.mjs +349 -0
- package/hub/workers/interface.mjs +41 -0
- package/hud/hud-qos-status.mjs +4 -2
- package/package.json +4 -1
- package/scripts/keyword-detector.mjs +15 -0
- package/scripts/lib/keyword-rules.mjs +4 -1
- package/scripts/psmux-steering-prototype.sh +368 -0
- package/scripts/setup.mjs +128 -70
- package/scripts/tfx-route-worker.mjs +161 -0
- package/scripts/tfx-route.sh +415 -80
- package/skills/tfx-auto/SKILL.md +90 -564
- package/skills/tfx-auto-codex/SKILL.md +1 -3
- package/skills/tfx-codex/SKILL.md +1 -4
- package/skills/tfx-doctor/SKILL.md +1 -0
- package/skills/tfx-gemini/SKILL.md +1 -4
- package/skills/tfx-setup/SKILL.md +1 -4
- 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
|
-
|
|
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);
|