@triflux/core 10.1.0 → 10.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +20 -8
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +267 -35
  7. package/hooks/hook-registry.json +343 -256
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +254 -33
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +831 -290
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -4
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -18
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +167 -38
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +285 -111
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +87 -13
  75. package/hub/research.mjs +33 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +127 -61
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +72 -71
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +391 -247
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1281 -115
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +70 -10
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +32 -7
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. package/scripts/lib/toml.mjs +113 -0
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+
3
+ // hooks/session-end-cleanup.mjs — SessionEnd lifecycle state reporter
4
+ //
5
+ // Report-only by default. Opt-in cleanup only removes the repo-local
6
+ // .triflux/subagents/subagents.json file when stale running lifecycle state is
7
+ // present. It never kills processes or touches external HOME/MCP configs.
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ const MARKER = "[session-end-cleanup]";
14
+ const STALE_MS = 30 * 60 * 1000;
15
+ const MAX_OUTPUT_BYTES = 2 * 1024;
16
+ const DEFAULT_TMUX_PREFIX = "tfx-*";
17
+ const MAX_TMUX_REPORT_SESSIONS = 8;
18
+
19
+ function readStdin() {
20
+ try {
21
+ return readFileSync(0, "utf8");
22
+ } catch {
23
+ return "";
24
+ }
25
+ }
26
+
27
+ function parseJson(raw) {
28
+ if (!raw?.trim()) return null;
29
+ try {
30
+ return JSON.parse(raw);
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function timestampMs(agent) {
37
+ const value =
38
+ agent?.startedAt ??
39
+ agent?.started_at ??
40
+ agent?.createdAt ??
41
+ agent?.created_at ??
42
+ agent?.timestamp;
43
+ const parsed = Date.parse(String(value ?? ""));
44
+ return Number.isFinite(parsed) ? parsed : null;
45
+ }
46
+
47
+ function isCompleted(agent) {
48
+ return (
49
+ Boolean(
50
+ agent?.stoppedAt ??
51
+ agent?.stopped_at ??
52
+ agent?.completedAt ??
53
+ agent?.completed_at ??
54
+ agent?.endedAt ??
55
+ agent?.ended_at ??
56
+ agent?.durationMs ??
57
+ agent?.duration_ms ??
58
+ agent?.result,
59
+ ) ||
60
+ agent?.status === "completed" ||
61
+ agent?.status === "stopped"
62
+ );
63
+ }
64
+
65
+ function agentEntries(state) {
66
+ const agents = state?.agents;
67
+ if (Array.isArray(agents)) return agents;
68
+ if (agents && typeof agents === "object") return Object.values(agents);
69
+ return [];
70
+ }
71
+
72
+ function compileSimpleGlob(pattern) {
73
+ const source = String(pattern || DEFAULT_TMUX_PREFIX)
74
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
75
+ .replace(/\*/g, ".*");
76
+ return new RegExp(`^${source}$`);
77
+ }
78
+
79
+ function formatAge(ageSec) {
80
+ if (!Number.isFinite(ageSec) || ageSec < 0) return "unknown";
81
+ if (ageSec < 60) return `${Math.floor(ageSec)}s`;
82
+ if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m`;
83
+ if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h`;
84
+ return `${Math.floor(ageSec / 86400)}d`;
85
+ }
86
+
87
+ function parseSessionCreated(rawValue) {
88
+ const parsed = Number.parseInt(String(rawValue || "").trim(), 10);
89
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
90
+ }
91
+
92
+ function parseTmuxListSessions(output, nowSec) {
93
+ const sessions = [];
94
+ for (const line of String(output || "").split(/\r?\n/)) {
95
+ const [name, attachedText, createdText] = line.split("\t");
96
+ if (!name) continue;
97
+ const attached = Number.parseInt(attachedText || "0", 10);
98
+ const created = parseSessionCreated(createdText);
99
+ sessions.push({
100
+ name,
101
+ attached: Number.isFinite(attached) ? attached : 0,
102
+ ageSec: created == null ? null : Math.max(0, nowSec - created),
103
+ });
104
+ }
105
+ return sessions;
106
+ }
107
+
108
+ function parseFirstPane(output) {
109
+ const first = String(output || "")
110
+ .split(/\r?\n/)
111
+ .find((line) => line.trim());
112
+ if (!first) return { cwd: null, command: null, pid: null };
113
+ const [cwd, command, pidText] = first.split("\t");
114
+ const pid = Number.parseInt(pidText || "", 10);
115
+ return {
116
+ cwd: cwd || null,
117
+ command: command || null,
118
+ pid: Number.isFinite(pid) && pid > 0 ? pid : null,
119
+ };
120
+ }
121
+
122
+ function queryMemoryEstimateMb(pid, execFile = execFileSync) {
123
+ if (!pid) return null;
124
+ try {
125
+ const output = execFile("ps", ["-o", "rss=", "-p", String(pid)], {
126
+ encoding: "utf8",
127
+ timeout: 400,
128
+ stdio: ["ignore", "pipe", "ignore"],
129
+ windowsHide: true,
130
+ });
131
+ const rssKb = Number.parseInt(String(output || "").trim(), 10);
132
+ if (!Number.isFinite(rssKb) || rssKb < 0) return null;
133
+ return Math.round(rssKb / 1024);
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ export function inspectDetachedTmuxSessions({
140
+ prefix = DEFAULT_TMUX_PREFIX,
141
+ now = Date.now(),
142
+ execFile = execFileSync,
143
+ limit = MAX_TMUX_REPORT_SESSIONS,
144
+ } = {}) {
145
+ const nowSec = Math.floor(now / 1000);
146
+ const matcher = compileSimpleGlob(prefix);
147
+ let output = "";
148
+ try {
149
+ output = execFile(
150
+ "tmux",
151
+ [
152
+ "list-sessions",
153
+ "-F",
154
+ "#{session_name}\t#{session_attached}\t#{session_created}",
155
+ ],
156
+ {
157
+ encoding: "utf8",
158
+ timeout: 500,
159
+ stdio: ["ignore", "pipe", "ignore"],
160
+ windowsHide: true,
161
+ },
162
+ );
163
+ } catch {
164
+ return { available: false, prefix, sessions: [] };
165
+ }
166
+
167
+ const sessions = [];
168
+ for (const session of parseTmuxListSessions(output, nowSec)) {
169
+ if (session.attached !== 0 || !matcher.test(session.name)) continue;
170
+ let pane = { cwd: null, command: null, pid: null };
171
+ try {
172
+ pane = parseFirstPane(
173
+ execFile(
174
+ "tmux",
175
+ [
176
+ "list-panes",
177
+ "-t",
178
+ session.name,
179
+ "-F",
180
+ "#{pane_current_path}\t#{pane_current_command}\t#{pane_pid}",
181
+ ],
182
+ {
183
+ encoding: "utf8",
184
+ timeout: 500,
185
+ stdio: ["ignore", "pipe", "ignore"],
186
+ windowsHide: true,
187
+ },
188
+ ),
189
+ );
190
+ } catch {
191
+ pane = { cwd: null, command: null, pid: null };
192
+ }
193
+ sessions.push({
194
+ name: session.name,
195
+ age: formatAge(session.ageSec),
196
+ cwd: pane.cwd,
197
+ command: pane.command,
198
+ memory_estimate_mb: queryMemoryEstimateMb(pane.pid, execFile),
199
+ });
200
+ if (sessions.length >= limit) break;
201
+ }
202
+
203
+ return { available: true, prefix, sessions };
204
+ }
205
+
206
+ export function summarizeLifecycleState(state, now = Date.now()) {
207
+ const completedEntries = Array.isArray(state?.completed)
208
+ ? state.completed
209
+ : [];
210
+ const summary = {
211
+ running: 0,
212
+ completed: completedEntries.length,
213
+ stale: 0,
214
+ total: completedEntries.length,
215
+ };
216
+
217
+ for (const agent of agentEntries(state)) {
218
+ if (!agent || typeof agent !== "object") continue;
219
+ summary.total += 1;
220
+
221
+ if (isCompleted(agent)) {
222
+ summary.completed += 1;
223
+ continue;
224
+ }
225
+
226
+ const started = timestampMs(agent);
227
+ if (started !== null && now - started >= STALE_MS) {
228
+ summary.stale += 1;
229
+ } else {
230
+ summary.running += 1;
231
+ }
232
+ }
233
+
234
+ return summary;
235
+ }
236
+
237
+ function pruneStaleRunningAgents(state, now = Date.now()) {
238
+ const agents = state?.agents;
239
+ if (!agents || typeof agents !== "object" || Array.isArray(agents)) {
240
+ return { nextState: state, removed: 0 };
241
+ }
242
+
243
+ let removed = 0;
244
+ const nextAgents = {};
245
+ for (const [key, agent] of Object.entries(agents)) {
246
+ const started = timestampMs(agent);
247
+ const stale =
248
+ !isCompleted(agent) && started !== null && now - started >= STALE_MS;
249
+ if (stale) {
250
+ removed += 1;
251
+ } else {
252
+ nextAgents[key] = agent;
253
+ }
254
+ }
255
+
256
+ return {
257
+ nextState: { ...state, agents: nextAgents },
258
+ removed,
259
+ };
260
+ }
261
+
262
+ function buildContext(summary, cleanup, tmuxReport) {
263
+ let context =
264
+ `${MARKER} subagent lifecycle: ` +
265
+ `running=${summary.running} completed=${summary.completed} ` +
266
+ `stale=${summary.stale} total=${summary.total} cleanup=${cleanup}`;
267
+
268
+ if (tmuxReport?.sessions?.length > 0) {
269
+ context += ` detached_tmux_sessions=${JSON.stringify(tmuxReport.sessions)}`;
270
+ }
271
+ return context;
272
+ }
273
+
274
+ function buildOutput(summary, cleanup, tmuxReport) {
275
+ const context = buildContext(summary, cleanup, tmuxReport);
276
+ const output = {
277
+ systemMessage: context,
278
+ };
279
+
280
+ let json = JSON.stringify(output);
281
+ if (Buffer.byteLength(json, "utf8") >= MAX_OUTPUT_BYTES) {
282
+ const bounded = buildContext(summary, cleanup, {
283
+ sessions: tmuxReport?.sessions?.slice(0, 2) || [],
284
+ });
285
+ json = JSON.stringify({
286
+ systemMessage: bounded,
287
+ });
288
+ }
289
+ return json;
290
+ }
291
+
292
+ export function run(input, env = process.env) {
293
+ if (input?.hook_event_name !== "SessionEnd") return "";
294
+
295
+ const root = env.CLAUDE_CWD || process.cwd();
296
+ const statePath = join(root, ".triflux", "subagents", "subagents.json");
297
+ if (!existsSync(statePath)) return "";
298
+
299
+ const state = parseJson(readFileSync(statePath, "utf8"));
300
+ if (!state) return "";
301
+
302
+ const summary = summarizeLifecycleState(state);
303
+ let cleanup = "report-only";
304
+ const tmuxReport = inspectDetachedTmuxSessions({
305
+ prefix: env.TFX_SESSION_END_TMUX_PREFIX || DEFAULT_TMUX_PREFIX,
306
+ });
307
+
308
+ if (env.TFX_SESSION_END_CLEANUP === "1" && summary.stale > 0) {
309
+ try {
310
+ const { nextState, removed } = pruneStaleRunningAgents(state);
311
+ const remainingAgents = Object.keys(nextState?.agents || {}).length;
312
+ const remainingCompleted = Array.isArray(nextState?.completed)
313
+ ? nextState.completed.length
314
+ : 0;
315
+ if (remainingAgents === 0 && remainingCompleted === 0) {
316
+ unlinkSync(statePath);
317
+ cleanup = "removed";
318
+ } else {
319
+ writeFileSync(
320
+ statePath,
321
+ `${JSON.stringify({ ...nextState, updatedAt: new Date().toISOString() }, null, 2)}\n`,
322
+ "utf8",
323
+ );
324
+ cleanup = `pruned-${removed}`;
325
+ }
326
+ } catch {
327
+ cleanup = "failed";
328
+ }
329
+ }
330
+
331
+ return buildOutput(summary, cleanup, tmuxReport);
332
+ }
333
+
334
+ function main() {
335
+ const input = parseJson(readStdin());
336
+ if (!input) return;
337
+ const output = run(input);
338
+ if (output) process.stdout.write(output);
339
+ }
340
+
341
+ try {
342
+ main();
343
+ } catch {
344
+ process.exit(0);
345
+ }
@@ -0,0 +1,214 @@
1
+ // hooks/session-start-fast.mjs — SessionStart in-process fast-path
2
+ //
3
+ // hook-orchestrator.mjs가 SessionStart 이벤트일 때 이 모듈을 dynamic import.
4
+ // 6개 훅을 1개 node 프로세스 안에서 실행하여 콜드스타트 7회 → 1회로 줄인다.
5
+ //
6
+ // 분류:
7
+ // BLOCKING (직렬, stdout 반환 전 완료): setup.runCritical, mcp-safety-guard.run, hub-ensure.run
8
+ // DEFERRED (병렬, 실패해도 안 죽음): mcp-gateway-ensure.run, setup.runDeferred
9
+ // BACKGROUND (fire-and-forget): preflight-cache.run
10
+ //
11
+ // external source 훅 (session-vault 등)은 여전히 execFile로 실행된다.
12
+
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { createModuleLogger } from "../scripts/lib/logger.mjs";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const SCRIPTS = join(__dirname, "..", "scripts");
19
+ const importMod = (p) => import(pathToFileURL(p).href);
20
+
21
+ const log = createModuleLogger("session-start-fast");
22
+
23
+ /**
24
+ * BLOCKING 훅을 순차 실행. 하나라도 throw하면 로그만 남기고 계속.
25
+ * @param {string} stdinData
26
+ * @returns {Promise<{stdout: string, stderr: string}>}
27
+ */
28
+ async function runBlocking(stdinData) {
29
+ const output = { stdout: "", stderr: "" };
30
+ const timings = [];
31
+
32
+ // 1. setup.runCritical — 환경 초기화 필수
33
+ try {
34
+ const t0 = performance.now();
35
+ const setup = await importMod(join(SCRIPTS, "setup.mjs"));
36
+ const result = await setup.runCritical(stdinData);
37
+ const dur = performance.now() - t0;
38
+ timings.push({ hook: "setup.critical", dur_ms: Math.round(dur) });
39
+ if (result?.stdout) output.stdout += result.stdout + "\n";
40
+ if (result?.stderr) output.stderr += result.stderr + "\n";
41
+ log.info(
42
+ { hook: "setup.critical", dur_ms: Math.round(dur) },
43
+ "hook.completed",
44
+ );
45
+ } catch (err) {
46
+ log.error(
47
+ { hook: "setup.critical", err: String(err.message || err) },
48
+ "hook.failed",
49
+ );
50
+ }
51
+
52
+ // 2. mcp-safety-guard.run — EPERM 방지
53
+ try {
54
+ const t0 = performance.now();
55
+ const guard = await importMod(join(SCRIPTS, "mcp-safety-guard.mjs"));
56
+ guard.run();
57
+ const dur = performance.now() - t0;
58
+ timings.push({ hook: "mcp-safety-guard", dur_ms: Math.round(dur) });
59
+ log.info(
60
+ { hook: "mcp-safety-guard", dur_ms: Math.round(dur) },
61
+ "hook.completed",
62
+ );
63
+ } catch (err) {
64
+ log.error(
65
+ { hook: "mcp-safety-guard", err: String(err.message || err) },
66
+ "hook.failed",
67
+ );
68
+ }
69
+
70
+ // 3. hub-ensure — Hub 필수 인프라, BLOCKING으로 실행
71
+ try {
72
+ const t0 = performance.now();
73
+ const hubMod = await importMod(join(SCRIPTS, "hub-ensure.mjs"));
74
+ const result = await hubMod.run(stdinData);
75
+ const dur = performance.now() - t0;
76
+ timings.push({ hook: "hub-ensure", dur_ms: Math.round(dur) });
77
+ if (result?.stdout) output.stdout += result.stdout + "\n";
78
+ if (result?.stderr) output.stderr += result.stderr + "\n";
79
+ if (result?.code !== 0) {
80
+ log.warn(
81
+ { hook: "hub-ensure", dur_ms: Math.round(dur), code: result?.code },
82
+ "hook.warn",
83
+ );
84
+ } else {
85
+ log.info(
86
+ { hook: "hub-ensure", dur_ms: Math.round(dur) },
87
+ "hook.completed",
88
+ );
89
+ }
90
+ } catch (err) {
91
+ log.error(
92
+ { hook: "hub-ensure", err: String(err.message || err) },
93
+ "hook.failed",
94
+ );
95
+ }
96
+
97
+ return { ...output, timings };
98
+ }
99
+
100
+ /**
101
+ * DEFERRED 훅을 병렬 실행. 실패해도 crash 안 함, 로그만 남김.
102
+ * Promise는 의도적으로 관리하지 않음 (fire-and-forget with logging).
103
+ * @param {string} stdinData
104
+ */
105
+ function runDeferred(stdinData) {
106
+ const tasks = [
107
+ {
108
+ name: "session-stale-cleanup",
109
+ fn: async () => {
110
+ const mod = await importMod(join(SCRIPTS, "session-stale-cleanup.mjs"));
111
+ if (typeof mod.main === "function") mod.main();
112
+ },
113
+ },
114
+ {
115
+ name: "claude-login-detect",
116
+ fn: async () => {
117
+ const mod = await importMod(join(SCRIPTS, "claude-login-detect.mjs"));
118
+ const result = mod.run?.();
119
+ if (result?.changed) {
120
+ return {
121
+ stdout: `[claude-login] HUD 캐시 ${result.cleared}개 초기화됨\n`,
122
+ };
123
+ }
124
+ },
125
+ },
126
+ {
127
+ name: "mcp-gateway-ensure",
128
+ fn: async () => {
129
+ const mod = await importMod(join(SCRIPTS, "mcp-gateway-ensure.mjs"));
130
+ return mod.run(stdinData);
131
+ },
132
+ },
133
+ {
134
+ name: "setup.deferred",
135
+ fn: async () => {
136
+ const mod = await importMod(join(SCRIPTS, "setup.mjs"));
137
+ return mod.runDeferred(stdinData);
138
+ },
139
+ },
140
+ ];
141
+
142
+ for (const task of tasks) {
143
+ const t0 = performance.now();
144
+ task
145
+ .fn()
146
+ .then((result) => {
147
+ const dur = performance.now() - t0;
148
+ log.info(
149
+ { hook: task.name, dur_ms: Math.round(dur), code: result?.code },
150
+ "deferred.completed",
151
+ );
152
+ })
153
+ .catch((err) => {
154
+ const dur = performance.now() - t0;
155
+ log.error(
156
+ {
157
+ hook: task.name,
158
+ dur_ms: Math.round(dur),
159
+ err: String(err.message || err),
160
+ },
161
+ "deferred.failed",
162
+ );
163
+ });
164
+ }
165
+ }
166
+
167
+ /**
168
+ * BACKGROUND 훅. fire-and-forget.
169
+ * @param {string} stdinData
170
+ */
171
+ function runBackground(stdinData) {
172
+ // preflight-cache
173
+ importMod(join(SCRIPTS, "preflight-cache.mjs"))
174
+ .then((mod) => mod.run(stdinData))
175
+ .catch(() => {}); // 완전 무시
176
+
177
+ // session-vault은 external source — hook-orchestrator가 execFile로 실행
178
+ }
179
+
180
+ /**
181
+ * SessionStart fast-path 진입점.
182
+ * hook-orchestrator.mjs에서 호출된다.
183
+ *
184
+ * @param {string} stdinData — orchestrator가 전달하는 stdin JSON
185
+ * @param {Array} externalHooks — source !== 'triflux'인 훅 목록 (orchestrator가 execFile로 실행)
186
+ * @returns {Promise<{stdout: string, stderr: string, timings: Array}>}
187
+ */
188
+ export async function execute(stdinData, externalHooks = []) {
189
+ const totalStart = performance.now();
190
+
191
+ // BLOCKING: 프롬프트 전 완료 필수
192
+ const blocking = await runBlocking(stdinData);
193
+
194
+ // DEFERRED + BACKGROUND: fire-and-forget
195
+ runDeferred(stdinData);
196
+ runBackground(stdinData);
197
+
198
+ const totalDur = performance.now() - totalStart;
199
+ log.info(
200
+ {
201
+ total_ms: Math.round(totalDur),
202
+ blocking_count: 3,
203
+ deferred_count: 2,
204
+ bg_count: 1,
205
+ },
206
+ "session-start.done",
207
+ );
208
+
209
+ return {
210
+ stdout: blocking.stdout,
211
+ stderr: blocking.stderr,
212
+ timings: blocking.timings,
213
+ };
214
+ }