triflux 10.20.2 → 10.22.0
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/CLAUDE.md +174 -0
- package/bin/tfx-doctor-tui.mjs +0 -0
- package/bin/tfx-doctor.mjs +0 -0
- package/bin/tfx-profile.mjs +0 -0
- package/bin/tfx-setup-tui.mjs +0 -0
- package/bin/tfx-setup.mjs +0 -0
- package/bin/triflux.mjs +609 -8
- package/hooks/keyword-rules.json +1 -1
- package/hub/cli-adapter-base.mjs +34 -1
- package/hub/codex-adapter.mjs +4 -0
- package/hub/dynamic-routing-engine.mjs +511 -0
- package/hub/lib/prompt-tmp.mjs +33 -0
- package/hub/lib/spawn-trace.mjs +5 -1
- package/hub/routing-snapshot.mjs +261 -0
- package/hub/team/conductor.mjs +67 -3
- package/hub/team/execution-mode.mjs +34 -30
- package/hub/team/headless.mjs +8 -6
- package/hub/team/launcher-template.mjs +5 -0
- package/hub/team/psmux.mjs +57 -30
- package/hub/team/session.mjs +54 -2
- package/hub/team/swarm-hypervisor.mjs +59 -0
- package/hub/team/terminal-opener.mjs +24 -4
- package/hub/team/tui-viewer.mjs +2 -1
- package/package.json +23 -67
- package/references/cli-parameter-reference.md +240 -0
- package/references/codex-plugin-cc-analysis.md +706 -0
- package/references/codex-plugin-cc-code-patterns.md +468 -0
- package/scripts/__tests__/setup-cleanup-stale-skills.test.mjs +19 -1
- package/scripts/check-codex-config-stable.mjs +166 -38
- package/scripts/doctor-dynamic-routing.mjs +87 -0
- package/scripts/hub-ensure.mjs +689 -33
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/psmux-info.mjs +59 -4
- package/scripts/pack.mjs +3 -0
- package/scripts/session-spawn-helper.mjs +2 -1
- package/scripts/setup.mjs +12 -4
- package/scripts/test-lock.mjs +194 -26
- package/scripts/tfx-route.sh +36 -0
- package/skills/tfx-auto/SKILL.md +8 -8
- package/skills/tfx-auto/SKILL.md.tmpl +8 -8
- package/.claude-plugin/marketplace.json +0 -34
- package/.claude-plugin/plugin.json +0 -22
- package/config/mcp-registry.json +0 -44
- package/tui/codex-profile.mjs +0 -459
- package/tui/core.mjs +0 -266
- package/tui/doctor.mjs +0 -375
- package/tui/gemini-profile.mjs +0 -299
- package/tui/monitor-data.mjs +0 -152
- package/tui/monitor.mjs +0 -317
- package/tui/setup.mjs +0 -599
package/bin/triflux.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
readdirSync,
|
|
13
13
|
readFileSync,
|
|
14
14
|
readSync,
|
|
15
|
+
realpathSync,
|
|
15
16
|
renameSync,
|
|
16
17
|
statSync,
|
|
17
18
|
unlinkSync,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
validateRuntimeCachePaths,
|
|
27
28
|
} from "../hub/lib/cache-guard.mjs";
|
|
28
29
|
import { getPipelineStateDbPath } from "../hub/pipeline/state.mjs";
|
|
30
|
+
import { getVersionHash } from "../hub/state.mjs";
|
|
29
31
|
import { forceCleanupTeam } from "../hub/team/nativeProxy.mjs";
|
|
30
32
|
import {
|
|
31
33
|
detectMultiplexer,
|
|
@@ -77,6 +79,7 @@ import {
|
|
|
77
79
|
getVersion,
|
|
78
80
|
getWindowsHubAutostartStatus,
|
|
79
81
|
hasProfileSection,
|
|
82
|
+
isLocalDevSkillDir,
|
|
80
83
|
isSetupUserStateFile,
|
|
81
84
|
LEGACY_CODEX_MODELS,
|
|
82
85
|
REQUIRED_CODEX_PROFILES,
|
|
@@ -118,6 +121,8 @@ const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
|
|
|
118
121
|
const _DOT = `${GRAY}·${RESET}`;
|
|
119
122
|
const STALE_TEAM_MAX_AGE_SEC = 3600;
|
|
120
123
|
const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
|
124
|
+
const HUB_DEFAULT_PORT = 27888;
|
|
125
|
+
const DOCTOR_HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");
|
|
121
126
|
|
|
122
127
|
const _EXIT_SUCCESS = 0;
|
|
123
128
|
const EXIT_ERROR = 1;
|
|
@@ -150,7 +155,7 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
150
155
|
},
|
|
151
156
|
doctor: {
|
|
152
157
|
usage:
|
|
153
|
-
"tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--json]",
|
|
158
|
+
"tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--json]",
|
|
154
159
|
description: "설치 상태 진단 및 자동 복구",
|
|
155
160
|
options: [
|
|
156
161
|
{
|
|
@@ -180,6 +185,30 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
180
185
|
description:
|
|
181
186
|
"--fix 와 함께 사용. cli-issues.jsonl 에서 7일 초과 항목 물리 삭제 (#144)",
|
|
182
187
|
},
|
|
188
|
+
{
|
|
189
|
+
name: "--dynamic-routing",
|
|
190
|
+
type: "boolean",
|
|
191
|
+
description:
|
|
192
|
+
"Phase 1 dynamic routing 상태 진단 (env / policy / snapshot cache / preview decision)",
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "--cleanup-stale-hubs",
|
|
196
|
+
type: "boolean",
|
|
197
|
+
description:
|
|
198
|
+
"PPID=1 hub/server.mjs 후보를 보고하고 opt-in 정리 모드를 활성화",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "--dry-run",
|
|
202
|
+
type: "boolean",
|
|
203
|
+
description:
|
|
204
|
+
"--cleanup-stale-hubs 와 함께 사용. active healthy hub를 제외하고 정리 후보만 표시",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "--apply",
|
|
208
|
+
type: "boolean",
|
|
209
|
+
description:
|
|
210
|
+
"--cleanup-stale-hubs 와 함께 사용. active healthy hub 제외 후 stale hub 종료",
|
|
211
|
+
},
|
|
183
212
|
{
|
|
184
213
|
name: "--json",
|
|
185
214
|
type: "boolean",
|
|
@@ -498,6 +527,403 @@ function createCliError(
|
|
|
498
527
|
return error;
|
|
499
528
|
}
|
|
500
529
|
|
|
530
|
+
function parseHubPort(value) {
|
|
531
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
532
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function isPidAliveForHub(pid, killFn = process.kill) {
|
|
536
|
+
const resolvedPid = Number(pid);
|
|
537
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return false;
|
|
538
|
+
try {
|
|
539
|
+
killFn(resolvedPid, 0);
|
|
540
|
+
return true;
|
|
541
|
+
} catch (error) {
|
|
542
|
+
return error?.code === "EPERM";
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function isHubServerCommand(command) {
|
|
547
|
+
return /(^|[\\/,\s])hub[\\/]server\.mjs(?=$|[\s"'`])/i.test(
|
|
548
|
+
String(command || ""),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function parsePortFromAddress(address) {
|
|
553
|
+
const match = String(address || "").match(/:(\d+)(?:\s|$)/);
|
|
554
|
+
return parseHubPort(match?.[1]);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function parseDetachedHubProcessRows(output) {
|
|
558
|
+
const rows = [];
|
|
559
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
560
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
|
561
|
+
if (!match) continue;
|
|
562
|
+
const [, pidText, ppidText, rssText, uptime, command] = match;
|
|
563
|
+
const pid = Number.parseInt(pidText, 10);
|
|
564
|
+
const ppid = Number.parseInt(ppidText, 10);
|
|
565
|
+
const rssKb = Number.parseInt(rssText, 10);
|
|
566
|
+
if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
|
|
567
|
+
if (ppid !== 1) continue;
|
|
568
|
+
if (!isHubServerCommand(command)) continue;
|
|
569
|
+
rows.push({
|
|
570
|
+
pid,
|
|
571
|
+
ppid,
|
|
572
|
+
rssKb: Number.isFinite(rssKb) ? rssKb : null,
|
|
573
|
+
uptime,
|
|
574
|
+
command: command.trim(),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return rows;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function queryDetachedHubProcessRows({
|
|
581
|
+
platform = process.platform,
|
|
582
|
+
execFile = execFileSync,
|
|
583
|
+
} = {}) {
|
|
584
|
+
if (platform === "win32") return [];
|
|
585
|
+
try {
|
|
586
|
+
const output = execFile("ps", ["-axo", "pid=,ppid=,rss=,etime=,command="], {
|
|
587
|
+
encoding: "utf8",
|
|
588
|
+
timeout: 5000,
|
|
589
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
590
|
+
windowsHide: true,
|
|
591
|
+
});
|
|
592
|
+
return parseDetachedHubProcessRows(output);
|
|
593
|
+
} catch {
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function parseLsofListeningPorts(output) {
|
|
599
|
+
const ports = new Set();
|
|
600
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
601
|
+
if (!/\(LISTEN\)/i.test(line)) continue;
|
|
602
|
+
const match = line.match(/TCP\s+\S+:(\d+)\s+\(LISTEN\)/i);
|
|
603
|
+
const port = parseHubPort(match?.[1]) ?? parsePortFromAddress(line);
|
|
604
|
+
if (port) ports.add(port);
|
|
605
|
+
}
|
|
606
|
+
return [...ports];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function queryListeningPortsForPid(
|
|
610
|
+
pid,
|
|
611
|
+
{ platform = process.platform, execFile = execFileSync } = {},
|
|
612
|
+
) {
|
|
613
|
+
const resolvedPid = Number(pid);
|
|
614
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return [];
|
|
615
|
+
if (platform === "win32") return [];
|
|
616
|
+
try {
|
|
617
|
+
const output = execFile(
|
|
618
|
+
"lsof",
|
|
619
|
+
["-nP", "-Pan", "-p", String(resolvedPid), "-iTCP", "-sTCP:LISTEN"],
|
|
620
|
+
{
|
|
621
|
+
encoding: "utf8",
|
|
622
|
+
timeout: 5000,
|
|
623
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
624
|
+
windowsHide: true,
|
|
625
|
+
},
|
|
626
|
+
);
|
|
627
|
+
return parseLsofListeningPorts(output);
|
|
628
|
+
} catch {
|
|
629
|
+
return [];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function queryEstablishedCountForPid(
|
|
634
|
+
pid,
|
|
635
|
+
{ platform = process.platform, execFile = execFileSync } = {},
|
|
636
|
+
) {
|
|
637
|
+
const resolvedPid = Number(pid);
|
|
638
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return 0;
|
|
639
|
+
if (platform === "win32") return 0;
|
|
640
|
+
try {
|
|
641
|
+
const output = execFile(
|
|
642
|
+
"lsof",
|
|
643
|
+
["-nP", "-Pan", "-p", String(resolvedPid), "-iTCP", "-sTCP:ESTABLISHED"],
|
|
644
|
+
{
|
|
645
|
+
encoding: "utf8",
|
|
646
|
+
timeout: 5000,
|
|
647
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
648
|
+
windowsHide: true,
|
|
649
|
+
},
|
|
650
|
+
);
|
|
651
|
+
return Math.max(0, output.trim().split(/\r?\n/).filter(Boolean).length - 1);
|
|
652
|
+
} catch {
|
|
653
|
+
return 0;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function queryPidCommand(
|
|
658
|
+
pid,
|
|
659
|
+
{ platform = process.platform, execFile = execFileSync } = {},
|
|
660
|
+
) {
|
|
661
|
+
const resolvedPid = Number(pid);
|
|
662
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return "";
|
|
663
|
+
try {
|
|
664
|
+
if (platform === "win32") return "";
|
|
665
|
+
return execFile("ps", ["-p", String(resolvedPid), "-o", "command="], {
|
|
666
|
+
encoding: "utf8",
|
|
667
|
+
timeout: 5000,
|
|
668
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
669
|
+
windowsHide: true,
|
|
670
|
+
}).trim();
|
|
671
|
+
} catch {
|
|
672
|
+
return "";
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function queryListeningPidByPort(
|
|
677
|
+
port,
|
|
678
|
+
{ platform = process.platform, execFile = execFileSync } = {},
|
|
679
|
+
) {
|
|
680
|
+
const targetPort = parseHubPort(port);
|
|
681
|
+
if (!targetPort || platform === "win32") return null;
|
|
682
|
+
try {
|
|
683
|
+
const output = execFile(
|
|
684
|
+
"lsof",
|
|
685
|
+
["-nP", "-iTCP:" + targetPort, "-sTCP:LISTEN", "-t"],
|
|
686
|
+
{
|
|
687
|
+
encoding: "utf8",
|
|
688
|
+
timeout: 5000,
|
|
689
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
690
|
+
windowsHide: true,
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
const pid = Number.parseInt(output.trim().split(/\r?\n/)[0] ?? "", 10);
|
|
694
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
695
|
+
} catch {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function fetchHubHealthForDoctor(
|
|
701
|
+
host,
|
|
702
|
+
port,
|
|
703
|
+
{ fetchImpl = fetch, timeoutMs = 1000 } = {},
|
|
704
|
+
) {
|
|
705
|
+
try {
|
|
706
|
+
const urlHost = String(host || "127.0.0.1").includes(":")
|
|
707
|
+
? `[${host}]`
|
|
708
|
+
: host || "127.0.0.1";
|
|
709
|
+
const response = await fetchImpl(`http://${urlHost}:${port}/health`, {
|
|
710
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
711
|
+
});
|
|
712
|
+
if (!response.ok) return { ok: false, version: null };
|
|
713
|
+
const body = await response.json().catch(() => null);
|
|
714
|
+
return {
|
|
715
|
+
ok: body?.ok === true,
|
|
716
|
+
version: typeof body?.version === "string" ? body.version : null,
|
|
717
|
+
raw: body,
|
|
718
|
+
};
|
|
719
|
+
} catch (error) {
|
|
720
|
+
return { ok: false, version: null, error };
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function readHubPidInfo({
|
|
725
|
+
pidFilePath = DOCTOR_HUB_PID_FILE,
|
|
726
|
+
exists = existsSync,
|
|
727
|
+
readFile = readFileSync,
|
|
728
|
+
} = {}) {
|
|
729
|
+
if (!exists(pidFilePath)) return null;
|
|
730
|
+
try {
|
|
731
|
+
return JSON.parse(readFile(pidFilePath, "utf8"));
|
|
732
|
+
} catch {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function resolveActiveHealthyHub({
|
|
738
|
+
expectedVersion = getVersionHash(),
|
|
739
|
+
pidFilePath = DOCTOR_HUB_PID_FILE,
|
|
740
|
+
exists = existsSync,
|
|
741
|
+
readFile = readFileSync,
|
|
742
|
+
killFn = process.kill,
|
|
743
|
+
platform = process.platform,
|
|
744
|
+
execFile = execFileSync,
|
|
745
|
+
fetchImpl = fetch,
|
|
746
|
+
} = {}) {
|
|
747
|
+
const info = readHubPidInfo({ pidFilePath, exists, readFile });
|
|
748
|
+
const pid = Number(info?.pid);
|
|
749
|
+
const port = parseHubPort(info?.port);
|
|
750
|
+
const host =
|
|
751
|
+
typeof info?.host === "string" && info.host.trim()
|
|
752
|
+
? info.host.trim()
|
|
753
|
+
: "127.0.0.1";
|
|
754
|
+
if (pid && port && isPidAliveForHub(pid, killFn)) {
|
|
755
|
+
const command = queryPidCommand(pid, { platform, execFile });
|
|
756
|
+
const health = await fetchHubHealthForDoctor(host, port, { fetchImpl });
|
|
757
|
+
if (
|
|
758
|
+
isHubServerCommand(command) &&
|
|
759
|
+
health.ok &&
|
|
760
|
+
health.version === expectedVersion
|
|
761
|
+
) {
|
|
762
|
+
return { pid, port, host, version: health.version, source: "pid-file" };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const defaultPid = queryListeningPidByPort(HUB_DEFAULT_PORT, {
|
|
767
|
+
platform,
|
|
768
|
+
execFile,
|
|
769
|
+
});
|
|
770
|
+
if (!defaultPid || !isPidAliveForHub(defaultPid, killFn)) return null;
|
|
771
|
+
const command = queryPidCommand(defaultPid, { platform, execFile });
|
|
772
|
+
if (!isHubServerCommand(command)) return null;
|
|
773
|
+
const health = await fetchHubHealthForDoctor("127.0.0.1", HUB_DEFAULT_PORT, {
|
|
774
|
+
fetchImpl,
|
|
775
|
+
});
|
|
776
|
+
if (!health.ok || health.version !== expectedVersion) return null;
|
|
777
|
+
return {
|
|
778
|
+
pid: defaultPid,
|
|
779
|
+
port: HUB_DEFAULT_PORT,
|
|
780
|
+
host: "127.0.0.1",
|
|
781
|
+
version: health.version,
|
|
782
|
+
source: "default-port",
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export async function inspectDetachedHubProcesses({
|
|
787
|
+
expectedVersion = getVersionHash(),
|
|
788
|
+
pidFilePath = DOCTOR_HUB_PID_FILE,
|
|
789
|
+
exists = existsSync,
|
|
790
|
+
readFile = readFileSync,
|
|
791
|
+
killFn = process.kill,
|
|
792
|
+
platform = process.platform,
|
|
793
|
+
execFile = execFileSync,
|
|
794
|
+
fetchImpl = fetch,
|
|
795
|
+
} = {}) {
|
|
796
|
+
const rows = queryDetachedHubProcessRows({ platform, execFile });
|
|
797
|
+
const activeHealthy = await resolveActiveHealthyHub({
|
|
798
|
+
expectedVersion,
|
|
799
|
+
pidFilePath,
|
|
800
|
+
exists,
|
|
801
|
+
readFile,
|
|
802
|
+
killFn,
|
|
803
|
+
platform,
|
|
804
|
+
execFile,
|
|
805
|
+
fetchImpl,
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const hubs = [];
|
|
809
|
+
for (const row of rows) {
|
|
810
|
+
const ports = queryListeningPortsForPid(row.pid, { platform, execFile });
|
|
811
|
+
const established = queryEstablishedCountForPid(row.pid, {
|
|
812
|
+
platform,
|
|
813
|
+
execFile,
|
|
814
|
+
});
|
|
815
|
+
let version = null;
|
|
816
|
+
let healthy = false;
|
|
817
|
+
for (const port of ports) {
|
|
818
|
+
const health = await fetchHubHealthForDoctor("127.0.0.1", port, {
|
|
819
|
+
fetchImpl,
|
|
820
|
+
});
|
|
821
|
+
if (!health.ok) continue;
|
|
822
|
+
version = health.version;
|
|
823
|
+
healthy = true;
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
hubs.push({
|
|
827
|
+
...row,
|
|
828
|
+
ports,
|
|
829
|
+
established,
|
|
830
|
+
version,
|
|
831
|
+
healthy,
|
|
832
|
+
activeHealthy: activeHealthy?.pid === row.pid,
|
|
833
|
+
staleCandidate: activeHealthy?.pid !== row.pid,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
expectedVersion,
|
|
839
|
+
activeHealthy,
|
|
840
|
+
hubs,
|
|
841
|
+
staleCandidates: hubs.filter((hub) => hub.staleCandidate),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function waitForHubProcessExit(
|
|
846
|
+
pid,
|
|
847
|
+
{ killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
|
|
848
|
+
) {
|
|
849
|
+
const deadline = Date.now() + Math.max(0, graceMs);
|
|
850
|
+
while (Date.now() <= deadline) {
|
|
851
|
+
if (!isPidAliveForHub(pid, killFn)) return true;
|
|
852
|
+
await delay(pollMs);
|
|
853
|
+
}
|
|
854
|
+
return !isPidAliveForHub(pid, killFn);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function retireDetachedHubPid(
|
|
858
|
+
pid,
|
|
859
|
+
{ killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
|
|
860
|
+
) {
|
|
861
|
+
if (!isPidAliveForHub(pid, killFn)) return { ok: true, reason: "dead" };
|
|
862
|
+
try {
|
|
863
|
+
killFn(pid, "SIGTERM");
|
|
864
|
+
} catch (error) {
|
|
865
|
+
return { ok: false, reason: "sigterm_failed", error };
|
|
866
|
+
}
|
|
867
|
+
if (await waitForHubProcessExit(pid, { killFn, graceMs, pollMs })) {
|
|
868
|
+
return { ok: true, reason: "sigterm" };
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
killFn(pid, "SIGKILL");
|
|
872
|
+
} catch (error) {
|
|
873
|
+
return { ok: false, reason: "sigkill_failed", error };
|
|
874
|
+
}
|
|
875
|
+
const exited = await waitForHubProcessExit(pid, {
|
|
876
|
+
killFn,
|
|
877
|
+
graceMs: 1000,
|
|
878
|
+
pollMs,
|
|
879
|
+
});
|
|
880
|
+
return { ok: exited, reason: exited ? "sigkill" : "still_alive" };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export async function cleanupDetachedHubProcesses({
|
|
884
|
+
hubs,
|
|
885
|
+
activeHealthy,
|
|
886
|
+
dryRun = true,
|
|
887
|
+
apply = false,
|
|
888
|
+
killFn = process.kill,
|
|
889
|
+
graceMs = 5000,
|
|
890
|
+
pollMs = 100,
|
|
891
|
+
} = {}) {
|
|
892
|
+
const results = [];
|
|
893
|
+
for (const hub of hubs || []) {
|
|
894
|
+
if (activeHealthy?.pid === hub.pid || hub.activeHealthy) {
|
|
895
|
+
results.push({ pid: hub.pid, action: "excluded-active", ok: true, hub });
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (!apply || dryRun) {
|
|
899
|
+
results.push({ pid: hub.pid, action: "dry-run-skip", ok: true, hub });
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const retired = await retireDetachedHubPid(hub.pid, {
|
|
903
|
+
killFn,
|
|
904
|
+
graceMs,
|
|
905
|
+
pollMs,
|
|
906
|
+
});
|
|
907
|
+
results.push({
|
|
908
|
+
pid: hub.pid,
|
|
909
|
+
action: retired.ok ? "retired" : "failed",
|
|
910
|
+
ok: retired.ok,
|
|
911
|
+
reason: retired.reason,
|
|
912
|
+
hub,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
dryRun: !apply || dryRun,
|
|
917
|
+
results,
|
|
918
|
+
failed: results.filter((result) => result.ok === false).length,
|
|
919
|
+
retired: results.filter((result) => result.action === "retired").length,
|
|
920
|
+
skipped: results.filter((result) => result.action === "dry-run-skip")
|
|
921
|
+
.length,
|
|
922
|
+
excluded: results.filter((result) => result.action === "excluded-active")
|
|
923
|
+
.length,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
501
927
|
function inferExitCode(error) {
|
|
502
928
|
if (Number.isInteger(error?.exitCode)) return error.exitCode;
|
|
503
929
|
if (error?.code === "ENOENT") return EXIT_CLI_MISSING;
|
|
@@ -583,15 +1009,22 @@ function which(cmd) {
|
|
|
583
1009
|
}
|
|
584
1010
|
|
|
585
1011
|
function whichInShell(cmd, shell) {
|
|
1012
|
+
const escapedCmd = cmd.replace(/(["\\$`])/g, "\\$1");
|
|
586
1013
|
const shellArgs = {
|
|
587
1014
|
bash: [
|
|
588
1015
|
"bash",
|
|
589
|
-
[
|
|
1016
|
+
[
|
|
1017
|
+
"-lc",
|
|
1018
|
+
`source ~/.bashrc 2>/dev/null || true; command -v "${escapedCmd}" 2>/dev/null`,
|
|
1019
|
+
],
|
|
590
1020
|
],
|
|
591
1021
|
cmd: ["cmd", ["/c", "where", cmd]],
|
|
592
1022
|
zsh: [
|
|
593
1023
|
"zsh",
|
|
594
|
-
[
|
|
1024
|
+
[
|
|
1025
|
+
"-lc",
|
|
1026
|
+
`source ~/.zshrc 2>/dev/null || true; command -v "${escapedCmd}" 2>/dev/null`,
|
|
1027
|
+
],
|
|
595
1028
|
],
|
|
596
1029
|
pwsh: [
|
|
597
1030
|
"pwsh",
|
|
@@ -626,6 +1059,7 @@ function isDevUpdateRequested(argv = process.argv) {
|
|
|
626
1059
|
function checkShellAvailable(shell) {
|
|
627
1060
|
const cmds = {
|
|
628
1061
|
bash: "bash --version",
|
|
1062
|
+
zsh: "zsh --version",
|
|
629
1063
|
cmd: "cmd /c echo ok",
|
|
630
1064
|
pwsh: "pwsh -NoProfile -c echo ok",
|
|
631
1065
|
};
|
|
@@ -1849,6 +2283,9 @@ async function cmdDoctor(options = {}) {
|
|
|
1849
2283
|
fix = false,
|
|
1850
2284
|
reset = false,
|
|
1851
2285
|
purgeLogs = false,
|
|
2286
|
+
cleanupStaleHubs = false,
|
|
2287
|
+
cleanupStaleHubsDryRun = true,
|
|
2288
|
+
cleanupStaleHubsApply = false,
|
|
1852
2289
|
json = false,
|
|
1853
2290
|
} = options;
|
|
1854
2291
|
const report = {
|
|
@@ -1858,6 +2295,7 @@ async function cmdDoctor(options = {}) {
|
|
|
1858
2295
|
actions: [],
|
|
1859
2296
|
hook_coverage: { total: 0, registered: 0, missing: [] },
|
|
1860
2297
|
fsmonitorDaemons: { stale: 0, killed: 0 },
|
|
2298
|
+
hubServers: { detached: 0, stale: 0, activeHealthy: null },
|
|
1861
2299
|
issue_count: 0,
|
|
1862
2300
|
};
|
|
1863
2301
|
|
|
@@ -2701,6 +3139,7 @@ async function cmdDoctor(options = {}) {
|
|
|
2701
3139
|
|
|
2702
3140
|
for (const n of readdirSync(userSkillsDir)) {
|
|
2703
3141
|
if (!n.startsWith("tfx-")) continue;
|
|
3142
|
+
if (isLocalDevSkillDir(join(userSkillsDir, n))) continue;
|
|
2704
3143
|
if (!pkgSkills.has(n)) staleSkills.push(n);
|
|
2705
3144
|
}
|
|
2706
3145
|
}
|
|
@@ -3216,6 +3655,96 @@ async function cmdDoctor(options = {}) {
|
|
|
3216
3655
|
}
|
|
3217
3656
|
}
|
|
3218
3657
|
|
|
3658
|
+
// 12.4. detached tfx-hub/server.mjs 누적 감지 및 opt-in 정리
|
|
3659
|
+
section("Hub Servers");
|
|
3660
|
+
try {
|
|
3661
|
+
const hubReport = await inspectDetachedHubProcesses();
|
|
3662
|
+
report.hubServers = {
|
|
3663
|
+
detached: hubReport.hubs.length,
|
|
3664
|
+
stale: hubReport.staleCandidates.length,
|
|
3665
|
+
activeHealthy: hubReport.activeHealthy,
|
|
3666
|
+
expectedVersion: hubReport.expectedVersion,
|
|
3667
|
+
hubs: hubReport.hubs.map((hub) => ({
|
|
3668
|
+
pid: hub.pid,
|
|
3669
|
+
ppid: hub.ppid,
|
|
3670
|
+
version: hub.version,
|
|
3671
|
+
uptime: hub.uptime,
|
|
3672
|
+
ports: hub.ports,
|
|
3673
|
+
established: hub.established,
|
|
3674
|
+
rssKb: hub.rssKb,
|
|
3675
|
+
activeHealthy: hub.activeHealthy,
|
|
3676
|
+
staleCandidate: hub.staleCandidate,
|
|
3677
|
+
})),
|
|
3678
|
+
};
|
|
3679
|
+
addDoctorCheck(report, {
|
|
3680
|
+
name: "hub-server-processes",
|
|
3681
|
+
status: hubReport.staleCandidates.length > 0 ? "warning" : "ok",
|
|
3682
|
+
detached: hubReport.hubs.length,
|
|
3683
|
+
stale: hubReport.staleCandidates.length,
|
|
3684
|
+
activeHealthy: hubReport.activeHealthy,
|
|
3685
|
+
});
|
|
3686
|
+
|
|
3687
|
+
if (hubReport.hubs.length === 0) {
|
|
3688
|
+
ok("PPID=1 hub/server.mjs 없음");
|
|
3689
|
+
} else {
|
|
3690
|
+
for (const hub of hubReport.hubs) {
|
|
3691
|
+
const portLabel = hub.ports.length > 0 ? hub.ports.join(",") : "?";
|
|
3692
|
+
const versionLabel = hub.version || "unknown";
|
|
3693
|
+
const rssLabel = hub.rssKb == null ? "?" : `${hub.rssKb}KB`;
|
|
3694
|
+
const activeLabel = hub.activeHealthy
|
|
3695
|
+
? " active healthy excluded"
|
|
3696
|
+
: " stale candidate";
|
|
3697
|
+
const line = `PID=${hub.pid} PPID=${hub.ppid} version=${versionLabel} uptime=${hub.uptime} port=${portLabel} ESTABLISHED=${hub.established} RSS=${rssLabel}${activeLabel}`;
|
|
3698
|
+
if (hub.activeHealthy) ok(line);
|
|
3699
|
+
else warn(line);
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
if (cleanupStaleHubs) {
|
|
3704
|
+
const cleanupResult = await cleanupDetachedHubProcesses({
|
|
3705
|
+
hubs: hubReport.hubs,
|
|
3706
|
+
activeHealthy: hubReport.activeHealthy,
|
|
3707
|
+
dryRun: cleanupStaleHubsDryRun,
|
|
3708
|
+
apply: cleanupStaleHubsApply,
|
|
3709
|
+
});
|
|
3710
|
+
report.actions.push({
|
|
3711
|
+
type: "cleanup-stale-hubs",
|
|
3712
|
+
status: cleanupResult.failed > 0 ? "failed" : "ok",
|
|
3713
|
+
dryRun: cleanupResult.dryRun,
|
|
3714
|
+
retired: cleanupResult.retired,
|
|
3715
|
+
skipped: cleanupResult.skipped,
|
|
3716
|
+
excluded: cleanupResult.excluded,
|
|
3717
|
+
failed: cleanupResult.failed,
|
|
3718
|
+
});
|
|
3719
|
+
for (const result of cleanupResult.results) {
|
|
3720
|
+
if (result.action === "excluded-active") {
|
|
3721
|
+
ok(`active healthy hub excluded: PID=${result.pid}`);
|
|
3722
|
+
} else if (result.action === "dry-run-skip") {
|
|
3723
|
+
info(`dry-run skip stale hub: PID=${result.pid}`);
|
|
3724
|
+
} else if (result.action === "retired") {
|
|
3725
|
+
ok(`stale hub retired: PID=${result.pid} (${result.reason})`);
|
|
3726
|
+
} else {
|
|
3727
|
+
fail(`stale hub cleanup failed: PID=${result.pid}`);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
issues += cleanupResult.failed;
|
|
3731
|
+
if (cleanupResult.dryRun && hubReport.staleCandidates.length > 0) {
|
|
3732
|
+
issues += hubReport.staleCandidates.length;
|
|
3733
|
+
}
|
|
3734
|
+
} else if (hubReport.staleCandidates.length > 0) {
|
|
3735
|
+
info("정리: tfx doctor --cleanup-stale-hubs --dry-run|--apply");
|
|
3736
|
+
issues += hubReport.staleCandidates.length;
|
|
3737
|
+
}
|
|
3738
|
+
} catch (error) {
|
|
3739
|
+
addDoctorCheck(report, {
|
|
3740
|
+
name: "hub-server-processes",
|
|
3741
|
+
status: "warning",
|
|
3742
|
+
error: error.message,
|
|
3743
|
+
});
|
|
3744
|
+
warn(`hub/server.mjs 검사 실패: ${error.message}`);
|
|
3745
|
+
issues++;
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3219
3748
|
// 12.5. 고아 node.exe 프로세스 정리 (Windows)
|
|
3220
3749
|
section("Orphan Processes");
|
|
3221
3750
|
if (process.platform === "win32") {
|
|
@@ -5954,10 +6483,70 @@ async function main() {
|
|
|
5954
6483
|
}
|
|
5955
6484
|
return;
|
|
5956
6485
|
}
|
|
6486
|
+
if (cmdArgs.includes("--dynamic-routing")) {
|
|
6487
|
+
const { diagnoseDynamicRouting } = await import(
|
|
6488
|
+
"../scripts/doctor-dynamic-routing.mjs"
|
|
6489
|
+
);
|
|
6490
|
+
const report = await diagnoseDynamicRouting();
|
|
6491
|
+
if (JSON_OUTPUT) {
|
|
6492
|
+
console.log(JSON.stringify(report, null, 2));
|
|
6493
|
+
} else {
|
|
6494
|
+
const mark = (b) =>
|
|
6495
|
+
b ? `${GREEN_BRIGHT}✓${RESET}` : `${RED}✗${RESET}`;
|
|
6496
|
+
console.log(
|
|
6497
|
+
`\n ${AMBER}${BOLD}⬡ triflux doctor — dynamic routing${RESET}\n`,
|
|
6498
|
+
);
|
|
6499
|
+
console.log(
|
|
6500
|
+
` ${mark(report.enabled)} enabled: ${report.enabled} (TRIFLUX_DYNAMIC_ROUTING=${report.envFlag ?? "미설정"})`,
|
|
6501
|
+
);
|
|
6502
|
+
console.log(
|
|
6503
|
+
` ${mark(report.policyLoaded)} policy loaded: ${report.policyLoaded} (scenarios=${report.policyScenarios.length})`,
|
|
6504
|
+
);
|
|
6505
|
+
console.log(
|
|
6506
|
+
` ${mark(report.snapshotCached)} snapshot cache: ${
|
|
6507
|
+
report.snapshotCached
|
|
6508
|
+
? `hit (age=${report.snapshotAgeMs}ms)`
|
|
6509
|
+
: "miss"
|
|
6510
|
+
}`,
|
|
6511
|
+
);
|
|
6512
|
+
if (report.previewDecision) {
|
|
6513
|
+
const d = report.previewDecision;
|
|
6514
|
+
console.log(
|
|
6515
|
+
` ${GREEN_BRIGHT}→${RESET} preview decision: scenario=${d.scenario}, mode=${d.mode}, lane=${d.lane}, shards[0].cli=${d.shards?.[0]?.cli ?? "?"}`,
|
|
6516
|
+
);
|
|
6517
|
+
}
|
|
6518
|
+
if (report.error) {
|
|
6519
|
+
console.log(` ${RED}✗${RESET} error: ${report.error}`);
|
|
6520
|
+
}
|
|
6521
|
+
console.log("");
|
|
6522
|
+
}
|
|
6523
|
+
return;
|
|
6524
|
+
}
|
|
5957
6525
|
const fix = cmdArgs.includes("--fix");
|
|
5958
6526
|
const reset = cmdArgs.includes("--reset");
|
|
5959
6527
|
const purgeLogs = cmdArgs.includes("--purge-logs");
|
|
5960
|
-
|
|
6528
|
+
const cleanupStaleHubs = cmdArgs.includes("--cleanup-stale-hubs");
|
|
6529
|
+
const cleanupApply = cmdArgs.includes("--apply");
|
|
6530
|
+
const cleanupDryRun = cmdArgs.includes("--dry-run") || !cleanupApply;
|
|
6531
|
+
if (cleanupStaleHubs && cleanupApply && cmdArgs.includes("--dry-run")) {
|
|
6532
|
+
throw createCliError(
|
|
6533
|
+
"--cleanup-stale-hubs 에서는 --dry-run 과 --apply 중 하나만 지정하세요",
|
|
6534
|
+
{
|
|
6535
|
+
exitCode: EXIT_ARG_ERROR,
|
|
6536
|
+
reason: "argError",
|
|
6537
|
+
fix: "tfx doctor --cleanup-stale-hubs --dry-run",
|
|
6538
|
+
},
|
|
6539
|
+
);
|
|
6540
|
+
}
|
|
6541
|
+
await cmdDoctor({
|
|
6542
|
+
fix,
|
|
6543
|
+
reset,
|
|
6544
|
+
purgeLogs,
|
|
6545
|
+
cleanupStaleHubs,
|
|
6546
|
+
cleanupStaleHubsDryRun: cleanupDryRun,
|
|
6547
|
+
cleanupStaleHubsApply: cleanupApply,
|
|
6548
|
+
json: JSON_OUTPUT,
|
|
6549
|
+
});
|
|
5961
6550
|
return;
|
|
5962
6551
|
}
|
|
5963
6552
|
case "mcp":
|
|
@@ -6231,8 +6820,20 @@ ${s.options.map((o) => ` ${DIM}${o.name.padEnd(16)}${RESET} ${GRAY}${o.descri
|
|
|
6231
6820
|
}
|
|
6232
6821
|
}
|
|
6233
6822
|
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6823
|
+
function isMainModule() {
|
|
6824
|
+
if (!process.argv[1]) return false;
|
|
6825
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
6826
|
+
try {
|
|
6827
|
+
return realpathSync(process.argv[1]) === modulePath;
|
|
6828
|
+
} catch {
|
|
6829
|
+
return resolve(process.argv[1]) === modulePath;
|
|
6830
|
+
}
|
|
6831
|
+
}
|
|
6832
|
+
|
|
6833
|
+
if (isMainModule()) {
|
|
6834
|
+
try {
|
|
6835
|
+
await main();
|
|
6836
|
+
} catch (error) {
|
|
6837
|
+
handleFatalError(error, { json: JSON_OUTPUT });
|
|
6838
|
+
}
|
|
6238
6839
|
}
|