triflux 10.31.0 → 10.32.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.
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { argv, exit, stdin, stdout } from "node:process";
4
+ import { pathToFileURL } from "node:url";
5
+ import { drainPendingSynapse as defaultDrainPendingSynapse } from "../hub/team/synapse-http.mjs";
6
+ import {
7
+ heartbeatInteractiveSession as defaultHeartbeatInteractiveSession,
8
+ registerInteractiveSession as defaultRegisterInteractiveSession,
9
+ } from "./session-start-fast.mjs";
10
+
11
+ // hub-ensure is loaded lazily so the byte-identical packages/core mirror of this
12
+ // file loads cleanly. packages/core mirrors scripts/lib only (not scripts/*), so a
13
+ // static `../scripts/hub-ensure.mjs` import would make the core copy throw
14
+ // ERR_MODULE_NOT_FOUND at load time. The core copy is published-but-dormant — the
15
+ // live codex hook always runs from the installed triflux package where the path
16
+ // resolves. This mirrors session-start-fast.mjs's dynamic script-import pattern.
17
+ async function defaultHubEnsureRun(stdinData) {
18
+ const { run } = await import(
19
+ new URL("../scripts/hub-ensure.mjs", import.meta.url).href
20
+ );
21
+ return run(stdinData);
22
+ }
23
+
24
+ function parsePayload(stdinData) {
25
+ try {
26
+ const raw = typeof stdinData === "string" ? stdinData : "";
27
+ return raw.trim()
28
+ ? { ok: true, payload: JSON.parse(raw) }
29
+ : { ok: false, payload: {} };
30
+ } catch {
31
+ return { ok: false, payload: {} };
32
+ }
33
+ }
34
+
35
+ function normalizeMode(mode, payload) {
36
+ const direct = String(mode || "")
37
+ .trim()
38
+ .toLowerCase();
39
+ if (direct === "register" || direct === "heartbeat") return direct;
40
+
41
+ const eventName = String(payload?.hook_event_name || "")
42
+ .trim()
43
+ .toLowerCase()
44
+ .replace(/-/g, "_");
45
+ if (eventName === "session_start" || eventName === "sessionstart") {
46
+ return "register";
47
+ }
48
+ if (eventName === "user_prompt_submit" || eventName === "userpromptsubmit") {
49
+ return "heartbeat";
50
+ }
51
+ return "";
52
+ }
53
+
54
+ export async function runCodexSessionHook(stdinData, opts = {}) {
55
+ const output = "{}\n";
56
+ const parsed = parsePayload(stdinData);
57
+ const mode = parsed.ok
58
+ ? normalizeMode(opts.argvMode ?? argv[2], parsed.payload)
59
+ : "";
60
+ const hubEnsureRun = opts.hubEnsureRun || defaultHubEnsureRun;
61
+ const registerInteractiveSession =
62
+ opts.registerInteractiveSession || defaultRegisterInteractiveSession;
63
+ const heartbeatInteractiveSession =
64
+ opts.heartbeatInteractiveSession || defaultHeartbeatInteractiveSession;
65
+ const drainPendingSynapse =
66
+ opts.drainPendingSynapse || defaultDrainPendingSynapse;
67
+
68
+ try {
69
+ if (mode === "register") {
70
+ try {
71
+ await hubEnsureRun(stdinData);
72
+ } catch {}
73
+ try {
74
+ registerInteractiveSession(stdinData);
75
+ } catch {}
76
+ try {
77
+ await drainPendingSynapse(1000);
78
+ } catch {}
79
+ } else if (mode === "heartbeat") {
80
+ try {
81
+ heartbeatInteractiveSession(stdinData);
82
+ } catch {}
83
+ try {
84
+ await drainPendingSynapse(500);
85
+ } catch {}
86
+ }
87
+ } catch {
88
+ // Codex session hooks are observational and must never block the session.
89
+ }
90
+
91
+ if (opts.writeStdout !== false) {
92
+ stdout.write(output);
93
+ }
94
+ return output;
95
+ }
96
+
97
+ function readStdin() {
98
+ return new Promise((resolve) => {
99
+ let data = "";
100
+ stdin.setEncoding("utf8");
101
+ stdin.on("data", (chunk) => {
102
+ data += chunk;
103
+ });
104
+ stdin.on("end", () => resolve(data));
105
+ stdin.on("error", () => resolve(data));
106
+ });
107
+ }
108
+
109
+ if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) {
110
+ const stdinData = await readStdin();
111
+ await runCodexSessionHook(stdinData);
112
+ exit(0);
113
+ }
package/hub/server.mjs CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  CallToolRequestSchema,
23
23
  ListToolsRequestSchema,
24
24
  } from "@modelcontextprotocol/sdk/types.js";
25
+ import { runCollect } from "../cto/collect.mjs";
25
26
  import { createModuleLogger } from "../scripts/lib/logger.mjs";
26
27
  import { broker as brokerInstance, reloadBroker } from "./account-broker.mjs";
27
28
  import { createAdaptiveEngine } from "./adaptive.mjs";
@@ -53,6 +54,7 @@ import {
53
54
  writeState,
54
55
  } from "./state.mjs";
55
56
  import { createStoreAdapter } from "./store-adapter.mjs";
57
+ import { createCtoAutoCollector } from "./team/cto-auto-collect.mjs";
56
58
  import { createGitPreflight } from "./team/git-preflight.mjs";
57
59
  import { nativeProxy } from "./team/nativeProxy.mjs";
58
60
  import { createSwarmLocks } from "./team/swarm-locks.mjs";
@@ -1009,10 +1011,16 @@ export async function startHub({
1009
1011
  registry: synapseRegistry,
1010
1012
  locks: swarmLocks,
1011
1013
  });
1014
+ const ctoAutoCollector = createCtoAutoCollector({
1015
+ registry: synapseRegistry,
1016
+ runCollect,
1017
+ logger: hubLog,
1018
+ });
1012
1019
 
1013
1020
  // Synapse Layer 5: emitter subscribers — bridge events to hub logging
1014
- synapseEmitter.on("synapse.session.started", ({ sessionId }) => {
1021
+ synapseEmitter.on("synapse.session.started", ({ sessionId, session }) => {
1015
1022
  hubLog.info({ sessionId }, "synapse.session.started");
1023
+ ctoAutoCollector.handleSessionStarted({ sessionId, session });
1016
1024
  });
1017
1025
  synapseEmitter.on("synapse.session.heartbeat", ({ sessionId }) => {
1018
1026
  hubLog.debug({ sessionId }, "synapse.session.heartbeat");
@@ -0,0 +1,109 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const DEFAULT_DEBOUNCE_MS = 120_000;
5
+ const OFF_VALUES = new Set(["0", "false", "off", "no"]);
6
+
7
+ export function isCtoAutoCollectDisabled(env = process.env) {
8
+ return OFF_VALUES.has(
9
+ String(env?.TFX_CTO_AUTO_COLLECT ?? "")
10
+ .trim()
11
+ .toLowerCase(),
12
+ );
13
+ }
14
+
15
+ function freshCurrentMd(projectRoot, now, debounceMs) {
16
+ try {
17
+ const currentMd = join(projectRoot, ".triflux", "lake", "current.md");
18
+ if (!existsSync(currentMd)) return false;
19
+ return now - statSync(currentMd).mtimeMs < debounceMs;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ export function createCtoAutoCollector(opts = {}) {
26
+ const registry = opts.registry;
27
+ const runCollect = opts.runCollect;
28
+ const logger = opts.logger || null;
29
+ const debounceMs = Number(opts.debounceMs || DEFAULT_DEBOUNCE_MS);
30
+ const now = opts.now || (() => Date.now());
31
+ const env = opts.env || process.env;
32
+ const lastCollectMs = opts.lastCollectMs || new Map();
33
+ const inFlight = new Set();
34
+
35
+ function handleSessionStarted({ sessionId, session } = {}) {
36
+ if (isCtoAutoCollectDisabled(env))
37
+ return { triggered: false, reason: "disabled" };
38
+ if (!registry || typeof registry.querySessions !== "function") {
39
+ return { triggered: false, reason: "missing-registry" };
40
+ }
41
+ if (typeof runCollect !== "function") {
42
+ return { triggered: false, reason: "missing-runCollect" };
43
+ }
44
+
45
+ const cwd = typeof session?.cwd === "string" ? session.cwd : "";
46
+ const worktree =
47
+ typeof session?.worktreePath === "string" ? session.worktreePath : "";
48
+ const projectRoot = worktree || cwd;
49
+ if (!projectRoot)
50
+ return { triggered: false, reason: "missing-project-root" };
51
+
52
+ let peers = [];
53
+ try {
54
+ peers = registry.querySessions({
55
+ cwd,
56
+ worktree,
57
+ excludeSessionId: sessionId,
58
+ });
59
+ } catch (error) {
60
+ logger?.warn?.(
61
+ { sessionId, err: String(error?.message || error) },
62
+ "cto.auto_collect.query_failed",
63
+ );
64
+ return { triggered: false, reason: "query-failed" };
65
+ }
66
+ if (!Array.isArray(peers) || peers.length < 1) {
67
+ return { triggered: false, reason: "no-peers" };
68
+ }
69
+
70
+ const currentTime = now();
71
+ const last = lastCollectMs.get(projectRoot) || 0;
72
+ if (currentTime - last < debounceMs) {
73
+ return { triggered: false, reason: "debounced" };
74
+ }
75
+ if (freshCurrentMd(projectRoot, currentTime, debounceMs)) {
76
+ lastCollectMs.set(projectRoot, currentTime);
77
+ return { triggered: false, reason: "fresh-lake" };
78
+ }
79
+
80
+ lastCollectMs.set(projectRoot, currentTime);
81
+ const promise = Promise.resolve()
82
+ .then(() => runCollect([], { rootDir: projectRoot }))
83
+ .catch((error) => {
84
+ logger?.warn?.(
85
+ { projectRoot, err: String(error?.message || error) },
86
+ "cto.auto_collect.failed",
87
+ );
88
+ })
89
+ .finally(() => {
90
+ inFlight.delete(promise);
91
+ });
92
+ inFlight.add(promise);
93
+ return {
94
+ triggered: true,
95
+ reason: "triggered",
96
+ projectRoot,
97
+ peerCount: peers.length,
98
+ };
99
+ }
100
+
101
+ async function drain() {
102
+ await Promise.allSettled([...inFlight]);
103
+ }
104
+
105
+ return Object.freeze({
106
+ handleSessionStarted,
107
+ drain,
108
+ });
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.31.0",
3
+ "version": "10.32.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from "node:crypto";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PROJECT_ROOT = dirname(__dirname);
17
+ const SESSION_START_MATCHER = "startup|resume|clear";
18
+ const TRIFLUX_HOOK_BASENAME = "codex-session-hook.mjs";
19
+
20
+ function atomicWriteFile(path, content) {
21
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
22
+ writeFileSync(tmpPath, content, "utf8");
23
+ renameSync(tmpPath, path);
24
+ }
25
+
26
+ function quoteCommandPath(value) {
27
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
28
+ }
29
+
30
+ function buildCommand(nodeBin, hookScriptPath, mode) {
31
+ return `${nodeBin} ${quoteCommandPath(hookScriptPath)} ${mode}`;
32
+ }
33
+
34
+ export function canonicalJson(value) {
35
+ if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
36
+ if (value && typeof value === "object") {
37
+ return Object.fromEntries(
38
+ Object.keys(value)
39
+ .sort()
40
+ .map((key) => [key, canonicalJson(value[key])]),
41
+ );
42
+ }
43
+ return value;
44
+ }
45
+
46
+ export function computeTrustedHookHash({
47
+ eventName,
48
+ matcher,
49
+ command,
50
+ timeout,
51
+ statusMessage,
52
+ }) {
53
+ const hook = {
54
+ type: "command",
55
+ command,
56
+ timeout: Math.max(1, Number(timeout ?? 600)),
57
+ async: false,
58
+ };
59
+ if (statusMessage) hook.statusMessage = statusMessage;
60
+ const identity = {
61
+ event_name: eventName,
62
+ ...(matcher ? { matcher } : {}),
63
+ hooks: [hook],
64
+ };
65
+ return `sha256:${createHash("sha256")
66
+ .update(JSON.stringify(canonicalJson(identity)))
67
+ .digest("hex")}`;
68
+ }
69
+
70
+ function normalizeHooksJson(raw) {
71
+ try {
72
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
73
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
74
+ return { hooks: {} };
75
+ }
76
+ if (!parsed.hooks || typeof parsed.hooks !== "object") parsed.hooks = {};
77
+ return parsed;
78
+ } catch {
79
+ return { hooks: {} };
80
+ }
81
+ }
82
+
83
+ function isTrifluxHookGroup(group) {
84
+ return Array.isArray(group?.hooks)
85
+ ? group.hooks.some(
86
+ (hook) =>
87
+ typeof hook?.command === "string" &&
88
+ hook.command.includes(TRIFLUX_HOOK_BASENAME),
89
+ )
90
+ : false;
91
+ }
92
+
93
+ function upsertHookGroup(groups, desired) {
94
+ const list = Array.isArray(groups) ? [...groups] : [];
95
+ const existingIndex = list.findIndex((group) => isTrifluxHookGroup(group));
96
+ if (existingIndex >= 0) {
97
+ list[existingIndex] = desired;
98
+ return { groups: list, index: existingIndex };
99
+ }
100
+ list.push(desired);
101
+ return { groups: list, index: list.length - 1 };
102
+ }
103
+
104
+ function buildDesiredGroups({ nodeBin, hookScriptPath }) {
105
+ return {
106
+ SessionStart: {
107
+ matcher: SESSION_START_MATCHER,
108
+ hooks: [
109
+ {
110
+ type: "command",
111
+ command: buildCommand(nodeBin, hookScriptPath, "register"),
112
+ timeout: 15,
113
+ },
114
+ ],
115
+ },
116
+ UserPromptSubmit: {
117
+ hooks: [
118
+ {
119
+ type: "command",
120
+ command: buildCommand(nodeBin, hookScriptPath, "heartbeat"),
121
+ timeout: 10,
122
+ },
123
+ ],
124
+ },
125
+ };
126
+ }
127
+
128
+ function formatHooksJson(value) {
129
+ return `${JSON.stringify(value, null, 2)}\n`;
130
+ }
131
+
132
+ function escapeTomlKey(key) {
133
+ return `"${String(key).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
134
+ }
135
+
136
+ function stateLine(key, hash) {
137
+ return `${escapeTomlKey(key)} = { trusted_hash = "${hash}" }`;
138
+ }
139
+
140
+ function escapeRegExp(value) {
141
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
142
+ }
143
+
144
+ function stripManagedStateLines(content, managedKeys, hooksPath) {
145
+ if (!content) return "";
146
+ const escapedKeys = [...managedKeys].map((key) =>
147
+ escapeRegExp(escapeTomlKey(key)),
148
+ );
149
+ for (const label of ["session_start", "user_prompt_submit"]) {
150
+ escapedKeys.push(
151
+ escapeRegExp(escapeTomlKey(`${hooksPath}:${label}:`)).replace(
152
+ /"$/,
153
+ '\\d+:\\d+"',
154
+ ),
155
+ );
156
+ }
157
+ const re = new RegExp(
158
+ `^\\s*(?:${escapedKeys.join("|")})\\s*=\\s*\\{\\s*trusted_hash\\s*=.*\\}\\s*$`,
159
+ "gm",
160
+ );
161
+ return content.replace(re, "").replace(/\n{3,}/g, "\n\n");
162
+ }
163
+
164
+ function mergeHooksState(content, entries, hooksPath) {
165
+ const managedKeys = new Set(entries.map((entry) => entry.key));
166
+ let updated = stripManagedStateLines(content, managedKeys, hooksPath);
167
+ const lines = entries.map((entry) => stateLine(entry.key, entry.hash));
168
+ const sectionRe = /^\[hooks\.state\]\s*$/m;
169
+ const match = sectionRe.exec(updated);
170
+
171
+ if (!match) {
172
+ if (updated.length > 0 && !updated.endsWith("\n")) updated += "\n";
173
+ if (updated.length > 0 && !updated.endsWith("\n\n")) updated += "\n";
174
+ return `${updated}[hooks.state]\n${lines.join("\n")}\n`;
175
+ }
176
+
177
+ const sectionStart = match.index;
178
+ const afterHeader = match.index + match[0].length;
179
+ const nextSectionMatch = /^\[[^\]]+\]\s*$/m.exec(updated.slice(afterHeader));
180
+ const sectionEnd = nextSectionMatch
181
+ ? afterHeader + nextSectionMatch.index
182
+ : updated.length;
183
+ let sectionBody = updated.slice(sectionStart, sectionEnd).trimEnd();
184
+ sectionBody += `\n${lines.join("\n")}\n`;
185
+ return (
186
+ updated.slice(0, sectionStart) + sectionBody + updated.slice(sectionEnd)
187
+ );
188
+ }
189
+
190
+ function timestamp() {
191
+ return new Date()
192
+ .toISOString()
193
+ .replace(/[-:.TZ]/g, "")
194
+ .slice(0, 14);
195
+ }
196
+
197
+ export function ensureCodexHooks(opts = {}) {
198
+ const codexHome = opts.codexHome || join(homedir(), ".codex");
199
+ if (!existsSync(codexHome)) {
200
+ return { skipped: true, changedHooks: false, changedConfig: false };
201
+ }
202
+
203
+ const hooksPath = resolve(codexHome, "hooks.json");
204
+ const configPath = resolve(codexHome, "config.toml");
205
+ const hookScriptPath = resolve(
206
+ opts.hookScriptPath || join(PROJECT_ROOT, "hooks", TRIFLUX_HOOK_BASENAME),
207
+ );
208
+ const nodeBin = opts.nodeBin || process.execPath;
209
+ const desired = buildDesiredGroups({ nodeBin, hookScriptPath });
210
+
211
+ const hooksJson = normalizeHooksJson(
212
+ existsSync(hooksPath) ? readFileSync(hooksPath, "utf8") : "",
213
+ );
214
+ const sessionStart = upsertHookGroup(
215
+ hooksJson.hooks.SessionStart,
216
+ desired.SessionStart,
217
+ );
218
+ const promptSubmit = upsertHookGroup(
219
+ hooksJson.hooks.UserPromptSubmit,
220
+ desired.UserPromptSubmit,
221
+ );
222
+ hooksJson.hooks.SessionStart = sessionStart.groups;
223
+ hooksJson.hooks.UserPromptSubmit = promptSubmit.groups;
224
+
225
+ const originalHooks = existsSync(hooksPath)
226
+ ? readFileSync(hooksPath, "utf8")
227
+ : "";
228
+ const nextHooks = formatHooksJson(hooksJson);
229
+ const changedHooks = originalHooks !== nextHooks;
230
+ if (changedHooks) {
231
+ mkdirSync(dirname(hooksPath), { recursive: true });
232
+ atomicWriteFile(hooksPath, nextHooks);
233
+ }
234
+
235
+ const stateEntries = [
236
+ {
237
+ key: `${hooksPath}:session_start:${sessionStart.index}:0`,
238
+ hash: computeTrustedHookHash({
239
+ eventName: "session_start",
240
+ matcher: desired.SessionStart.matcher,
241
+ command: desired.SessionStart.hooks[0].command,
242
+ timeout: desired.SessionStart.hooks[0].timeout,
243
+ }),
244
+ },
245
+ {
246
+ key: `${hooksPath}:user_prompt_submit:${promptSubmit.index}:0`,
247
+ hash: computeTrustedHookHash({
248
+ eventName: "user_prompt_submit",
249
+ command: desired.UserPromptSubmit.hooks[0].command,
250
+ timeout: desired.UserPromptSubmit.hooks[0].timeout,
251
+ }),
252
+ },
253
+ ];
254
+
255
+ const originalConfig = existsSync(configPath)
256
+ ? readFileSync(configPath, "utf8")
257
+ : "";
258
+ const nextConfig = mergeHooksState(originalConfig, stateEntries, hooksPath);
259
+ const changedConfig = originalConfig !== nextConfig;
260
+ if (changedConfig) {
261
+ if (existsSync(configPath)) {
262
+ const backupPath = `${configPath}.bak-tfx-codex-hooks-${opts.backupTimestamp || timestamp()}`;
263
+ if (!existsSync(backupPath)) {
264
+ writeFileSync(backupPath, originalConfig, "utf8");
265
+ }
266
+ }
267
+ atomicWriteFile(configPath, nextConfig);
268
+ }
269
+
270
+ return {
271
+ skipped: false,
272
+ changedHooks,
273
+ changedConfig,
274
+ hooksPath,
275
+ configPath,
276
+ };
277
+ }
278
+
279
+ if (import.meta.url === `file://${process.argv[1]}`) {
280
+ const result = ensureCodexHooks();
281
+ process.stdout.write(`${JSON.stringify(result)}\n`);
282
+ }
package/scripts/setup.mjs CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  ensureTfxSection,
27
27
  getLatestRoutingTable,
28
28
  } from "./claudemd-sync.mjs";
29
+ import { ensureCodexHooks } from "./ensure-codex-hooks.mjs";
29
30
  import { addPluginRootFallbackToCommand } from "./lib/doctor-env-checks.mjs";
30
31
  import { cleanupTmpFiles } from "./tmp-cleanup.mjs";
31
32
 
@@ -1373,6 +1374,9 @@ function ensureCriticalSetup() {
1373
1374
  try {
1374
1375
  ensureCodexProfiles();
1375
1376
  } catch {}
1377
+ try {
1378
+ ensureCodexHooks();
1379
+ } catch {}
1376
1380
  }
1377
1381
 
1378
1382
  export {
@@ -1383,6 +1387,7 @@ export {
1383
1387
  cleanupStaleSkills,
1384
1388
  DEPRECATED_SKILLS,
1385
1389
  detectDevMode,
1390
+ ensureCodexHooks,
1386
1391
  ensureCodexHubServerConfig,
1387
1392
  ensureCodexProfiles,
1388
1393
  ensureHooksInSettings,
@@ -2147,6 +2152,21 @@ export async function runDeferred(stdinData) {
2147
2152
  synced++;
2148
2153
  }
2149
2154
 
2155
+ try {
2156
+ const codexHooksResult = ensureCodexHooks();
2157
+ if (
2158
+ !codexHooksResult?.skipped &&
2159
+ (codexHooksResult?.changedHooks || codexHooksResult?.changedConfig)
2160
+ ) {
2161
+ io.log(" \x1b[32m✓\x1b[0m Codex hooks: registered session sync hooks");
2162
+ synced++;
2163
+ }
2164
+ } catch (error) {
2165
+ io.log(
2166
+ ` \x1b[33m⚠\x1b[0m Codex hooks 등록 실패: ${error.message || error}`,
2167
+ );
2168
+ }
2169
+
2150
2170
  // ── Windows Codex 단독 실행 보호: 로그인 시 hub-ensure 등록 ──
2151
2171
  // Claude SessionStart 훅이 없는 순수 Codex 시작 경로에서도 tfx-hub가 살아있게 한다.
2152
2172
  if (enableHubAutostart) {