@nexrall/code-core 1.4.66 → 1.4.67

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 (75) hide show
  1. package/dist/agent/agentTypes.d.ts +6 -2
  2. package/dist/agent/agentTypes.d.ts.map +1 -1
  3. package/dist/agent/agentTypes.js +13 -4
  4. package/dist/agent/askOnce.d.ts +12 -0
  5. package/dist/agent/askOnce.d.ts.map +1 -0
  6. package/dist/agent/askOnce.js +41 -0
  7. package/dist/agent/compaction.d.ts +244 -0
  8. package/dist/agent/compaction.d.ts.map +1 -0
  9. package/dist/agent/compaction.js +976 -0
  10. package/dist/agent/fileLocks.d.ts +34 -0
  11. package/dist/agent/fileLocks.d.ts.map +1 -0
  12. package/dist/agent/fileLocks.js +114 -0
  13. package/dist/agent/hooks.d.ts +324 -0
  14. package/dist/agent/hooks.d.ts.map +1 -0
  15. package/dist/agent/hooks.js +1228 -0
  16. package/dist/agent/iterationPolicy.d.ts +121 -0
  17. package/dist/agent/iterationPolicy.d.ts.map +1 -0
  18. package/dist/agent/iterationPolicy.js +297 -0
  19. package/dist/agent/lifecycleHost.d.ts +55 -0
  20. package/dist/agent/lifecycleHost.d.ts.map +1 -0
  21. package/dist/agent/lifecycleHost.js +294 -0
  22. package/dist/agent/loop.d.ts +11 -491
  23. package/dist/agent/loop.d.ts.map +1 -1
  24. package/dist/agent/loop.js +417 -3031
  25. package/dist/agent/planMode.d.ts.map +1 -1
  26. package/dist/agent/planMode.js +1 -0
  27. package/dist/agent/sharedTasks.d.ts +7 -0
  28. package/dist/agent/sharedTasks.d.ts.map +1 -1
  29. package/dist/agent/sharedTasks.js +16 -0
  30. package/dist/agent/subAgentBudget.d.ts +65 -0
  31. package/dist/agent/subAgentBudget.d.ts.map +1 -0
  32. package/dist/agent/subAgentBudget.js +269 -0
  33. package/dist/agent/subTask.d.ts +6 -0
  34. package/dist/agent/subTask.d.ts.map +1 -0
  35. package/dist/agent/subTask.js +713 -0
  36. package/dist/agent/subTaskSupport.d.ts +156 -0
  37. package/dist/agent/subTaskSupport.d.ts.map +1 -0
  38. package/dist/agent/subTaskSupport.js +409 -0
  39. package/dist/agent/toolDescriptions.d.ts +3 -0
  40. package/dist/agent/toolDescriptions.d.ts.map +1 -0
  41. package/dist/agent/toolDescriptions.js +116 -0
  42. package/dist/api/client.d.ts.map +1 -1
  43. package/dist/api/client.js +17 -0
  44. package/dist/index.d.ts +3 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -0
  47. package/dist/mcp/client.d.ts +104 -0
  48. package/dist/mcp/client.d.ts.map +1 -1
  49. package/dist/mcp/client.js +136 -2
  50. package/dist/mcp/httpClient.d.ts +17 -1
  51. package/dist/mcp/httpClient.d.ts.map +1 -1
  52. package/dist/mcp/httpClient.js +120 -19
  53. package/dist/mcp/manager.d.ts +77 -2
  54. package/dist/mcp/manager.d.ts.map +1 -1
  55. package/dist/mcp/manager.js +275 -9
  56. package/dist/mcp/sseClient.d.ts +7 -1
  57. package/dist/mcp/sseClient.d.ts.map +1 -1
  58. package/dist/mcp/sseClient.js +45 -1
  59. package/dist/mcp/stats.d.ts +41 -0
  60. package/dist/mcp/stats.d.ts.map +1 -0
  61. package/dist/mcp/stats.js +108 -0
  62. package/dist/permissions/destructive.d.ts +2 -0
  63. package/dist/permissions/destructive.d.ts.map +1 -1
  64. package/dist/permissions/destructive.js +6 -2
  65. package/dist/permissions/destructiveTokens.d.ts +5 -0
  66. package/dist/permissions/destructiveTokens.d.ts.map +1 -1
  67. package/dist/permissions/destructiveTokens.js +9 -3
  68. package/dist/permissions/modePolicy.d.ts.map +1 -1
  69. package/dist/permissions/modePolicy.js +5 -2
  70. package/dist/permissions/rules.d.ts +4 -1
  71. package/dist/permissions/rules.d.ts.map +1 -1
  72. package/dist/permissions/rules.js +29 -0
  73. package/dist/types.d.ts +45 -1
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +1 -1
@@ -0,0 +1,1228 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.withAgentHooks = withAgentHooks;
37
+ exports.loadHooks = loadHooks;
38
+ exports.setHookWakeListener = setHookWakeListener;
39
+ exports.setHookStatusListener = setHookStatusListener;
40
+ exports.drainHookDeliveries = drainHookDeliveries;
41
+ exports.takeHookWakes = takeHookWakes;
42
+ exports.hasPendingHookWake = hasPendingHookWake;
43
+ exports.formatHookDeliveries = formatHookDeliveries;
44
+ exports.resetHookOnceState = resetHookOnceState;
45
+ exports.setHookMcpCaller = setHookMcpCaller;
46
+ exports.setHookEventListener = setHookEventListener;
47
+ exports.hasHookEventListener = hasHookEventListener;
48
+ exports.parsePromptHookReply = parsePromptHookReply;
49
+ exports.hookRunOptsFor = hookRunOptsFor;
50
+ exports.classifyStopFailure = classifyStopFailure;
51
+ exports.apiFailureForStopReason = apiFailureForStopReason;
52
+ exports.runToolHooks = runToolHooks;
53
+ exports.runSimpleHooks = runSimpleHooks;
54
+ exports.runLifecycleHooks = runLifecycleHooks;
55
+ exports.runPermissionRequestHooks = runPermissionRequestHooks;
56
+ exports.runElicitationHooks = runElicitationHooks;
57
+ exports.runElicitationResultHooks = runElicitationResultHooks;
58
+ exports.fireObserverHook = fireObserverHook;
59
+ exports.fireNotificationHooks = fireNotificationHooks;
60
+ exports.fireManualPostCompactHook = fireManualPostCompactHook;
61
+ exports.fireManualPreCompactHook = fireManualPreCompactHook;
62
+ const client_1 = require("../api/client");
63
+ const executor_1 = require("../tools/executor");
64
+ const agentTypes_1 = require("./agentTypes");
65
+ const rules_1 = require("../permissions/rules");
66
+ const index_1 = require("../plugins/index");
67
+ const fs = __importStar(require("fs"));
68
+ const path = __importStar(require("path"));
69
+ const child_process_1 = require("child_process");
70
+ function withAgentHooks(base, agent) {
71
+ if (!agent)
72
+ return base;
73
+ return {
74
+ ...base,
75
+ PreToolUse: [...(base.PreToolUse ?? []), ...(agent.PreToolUse ?? [])],
76
+ PostToolUse: [...(base.PostToolUse ?? []), ...(agent.PostToolUse ?? [])],
77
+ SubagentStop: [...(base.SubagentStop ?? []), ...(agent.Stop ?? [])],
78
+ };
79
+ }
80
+ function loadHooks(workDir) {
81
+ let fromSettings = {};
82
+ try {
83
+ const p = path.join(workDir, '.nexrall', 'settings.json');
84
+ if (fs.existsSync(p))
85
+ fromSettings = JSON.parse(fs.readFileSync(p, 'utf-8')).hooks ?? {};
86
+ }
87
+ catch { /* ignore */ }
88
+ // `--settings`: hooks from the run's own settings object run after the project's.
89
+ const sess = (0, rules_1.getSessionSettings)()?.hooks;
90
+ if (sess && typeof sess === 'object') {
91
+ const base = { ...fromSettings };
92
+ for (const [phase, list] of Object.entries(sess)) {
93
+ if (Array.isArray(list))
94
+ base[phase] = [...(base[phase] ?? []), ...list];
95
+ }
96
+ fromSettings = base;
97
+ }
98
+ // Merge plugin-provided hooks AFTER the project's own (project hooks run first).
99
+ const fromPlugins = (0, index_1.pluginHooks)(workDir);
100
+ const merged = { ...fromSettings };
101
+ for (const phase of Object.keys(fromPlugins)) {
102
+ const extra = fromPlugins[phase];
103
+ if (!Array.isArray(extra) || !extra.length)
104
+ continue;
105
+ merged[phase] = [
106
+ ...((merged[phase]) ?? []),
107
+ ...extra,
108
+ ];
109
+ }
110
+ // One normalising pass over every entry-list phase, whatever source it came from.
111
+ for (const phase of ENTRY_LIST_PHASES) {
112
+ const list = merged[phase];
113
+ if (Array.isArray(list) && list.length) {
114
+ merged[phase] = toHookEntries(list);
115
+ }
116
+ }
117
+ return merged;
118
+ }
119
+ /** The phases whose value is an array of ENTRIES (each carrying a matcher + handlers). */
120
+ const ENTRY_LIST_PHASES = [
121
+ 'PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'PreCompact',
122
+ 'PermissionRequest', 'Notification', 'PostToolUseFailure', 'SubagentStart',
123
+ 'PostCompact', 'SessionEnd', 'FileChanged', 'InstructionsLoaded', 'StopFailure',
124
+ 'PermissionDenied', 'PreModelSwitch', 'PostModelSwitch', 'CwdChanged', 'DirectoryAdded',
125
+ 'ConfigChange', 'WorktreeCreate', 'WorktreeRemove', 'Setup',
126
+ 'PostToolBatch', 'TaskCreated', 'TaskCompleted',
127
+ 'Elicitation', 'ElicitationResult',
128
+ ];
129
+ /**
130
+ * Accept BOTH config shapes for the same list, so a hook can never be silently dropped:
131
+ *
132
+ * • Claude Code's nested form — `[{ "matcher": "Edit|Write", "hooks": [{ "type": "command", … }] }]`
133
+ * • the FLAT/short form our docs show — `[{ "matcher": "Edit|Write", "command": "…" }]`
134
+ *
135
+ * A short-form entry used to satisfy neither parser: the entry-lists walk `entry.hooks`
136
+ * (absent → skipped) and the flat lists require a known `type` (absent → skipped). So a
137
+ * gate written exactly as documented — the lint-on-write script in hooks.md — never ran,
138
+ * and nothing said so. Normalise once, here, where every consumer reads its config from.
139
+ */
140
+ function toHookEntries(list) {
141
+ return list.map((it) => {
142
+ const e = it;
143
+ if (Array.isArray(e.hooks))
144
+ return { matcher: e.matcher, hooks: e.hooks };
145
+ // The flat form: the entry IS the handler. No `type` means a command — the only
146
+ // transport that needs no extra field, and the one the short form documents.
147
+ const { matcher, ...rest } = e;
148
+ const hook = { ...rest };
149
+ // No `type`: a command — unless the entry names a server AND a tool and has no
150
+ // command of its own, which is unambiguously an mcp_tool handler.
151
+ if (!isKnownHandlerType(hook.type)) {
152
+ hook.type = !hook.command && !hook.url && !hook.prompt && hook.server && hook.tool ? 'mcp_tool' : 'command';
153
+ }
154
+ return { matcher, hooks: [hook] };
155
+ });
156
+ }
157
+ const _hookDeliveries = [];
158
+ let _hookWakeListener = null;
159
+ let _hookStatusListener = null;
160
+ /** Called (if set) the moment a wake lands, so an idle host can react at once. */
161
+ function setHookWakeListener(fn) { _hookWakeListener = fn; }
162
+ /** Where `statusMessage` goes. The message is null when no hook is running. */
163
+ function setHookStatusListener(fn) { _hookStatusListener = fn; }
164
+ /** Everything queued so far — the loop calls this before each request. */
165
+ function drainHookDeliveries() { return _hookDeliveries.splice(0); }
166
+ /** Only the wakes — an idle host must not start a turn just for async context. */
167
+ function takeHookWakes() {
168
+ const out = _hookDeliveries.filter((d) => d.kind === 'wake');
169
+ for (let i = _hookDeliveries.length - 1; i >= 0; i--)
170
+ if (_hookDeliveries[i].kind === 'wake')
171
+ _hookDeliveries.splice(i, 1);
172
+ return out;
173
+ }
174
+ function hasPendingHookWake() { return _hookDeliveries.some((d) => d.kind === 'wake'); }
175
+ /** The text a delivery batch becomes in the conversation — the same envelope sync hooks use. */
176
+ function formatHookDeliveries(list) {
177
+ return list.map((d) => `<hook-context source="${d.source}">\n${d.text}\n</hook-context>`).join('\n\n');
178
+ }
179
+ // `once`: which hooks have already run successfully, keyed by STABLE identity — the
180
+ // definition itself, not the object (loadHooks rebuilds the objects every call).
181
+ const _hookOnceDone = new Set();
182
+ function hookIdentity(event, hook, matcher, dir) {
183
+ const body = hook.command ?? hook.url ?? hook.prompt ?? `${hook.server ?? ''}__${hook.tool ?? ''}`;
184
+ // `dir` is part of it: ONE process can serve several projects (worktrees, /add-dir), and
185
+ // "once" must not leak from one project into another.
186
+ return `${dir ?? ''}|${event}|${matcher ?? ''}|${body}`;
187
+ }
188
+ /** Forget which `once` hooks have run (a new session: /clear, a fresh run). */
189
+ function resetHookOnceState() { _hookOnceDone.clear(); }
190
+ // While any hook with a statusMessage is running the UI shows the NEWEST of those
191
+ // messages — null once they all finish. Ref-counted because parallel sub-agents run
192
+ // hooks concurrently, and "the first hook to finish" must not clear the label of one
193
+ // still running.
194
+ const _activeStatuses = new Map();
195
+ let _statusSeq = 0;
196
+ function hookStatusBegin(message) {
197
+ const id = ++_statusSeq;
198
+ _activeStatuses.set(id, message);
199
+ _hookStatusListener?.(message);
200
+ return id;
201
+ }
202
+ function hookStatusEnd(id) {
203
+ if (!_activeStatuses.delete(id))
204
+ return;
205
+ if (!_activeStatuses.size) {
206
+ _hookStatusListener?.(null);
207
+ return;
208
+ }
209
+ _hookStatusListener?.([..._activeStatuses.values()].pop() ?? null);
210
+ }
211
+ let _hookMcpCaller = null;
212
+ /** Hosts with connected MCP servers register their caller once; null = mcp_tool hooks report "no MCP client". */
213
+ function setHookMcpCaller(fn) { _hookMcpCaller = fn; }
214
+ let _hookEventListener = null;
215
+ /**
216
+ * Install (or clear) the sink that receives every hook run's lifecycle records.
217
+ * One per process: hooks fire from the loop, the lifecycle host and the permission
218
+ * gate, but they all belong to the same session's stream.
219
+ */
220
+ function setHookEventListener(fn) { _hookEventListener = fn; }
221
+ /** True when a sink is installed, for hosts skipping work that would only feed the stream. */
222
+ function hasHookEventListener() { return _hookEventListener !== null; }
223
+ let _hookIdSeq = 0;
224
+ function emitHookLifecycle(evt) {
225
+ try {
226
+ _hookEventListener?.(evt);
227
+ }
228
+ catch { /* the host's listener must not break the hook */ }
229
+ }
230
+ /** The stream's hook_name: what the handler IS, in the config author's terms. */
231
+ function hookDisplayName(hook) {
232
+ if (hook.type === 'http')
233
+ return String(hook.url ?? 'http');
234
+ if (hook.type === 'mcp_tool')
235
+ return `${String(hook.server ?? '')}:${String(hook.tool ?? '')}`;
236
+ if (hook.type === 'prompt' || hook.type === 'agent')
237
+ return hook.type;
238
+ return String(hook.command ?? 'command');
239
+ }
240
+ /**
241
+ * Output produced before this much runtime never becomes progress — a fast hook
242
+ * stays a started/response pair, matching Claude Code's rule that progress is for
243
+ * command hooks running longer than a second.
244
+ */
245
+ const HOOK_PROGRESS_AFTER_MS = 1000;
246
+ /** At most one progress record per hook per window, so a chatty hook cannot flood the stream. */
247
+ const HOOK_PROGRESS_FLUSH_MS = 250;
248
+ /**
249
+ * Run one hook with lifecycle records around it. With no listener installed this is
250
+ * a straight pass-through — no ids, no timers, no bookkeeping.
251
+ *
252
+ * Progress covers output produced after the hook has run ≥1 s, flushed at most
253
+ * every 250 ms; the final unflushed chunk is DROPPED rather than emitted late,
254
+ * because `response` already carries the complete output and a progress record
255
+ * must never follow it.
256
+ */
257
+ async function runWithHookEvents(hook, event, run) {
258
+ if (!_hookEventListener)
259
+ return run(undefined);
260
+ const hookId = `hook_${Date.now().toString(36)}_${(++_hookIdSeq).toString(36)}`;
261
+ const hookName = hookDisplayName(hook);
262
+ const startedAt = Date.now();
263
+ emitHookLifecycle({ kind: 'started', hookId, hookName, hookEvent: event });
264
+ let pendingOut = '';
265
+ let pendingErr = '';
266
+ let lastFlush = 0;
267
+ let flushTimer = null;
268
+ const flush = () => {
269
+ flushTimer = null;
270
+ if (!pendingOut && !pendingErr)
271
+ return;
272
+ const stdout = pendingOut, stderr = pendingErr;
273
+ pendingOut = '';
274
+ pendingErr = '';
275
+ lastFlush = Date.now();
276
+ emitHookLifecycle({ kind: 'progress', hookId, hookName, hookEvent: event, stdout, stderr, output: stdout + stderr });
277
+ };
278
+ const onData = (d) => {
279
+ if (Date.now() - startedAt < HOOK_PROGRESS_AFTER_MS)
280
+ return;
281
+ if (d.stdout)
282
+ pendingOut += d.stdout;
283
+ if (d.stderr)
284
+ pendingErr += d.stderr;
285
+ if (!pendingOut && !pendingErr)
286
+ return;
287
+ const since = Date.now() - lastFlush;
288
+ if (since >= HOOK_PROGRESS_FLUSH_MS)
289
+ flush();
290
+ else if (!flushTimer)
291
+ flushTimer = setTimeout(flush, HOOK_PROGRESS_FLUSH_MS - since);
292
+ };
293
+ let r;
294
+ try {
295
+ r = await run(onData);
296
+ }
297
+ catch (err) {
298
+ if (flushTimer)
299
+ clearTimeout(flushTimer);
300
+ const message = err?.message ?? String(err);
301
+ emitHookLifecycle({ kind: 'response', hookId, hookName, hookEvent: event, stdout: '', stderr: message, output: message, outcome: 'error' });
302
+ throw err;
303
+ }
304
+ if (flushTimer)
305
+ clearTimeout(flushTimer);
306
+ const stdout = r.stdout ?? '';
307
+ const stderr = r.stderr ?? '';
308
+ emitHookLifecycle({
309
+ kind: 'response',
310
+ hookId,
311
+ hookName,
312
+ hookEvent: event,
313
+ stdout,
314
+ stderr,
315
+ output: [stdout.trim(), stderr.trim()].filter(Boolean).join('\n'),
316
+ // status null = killed / never exited (spawnHook's timeout path) — there is no exit code to report.
317
+ ...(r.status === null ? {} : { exitCode: r.status }),
318
+ outcome: r.status === 0 ? 'success' : r.status === null ? 'cancelled' : 'error',
319
+ });
320
+ return r;
321
+ }
322
+ /** async / asyncRewake are command-only (Claude Code: "only available on type:command hooks"). */
323
+ function isBackgroundHook(hook) {
324
+ return hook.type === 'command' && (hook.async === true || hook.asyncRewake === true);
325
+ }
326
+ function hookGate(hook, event, matcher, dir) {
327
+ const key = hook.once ? hookIdentity(event, hook, matcher, dir) : undefined;
328
+ if (key && _hookOnceDone.has(key))
329
+ return { skip: true };
330
+ if (isBackgroundHook(hook))
331
+ return { background: true, key };
332
+ return { key, statusId: hook.statusMessage ? hookStatusBegin(hook.statusMessage) : undefined };
333
+ }
334
+ /**
335
+ * Start a background hook. Its result goes to the delivery queue; the caller does not
336
+ * wait. `timeoutMs: null` = no timeout enforced (plain `async`, like Claude Code);
337
+ * `asyncRewake` passes a real timeout so a stuck hook cannot strand the wake.
338
+ */
339
+ function startBackgroundHook(hook, payload, opts) {
340
+ const statusId = hook.statusMessage ? hookStatusBegin(hook.statusMessage) : undefined;
341
+ const done = (r) => {
342
+ if (statusId !== undefined)
343
+ hookStatusEnd(statusId);
344
+ if (r.status === 0 && opts.key)
345
+ _hookOnceDone.add(opts.key);
346
+ const out = (r.stdout ?? '').toString().trim();
347
+ if (hook.asyncRewake && r.status === 2) {
348
+ const text = (r.stderr ?? '').toString().trim() || out;
349
+ _hookDeliveries.push({ kind: 'wake', text: text || 'Hook asked the session to wake (exit 2).', source: opts.eventLabel });
350
+ try {
351
+ _hookWakeListener?.();
352
+ }
353
+ catch { /* the host's listener must not break the hook */ }
354
+ return;
355
+ }
356
+ const context = directiveContext(out);
357
+ if (context)
358
+ _hookDeliveries.push({ kind: 'context', text: context, source: opts.eventLabel });
359
+ };
360
+ // Through runHookHandler, not spawnHook directly: background runs get the same
361
+ // start/finish lifecycle records, and command dispatch stays in one place.
362
+ void runHookHandler(hook, payload, { cwd: opts.cwd, timeoutMs: opts.timeoutMs, env: opts.env, event: opts.event })
363
+ .then(done, () => done({ status: null, stdout: '', stderr: '' }));
364
+ }
365
+ /** additionalContext + systemMessage from a background hook's JSON stdout — both go to the model. */
366
+ function directiveContext(out) {
367
+ if (!out.startsWith('{'))
368
+ return '';
369
+ try {
370
+ const j = JSON.parse(out);
371
+ const parts = [];
372
+ if (typeof j.additionalContext === 'string' && j.additionalContext)
373
+ parts.push(j.additionalContext);
374
+ if (typeof j.systemMessage === 'string' && j.systemMessage)
375
+ parts.push(j.systemMessage);
376
+ return parts.join('\n');
377
+ }
378
+ catch {
379
+ return '';
380
+ }
381
+ }
382
+ // Run PreToolUse / PostToolUse hooks with a Claude-Code-style control protocol.
383
+ //
384
+ // Each hook command receives a JSON payload on stdin and NEXRALL_TOOL_* env vars.
385
+ // It controls the agent via:
386
+ // • exit code 2 → BLOCK the tool; stderr becomes the reason
387
+ // • stdout JSON object → { "decision": "block"|"allow", "reason": "...",
388
+ // "additionalContext": "text to feed the model" }
389
+ // • any other exit code → non-blocking (stderr logged, tool proceeds)
390
+ /**
391
+ * Run one hook command WITHOUT blocking the event loop. spawnSync froze everything in
392
+ * the process for up to the hook's timeout — parallel sub-agents' streams, the UI, the
393
+ * stall watchdogs — which a 60 s PostToolUse test hook turned into a visible hang.
394
+ */
395
+ function spawnHook(command, opts) {
396
+ return new Promise((resolve) => {
397
+ let stdout = '';
398
+ let stderr = '';
399
+ let settled = false;
400
+ const done = (status) => {
401
+ if (settled)
402
+ return;
403
+ settled = true;
404
+ if (timer)
405
+ clearTimeout(timer);
406
+ resolve({ status, stdout, stderr });
407
+ };
408
+ let child;
409
+ try {
410
+ child = (0, child_process_1.spawn)(command, { shell: true, cwd: opts.cwd, env: opts.env ?? process.env, stdio: ['pipe', 'pipe', 'pipe'] });
411
+ }
412
+ catch {
413
+ resolve({ status: null, stdout: '', stderr: '' });
414
+ return;
415
+ }
416
+ // timeout === null = no timer at all: plain `async` hooks are not bounded
417
+ // (Claude Code enforces no timeout on them), and a clamped setTimeout would
418
+ // fire immediately and kill the hook instead of never firing.
419
+ const timer = opts.timeout === null ? null : setTimeout(() => { try {
420
+ child.kill('SIGTERM');
421
+ }
422
+ catch { /* gone */ } done(null); }, opts.timeout);
423
+ const cap = 16 * 1024 * 1024;
424
+ // `onData` is how `--include-hook-events` sees a long hook's output as it
425
+ // arrives; without such a listener it is undefined and costs one check.
426
+ child.stdout?.on('data', (d) => {
427
+ const s = d.toString('utf-8');
428
+ if (stdout.length < cap)
429
+ stdout += s;
430
+ opts.onData?.({ stdout: s });
431
+ });
432
+ child.stderr?.on('data', (d) => {
433
+ const s = d.toString('utf-8');
434
+ if (stderr.length < cap)
435
+ stderr += s;
436
+ opts.onData?.({ stderr: s });
437
+ });
438
+ child.on('error', () => done(null));
439
+ child.on('close', (code) => done(code));
440
+ child.stdin?.on('error', () => { });
441
+ child.stdin?.end(opts.input ?? '');
442
+ });
443
+ }
444
+ /**
445
+ * Run one `type:"http"` hook: POST the payload as JSON, read the answer with the
446
+ * SAME protocol as a command hook's stdout (Claude Code's HTTP-hook contract).
447
+ *
448
+ * • 2xx + empty body → success (exit 0, no output)
449
+ * • 2xx + JSON object → parsed as the { decision, reason, additionalContext } directive
450
+ * • 2xx + other body → non-blocking error
451
+ * • non-2xx / failure → non-blocking error (never blocks the tool)
452
+ * There is no HTTP equivalent of exit code 2: to block, return 2xx with a directive.
453
+ */
454
+ async function httpHook(hook, payload, timeoutMs) {
455
+ const url = String(hook.url ?? '');
456
+ if (!/^https?:\/\//i.test(url))
457
+ return { status: 1, stdout: '', stderr: `hook url must be http(s): ${url}` };
458
+ const headers = { 'content-type': 'application/json' };
459
+ const allowed = new Set(hook.allowedEnvVars ?? []);
460
+ for (const [k, v] of Object.entries(hook.headers ?? {})) {
461
+ // Unlisted variables expand to nothing rather than leaking the whole environment
462
+ // into a request header.
463
+ headers[k] = String(v).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_m, a1, a2) => (allowed.has(a1 ?? a2) ? (process.env[a1 ?? a2] ?? '') : ''));
464
+ }
465
+ try {
466
+ const res = await fetch(url, { method: 'POST', headers, body: payload, signal: AbortSignal.timeout(timeoutMs) });
467
+ const text = (await res.text()).trim();
468
+ if (res.status < 200 || res.status >= 300) {
469
+ return { status: 1, stdout: '', stderr: `hook returned HTTP ${res.status}` };
470
+ }
471
+ if (!text)
472
+ return { status: 0, stdout: '', stderr: '' };
473
+ if (text.startsWith('{'))
474
+ return { status: 0, stdout: text, stderr: '' };
475
+ return { status: 1, stdout: '', stderr: 'hook returned a non-JSON body' };
476
+ }
477
+ catch (err) {
478
+ return { status: 1, stdout: '', stderr: `hook request failed: ${err.message}` };
479
+ }
480
+ }
481
+ /**
482
+ * `type:"mcp_tool"` (Nexrall extension, same spirit as Claude Code's MCP-tool handlers):
483
+ * call a tool on a CONNECTED MCP server and treat its text like a command hook's stdout —
484
+ * so every caller's directive/blocking logic works unchanged.
485
+ *
486
+ * • tool text starts with "{" → parsed as the { decision, reason, additionalContext } directive
487
+ * • any other text → wrapped as additionalContext (context for the model)
488
+ * • no caller / server error → non-blocking error (never blocks the tool)
489
+ * There is no exit-2 equivalent: to block, return the JSON directive.
490
+ */
491
+ async function mcpToolHook(hook, payload) {
492
+ const caller = _hookMcpCaller;
493
+ const server = String(hook.server ?? '');
494
+ const tool = String(hook.tool ?? '');
495
+ if (!caller) {
496
+ return { status: 1, stdout: '', stderr: `mcp_tool hook "${server}:${tool}" needs a connected MCP client` };
497
+ }
498
+ try {
499
+ const res = await caller(server, tool, mcpHookInput(hook.input, payload));
500
+ if ('error' in res)
501
+ return { status: 1, stdout: '', stderr: res.error };
502
+ const text = (res.text ?? '').trim();
503
+ if (!text)
504
+ return { status: 0, stdout: '', stderr: '' };
505
+ return { status: 0, stdout: text.startsWith('{') ? text : JSON.stringify({ additionalContext: text }), stderr: '' };
506
+ }
507
+ catch (err) {
508
+ return { status: 1, stdout: '', stderr: `mcp_tool hook ${server}:${tool} failed: ${err.message}` };
509
+ }
510
+ }
511
+ /**
512
+ * Resolve `${path.to.value}` placeholders in an mcp_tool hook's `input` against the
513
+ * hook's input JSON. Claude Code spells the tool arguments `${tool_input.file_path}`;
514
+ * plain `${input.file_path}` and a bare `${file_path}` resolve the same way. A value
515
+ * that is EXACTLY one `${…}` keeps its original type (number, boolean, object…).
516
+ */
517
+ function mcpHookInput(input, payload) {
518
+ if (!input)
519
+ return {};
520
+ let root = {};
521
+ try {
522
+ const j = JSON.parse(payload);
523
+ if (j && typeof j === 'object' && !Array.isArray(j))
524
+ root = j;
525
+ }
526
+ catch { /* non-JSON payload: nothing to substitute from */ }
527
+ const nav = (parts) => {
528
+ let cur = root;
529
+ for (const p of parts) {
530
+ if (cur === null || typeof cur !== 'object')
531
+ return undefined;
532
+ cur = cur[p];
533
+ }
534
+ return cur;
535
+ };
536
+ const lookup = (path) => {
537
+ const parts = path.split('.').map((s) => s.trim()).filter(Boolean);
538
+ if (!parts.length)
539
+ return undefined;
540
+ const candidates = [parts];
541
+ if (parts[0] === 'tool_input' || parts[0] === 'input') {
542
+ const rest = parts.slice(1);
543
+ // Both payload layouts: ours nests the args under `input`, Claude Code's under `tool_input`.
544
+ candidates.unshift(rest, ['input', ...rest], ['tool_input', ...rest]);
545
+ }
546
+ for (const c of candidates) {
547
+ const v = nav(c);
548
+ if (v !== undefined)
549
+ return v;
550
+ }
551
+ return undefined;
552
+ };
553
+ const subst = (v) => {
554
+ if (typeof v !== 'string')
555
+ return v;
556
+ const whole = v.match(/^\$\{([^}]+)\}$/);
557
+ if (whole) {
558
+ const hit = lookup(whole[1]);
559
+ return hit === undefined ? '' : hit;
560
+ }
561
+ return v.replace(/\$\{([^}]+)\}/g, (_m, p) => {
562
+ const hit = lookup(p);
563
+ if (hit === undefined)
564
+ return '';
565
+ return typeof hit === 'string' ? hit : JSON.stringify(hit);
566
+ });
567
+ };
568
+ const out = {};
569
+ for (const [k, val] of Object.entries(input))
570
+ out[k] = subst(val);
571
+ return out;
572
+ }
573
+ /** One handler, whichever transport it declares. `event` labels its lifecycle records. */
574
+ async function runHookHandler(hook, payload, opts) {
575
+ return runWithHookEvents(hook, opts.event ?? 'hook', (onData) => {
576
+ if (hook.type === 'http')
577
+ return httpHook(hook, payload, opts.timeoutMs ?? 60000);
578
+ if (hook.type === 'mcp_tool')
579
+ return mcpToolHook(hook, payload);
580
+ if (hook.type === 'prompt' || hook.type === 'agent') {
581
+ return promptHook(hook, payload, { ...opts, timeoutMs: opts.timeoutMs ?? 60000, envCtx: opts.hookEnv });
582
+ }
583
+ return spawnHook(String(hook.command ?? ''), { cwd: opts.cwd, timeout: opts.timeoutMs, input: payload, env: opts.env, onData });
584
+ });
585
+ }
586
+ // ─── Prompt / agent hooks ──────────────────────────────────────────────────────
587
+ //
588
+ // Claude Code's `type:"prompt"` hooks ask a MODEL to decide. We map the model's answer
589
+ // onto the SAME {status, stdout, stderr} contract the process hooks already use, so
590
+ // every caller's blocking logic works unchanged:
591
+ //
592
+ // {"ok":true} → status 0 (allow / pass)
593
+ // {"ok":false,"reason":"…"} → status 2 (exactly the exit-code-2 protocol:
594
+ // blocks where blocking is possible,
595
+ // the reason is what the model/user sees)
596
+ // {"additionalContext":"…"} → passed through as stdout JSON at status 0
597
+ // no/invalid JSON, crash → status 1 (non-blocking error — never a veto)
598
+ //
599
+ // `type:"agent"` is the same thing plus the four READ-ONLY project tools, so the model
600
+ // can look at the repo before deciding. Both call the session's model (or the hook's
601
+ // own `model:`), so they BILL the account — the config docs say so.
602
+ /** The only tools an agent hook may use — read-only, no writes, no bash, no network. */
603
+ const HOOK_AGENT_TOOLS = [
604
+ {
605
+ name: 'read_file',
606
+ description: 'Read a file from the working directory.',
607
+ input_schema: {
608
+ type: 'object',
609
+ properties: {
610
+ path: { type: 'string', description: 'File path, relative to the working directory.' },
611
+ offset: { type: 'number', description: '0-based line to start from.' },
612
+ limit: { type: 'number', description: 'Maximum number of lines.' },
613
+ },
614
+ required: ['path'],
615
+ },
616
+ },
617
+ {
618
+ name: 'search_files',
619
+ description: 'Search file contents (regex) or find files by name in the working directory.',
620
+ input_schema: {
621
+ type: 'object',
622
+ properties: {
623
+ pattern: { type: 'string', description: 'Regex or literal string to search for.' },
624
+ path: { type: 'string', description: 'Directory to search in.' },
625
+ type: { type: 'string', description: 'files (paths only) | content (matching lines) | filename.' },
626
+ },
627
+ required: ['pattern'],
628
+ },
629
+ },
630
+ {
631
+ name: 'list_directory',
632
+ description: 'List the files and directories at a path.',
633
+ input_schema: { type: 'object', properties: { path: { type: 'string' } } },
634
+ },
635
+ {
636
+ name: 'glob',
637
+ description: 'Find files matching a glob pattern, e.g. "**/*.ts".',
638
+ input_schema: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] },
639
+ },
640
+ ];
641
+ /** A model name that cannot exist — the allowlist filter then sends NO tools at all. */
642
+ const NO_TOOLS_MARKER = '\u0000hook-no-tools';
643
+ /** Pull the decision object out of a model reply: first {...} block, fences tolerated. */
644
+ function parsePromptHookReply(reply) {
645
+ const text = String(reply ?? '');
646
+ const start = text.indexOf('{');
647
+ const end = text.lastIndexOf('}');
648
+ if (start < 0 || end <= start)
649
+ return null;
650
+ try {
651
+ const j = JSON.parse(text.slice(start, end + 1));
652
+ if (typeof j.ok !== 'boolean' && typeof j.decision !== 'string')
653
+ return null;
654
+ return {
655
+ ok: typeof j.ok === 'boolean' ? j.ok : undefined,
656
+ reason: typeof j.reason === 'string' ? j.reason : undefined,
657
+ additionalContext: typeof j.additionalContext === 'string' ? j.additionalContext : undefined,
658
+ decision: typeof j.decision === 'string' ? j.decision : undefined,
659
+ };
660
+ }
661
+ catch {
662
+ return null;
663
+ }
664
+ }
665
+ const PROMPT_HOOK_RULES = 'You are a hook evaluator for a coding agent. The hook input JSON is given below, followed by ' +
666
+ 'the hook\'s own instruction. Decide whether the agent action described in the input should ' +
667
+ 'proceed. Reply with ONLY one JSON object and nothing else: {"ok":true} to allow, or ' +
668
+ '{"ok":false,"reason":"short explanation"} to block.';
669
+ async function promptHook(hook, payload, opts) {
670
+ const instruction = String(hook.prompt ?? '');
671
+ if (!instruction)
672
+ return { status: 1, stdout: '', stderr: 'prompt hook has no "prompt"' };
673
+ // `\$ARGUMENTS` escapes the substitution (Claude Code's own rule).
674
+ const body = instruction.replace(/\\\$ARGUMENTS/g, '\u0001').replace(/\$ARGUMENTS/g, payload).replace(/\u0001/g, '$ARGUMENTS');
675
+ // Abort bridge: streamChat polls a plain {aborted} object, and a hook must never hang
676
+ // the turn — the timer flips it at the hook's own timeout, the outer signal aborts too.
677
+ const ctl = { aborted: false };
678
+ const timer = setTimeout(() => { ctl.aborted = true; }, Math.max(1000, opts.timeoutMs));
679
+ const outerPoll = opts.abortSignal
680
+ ? setInterval(() => { if (opts.abortSignal?.aborted)
681
+ ctl.aborted = true; }, 100)
682
+ : undefined;
683
+ if (opts.abortSignal?.aborted) {
684
+ clearTimeout(timer);
685
+ if (outerPoll)
686
+ clearInterval(outerPoll);
687
+ return { status: 1, stdout: '', stderr: 'aborted' };
688
+ }
689
+ try {
690
+ return await runModel();
691
+ }
692
+ finally {
693
+ clearTimeout(timer);
694
+ if (outerPoll)
695
+ clearInterval(outerPoll);
696
+ }
697
+ async function runModel() {
698
+ const messages = [{
699
+ role: 'user',
700
+ content: [{ type: 'text', text: `${PROMPT_HOOK_RULES}\n\nHook input:\n${payload}\n\nHook instruction:\n${body}` }],
701
+ }];
702
+ const isAgent = hook.type === 'agent';
703
+ try {
704
+ let replyText = '';
705
+ for (let round = 0; round < (isAgent ? 6 : 1); round++) {
706
+ const msg = await (0, client_1.streamChat)(messages, {
707
+ model: hook.model ?? opts.model ?? 'turbo',
708
+ env: opts.envCtx ?? { cwd: opts.cwd },
709
+ abortSignal: ctl,
710
+ // Wire the tools by allowlist rather than a separate channel: the backend
711
+ // filters builtins+extra against it, so an agent hook sees ONLY the four
712
+ // read-only tools and a prompt hook sees none (the marker matches nothing).
713
+ toolAllowlist: isAgent ? HOOK_AGENT_TOOLS.map((t) => t.name) : [NO_TOOLS_MARKER],
714
+ extraTools: isAgent ? HOOK_AGENT_TOOLS : undefined,
715
+ }, () => { });
716
+ const blocks = Array.isArray(msg.content) ? msg.content : [];
717
+ const toolUses = blocks.filter((b) => b.type === 'tool_use');
718
+ if (!toolUses.length || !isAgent) {
719
+ replyText = blocks.filter((b) => b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
720
+ break;
721
+ }
722
+ // Run the read-only tool round and feed the results back.
723
+ messages.push({ role: 'assistant', content: msg.content });
724
+ const results = [];
725
+ for (const tu of toolUses) {
726
+ let out;
727
+ try {
728
+ out = getHookToolExecutors().includes(String(tu.name))
729
+ ? await (0, executor_1.executeTool)(String(tu.name), tu.input ?? {}, ctl, undefined, opts.cwd)
730
+ : { error: `Tool ${String(tu.name)} is not available to a hook — only ${getHookToolExecutors().join(', ')}.` };
731
+ }
732
+ catch (err) {
733
+ out = { error: err.message || 'tool failed' };
734
+ }
735
+ results.push({
736
+ type: 'tool_result',
737
+ tool_use_id: String(tu.id),
738
+ content: out.error ? (out.output ? `Error: ${out.error}\n\n${out.output}` : `Error: ${out.error}`) : (out.output ?? ''),
739
+ is_error: out.error !== undefined,
740
+ });
741
+ }
742
+ messages.push({ role: 'user', content: results });
743
+ }
744
+ const parsed = parsePromptHookReply(replyText);
745
+ if (!parsed) {
746
+ // No decision = non-blocking error. A hook that cannot answer must never veto,
747
+ // and it must not silently pass as "allowed" either — the caller logs stderr.
748
+ return { status: 1, stdout: '', stderr: 'prompt hook returned no decision' };
749
+ }
750
+ const decided = parsed.ok === false || parsed.decision === 'block' || parsed.decision === 'deny';
751
+ if (decided)
752
+ return { status: 2, stdout: '', stderr: parsed.reason || `Blocked by ${hook.type} hook` };
753
+ // Allow — carry additionalContext through the same JSON channel callers parse.
754
+ const passthrough = parsed.additionalContext ? JSON.stringify({ additionalContext: parsed.additionalContext }) : '';
755
+ return { status: 0, stdout: passthrough, stderr: '' };
756
+ }
757
+ catch (err) {
758
+ if (ctl.aborted)
759
+ return { status: 1, stdout: '', stderr: 'prompt hook timed out' };
760
+ return { status: 1, stdout: '', stderr: `prompt hook failed: ${err.message}` };
761
+ }
762
+ }
763
+ }
764
+ /** Names executeTool is allowed to run on behalf of an agent hook. */
765
+ function getHookToolExecutors() {
766
+ return ['read_file', 'search_files', 'list_directory', 'glob'];
767
+ }
768
+ /** Model / env / abort for prompt+agent hooks, taken from the run that fires them. */
769
+ function hookRunOptsFor(options) {
770
+ return { model: options.model ?? 'turbo', abortSignal: options.abortSignal, hookEnv: options.env };
771
+ }
772
+ /**
773
+ * Map a stream/API error onto Claude Code's StopFailure error types, so a matcher
774
+ * written for one works here: rate_limit, overloaded, authentication_failed,
775
+ * billing_error, invalid_request, model_not_found, server_error, max_output_tokens,
776
+ * cloud_credential_error, account_on_hold, unknown.
777
+ *
778
+ * Only what the error actually carries is used (HTTP status, then code) — no guessing
779
+ * from message text, which would misfile e.g. a user-facing "rate limit" in a tool error.
780
+ */
781
+ function classifyStopFailure(err) {
782
+ const status = err?.status;
783
+ const code = err?.code;
784
+ const details = [status ? `HTTP ${status}` : '', code ? String(code) : ''].filter(Boolean).join(' ') || undefined;
785
+ if (status === 429)
786
+ return { error: 'rate_limit', error_details: details };
787
+ if (status === 529)
788
+ return { error: 'overloaded', error_details: details };
789
+ if (status === 401)
790
+ return { error: 'authentication_failed', error_details: details };
791
+ if (status === 402)
792
+ return { error: 'billing_error', error_details: details };
793
+ if (status === 403)
794
+ return { error: 'authentication_failed', error_details: details };
795
+ if (status === 400 || status === 422)
796
+ return { error: 'invalid_request', error_details: details };
797
+ if (status === 404)
798
+ return { error: 'model_not_found', error_details: details };
799
+ if (typeof status === 'number' && status >= 500)
800
+ return { error: 'server_error', error_details: details };
801
+ return { error: 'unknown', error_details: details };
802
+ }
803
+ /** The same mapping for stops that ended the loop WITHOUT a throw. */
804
+ function apiFailureForStopReason(stopReason) {
805
+ if (stopReason === 'output-limit')
806
+ return { error: 'max_output_tokens' }; // cut off at the ceiling
807
+ if (stopReason === 'no-balance')
808
+ return { error: 'billing_error', error_details: 'wallet empty (402)' };
809
+ if (stopReason === 'no-team-budget')
810
+ return { error: 'billing_error', error_details: 'team pool empty (402)' };
811
+ if (stopReason === 'reported-elsewhere')
812
+ return { error: 'billing_error', error_details: 'pre-flight 402' };
813
+ if (stopReason === 'team-unavailable')
814
+ return { error: 'account_on_hold', error_details: 'team billing unusable (403)' };
815
+ return null;
816
+ }
817
+ /** `timeout_ms` (Nexrall) or `timeout` seconds (Claude Code), capped at 10 minutes. */
818
+ function hookTimeoutMs(hook, fallbackMs = 60000) {
819
+ if (typeof hook.timeout_ms === 'number' && hook.timeout_ms > 0)
820
+ return Math.min(hook.timeout_ms, 600000);
821
+ if (typeof hook.timeout === 'number' && hook.timeout > 0)
822
+ return Math.min(hook.timeout * 1000, 600000);
823
+ return fallbackMs;
824
+ }
825
+ /**
826
+ * Does a hook's matcher select this tool? Empty / "*" = every tool. "A|B" = either.
827
+ * Each alternative may be a Claude Code tool name ("Bash", "Edit") or a Nexrall one, and
828
+ * a Nexrall-name alternative keeps the old substring behaviour ("file" matches read_file).
829
+ */
830
+ function hookMatches(matcher, toolName) {
831
+ if (!matcher || matcher === '*')
832
+ return true;
833
+ return matcher.split('|').map((m) => m.trim()).filter(Boolean).some((alt) => {
834
+ const norm = (0, agentTypes_1.normaliseToolName)(alt);
835
+ return norm === toolName || (norm === alt && toolName.includes(alt));
836
+ });
837
+ }
838
+ async function runToolHooks(entries, phase, toolName, input, workDir, result, hookOpts) {
839
+ const outcome = { block: false };
840
+ if (!entries?.length)
841
+ return outcome;
842
+ const payload = JSON.stringify({
843
+ phase,
844
+ tool: toolName,
845
+ input,
846
+ ...(result ? { result: { output: result.output, error: result.error } } : {}),
847
+ });
848
+ const eventLabel = `${phase} (${toolName})`;
849
+ const hookEnv = {
850
+ ...process.env,
851
+ NEXRALL_TOOL_NAME: toolName,
852
+ NEXRALL_TOOL_INPUT: JSON.stringify(input),
853
+ NEXRALL_HOOK_PHASE: phase,
854
+ };
855
+ for (const entry of entries) {
856
+ if (!hookMatches(entry.matcher, toolName))
857
+ continue;
858
+ for (const hook of entry.hooks ?? []) {
859
+ // `type:"command"` runs a shell command; `type:"http"` POSTs the same payload
860
+ // to a URL; `type:"prompt"|"agent"` asks the model to decide; `type:"mcp_tool"`
861
+ // calls a tool on a connected MCP server. Anything else is skipped rather than
862
+ // guessed at.
863
+ if (!isRunnableHook(hook))
864
+ continue;
865
+ const gate = hookGate(hook, phase, entry.matcher, workDir);
866
+ if (gate.skip)
867
+ continue;
868
+ // async / asyncRewake: started and NOT awaited — a background hook can never
869
+ // block this tool; its directive output is delivered ahead of the next request.
870
+ // asyncRewake keeps a timeout so a stuck hook cannot strand the wake.
871
+ if (gate.background) {
872
+ startBackgroundHook(hook, payload, {
873
+ cwd: workDir,
874
+ env: hookEnv,
875
+ eventLabel,
876
+ event: phase,
877
+ timeoutMs: hook.asyncRewake ? hookTimeoutMs(hook, 60000) : null,
878
+ key: gate.key,
879
+ });
880
+ continue;
881
+ }
882
+ // Per-hook timeout. Default 60s (was a hard 10s that made the canonical
883
+ // "auto-run tests on PostToolUse" use-case useless — any real suite is
884
+ // slower). Configurable via `timeout_ms` on the hook, capped at 10min.
885
+ const r = await runHookHandler(hook, payload, {
886
+ cwd: workDir,
887
+ timeoutMs: hookTimeoutMs(hook, 60000),
888
+ env: hookEnv,
889
+ event: phase,
890
+ ...hookOpts,
891
+ });
892
+ // Optional JSON directive on stdout
893
+ const out = (r.stdout ?? '').toString().trim();
894
+ if (out.startsWith('{')) {
895
+ try {
896
+ const j = JSON.parse(out);
897
+ if (j.decision === 'block') {
898
+ outcome.block = true;
899
+ outcome.reason = j.reason ?? outcome.reason ?? 'Blocked by hook';
900
+ }
901
+ if (typeof j.additionalContext === 'string' && j.additionalContext) {
902
+ outcome.context = (outcome.context ? outcome.context + '\n' : '') + j.additionalContext;
903
+ }
904
+ }
905
+ catch { /* not a directive — ignore */ }
906
+ }
907
+ // Exit code 2 → hard block; stderr is the reason fed back to the model
908
+ if (r.status === 2) {
909
+ outcome.block = true;
910
+ const err = (r.stderr ?? '').toString().trim();
911
+ outcome.reason = err || outcome.reason || `Blocked by ${phase} hook`;
912
+ }
913
+ if (gate.statusId !== undefined)
914
+ hookStatusEnd(gate.statusId);
915
+ if (r.status === 0 && gate.key)
916
+ _hookOnceDone.add(gate.key);
917
+ }
918
+ }
919
+ return outcome;
920
+ }
921
+ async function runSimpleHooks(defs, workDir, extraEnv, hookOpts, event = 'hook') {
922
+ if (!defs?.length)
923
+ return;
924
+ const env = extraEnv ? { ...process.env, ...extraEnv } : undefined;
925
+ for (const hook of defs) {
926
+ if (!isRunnableHook(hook))
927
+ continue;
928
+ const gate = hookGate(hook, event, undefined, workDir);
929
+ if (gate.skip)
930
+ continue;
931
+ if (gate.background) {
932
+ startBackgroundHook(hook, '', { cwd: workDir, env, eventLabel: event, event, timeoutMs: hook.asyncRewake ? hookTimeoutMs(hook, 10000) : null, key: gate.key });
933
+ continue;
934
+ }
935
+ const r = await runHookHandler(hook, '', { cwd: workDir, timeoutMs: hookTimeoutMs(hook, 10000), env, event, ...hookOpts });
936
+ if (gate.statusId !== undefined)
937
+ hookStatusEnd(gate.statusId);
938
+ if (r.status === 0 && gate.key)
939
+ _hookOnceDone.add(gate.key);
940
+ }
941
+ }
942
+ // ─── Lifecycle hooks (SessionStart / UserPromptSubmit / PreCompact / Notification) ───
943
+ //
944
+ // Same stdin-JSON + exit-code-2 protocol as the tool hooks. `event` is passed as
945
+ // `hook_event_name`; the payload also carries session_id/cwd, plus whatever the event adds.
946
+ // Config accepts Claude Code's `[{ "matcher": "…", "hooks": [{type:"command",…}] }]` and the
947
+ // flat `[{type:"command",…}]` the older Nexrall events use.
948
+ function flattenHookDefs(items, matchValue) {
949
+ const out = [];
950
+ for (const it of items ?? []) {
951
+ if (it && Array.isArray(it.hooks)) {
952
+ const m = it.matcher;
953
+ // For these events the matcher (if any) selects on a plain value (e.g. the compact
954
+ // trigger "auto"/"manual"); empty or "*" matches all.
955
+ if (m && m !== '*' && matchValue !== undefined && !m.split('|').map((x) => x.trim()).includes(matchValue))
956
+ continue;
957
+ out.push(...it.hooks);
958
+ }
959
+ else if (it && isKnownHandlerType(it.type))
960
+ out.push(it);
961
+ }
962
+ return out;
963
+ }
964
+ /** Every handler transport this file knows how to run. */
965
+ function isKnownHandlerType(t) {
966
+ return t === 'command' || t === 'http' || t === 'prompt' || t === 'agent' || t === 'mcp_tool';
967
+ }
968
+ /** A handler whose required field is present (prompt hooks need `prompt`, http need `url`…). */
969
+ function isRunnableHook(hook) {
970
+ if (!isKnownHandlerType(hook.type))
971
+ return false;
972
+ if (hook.type === 'command')
973
+ return !!hook.command;
974
+ if (hook.type === 'http')
975
+ return !!hook.url;
976
+ if (hook.type === 'mcp_tool')
977
+ return !!hook.server && !!hook.tool;
978
+ return !!hook.prompt;
979
+ }
980
+ async function runLifecycleHooks(items, event, workDir, payload, matchValue, hookOpts) {
981
+ const outcome = { block: false };
982
+ const defs = flattenHookDefs(items, matchValue);
983
+ if (!defs.length)
984
+ return outcome;
985
+ const input = JSON.stringify({ hook_event_name: event, cwd: workDir, ...payload });
986
+ for (const hook of defs) {
987
+ if (!isRunnableHook(hook))
988
+ continue;
989
+ const gate = hookGate(hook, event, matchValue, workDir);
990
+ if (gate.skip)
991
+ continue;
992
+ if (gate.background) {
993
+ startBackgroundHook(hook, input, {
994
+ cwd: workDir,
995
+ env: { ...process.env, NEXRALL_HOOK_PHASE: event },
996
+ eventLabel: event,
997
+ event,
998
+ timeoutMs: hook.asyncRewake ? hookTimeoutMs(hook, 60000) : null,
999
+ key: gate.key,
1000
+ });
1001
+ continue;
1002
+ }
1003
+ const timeout = hookTimeoutMs(hook, hook.type === 'agent' ? 60000 : hook.type === 'prompt' ? 30000 : 30000);
1004
+ const r = await runHookHandler(hook, input, { cwd: workDir, timeoutMs: timeout, env: { ...process.env, NEXRALL_HOOK_PHASE: event }, event, ...hookOpts });
1005
+ const out = (r.stdout ?? '').toString().trim();
1006
+ if (out.startsWith('{')) {
1007
+ try {
1008
+ const j = JSON.parse(out);
1009
+ // PermissionRequest answers, in nex's flat form or Claude Code's hookSpecificOutput form.
1010
+ if (event === 'PermissionRequest') {
1011
+ const d = j.hookSpecificOutput?.decision?.behavior ?? (typeof j.decision === 'string' ? j.decision : j.decision?.behavior);
1012
+ if (d === 'allow' || d === 'approve')
1013
+ outcome.allow = true;
1014
+ else if (d === 'deny' || d === 'block') {
1015
+ outcome.block = true;
1016
+ outcome.reason = j.reason ?? j.hookSpecificOutput?.decision?.message ?? outcome.reason ?? 'Denied by PermissionRequest hook';
1017
+ }
1018
+ }
1019
+ if (j.decision === 'block') {
1020
+ outcome.block = true;
1021
+ outcome.reason = j.reason ?? outcome.reason ?? `Blocked by ${event} hook`;
1022
+ }
1023
+ if (typeof j.additionalContext === 'string' && j.additionalContext)
1024
+ outcome.context = (outcome.context ? outcome.context + '\n' : '') + j.additionalContext;
1025
+ // FileChanged: watchPaths is the event's one data-carrying field; systemMessage is shown.
1026
+ if (event === 'FileChanged' && Array.isArray(j.watchPaths))
1027
+ outcome.watchPaths = j.watchPaths.filter((p) => typeof p === 'string');
1028
+ if (typeof j.systemMessage === 'string' && j.systemMessage)
1029
+ outcome.systemMessage = j.systemMessage;
1030
+ }
1031
+ catch { /* not a directive */ }
1032
+ }
1033
+ else if (out && (event === 'SessionStart' || event === 'UserPromptSubmit') && r.status === 0) {
1034
+ // Claude Code parity: plain stdout from these two events is added as context.
1035
+ outcome.context = (outcome.context ? outcome.context + '\n' : '') + out;
1036
+ }
1037
+ if (r.status === 2) {
1038
+ outcome.block = true;
1039
+ outcome.reason = (r.stderr ?? '').toString().trim() || outcome.reason || `Blocked by ${event} hook`;
1040
+ }
1041
+ if (gate.statusId !== undefined)
1042
+ hookStatusEnd(gate.statusId);
1043
+ if (r.status === 0 && gate.key)
1044
+ _hookOnceDone.add(gate.key);
1045
+ }
1046
+ return outcome;
1047
+ }
1048
+ /**
1049
+ * For hosts that show their own permission prompt: ask PermissionRequest hooks first.
1050
+ * 'allow' / 'deny' = a hook answered, so the human is not asked; null = nobody answered,
1051
+ * ask as usual. A DENY wins over an allow (a stricter hook is never overruled by a looser one),
1052
+ * and a hook that crashes or times out answers nothing — it can never silently approve.
1053
+ */
1054
+ async function runPermissionRequestHooks(workDir, tool, input, sessionId = '') {
1055
+ try {
1056
+ const items = loadHooks(workDir).PermissionRequest;
1057
+ if (!items?.length)
1058
+ return { answer: null };
1059
+ const o = await runLifecycleHooks(items, 'PermissionRequest', workDir, { session_id: sessionId, tool_name: tool, tool_input: input }, tool);
1060
+ if (o.block)
1061
+ return { answer: 'deny', reason: o.reason };
1062
+ if (o.allow)
1063
+ return { answer: 'allow' };
1064
+ }
1065
+ catch { /* a hook must never break the prompt */ }
1066
+ return { answer: null };
1067
+ }
1068
+ /** `hookSpecificOutput:{action,content}` (Claude Code's shape) or the flat form; null = no answer. */
1069
+ function parseElicitationAnswer(stdout) {
1070
+ const out = stdout.trim();
1071
+ if (!out.startsWith('{'))
1072
+ return null;
1073
+ try {
1074
+ const j = JSON.parse(out);
1075
+ const src = j.hookSpecificOutput ?? j;
1076
+ if (src.action !== 'accept' && src.action !== 'decline' && src.action !== 'cancel')
1077
+ return null;
1078
+ const content = src.content && typeof src.content === 'object' && !Array.isArray(src.content)
1079
+ ? src.content
1080
+ : undefined;
1081
+ return content ? { action: src.action, content } : { action: src.action };
1082
+ }
1083
+ catch {
1084
+ return null;
1085
+ }
1086
+ }
1087
+ /**
1088
+ * Ask Elicitation hooks before the user sees the dialog. Returns the answer a hook
1089
+ * supplied, or null when none did (the caller then asks the human). Exit 2 = decline.
1090
+ * The FIRST hook that answers wins — later hooks are not consulted, so two hooks can
1091
+ * never fight over one dialog. A background (`async`) hook cannot answer by design.
1092
+ */
1093
+ async function runElicitationHooks(workDir, server, input, sessionId = '') {
1094
+ try {
1095
+ const items = loadHooks(workDir).Elicitation;
1096
+ if (!items?.length)
1097
+ return null;
1098
+ const defs = flattenHookDefs(items, server);
1099
+ if (!defs.length)
1100
+ return null;
1101
+ const payload = JSON.stringify({ session_id: sessionId, cwd: workDir, hook_event_name: 'Elicitation', mcp_server_name: server, ...input });
1102
+ for (const hook of defs) {
1103
+ if (!isRunnableHook(hook))
1104
+ continue;
1105
+ const gate = hookGate(hook, 'Elicitation', server, workDir);
1106
+ if (gate.skip)
1107
+ continue;
1108
+ if (gate.background) {
1109
+ startBackgroundHook(hook, payload, { cwd: workDir, env: { ...process.env, NEXRALL_HOOK_PHASE: 'Elicitation' }, eventLabel: 'Elicitation', event: 'Elicitation', timeoutMs: hook.asyncRewake ? hookTimeoutMs(hook, 60000) : null, key: gate.key });
1110
+ continue;
1111
+ }
1112
+ const r = await runHookHandler(hook, payload, { cwd: workDir, timeoutMs: hookTimeoutMs(hook, 30000), env: { ...process.env, NEXRALL_HOOK_PHASE: 'Elicitation' }, event: 'Elicitation' });
1113
+ if (gate.statusId !== undefined)
1114
+ hookStatusEnd(gate.statusId);
1115
+ if (r.status === 0 && gate.key)
1116
+ _hookOnceDone.add(gate.key);
1117
+ if (r.status === 2)
1118
+ return { action: 'decline' };
1119
+ const answer = parseElicitationAnswer((r.stdout ?? '').toString());
1120
+ if (answer)
1121
+ return answer;
1122
+ }
1123
+ }
1124
+ catch { /* a hook must never break elicitation */ }
1125
+ return null;
1126
+ }
1127
+ /**
1128
+ * Run ElicitationResult hooks after an elicitation was answered and override the answer
1129
+ * with whatever they return (exit 2 = decline, content dropped). Always returns an answer
1130
+ * safe to send to the server, so a throw inside a hook can never turn one into none.
1131
+ */
1132
+ async function runElicitationResultHooks(workDir, server, answer, meta = {}) {
1133
+ try {
1134
+ const items = loadHooks(workDir).ElicitationResult;
1135
+ if (!items?.length)
1136
+ return answer;
1137
+ const defs = flattenHookDefs(items, server);
1138
+ if (!defs.length)
1139
+ return answer;
1140
+ let current = answer;
1141
+ for (const hook of defs) {
1142
+ if (!isRunnableHook(hook))
1143
+ continue;
1144
+ const gate = hookGate(hook, 'ElicitationResult', server, workDir);
1145
+ if (gate.skip)
1146
+ continue;
1147
+ const payload = JSON.stringify({
1148
+ session_id: meta.sessionId ?? '', cwd: workDir, hook_event_name: 'ElicitationResult', mcp_server_name: server,
1149
+ action: current.action, content: current.content, mode: meta.mode, elicitation_id: meta.elicitationId,
1150
+ });
1151
+ if (gate.background) {
1152
+ startBackgroundHook(hook, payload, { cwd: workDir, env: { ...process.env, NEXRALL_HOOK_PHASE: 'ElicitationResult' }, eventLabel: 'ElicitationResult', event: 'ElicitationResult', timeoutMs: hook.asyncRewake ? hookTimeoutMs(hook, 60000) : null, key: gate.key });
1153
+ continue;
1154
+ }
1155
+ const r = await runHookHandler(hook, payload, { cwd: workDir, timeoutMs: hookTimeoutMs(hook, 30000), env: { ...process.env, NEXRALL_HOOK_PHASE: 'ElicitationResult' }, event: 'ElicitationResult' });
1156
+ if (gate.statusId !== undefined)
1157
+ hookStatusEnd(gate.statusId);
1158
+ if (r.status === 0 && gate.key)
1159
+ _hookOnceDone.add(gate.key);
1160
+ if (r.status === 2)
1161
+ return { action: 'decline' };
1162
+ const override = parseElicitationAnswer((r.stdout ?? '').toString());
1163
+ if (override) {
1164
+ if (override.action === 'accept') {
1165
+ const content = override.content ?? (current.action === 'accept' ? current.content : undefined);
1166
+ current = content ? { action: 'accept', content } : { action: 'accept' };
1167
+ }
1168
+ else {
1169
+ current = { action: override.action };
1170
+ }
1171
+ }
1172
+ }
1173
+ return current;
1174
+ }
1175
+ catch {
1176
+ return answer;
1177
+ }
1178
+ }
1179
+ /**
1180
+ * Tell hooks that something happened. Awaitable, but it can NEVER block, throw or change the
1181
+ * outcome: an observer event is a notification, so a hook that exits 2, prints a decision or
1182
+ * crashes is ignored. `matchValue` is the event's selector (tool name, model id, setting key).
1183
+ * Returns how many handler groups were configured, so a caller/test can tell "nothing
1184
+ * listening" from "ran".
1185
+ */
1186
+ async function fireObserverHook(workDir, event, payload, matchValue) {
1187
+ try {
1188
+ const items = loadHooks(workDir)[event];
1189
+ if (!items?.length)
1190
+ return 0;
1191
+ await runLifecycleHooks(items, event, workDir, payload, matchValue);
1192
+ return items.length;
1193
+ }
1194
+ catch {
1195
+ return 0; /* a hook must never break the host */
1196
+ }
1197
+ }
1198
+ /** For hosts (the CLI) that show the prompt themselves: run Notification hooks, never blocking. */
1199
+ function fireNotificationHooks(workDir, message, type = 'permission_prompt') {
1200
+ try {
1201
+ const items = loadHooks(workDir).Notification;
1202
+ if (!items?.length)
1203
+ return;
1204
+ void runLifecycleHooks(items, 'Notification', workDir, { message, notification_type: type }).catch(() => { });
1205
+ }
1206
+ catch { /* ignore */ }
1207
+ }
1208
+ /** For hosts that run their own manual /compact: fire PreCompact (trigger "manual"), awaited, never blocking. */
1209
+ /** The manual /compact counterpart of the auto PostCompact hook (trigger "manual"). */
1210
+ async function fireManualPostCompactHook(workDir, sessionId, messagesSummarized) {
1211
+ try {
1212
+ const items = loadHooks(workDir).PostCompact;
1213
+ if (!items?.length)
1214
+ return;
1215
+ await runLifecycleHooks(items, 'PostCompact', workDir, { trigger: 'manual', session_id: sessionId, messages_summarized: messagesSummarized }, 'manual');
1216
+ }
1217
+ catch { /* a hook must never break compaction */ }
1218
+ }
1219
+ async function fireManualPreCompactHook(workDir, sessionId, messagesToSummarize) {
1220
+ try {
1221
+ const items = loadHooks(workDir).PreCompact;
1222
+ if (!items?.length)
1223
+ return;
1224
+ await runLifecycleHooks(items, 'PreCompact', workDir, { trigger: 'manual', session_id: sessionId, messages_to_summarize: messagesToSummarize }, 'manual');
1225
+ }
1226
+ catch { /* a hook must never break compaction */ }
1227
+ }
1228
+ //# sourceMappingURL=hooks.js.map