triflux 8.12.2 → 9.0.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.
Files changed (49) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/bin/triflux.mjs +64 -0
  4. package/hub/team/backend.mjs +2 -1
  5. package/hub/team/cli/commands/start/index.mjs +2 -2
  6. package/hub/team/cli/commands/start/parse-args.mjs +10 -0
  7. package/hub/workers/delegator-mcp.mjs +2 -5
  8. package/package.json +1 -1
  9. package/scripts/cache-buildup.mjs +24 -395
  10. package/scripts/cache-doctor.mjs +149 -0
  11. package/scripts/cache-warmup.mjs +514 -0
  12. package/scripts/cross-review-gate.mjs +180 -0
  13. package/scripts/cross-review-tracker.mjs +279 -0
  14. package/scripts/headless-guard.mjs +38 -0
  15. package/scripts/lib/env-probe.mjs +130 -0
  16. package/scripts/lib/mcp-filter.mjs +730 -720
  17. package/scripts/lib/mcp-manifest.mjs +79 -0
  18. package/scripts/mcp-gateway-config.mjs +104 -7
  19. package/scripts/mcp-gateway-start.mjs +7 -0
  20. package/scripts/mcp-gateway-verify.mjs +15 -1
  21. package/scripts/preflight-cache.mjs +68 -137
  22. package/scripts/session-spawn-helper.mjs +184 -0
  23. package/scripts/setup.mjs +7 -8
  24. package/scripts/tfx-route-worker.mjs +59 -1
  25. package/skills/merge-worktree/SKILL.md +144 -0
  26. package/skills/tfx-analysis/SKILL.md +1 -0
  27. package/skills/tfx-auto/SKILL.md +1 -0
  28. package/skills/tfx-auto-codex/SKILL.md +1 -0
  29. package/skills/tfx-autopilot/SKILL.md +1 -2
  30. package/skills/tfx-codex/SKILL.md +2 -0
  31. package/skills/tfx-codex-swarm/SKILL.md +62 -18
  32. package/skills/tfx-codex-swarm/mcp-daemon/start-daemons.ps1 +54 -0
  33. package/skills/tfx-codex-swarm/mcp-daemon/stop-daemons.ps1 +15 -0
  34. package/skills/tfx-consensus/SKILL.md +1 -0
  35. package/skills/tfx-deep-analysis/SKILL.md +1 -0
  36. package/skills/tfx-deep-plan/SKILL.md +1 -0
  37. package/skills/tfx-deep-qa/SKILL.md +1 -0
  38. package/skills/tfx-deep-research/SKILL.md +1 -0
  39. package/skills/tfx-deep-review/SKILL.md +1 -0
  40. package/skills/tfx-doctor/SKILL.md +5 -0
  41. package/skills/tfx-gemini/SKILL.md +1 -0
  42. package/skills/tfx-hub/SKILL.md +1 -0
  43. package/skills/tfx-multi/SKILL.md +1 -0
  44. package/skills/tfx-plan/SKILL.md +1 -0
  45. package/skills/tfx-qa/SKILL.md +1 -0
  46. package/skills/tfx-ralph/SKILL.md +2 -5
  47. package/skills/tfx-research/SKILL.md +1 -0
  48. package/skills/tfx-review/SKILL.md +2 -0
  49. package/skills/tfx-setup/SKILL.md +182 -7
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ const SESSION_TTL_SEC = 30 * 60;
7
+ const STATE_REL_PATH = join(".omc", "state", "cross-review.json");
8
+
9
+ function readStdin() {
10
+ return new Promise((resolve) => {
11
+ let raw = "";
12
+ process.stdin.setEncoding("utf8");
13
+ process.stdin.on("data", (chunk) => {
14
+ raw += chunk;
15
+ });
16
+ process.stdin.on("end", () => resolve(raw));
17
+ process.stdin.on("error", () => resolve(""));
18
+ });
19
+ }
20
+
21
+ function parseJson(raw) {
22
+ try {
23
+ return JSON.parse(raw);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function nowSec() {
30
+ return Math.floor(Date.now() / 1000);
31
+ }
32
+
33
+ function resolveBaseDir(payload) {
34
+ if (typeof payload?.cwd === "string" && payload.cwd.trim()) return payload.cwd;
35
+ if (typeof payload?.directory === "string" && payload.directory.trim()) return payload.directory;
36
+ return process.cwd();
37
+ }
38
+
39
+ function expectedReviewer(author) {
40
+ if (author === "claude") return "codex";
41
+ if (author === "codex") return "claude";
42
+ if (author === "gemini") return "claude";
43
+ return "";
44
+ }
45
+
46
+ function shouldTrackPath(filePath) {
47
+ if (typeof filePath !== "string" || !filePath.trim()) return false;
48
+
49
+ const lower = filePath.toLowerCase();
50
+ if (lower.startsWith(".omc/") || lower.startsWith(".claude/")) return false;
51
+ if (lower === "package-lock.json" || lower.endsWith("/package-lock.json")) return false;
52
+ if (/\.(md|lock|yml|yaml)$/i.test(lower)) return false;
53
+ return true;
54
+ }
55
+
56
+ function loadState(statePath) {
57
+ if (!existsSync(statePath)) return null;
58
+
59
+ try {
60
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
61
+ const startedAt = Number(state?.session_start || 0);
62
+ const expired = !startedAt || nowSec() - startedAt > SESSION_TTL_SEC;
63
+ if (expired) {
64
+ try {
65
+ unlinkSync(statePath);
66
+ } catch {}
67
+ return null;
68
+ }
69
+ return state;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function isGitCommitCommand(command) {
76
+ if (typeof command !== "string") return false;
77
+ return /\bgit\s+commit\b/i.test(command);
78
+ }
79
+
80
+ function nudge(message) {
81
+ process.stdout.write(JSON.stringify({
82
+ hookSpecificOutput: {
83
+ hookEventName: "PreToolUse",
84
+ additionalContext: message,
85
+ },
86
+ }));
87
+ process.exit(0);
88
+ }
89
+
90
+ function deny(message) {
91
+ process.stderr.write(message);
92
+ process.exit(2);
93
+ }
94
+
95
+ function summarizePending(entries) {
96
+ return entries
97
+ .map((item) => {
98
+ const reviewer = item.expectedReviewer || "cross-reviewer";
99
+ return ` * ${item.path} (author=${item.author}, reviewer=${reviewer})`;
100
+ })
101
+ .join("\n");
102
+ }
103
+
104
+ async function main() {
105
+ if (process.env.TFX_SKIP_CROSS_REVIEW === "1") {
106
+ process.exit(0);
107
+ }
108
+
109
+ const raw = await readStdin();
110
+ if (!raw.trim()) process.exit(0);
111
+
112
+ const payload = parseJson(raw);
113
+ if (!payload) process.exit(0);
114
+
115
+ const toolName = payload.tool_name || "";
116
+ const toolInput = payload.tool_input || {};
117
+
118
+ if (toolName !== "Bash") process.exit(0);
119
+ if (!isGitCommitCommand(toolInput.command || "")) process.exit(0);
120
+
121
+ // cwd 전파: tracker와 동일한 resolveBaseDir 사용
122
+ const baseDir = resolveBaseDir(payload);
123
+ const statePath = join(baseDir, STATE_REL_PATH);
124
+
125
+ const state = loadState(statePath);
126
+ if (!state || !state.files || typeof state.files !== "object") process.exit(0);
127
+
128
+ const pending = [];
129
+ const selfApproved = [];
130
+
131
+ for (const [path, info] of Object.entries(state.files)) {
132
+ if (!shouldTrackPath(path)) continue;
133
+ const meta = info && typeof info === "object" ? info : {};
134
+ const author = String(meta.author || "").toLowerCase();
135
+ const reviewer = String(meta.reviewer || "").toLowerCase();
136
+ const reviewed = meta.reviewed === true;
137
+ const requiredReviewer = expectedReviewer(author);
138
+
139
+ // tracker가 설정한 self_approved 플래그 명시적 체크
140
+ if (meta.self_approved === true) {
141
+ selfApproved.push({ path, author, reviewer: meta.reviewer || author, expectedReviewer: requiredReviewer });
142
+ continue;
143
+ }
144
+
145
+ if (reviewed && reviewer && reviewer === author) {
146
+ selfApproved.push({ path, author, reviewer, expectedReviewer: requiredReviewer });
147
+ continue;
148
+ }
149
+
150
+ if (reviewed && requiredReviewer && reviewer && reviewer !== requiredReviewer) {
151
+ selfApproved.push({ path, author, reviewer, expectedReviewer: requiredReviewer });
152
+ continue;
153
+ }
154
+
155
+ if (!reviewed) {
156
+ pending.push({ path, author, expectedReviewer: requiredReviewer });
157
+ }
158
+ }
159
+
160
+ if (selfApproved.length > 0) {
161
+ const lines = selfApproved
162
+ .map((item) => ` * ${item.path} (author=${item.author}, reviewer=${item.reviewer}, required=${item.expectedReviewer || "n/a"})`)
163
+ .join("\n");
164
+ deny(
165
+ `[cross-review] self-approve 차단: 동일/비허용 reviewer가 감지되었습니다.\n${lines}\n` +
166
+ "규칙: author=claude -> reviewer=codex, author=codex -> reviewer=claude",
167
+ );
168
+ }
169
+
170
+ if (pending.length > 0) {
171
+ nudge(
172
+ `[cross-review] git commit 전에 교차 검증이 필요합니다.\n${summarizePending(pending)}\n` +
173
+ "규칙: author=claude -> reviewer=codex, author=codex -> reviewer=claude",
174
+ );
175
+ }
176
+
177
+ process.exit(0);
178
+ }
179
+
180
+ main().catch(() => process.exit(0));
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, relative } from "node:path";
5
+
6
+ const SESSION_TTL_SEC = 30 * 60;
7
+ const STATE_REL_PATH = join(".omc", "state", "cross-review.json");
8
+ const EXCLUDED_FILE_PATTERN = /\.(md|lock|yml|yaml)$/i;
9
+
10
+ function nowSec() {
11
+ return Math.floor(Date.now() / 1000);
12
+ }
13
+
14
+ function readStdin() {
15
+ return new Promise((resolve) => {
16
+ let raw = "";
17
+ process.stdin.setEncoding("utf8");
18
+ process.stdin.on("data", (chunk) => {
19
+ raw += chunk;
20
+ });
21
+ process.stdin.on("end", () => resolve(raw));
22
+ process.stdin.on("error", () => resolve(""));
23
+ });
24
+ }
25
+
26
+ function parseJson(raw) {
27
+ try {
28
+ return JSON.parse(raw);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function resolveBaseDir(payload) {
35
+ if (typeof payload?.cwd === "string" && payload.cwd.trim()) return payload.cwd;
36
+ if (typeof payload?.directory === "string" && payload.directory.trim()) return payload.directory;
37
+ return process.cwd();
38
+ }
39
+
40
+ function resolveStatePath(baseDir) {
41
+ return join(baseDir, STATE_REL_PATH);
42
+ }
43
+
44
+ function createEmptyState() {
45
+ return {
46
+ files: {},
47
+ session_start: nowSec(),
48
+ };
49
+ }
50
+
51
+ function loadState(statePath) {
52
+ if (!existsSync(statePath)) return createEmptyState();
53
+
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(statePath, "utf8"));
56
+ const sessionStart = Number(parsed?.session_start || 0);
57
+ const expired = !sessionStart || nowSec() - sessionStart > SESSION_TTL_SEC;
58
+ if (expired) {
59
+ try {
60
+ unlinkSync(statePath);
61
+ } catch {}
62
+ return createEmptyState();
63
+ }
64
+
65
+ return {
66
+ files: parsed?.files && typeof parsed.files === "object" ? parsed.files : {},
67
+ session_start: sessionStart,
68
+ };
69
+ } catch {
70
+ return createEmptyState();
71
+ }
72
+ }
73
+
74
+ function saveState(statePath, state) {
75
+ mkdirSync(dirname(statePath), { recursive: true });
76
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
77
+ }
78
+
79
+ function normalizePath(filePath, baseDir) {
80
+ if (typeof filePath !== "string" || !filePath.trim()) return "";
81
+
82
+ const raw = filePath.trim();
83
+ let normalized = raw;
84
+
85
+ if (isAbsolute(raw)) {
86
+ const relPath = relative(baseDir, raw);
87
+ if (relPath.startsWith("..")) return "";
88
+ normalized = relPath;
89
+ }
90
+
91
+ return normalized.replace(/\\/g, "/").replace(/^\.\//, "");
92
+ }
93
+
94
+ function shouldTrackPath(filePath) {
95
+ if (!filePath) return false;
96
+ const lower = filePath.toLowerCase();
97
+
98
+ if (lower.startsWith(".omc/") || lower.startsWith(".claude/")) return false;
99
+ if (lower === "package-lock.json" || lower.endsWith("/package-lock.json")) return false;
100
+ if (EXCLUDED_FILE_PATTERN.test(lower)) return false;
101
+ return true;
102
+ }
103
+
104
+ function extractFilePath(toolInput) {
105
+ if (!toolInput || typeof toolInput !== "object") return "";
106
+ const candidate = toolInput.file_path ?? toolInput.path ?? toolInput.filePath ?? "";
107
+ return typeof candidate === "string" ? candidate : "";
108
+ }
109
+
110
+ function extractCandidatePaths(payload, baseDir) {
111
+ const candidates = new Set();
112
+
113
+ const looksLikePath = (value) => {
114
+ if (typeof value !== "string") return false;
115
+ const trimmed = value.trim();
116
+ if (!trimmed || /\s/.test(trimmed)) return false;
117
+ if (trimmed.length > 260) return false;
118
+ if (!trimmed.includes(".") && !trimmed.includes("/") && !trimmed.includes("\\")) return false;
119
+ return /^[./\\A-Za-z0-9_-]/.test(trimmed);
120
+ };
121
+
122
+ const addPath = (value) => {
123
+ if (!looksLikePath(value)) return;
124
+ const normalized = normalizePath(value, baseDir);
125
+ if (shouldTrackPath(normalized)) candidates.add(normalized);
126
+ };
127
+
128
+ const scanValue = (value, depth = 0) => {
129
+ if (depth > 3 || value == null) return;
130
+ if (typeof value === "string") {
131
+ addPath(value);
132
+ return;
133
+ }
134
+ if (Array.isArray(value)) {
135
+ for (const item of value) scanValue(item, depth + 1);
136
+ return;
137
+ }
138
+ if (typeof value !== "object") return;
139
+
140
+ for (const [key, child] of Object.entries(value)) {
141
+ const keyLower = key.toLowerCase();
142
+ if (keyLower.includes("file") || keyLower.includes("path")) {
143
+ scanValue(child, depth + 1);
144
+ }
145
+ }
146
+ };
147
+
148
+ addPath(extractFilePath(payload?.tool_input));
149
+
150
+ scanValue(payload?.tool_response);
151
+ scanValue(payload?.tool_output);
152
+ scanValue(payload?.result);
153
+ scanValue(payload?.output);
154
+
155
+ return [...candidates];
156
+ }
157
+
158
+ function collectStrings(value, out = [], depth = 0) {
159
+ if (depth > 4) return out;
160
+ if (typeof value === "string") {
161
+ out.push(value);
162
+ return out;
163
+ }
164
+ if (!value || typeof value !== "object") return out;
165
+ if (Array.isArray(value)) {
166
+ for (const item of value) collectStrings(item, out, depth + 1);
167
+ return out;
168
+ }
169
+
170
+ for (const key of Object.keys(value)) {
171
+ collectStrings(value[key], out, depth + 1);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ function detectCliActor(payload) {
177
+ const lines = collectStrings(payload).join("\n");
178
+ const match = lines.match(/\bcli\s*[:=]\s*(claude|codex|gemini)\b/i);
179
+ return match ? match[1].toLowerCase() : "";
180
+ }
181
+
182
+ function detectAuthor(payload) {
183
+ const actor = detectCliActor(payload);
184
+ if (actor) return actor;
185
+ return "claude";
186
+ }
187
+
188
+ function expectedReviewer(author) {
189
+ if (author === "claude") return "codex";
190
+ if (author === "codex") return "claude";
191
+ if (author === "gemini") return "claude";
192
+ return "";
193
+ }
194
+
195
+ function applyReviewer(state, reviewer, ts) {
196
+ for (const [filePath, meta] of Object.entries(state.files)) {
197
+ if (!meta || typeof meta !== "object") continue;
198
+ if (!shouldTrackPath(filePath)) continue;
199
+
200
+ const author = String(meta.author || "").toLowerCase();
201
+ const expected = expectedReviewer(author);
202
+
203
+ if (expected && reviewer === expected) {
204
+ meta.reviewed = true;
205
+ meta.reviewer = reviewer;
206
+ meta.reviewed_ts = ts;
207
+ delete meta.self_approved;
208
+ continue;
209
+ }
210
+
211
+ if (reviewer === author) {
212
+ meta.reviewed = false;
213
+ meta.reviewer = reviewer;
214
+ meta.reviewed_ts = ts;
215
+ meta.self_approved = true;
216
+ }
217
+ }
218
+ }
219
+
220
+ async function main() {
221
+ if (process.env.TFX_SKIP_CROSS_REVIEW === "1") {
222
+ process.exit(0);
223
+ }
224
+
225
+ const raw = await readStdin();
226
+ if (!raw.trim()) {
227
+ process.exit(0);
228
+ }
229
+
230
+ const payload = parseJson(raw);
231
+ if (!payload) {
232
+ process.exit(0);
233
+ }
234
+
235
+ const baseDir = resolveBaseDir(payload);
236
+ const statePath = resolveStatePath(baseDir);
237
+ const state = loadState(statePath);
238
+ const toolName = payload.tool_name || "";
239
+ const ts = nowSec();
240
+ let changed = false;
241
+
242
+ if (toolName === "Edit" || toolName === "Write") {
243
+ const toolInput = payload.tool_input || {};
244
+ const normalizedPath = normalizePath(extractFilePath(toolInput), baseDir);
245
+ if (shouldTrackPath(normalizedPath)) {
246
+ state.files[normalizedPath] = {
247
+ author: detectAuthor(payload),
248
+ ts,
249
+ reviewed: false,
250
+ };
251
+ changed = true;
252
+ }
253
+ } else if (toolName === "Bash") {
254
+ const actor = detectCliActor(payload);
255
+ if (actor) {
256
+ const paths = extractCandidatePaths(payload, baseDir);
257
+ if (paths.length > 0) {
258
+ for (const path of paths) {
259
+ state.files[path] = {
260
+ author: actor,
261
+ ts,
262
+ reviewed: false,
263
+ };
264
+ }
265
+ } else {
266
+ applyReviewer(state, actor, ts);
267
+ }
268
+ changed = true;
269
+ }
270
+ }
271
+
272
+ if (changed) {
273
+ saveState(statePath, state);
274
+ }
275
+
276
+ process.exit(0);
277
+ }
278
+
279
+ main().catch(() => process.exit(0));
@@ -257,6 +257,44 @@ async function main() {
257
257
  }
258
258
  }
259
259
 
260
+ // ── Edit/Write: tfx-multi 코드 수정 게이트 ──
261
+ if (toolName === "Edit" || toolName === "Write") {
262
+ const multiState = readMultiState();
263
+
264
+ // 일반 모드(tfx-multi 비활성)에서는 기존처럼 통과
265
+ if (!multiState || !multiState.active) {
266
+ process.exit(0);
267
+ }
268
+
269
+ if (!multiState.dispatched) {
270
+ multiState.nativeWorkCalls = (multiState.nativeWorkCalls || 0) + 1;
271
+ writeMultiState(multiState);
272
+
273
+ if (multiState.nativeWorkCalls > GATE_THRESHOLD) {
274
+ deny(
275
+ `[headless-guard] tfx-multi gate: ${toolName} 호출 ${multiState.nativeWorkCalls}회 — headless dispatch 먼저 하세요.\n` +
276
+ 'Bash("tfx multi --teammate-mode headless --auto-attach --dashboard --assign \'codex:프롬프트:역할\' --timeout 600")',
277
+ );
278
+ }
279
+
280
+ nudge(
281
+ `[headless-guard] tfx-multi 활성 (${multiState.nativeWorkCalls}/${GATE_THRESHOLD}). ` +
282
+ "headless dispatch 후 작업을 시작하세요.",
283
+ );
284
+ }
285
+
286
+ // H3 fix: Agent gate와 동일하게 NUDGE_THRESHOLD 기반 주기적 nudge
287
+ multiState.nativeWorkCallsSinceDispatch = (multiState.nativeWorkCallsSinceDispatch || 0) + 1;
288
+ writeMultiState(multiState);
289
+
290
+ if (multiState.nativeWorkCallsSinceDispatch >= NUDGE_THRESHOLD) {
291
+ multiState.nativeWorkCallsSinceDispatch = 0;
292
+ writeMultiState(multiState);
293
+ nudge("[headless-guard] nudge: headless 워커가 코드 수정 중. 직접 수정은 충돌 위험.");
294
+ }
295
+ process.exit(0); // threshold 미만이면 조용히 통과
296
+ }
297
+
260
298
  // ── Agent: A(gate) + B(nudge) + CLI 래핑 deny ──
261
299
  if (toolName === "Agent") {
262
300
  const subType = (toolInput.subagent_type || "").toLowerCase();
@@ -0,0 +1,130 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { execSync, spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const DEFAULT_STATUS_URL = "http://127.0.0.1:27888/status";
8
+ const _sab = new Int32Array(new SharedArrayBuffer(4));
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const DEFAULT_PKG_ROOT = join(dirname(__filename), "..", "..");
12
+
13
+ function sleepSync(ms) {
14
+ Atomics.wait(_sab, 0, 0, ms);
15
+ }
16
+
17
+ function fetchHubStatus({
18
+ execSyncFn = execSync,
19
+ statusUrl = DEFAULT_STATUS_URL,
20
+ timeout = 3000,
21
+ } = {}) {
22
+ const response = execSyncFn(`curl -sf ${statusUrl}`, {
23
+ timeout,
24
+ encoding: "utf8",
25
+ windowsHide: true,
26
+ });
27
+ const data = JSON.parse(response);
28
+ return {
29
+ ok: true,
30
+ state: data?.hub?.state || "unknown",
31
+ pid: data?.pid,
32
+ };
33
+ }
34
+
35
+ export function checkCli(name, { execSyncFn = execSync } = {}) {
36
+ const command = process.platform === "win32"
37
+ ? `where ${name} 2>nul`
38
+ : `which ${name} 2>/dev/null`;
39
+ try {
40
+ const path = execSyncFn(command, {
41
+ encoding: "utf8",
42
+ timeout: 2000,
43
+ windowsHide: true,
44
+ }).trim();
45
+ return { ok: !!path, path };
46
+ } catch {
47
+ return { ok: false };
48
+ }
49
+ }
50
+
51
+ export function detectCodexPlan({
52
+ homeDir = homedir(),
53
+ existsSyncFn = existsSync,
54
+ readFileSyncFn = readFileSync,
55
+ } = {}) {
56
+ try {
57
+ const authPath = join(homeDir, ".codex", "auth.json");
58
+ if (!existsSyncFn(authPath)) return { plan: "unknown", source: "no_auth" };
59
+
60
+ const auth = JSON.parse(readFileSyncFn(authPath, "utf8"));
61
+ if (auth.auth_mode !== "chatgpt") return { plan: "api", source: "api_key" };
62
+
63
+ const token = auth.tokens?.id_token || auth.tokens?.access_token;
64
+ if (!token) return { plan: "unknown", source: "no_token" };
65
+
66
+ const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
67
+ const plan = payload?.["https://api.openai.com/auth"]?.chatgpt_plan_type || "unknown";
68
+ return { plan, source: "jwt" };
69
+ } catch {
70
+ return { plan: "unknown", source: "error" };
71
+ }
72
+ }
73
+
74
+ export function checkHub({
75
+ pkgRoot = DEFAULT_PKG_ROOT,
76
+ statusUrl = DEFAULT_STATUS_URL,
77
+ restart = true,
78
+ requestTimeoutMs = 3000,
79
+ pollAttempts = 8,
80
+ pollIntervalMs = 500,
81
+ execSyncFn = execSync,
82
+ spawnFn = spawn,
83
+ existsSyncFn = existsSync,
84
+ sleepSyncFn = sleepSync,
85
+ } = {}) {
86
+ try {
87
+ return fetchHubStatus({
88
+ execSyncFn,
89
+ statusUrl,
90
+ timeout: requestTimeoutMs,
91
+ });
92
+ } catch {}
93
+
94
+ if (!restart) return { ok: false, state: "unreachable", restart: "disabled" };
95
+
96
+ const serverPath = join(pkgRoot, "hub", "server.mjs");
97
+ if (!existsSyncFn(serverPath)) return { ok: false, state: "unreachable", restart: "no_server" };
98
+
99
+ try {
100
+ const child = spawnFn(process.execPath, [serverPath], {
101
+ detached: true,
102
+ stdio: "ignore",
103
+ windowsHide: true,
104
+ });
105
+ child.unref();
106
+ } catch {
107
+ return { ok: false, state: "unreachable", restart: "spawn_failed" };
108
+ }
109
+
110
+ for (let i = 0; i < pollAttempts; i++) {
111
+ sleepSyncFn(pollIntervalMs);
112
+ try {
113
+ const status = fetchHubStatus({
114
+ execSyncFn,
115
+ statusUrl,
116
+ timeout: Math.min(requestTimeoutMs, 1000),
117
+ });
118
+ if (status.state === "healthy") {
119
+ return { ...status, restarted: true };
120
+ }
121
+ } catch {}
122
+ }
123
+
124
+ return { ok: false, state: "unreachable", restart: "timeout" };
125
+ }
126
+
127
+ export {
128
+ DEFAULT_PKG_ROOT,
129
+ DEFAULT_STATUS_URL,
130
+ };