@namzu/sdk 63.1.0 → 64.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/dist/agents/QueryAgent.d.ts.map +1 -1
- package/dist/agents/QueryAgent.js +2 -1
- package/dist/agents/QueryAgent.js.map +1 -1
- package/dist/compaction/hooks.d.ts +57 -0
- package/dist/compaction/hooks.d.ts.map +1 -0
- package/dist/compaction/hooks.js +44 -0
- package/dist/compaction/hooks.js.map +1 -0
- package/dist/compaction/manual.d.ts +9 -0
- package/dist/compaction/manual.d.ts.map +1 -1
- package/dist/compaction/manual.js +74 -5
- package/dist/compaction/manual.js.map +1 -1
- package/dist/compaction/model-summary.d.ts +6 -0
- package/dist/compaction/model-summary.d.ts.map +1 -1
- package/dist/compaction/model-summary.js +13 -3
- package/dist/compaction/model-summary.js.map +1 -1
- package/dist/compaction/verifier.d.ts +8 -2
- package/dist/compaction/verifier.d.ts.map +1 -1
- package/dist/compaction/verifier.js +23 -7
- package/dist/compaction/verifier.js.map +1 -1
- package/dist/plugin/hook-context.d.ts +16 -0
- package/dist/plugin/hook-context.d.ts.map +1 -1
- package/dist/plugin/hook-context.js +40 -0
- package/dist/plugin/hook-context.js.map +1 -1
- package/dist/plugin/index.d.ts +4 -3
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -2
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/shell-hook-command.d.ts +25 -0
- package/dist/plugin/shell-hook-command.d.ts.map +1 -0
- package/dist/plugin/shell-hook-command.js +218 -0
- package/dist/plugin/shell-hook-command.js.map +1 -0
- package/dist/plugin/shell-hook-tool-names.d.ts +20 -0
- package/dist/plugin/shell-hook-tool-names.d.ts.map +1 -0
- package/dist/plugin/shell-hook-tool-names.js +43 -0
- package/dist/plugin/shell-hook-tool-names.js.map +1 -0
- package/dist/plugin/shell-hook.d.ts +62 -20
- package/dist/plugin/shell-hook.d.ts.map +1 -1
- package/dist/plugin/shell-hook.js +372 -85
- package/dist/plugin/shell-hook.js.map +1 -1
- package/dist/public-runtime.d.ts +3 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +2 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +2 -1
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/query/compact-session.d.ts +7 -1
- package/dist/runtime/query/compact-session.d.ts.map +1 -1
- package/dist/runtime/query/compact-session.js +8 -3
- package/dist/runtime/query/compact-session.js.map +1 -1
- package/dist/runtime/query/executor/tool-call-admission.d.ts +1 -1
- package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
- package/dist/runtime/query/executor/tool-call-admission.js +4 -3
- package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +5 -3
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/finalize-turn.d.ts.map +1 -1
- package/dist/runtime/query/finalize-turn.js +9 -2
- package/dist/runtime/query/finalize-turn.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +2 -1
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +24 -12
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +2 -0
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/plugin-hooks.d.ts +13 -0
- package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
- package/dist/runtime/query/plugin-hooks.js +29 -2
- package/dist/runtime/query/plugin-hooks.js.map +1 -1
- package/dist/types/agent/query.d.ts +8 -0
- package/dist/types/agent/query.d.ts.map +1 -1
- package/dist/types/plugin/index.d.ts +55 -11
- package/dist/types/plugin/index.d.ts.map +1 -1
- package/dist/types/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/QueryAgent.ts +2 -1
- package/src/compaction/hooks.ts +104 -0
- package/src/compaction/manual.ts +117 -4
- package/src/compaction/model-summary.ts +19 -3
- package/src/compaction/verifier.ts +33 -3
- package/src/plugin/hook-context.ts +46 -0
- package/src/plugin/index.ts +14 -0
- package/src/plugin/shell-hook-command.ts +215 -0
- package/src/plugin/shell-hook-tool-names.ts +46 -0
- package/src/plugin/shell-hook.ts +405 -79
- package/src/public-runtime.ts +12 -0
- package/src/public-types.ts +2 -0
- package/src/runtime/query/compact-session.ts +16 -4
- package/src/runtime/query/executor/tool-call-admission.ts +10 -2
- package/src/runtime/query/executor.ts +5 -2
- package/src/runtime/query/finalize-turn.ts +9 -2
- package/src/runtime/query/iteration/index.ts +2 -1
- package/src/runtime/query/iteration/phases/compaction.ts +31 -12
- package/src/runtime/query/iteration/phases/tool-review.ts +2 -0
- package/src/runtime/query/plugin-hooks.ts +32 -2
- package/src/types/agent/query.ts +9 -0
- package/src/types/plugin/index.ts +66 -22
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
* same facts as `NAMZU_HOOK_EVENT`, `NAMZU_SESSION_ID`, `NAMZU_TURN_ID`,
|
|
25
25
|
* `NAMZU_TOOL_NAME` and, when the input names a file, `NAMZU_TOOL_PATH`;
|
|
26
26
|
* `NAMZU_PROJECT_DIR` is the folder the session was started for.
|
|
27
|
-
* `session_start` also sends `source` (`startup` or `
|
|
28
|
-
* `reason` (`exit` or `
|
|
27
|
+
* `session_start` also sends `source` (`startup`, `resume` or `clear`) and
|
|
28
|
+
* `session_end` `reason` (`exit`, `replaced` or `clear`).
|
|
29
29
|
*
|
|
30
30
|
* `session_start` and `session_end` run outside any turn, so they carry no
|
|
31
31
|
* `turn_id` on stdin and no `NAMZU_TURN_ID` in the environment — not even
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
* stdout. The exit codes:
|
|
37
37
|
*
|
|
38
38
|
* - `0` — carry on. On `user_prompt_submit`, `subagent_start` and
|
|
39
|
-
* `session_start`, plain (non-JSON) stdout becomes context for the model
|
|
39
|
+
* `session_start`, plain (non-JSON) stdout becomes context for the model;
|
|
40
|
+
* on `pre_compact` it is instructions for the summariser.
|
|
40
41
|
* - `2` — **block**, read per event: before a tool (`pre_tool_use`) the call is
|
|
41
42
|
* skipped and the model is told why, with the hook's stderr as the reason;
|
|
42
43
|
* on `user_prompt_submit` the turn ends; on `turn_end` and `subagent_stop`
|
|
@@ -112,7 +113,9 @@ import { RENAMED_PLUGIN_HOOK_EVENTS } from '../types/plugin/index.js';
|
|
|
112
113
|
import { asPluginId } from '../utils/id.js';
|
|
113
114
|
import { NOOP_LOGGER } from '../utils/log/create-logger.js';
|
|
114
115
|
import { boundHookReplacement } from './hook-context.js';
|
|
116
|
+
import { readHookCommand } from './shell-hook-command.js';
|
|
115
117
|
import { postHookJson } from './shell-hook-http.js';
|
|
118
|
+
import { shellHookToolName } from './shell-hook-tool-names.js';
|
|
116
119
|
export const SHELL_HOOK_EVENTS = [
|
|
117
120
|
'user_prompt_submit',
|
|
118
121
|
'session_start',
|
|
@@ -169,12 +172,28 @@ const WRITTEN_TOOL_NAMES = Object.freeze({
|
|
|
169
172
|
taskupdate: 'TaskUpdate',
|
|
170
173
|
tasklist: 'TaskList',
|
|
171
174
|
});
|
|
175
|
+
/**
|
|
176
|
+
* The tools a written name stands for. The skill alias table sends `Task` and
|
|
177
|
+
* `Agent` to `create_task`, but the command line's sub-agent tool is named
|
|
178
|
+
* `Agent`, and a hook written for either spelling means it: both name it.
|
|
179
|
+
*/
|
|
180
|
+
function toolsNamedBy(written) {
|
|
181
|
+
const alias = SKILL_TOOL_NAME_ALIASES[written];
|
|
182
|
+
const names = alias !== undefined ? [alias] : [];
|
|
183
|
+
if (written === 'task' || written === 'agent')
|
|
184
|
+
names.push('agent');
|
|
185
|
+
return names;
|
|
186
|
+
}
|
|
187
|
+
/** A name with the separators dropped: `ViewImage` and `view_image` are one tool. */
|
|
188
|
+
function squashed(name) {
|
|
189
|
+
return name.toLowerCase().replace(/[_-]/g, '');
|
|
190
|
+
}
|
|
172
191
|
/** The names a skill writes for the tool called `toolName` here. */
|
|
173
192
|
function writtenNamesFor(toolName) {
|
|
174
193
|
const lower = toolName.toLowerCase();
|
|
175
194
|
const names = [];
|
|
176
|
-
for (const
|
|
177
|
-
if (
|
|
195
|
+
for (const written of new Set([...Object.keys(SKILL_TOOL_NAME_ALIASES), 'agent', 'task'])) {
|
|
196
|
+
if (!toolsNamedBy(written).includes(lower))
|
|
178
197
|
continue;
|
|
179
198
|
names.push(written);
|
|
180
199
|
const spelled = WRITTEN_TOOL_NAMES[written];
|
|
@@ -183,27 +202,74 @@ function writtenNamesFor(toolName) {
|
|
|
183
202
|
}
|
|
184
203
|
return names;
|
|
185
204
|
}
|
|
205
|
+
/** A glob over names: `*` matches any run of characters, the rest is literal. */
|
|
206
|
+
function globMatches(pattern, subject) {
|
|
207
|
+
const source = pattern
|
|
208
|
+
.split('*')
|
|
209
|
+
.map((piece) => piece.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
210
|
+
.join('.*');
|
|
211
|
+
return new RegExp(`^${source}$`, 'i').test(subject);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Whether a list part (`Edit`, `Web*`, `mcp__*__search`) names `subject`, as a
|
|
215
|
+
* tool name or not. A `*` at the end is a prefix; a `*` anywhere else is a
|
|
216
|
+
* glob. For a tool, a bare tool-server name (`mcp__github`) names every tool
|
|
217
|
+
* of that server.
|
|
218
|
+
*/
|
|
219
|
+
function listPartNames(part, subject, tool) {
|
|
220
|
+
const prefix = part.endsWith('*');
|
|
221
|
+
const core = prefix ? part.slice(0, -1) : part;
|
|
222
|
+
const written = core.toLowerCase();
|
|
223
|
+
const lower = subject.toLowerCase();
|
|
224
|
+
if (core.includes('*')) {
|
|
225
|
+
if (globMatches(part, subject))
|
|
226
|
+
return true;
|
|
227
|
+
return tool && writtenNamesFor(subject).some((name) => globMatches(part, name));
|
|
228
|
+
}
|
|
229
|
+
const spellings = [written];
|
|
230
|
+
if (tool)
|
|
231
|
+
spellings.push(...toolsNamedBy(written));
|
|
232
|
+
return spellings.some((name) => {
|
|
233
|
+
if (prefix ? lower.startsWith(name) : lower === name)
|
|
234
|
+
return true;
|
|
235
|
+
if (!tool)
|
|
236
|
+
return false;
|
|
237
|
+
// A tool's name also compares without its `_` and `-`.
|
|
238
|
+
if (prefix ? squashed(lower).startsWith(squashed(name)) : squashed(lower) === squashed(name)) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return !prefix && name.startsWith('mcp__') && !name.slice(5).includes('__')
|
|
242
|
+
? lower.startsWith(`${name}__`)
|
|
243
|
+
: false;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const MATCHER_LIST_PART = /^[A-Za-z0-9_.:*-]+$/;
|
|
186
247
|
/**
|
|
187
248
|
* `matcher` against a name (a tool's, or a notification's type).
|
|
188
249
|
*
|
|
189
250
|
* Empty or `*` matches everything. A list of names separated by `|` or `,`
|
|
190
251
|
* matches exactly, each part optionally ending in `*` as a prefix (the
|
|
191
252
|
* spelling this adapter has always taken). Anything else is a regular
|
|
192
|
-
* expression, tested unanchored against the name
|
|
193
|
-
* matches
|
|
194
|
-
*
|
|
253
|
+
* expression, tested unanchored against the name. This function called with no
|
|
254
|
+
* name matches only `*`; a hook on an event that has no name (`turn_end`)
|
|
255
|
+
* ignores its matcher and fires (see `createShellHook`).
|
|
256
|
+
*
|
|
257
|
+
* A text that reads both ways (it is a list, and it holds a `.*`
|
|
258
|
+
* that a regular expression reads differently: `Bash.*`,
|
|
259
|
+
* `mcp__.*__search`) fires when either reading matches. A pattern that does
|
|
260
|
+
* not compile cannot be read, and a guard that silently matches nothing is
|
|
261
|
+
* the failure to avoid, so it matches every name; `/hooks` and
|
|
262
|
+
* `namzu doctor` say that it cannot be read (`shellHookMatcherProblem`).
|
|
195
263
|
*
|
|
196
264
|
* Names in a list compare without regard to case. With `tool: true` (the
|
|
197
265
|
* event's name is a tool: the tool events and `permission_request`) they also
|
|
198
|
-
* take the names skills write, the way an entry's `if` does
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* A list part ending in `.*` is also tried as a regular expression.
|
|
266
|
+
* take the names skills write, the way an entry's `if` does, and a tool name
|
|
267
|
+
* compares without its `_` and `-`: `Bash` matches the tool `bash`,
|
|
268
|
+
* `MultiEdit` matches `edit`, `ViewImage` matches `view_image`, `Task` and
|
|
269
|
+
* `Agent` match the sub-agent tool `Agent`, and a prefix such as `Web*`
|
|
270
|
+
* matches `web_fetch`. Where the name is not a tool (a notification's type,
|
|
271
|
+
* an agent's name, a session's `source` or `reason`) no tool alias applies,
|
|
272
|
+
* since those words are not tool names; case is still ignored there.
|
|
207
273
|
*
|
|
208
274
|
* A regular expression is tested, case-sensitively as written, against the
|
|
209
275
|
* real name and, with `tool: true`, against each name skills write for that
|
|
@@ -217,23 +283,13 @@ export function shellHookMatches(matcher, toolName, options = {}) {
|
|
|
217
283
|
return false;
|
|
218
284
|
const tool = options.tool === true;
|
|
219
285
|
const parts = matcher.split(/[|,]/).map((part) => part.trim());
|
|
220
|
-
if (parts.every((part) =>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const alias = SKILL_TOOL_NAME_ALIASES[written];
|
|
228
|
-
if (alias !== undefined)
|
|
229
|
-
spellings.push(alias);
|
|
230
|
-
}
|
|
231
|
-
return spellings.some((name) => (prefix ? subject.startsWith(name) : subject === name));
|
|
232
|
-
});
|
|
233
|
-
// `Bash.*` reads as the prefix `bash.` above, which no tool has. A part
|
|
234
|
-
// ending in `.*` is also a regular expression, so it is tried as one.
|
|
235
|
-
if (listed || !matcher.includes('.*'))
|
|
236
|
-
return listed;
|
|
286
|
+
if (parts.every((part) => MATCHER_LIST_PART.test(part))) {
|
|
287
|
+
if (parts.some((part) => listPartNames(part, toolName, tool)))
|
|
288
|
+
return true;
|
|
289
|
+
// A list of names is exact (a `*` in it is a prefix or a glob). Only `.*` reads differently
|
|
290
|
+
// as a regular expression, so only then is the second reading tried.
|
|
291
|
+
if (!matcher.includes('.*'))
|
|
292
|
+
return false;
|
|
237
293
|
}
|
|
238
294
|
try {
|
|
239
295
|
const expression = new RegExp(matcher);
|
|
@@ -242,41 +298,95 @@ export function shellHookMatches(matcher, toolName, options = {}) {
|
|
|
242
298
|
return tool && writtenNamesFor(toolName).some((name) => expression.test(name));
|
|
243
299
|
}
|
|
244
300
|
catch {
|
|
245
|
-
return
|
|
301
|
+
return true;
|
|
246
302
|
}
|
|
247
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Why a `matcher` cannot be read, or `undefined` when it can. A matcher that
|
|
306
|
+
* is not a list of names and does not compile as a regular expression fires
|
|
307
|
+
* for every name (see {@link shellHookMatches}).
|
|
308
|
+
*/
|
|
309
|
+
export function shellHookMatcherProblem(matcher) {
|
|
310
|
+
if (matcher === undefined || matcher.trim() === '' || matcher.trim() === '*')
|
|
311
|
+
return undefined;
|
|
312
|
+
const parts = matcher.split(/[|,]/).map((part) => part.trim());
|
|
313
|
+
if (parts.every((part) => MATCHER_LIST_PART.test(part)))
|
|
314
|
+
return undefined;
|
|
315
|
+
// `Bash(git push *)` is a tool and a pattern, which is what an `if` reads. As a
|
|
316
|
+
// matcher it is a regular expression that no tool name satisfies, so the hook
|
|
317
|
+
// would never fire; say so, and name the `if`.
|
|
318
|
+
if (/^[A-Za-z0-9_.:*-]+\([\s\S]*\)$/.test(matcher.trim())) {
|
|
319
|
+
return `the matcher "${matcher}" is written as Tool(pattern), which is an if, not a matcher: as a matcher it matches no tool name and the hook never fires; put the tool in matcher and the pattern in if`;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
new RegExp(matcher);
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
return `the matcher "${matcher}" is not a list of names and is not a regular expression (${error.message}), so it cannot be read and the hook fires for every call`;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const IF_SHAPE = /^([A-Za-z0-9_.:*|, -]+)(?:\(([\s\S]*)\))?$/;
|
|
330
|
+
/**
|
|
331
|
+
* Why an `if` cannot be read, or `undefined` when it can. A filter that
|
|
332
|
+
* cannot be read runs the hook.
|
|
333
|
+
*/
|
|
334
|
+
export function shellHookIfProblem(filter) {
|
|
335
|
+
if (filter === undefined || filter.trim() === '')
|
|
336
|
+
return undefined;
|
|
337
|
+
return IF_SHAPE.test(filter.trim())
|
|
338
|
+
? undefined
|
|
339
|
+
: `the if "${filter}" is not Tool or Tool(pattern), so it cannot be read and the hook runs for every call`;
|
|
340
|
+
}
|
|
248
341
|
/**
|
|
249
342
|
* A hook's `if` against one tool call: `Tool` or `Tool(pattern)`. The tool
|
|
250
|
-
* part
|
|
251
|
-
* `Read`)
|
|
252
|
-
*
|
|
253
|
-
*
|
|
343
|
+
* part takes every spelling a `matcher` takes as a list: names skills write
|
|
344
|
+
* (`Bash`, `Read`, `Agent`), separated by `|` or `,`, a prefix ending in `*`,
|
|
345
|
+
* case ignored; a regular expression is not read here (use `matcher`). A
|
|
346
|
+
* pattern is matched against a shell tool's command line (one command at a
|
|
347
|
+
* time) or, for a file tool, a path the host reads; for a tool with neither
|
|
348
|
+
* the filter cannot be read, which runs the hook.
|
|
254
349
|
*/
|
|
255
350
|
export function shellHookIfMatches(filter, toolName, toolInput, pathFilter) {
|
|
256
351
|
if (filter === undefined || filter.trim() === '')
|
|
257
352
|
return true;
|
|
258
353
|
if (toolName === undefined)
|
|
259
354
|
return true;
|
|
260
|
-
const shape =
|
|
355
|
+
const shape = IF_SHAPE.exec(filter.trim());
|
|
261
356
|
if (!shape)
|
|
262
357
|
return true;
|
|
263
|
-
const written = (shape[1] ?? '').toLowerCase();
|
|
264
|
-
const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written;
|
|
265
358
|
const specifier = shape[2]?.trim();
|
|
266
359
|
const hasPattern = specifier !== undefined && specifier !== '' && specifier.replaceAll('*', '') !== '';
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return answer;
|
|
273
|
-
}
|
|
274
|
-
if (tool !== toolName.toLowerCase())
|
|
275
|
-
return false;
|
|
276
|
-
if (!hasPattern)
|
|
360
|
+
const parts = (shape[1] ?? '')
|
|
361
|
+
.split(/[|,]/)
|
|
362
|
+
.map((part) => part.trim())
|
|
363
|
+
.filter((part) => part !== '');
|
|
364
|
+
if (parts.length === 0)
|
|
277
365
|
return true;
|
|
278
|
-
if (
|
|
366
|
+
if (parts.some((part) => part === '*'))
|
|
279
367
|
return true;
|
|
368
|
+
const callTool = toolName.toLowerCase();
|
|
369
|
+
return parts.some((part) => {
|
|
370
|
+
const written = part.replace(/\*$/, '').toLowerCase();
|
|
371
|
+
const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written;
|
|
372
|
+
// A path pattern is the permission rules' own: the host that has them says
|
|
373
|
+
// whether this call's path is named. It decides for the tools it knows.
|
|
374
|
+
if (hasPattern && pathFilter !== undefined && !part.endsWith('*')) {
|
|
375
|
+
const answer = pathFilter(tool, specifier, toolName, toolInput);
|
|
376
|
+
if (answer !== undefined)
|
|
377
|
+
return answer;
|
|
378
|
+
}
|
|
379
|
+
if (!listPartNames(part, toolName, true))
|
|
380
|
+
return false;
|
|
381
|
+
if (!hasPattern)
|
|
382
|
+
return true;
|
|
383
|
+
if (callTool !== 'bash')
|
|
384
|
+
return true;
|
|
385
|
+
return commandMatches(specifier, toolInput);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/** A shell tool's command line against a permission-rule pattern, per command. */
|
|
389
|
+
function commandMatches(specifier, toolInput) {
|
|
280
390
|
const line = toolInput !== null && typeof toolInput === 'object'
|
|
281
391
|
? toolInput.command
|
|
282
392
|
: undefined;
|
|
@@ -291,7 +401,17 @@ export function shellHookIfMatches(filter, toolName, toolInput, pathFilter) {
|
|
|
291
401
|
// whole line is visible and none of its commands matches.
|
|
292
402
|
if (decomposed.opaque)
|
|
293
403
|
return true;
|
|
294
|
-
|
|
404
|
+
// Wrappers, a program's full path and git's global options do not change
|
|
405
|
+
// which command runs; a command whose name is only known at run time
|
|
406
|
+
// cannot be compared, so the hook runs.
|
|
407
|
+
const reading = readHookCommand(line);
|
|
408
|
+
if (reading.unknowable)
|
|
409
|
+
return true;
|
|
410
|
+
const candidates = [
|
|
411
|
+
...decomposed.segments,
|
|
412
|
+
...decodedCommands(line, 'sh'),
|
|
413
|
+
...reading.candidates,
|
|
414
|
+
].map((each) => each.trim());
|
|
295
415
|
return candidates.some((each) => matcher.test(each));
|
|
296
416
|
}
|
|
297
417
|
catch {
|
|
@@ -302,7 +422,7 @@ export function shellHookIfMatches(filter, toolName, toolInput, pathFilter) {
|
|
|
302
422
|
export function shellHookPayload(input) {
|
|
303
423
|
return JSON.stringify({
|
|
304
424
|
event: input.event,
|
|
305
|
-
hook_event_name:
|
|
425
|
+
hook_event_name: SHELL_HOOK_PASCAL_EVENT_NAMES[input.event] ?? input.event,
|
|
306
426
|
cwd: input.cwd,
|
|
307
427
|
session_id: input.sessionId,
|
|
308
428
|
...(input.transcriptPath !== undefined ? { transcript_path: input.transcriptPath } : {}),
|
|
@@ -315,9 +435,14 @@ export function shellHookPayload(input) {
|
|
|
315
435
|
? { agent_id: input.sessionId, agent_type: input.agentType }
|
|
316
436
|
: {}),
|
|
317
437
|
...(input.prompt !== undefined ? { prompt: input.prompt } : {}),
|
|
318
|
-
...(input.compaction !== undefined ?
|
|
319
|
-
...(input.toolName !== undefined
|
|
320
|
-
|
|
438
|
+
...(input.compaction !== undefined ? compactionFields(input.event, input.compaction) : {}),
|
|
439
|
+
...(input.toolName !== undefined
|
|
440
|
+
? { tool_name: shellHookToolName(input.toolName, input.toolNames) }
|
|
441
|
+
: {}),
|
|
442
|
+
...(input.toolCallId !== undefined ? { tool_use_id: input.toolCallId } : {}),
|
|
443
|
+
...(input.toolInput !== undefined
|
|
444
|
+
? { tool_input: withFilePathKey(input.toolName, input.toolInput) }
|
|
445
|
+
: {}),
|
|
321
446
|
...(input.toolResult !== undefined
|
|
322
447
|
? { tool_result: input.toolResult, tool_response: input.toolResult }
|
|
323
448
|
: {}),
|
|
@@ -327,8 +452,8 @@ export function shellHookPayload(input) {
|
|
|
327
452
|
...(input.toolCalls !== undefined
|
|
328
453
|
? {
|
|
329
454
|
tool_calls: input.toolCalls.map((call) => ({
|
|
330
|
-
tool_name: call.toolName,
|
|
331
|
-
tool_input: call.toolInput,
|
|
455
|
+
tool_name: shellHookToolName(call.toolName, input.toolNames),
|
|
456
|
+
tool_input: withFilePathKey(call.toolName, call.toolInput),
|
|
332
457
|
tool_use_id: call.toolCallId,
|
|
333
458
|
tool_response: call.output,
|
|
334
459
|
is_error: call.isError,
|
|
@@ -348,8 +473,26 @@ export function shellHookPayload(input) {
|
|
|
348
473
|
}
|
|
349
474
|
: {}),
|
|
350
475
|
...(input.stopHookActive === true ? { stop_hook_active: true } : {}),
|
|
476
|
+
...(input.lastAssistantMessage !== undefined
|
|
477
|
+
? { last_assistant_message: input.lastAssistantMessage }
|
|
478
|
+
: {}),
|
|
351
479
|
});
|
|
352
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* A compaction hook's fields. `compaction` keeps what it always had and gains
|
|
483
|
+
* `trigger`; the person's instructions and the summary are top-level
|
|
484
|
+
* (`custom_instructions`, null when there are none, and `compact_summary`),
|
|
485
|
+
* where a script reads them without opening the object.
|
|
486
|
+
*/
|
|
487
|
+
function compactionFields(event, compaction) {
|
|
488
|
+
const { instructions, summary, ...rest } = compaction;
|
|
489
|
+
return {
|
|
490
|
+
compaction: rest,
|
|
491
|
+
...(compaction.trigger !== undefined ? { trigger: compaction.trigger } : {}),
|
|
492
|
+
...(event === 'pre_compact' ? { custom_instructions: instructions ?? null } : {}),
|
|
493
|
+
...(event === 'post_compact' && summary !== undefined ? { compact_summary: summary } : {}),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
353
496
|
/** Run one hook command to completion, bounded by its deadline and capture. */
|
|
354
497
|
export function runShellHook(entry, input, signal) {
|
|
355
498
|
const timeoutMs = Math.min(MAX_SHELL_HOOK_TIMEOUT_MS, Math.max(1, entry.timeoutMs ?? DEFAULT_SHELL_HOOK_TIMEOUT_MS));
|
|
@@ -367,7 +510,9 @@ export function runShellHook(entry, input, signal) {
|
|
|
367
510
|
NAMZU_SESSION_ID: input.sessionId,
|
|
368
511
|
NAMZU_PROJECT_DIR: input.projectDir ?? input.cwd,
|
|
369
512
|
...(input.turnId !== undefined ? { NAMZU_TURN_ID: input.turnId } : {}),
|
|
370
|
-
...(input.toolName !== undefined
|
|
513
|
+
...(input.toolName !== undefined
|
|
514
|
+
? { NAMZU_TOOL_NAME: shellHookToolName(input.toolName, input.toolNames) }
|
|
515
|
+
: {}),
|
|
371
516
|
...(toolPath !== undefined ? { NAMZU_TOOL_PATH: toolPath } : {}),
|
|
372
517
|
};
|
|
373
518
|
// Its own process group, so the deadline can kill everything the
|
|
@@ -492,6 +637,56 @@ export function runShellHook(entry, input, signal) {
|
|
|
492
637
|
child.stdin?.end(payload);
|
|
493
638
|
});
|
|
494
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* The tools whose input names a file or folder `path`. A moved script reads
|
|
642
|
+
* that file under the key `file_path`, so the input a hook receives carries
|
|
643
|
+
* both, with the same value.
|
|
644
|
+
*/
|
|
645
|
+
const FILE_PATH_TOOLS = new Set([
|
|
646
|
+
'read',
|
|
647
|
+
'write',
|
|
648
|
+
'edit',
|
|
649
|
+
'grep',
|
|
650
|
+
'glob',
|
|
651
|
+
'ls',
|
|
652
|
+
'view_image',
|
|
653
|
+
'lsp',
|
|
654
|
+
]);
|
|
655
|
+
/** `toolInput` as a hook receives it: a file tool's `path` is also `file_path`. */
|
|
656
|
+
function withFilePathKey(toolName, toolInput) {
|
|
657
|
+
if (toolName === undefined || !FILE_PATH_TOOLS.has(toolName.toLowerCase()))
|
|
658
|
+
return toolInput;
|
|
659
|
+
if (!isRecord(toolInput))
|
|
660
|
+
return toolInput;
|
|
661
|
+
const path = toolInput.path;
|
|
662
|
+
if (typeof path !== 'string' || 'file_path' in toolInput)
|
|
663
|
+
return toolInput;
|
|
664
|
+
return { ...toolInput, file_path: path };
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* A rewritten input read back. An echoed `file_path` is never a reason to
|
|
668
|
+
* refuse the rewrite: when only one of the two keys was changed that change is
|
|
669
|
+
* the new path; when both were changed to different values the answer cannot be
|
|
670
|
+
* read (`undefined`). The tool never sees `file_path`.
|
|
671
|
+
*/
|
|
672
|
+
function readRewrittenInput(toolName, original, rewritten) {
|
|
673
|
+
if (toolName === undefined || !FILE_PATH_TOOLS.has(toolName.toLowerCase()))
|
|
674
|
+
return rewritten;
|
|
675
|
+
if (!('file_path' in rewritten))
|
|
676
|
+
return rewritten;
|
|
677
|
+
const { file_path: echoed, ...rest } = rewritten;
|
|
678
|
+
const before = isRecord(original) ? original.path : undefined;
|
|
679
|
+
const pathChanged = 'path' in rest && rest.path !== before;
|
|
680
|
+
const echoChanged = echoed !== before;
|
|
681
|
+
if (pathChanged && echoChanged && echoed !== rest.path)
|
|
682
|
+
return undefined;
|
|
683
|
+
if (!pathChanged && echoChanged) {
|
|
684
|
+
if (typeof echoed !== 'string')
|
|
685
|
+
return undefined;
|
|
686
|
+
return { ...rest, path: echoed };
|
|
687
|
+
}
|
|
688
|
+
return rest;
|
|
689
|
+
}
|
|
495
690
|
function pathOf(toolInput) {
|
|
496
691
|
if (toolInput === null || typeof toolInput !== 'object')
|
|
497
692
|
return undefined;
|
|
@@ -504,7 +699,7 @@ function pathOf(toolInput) {
|
|
|
504
699
|
return undefined;
|
|
505
700
|
}
|
|
506
701
|
/** The PascalCase spelling of each event name, which a hook's JSON may use. */
|
|
507
|
-
const
|
|
702
|
+
export const SHELL_HOOK_PASCAL_EVENT_NAMES = {
|
|
508
703
|
user_prompt_submit: 'UserPromptSubmit',
|
|
509
704
|
session_start: 'SessionStart',
|
|
510
705
|
session_end: 'SessionEnd',
|
|
@@ -522,6 +717,31 @@ const PASCAL_EVENT_NAMES = {
|
|
|
522
717
|
turn_failure: 'StopFailure',
|
|
523
718
|
turn_end: 'Stop',
|
|
524
719
|
};
|
|
720
|
+
/** The keys that carry a replacement for a tool's result. */
|
|
721
|
+
const REPLACEMENT_KEYS = ['updatedToolOutput', 'updatedMCPToolOutput'];
|
|
722
|
+
/**
|
|
723
|
+
* A replacement for a tool's result, whatever its JSON type. A string is the
|
|
724
|
+
* text; an object that has the shape of the result object a hook is sent
|
|
725
|
+
* (`output` a string, `success` a boolean and `error` a string when present)
|
|
726
|
+
* is read as that; anything else (another object, an array, a number, a
|
|
727
|
+
* boolean) is shown as its JSON text. The call's own success is not changed.
|
|
728
|
+
*/
|
|
729
|
+
function replacementText(value) {
|
|
730
|
+
if (typeof value === 'string')
|
|
731
|
+
return value;
|
|
732
|
+
if (isRecord(value) &&
|
|
733
|
+
typeof value.output === 'string' &&
|
|
734
|
+
(value.success === undefined || typeof value.success === 'boolean') &&
|
|
735
|
+
(value.error === undefined || typeof value.error === 'string')) {
|
|
736
|
+
if (value.success === false && typeof value.error === 'string' && value.error !== '') {
|
|
737
|
+
return value.output === ''
|
|
738
|
+
? `Error: ${value.error}`
|
|
739
|
+
: `${value.output}\n\nError: ${value.error}`;
|
|
740
|
+
}
|
|
741
|
+
return value.output;
|
|
742
|
+
}
|
|
743
|
+
return JSON.stringify(value);
|
|
744
|
+
}
|
|
525
745
|
function isRecord(value) {
|
|
526
746
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
527
747
|
}
|
|
@@ -531,7 +751,7 @@ function isRecord(value) {
|
|
|
531
751
|
* sets the whole object aside rather than half-obeying it, so a typo in a
|
|
532
752
|
* guard's `decision` is reported instead of silently becoming "continue".
|
|
533
753
|
*/
|
|
534
|
-
function readShellHookJson(event, stdout) {
|
|
754
|
+
function readShellHookJson(event, stdout, call = {}) {
|
|
535
755
|
const text = stdout.trim();
|
|
536
756
|
if (!text.startsWith('{'))
|
|
537
757
|
return undefined;
|
|
@@ -570,6 +790,20 @@ function readShellHookJson(event, stdout) {
|
|
|
570
790
|
parsed.decision !== 'block') {
|
|
571
791
|
return { invalid: '`decision` must be "approve" or "block"' };
|
|
572
792
|
}
|
|
793
|
+
// A replacement written where it is not read (at the top level of the answer
|
|
794
|
+
// instead of under `hookSpecificOutput`) is a hook that clearly tried to say
|
|
795
|
+
// something we cannot read: set the answer aside as unreadable, so
|
|
796
|
+
// `onFailure: block` withholds the result rather than showing the original.
|
|
797
|
+
if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
|
|
798
|
+
const misplaced = REPLACEMENT_KEYS.find((key) => parsed[key] !== undefined);
|
|
799
|
+
const inSpecific = isRecord(parsed.hookSpecificOutput) &&
|
|
800
|
+
REPLACEMENT_KEYS.some((key) => parsed.hookSpecificOutput[key] !== undefined);
|
|
801
|
+
if (misplaced !== undefined && !inSpecific) {
|
|
802
|
+
return {
|
|
803
|
+
invalid: `\`${misplaced}\` is read under \`hookSpecificOutput\`, not at the top level of the answer`,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
}
|
|
573
807
|
const reading = {};
|
|
574
808
|
if (parsed.suppressOutput === true)
|
|
575
809
|
reading.suppressOutput = true;
|
|
@@ -595,7 +829,7 @@ function readShellHookJson(event, stdout) {
|
|
|
595
829
|
if (typeof named !== 'string') {
|
|
596
830
|
return { invalid: '`hookSpecificOutput` is missing its `hookEventName`' };
|
|
597
831
|
}
|
|
598
|
-
if (named !== event && named !==
|
|
832
|
+
if (named !== event && named !== SHELL_HOOK_PASCAL_EVENT_NAMES[event]) {
|
|
599
833
|
return {
|
|
600
834
|
invalid: `\`hookSpecificOutput.hookEventName\` is "${named}" but this hook ran for ${event}`,
|
|
601
835
|
};
|
|
@@ -632,14 +866,25 @@ function readShellHookJson(event, stdout) {
|
|
|
632
866
|
if (specific.updatedInput !== undefined) {
|
|
633
867
|
if (!isRecord(specific.updatedInput))
|
|
634
868
|
return { invalid: '`updatedInput` must be an object' };
|
|
635
|
-
|
|
869
|
+
const rewritten = readRewrittenInput(call.toolName, call.toolInput, specific.updatedInput);
|
|
870
|
+
if (rewritten === undefined) {
|
|
871
|
+
return {
|
|
872
|
+
invalid: '`updatedInput` changes both `path` and `file_path`, to different values: say which file is meant',
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
reading.updatedInput = rewritten;
|
|
636
876
|
}
|
|
637
877
|
}
|
|
638
|
-
if (event === 'post_tool_use'
|
|
639
|
-
|
|
640
|
-
|
|
878
|
+
if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
|
|
879
|
+
// For a tool-server tool the common contract has a second field, read
|
|
880
|
+
// when `updatedToolOutput` is absent or null. `updatedToolOutput` wins when
|
|
881
|
+
// both carry a value: it is the field for every tool, so a script that
|
|
882
|
+
// sets both has said the general thing last.
|
|
883
|
+
const toolServer = call.toolName?.startsWith('mcp__') === true;
|
|
884
|
+
const chosen = specific.updatedToolOutput ?? (toolServer ? specific.updatedMCPToolOutput : undefined);
|
|
885
|
+
if (chosen !== undefined && chosen !== null) {
|
|
886
|
+
reading.replacement = boundHookReplacement(replacementText(chosen));
|
|
641
887
|
}
|
|
642
|
-
reading.replacement = boundHookReplacement(specific.updatedToolOutput);
|
|
643
888
|
}
|
|
644
889
|
if (event === 'permission_request') {
|
|
645
890
|
const decision = specific.decision;
|
|
@@ -667,7 +912,7 @@ function readShellHookJson(event, stdout) {
|
|
|
667
912
|
* so the mapping — the part that decides what the model is told — is
|
|
668
913
|
* testable without a process.
|
|
669
914
|
*/
|
|
670
|
-
export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER) {
|
|
915
|
+
export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER, call = {}) {
|
|
671
916
|
const label = describeShellHook(entry);
|
|
672
917
|
const attrs = {
|
|
673
918
|
'namzu.hook.event': event,
|
|
@@ -692,7 +937,7 @@ export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER) {
|
|
|
692
937
|
disclose: true,
|
|
693
938
|
};
|
|
694
939
|
}
|
|
695
|
-
return shellHookVerdict(event, { ...entry, onFailure: 'continue' }, { exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` }, log);
|
|
940
|
+
return shellHookVerdict(event, { ...entry, onFailure: 'continue' }, { exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` }, log, call);
|
|
696
941
|
};
|
|
697
942
|
if (outcome.spawnError !== undefined) {
|
|
698
943
|
log.warn('hook could not start', {
|
|
@@ -745,12 +990,27 @@ export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER) {
|
|
|
745
990
|
? {
|
|
746
991
|
invalid: `answer is too large: the hook printed more than the ${MAX_CAPTURE_BYTES} characters of stdout that are read`,
|
|
747
992
|
}
|
|
748
|
-
: readShellHookJson(event, outcome.stdout);
|
|
993
|
+
: readShellHookJson(event, outcome.stdout, call);
|
|
749
994
|
if (reading === undefined) {
|
|
995
|
+
// A post-tool hook whose plain output (or whose stderr) names a replacement
|
|
996
|
+
// key tried to replace the result and put the answer where it is not read
|
|
997
|
+
// (a log line before the JSON, the JSON on stderr). That is an answer that
|
|
998
|
+
// does not read, so it follows `onFailure` like one.
|
|
999
|
+
if ((event === 'post_tool_use' || event === 'post_tool_use_failure') &&
|
|
1000
|
+
REPLACEMENT_KEYS.some((key) => outcome.stdout.includes(key) || outcome.stderr.includes(key))) {
|
|
1001
|
+
log.warn('hook output ignored', {
|
|
1002
|
+
...attrs,
|
|
1003
|
+
'namzu.hook.problem': 'a replacement is named but the answer is not a JSON object on stdout',
|
|
1004
|
+
});
|
|
1005
|
+
return failed('its answer names a replacement but is not a JSON object that starts stdout');
|
|
1006
|
+
}
|
|
750
1007
|
// On the prompt event, what the hook printed is context for the
|
|
751
1008
|
// model — the branch, the time, a ticket. Everywhere else stdout is
|
|
752
1009
|
// the operator's to read and the model's business not at all.
|
|
753
|
-
if ((event === 'user_prompt_submit' ||
|
|
1010
|
+
if ((event === 'user_prompt_submit' ||
|
|
1011
|
+
event === 'subagent_start' ||
|
|
1012
|
+
event === 'session_start' ||
|
|
1013
|
+
event === 'pre_compact') &&
|
|
754
1014
|
text.length > 0)
|
|
755
1015
|
return { action: 'annotate', text };
|
|
756
1016
|
return { action: 'continue' };
|
|
@@ -871,6 +1131,8 @@ export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER) {
|
|
|
871
1131
|
};
|
|
872
1132
|
}
|
|
873
1133
|
case 'pre_compact':
|
|
1134
|
+
// What the hook said is for the summariser: it rides as `context` on
|
|
1135
|
+
// either answer (a refusal a forced pass overrides is still heard).
|
|
874
1136
|
return reading.block !== undefined
|
|
875
1137
|
? { action: 'skip', reason: reading.block, ...notes }
|
|
876
1138
|
: { action: 'continue', ...notes };
|
|
@@ -1009,21 +1271,30 @@ export function createShellHook(event, configured, options) {
|
|
|
1009
1271
|
if (spent)
|
|
1010
1272
|
return { action: 'continue' };
|
|
1011
1273
|
// A notification is matched on its type; every other event on the tool.
|
|
1012
|
-
const namesATool = !(event === '
|
|
1274
|
+
const namesATool = !(event === 'pre_compact' ||
|
|
1275
|
+
event === 'post_compact' ||
|
|
1276
|
+
event === 'notification' ||
|
|
1013
1277
|
event === 'subagent_start' ||
|
|
1014
1278
|
event === 'subagent_stop' ||
|
|
1015
1279
|
event === 'session_start' ||
|
|
1016
1280
|
event === 'session_end');
|
|
1017
1281
|
const subject = event === 'notification'
|
|
1018
1282
|
? context.notification?.type
|
|
1019
|
-
: event === '
|
|
1020
|
-
? context.
|
|
1021
|
-
: event === '
|
|
1022
|
-
? context.
|
|
1023
|
-
: event === '
|
|
1024
|
-
? context.
|
|
1025
|
-
:
|
|
1026
|
-
|
|
1283
|
+
: event === 'pre_compact' || event === 'post_compact'
|
|
1284
|
+
? context.compaction?.trigger
|
|
1285
|
+
: event === 'subagent_start' || event === 'subagent_stop'
|
|
1286
|
+
? context.agentType
|
|
1287
|
+
: event === 'session_start'
|
|
1288
|
+
? context.source
|
|
1289
|
+
: event === 'session_end'
|
|
1290
|
+
? context.endReason
|
|
1291
|
+
: context.toolName;
|
|
1292
|
+
// An event that has no name to match (`turn_end`, `user_prompt_submit`,
|
|
1293
|
+
// `turn_start`, `post_tool_batch`) ignores the matcher and the hook
|
|
1294
|
+
// fires: a matcher cannot be read against a name that is not there, and
|
|
1295
|
+
// a guard that never fires is the failure to avoid. The compaction
|
|
1296
|
+
// events are matched on `trigger`.
|
|
1297
|
+
if (subject !== undefined && !shellHookMatches(entry.matcher, subject, { tool: namesATool }))
|
|
1027
1298
|
return { action: 'continue' };
|
|
1028
1299
|
if (!shellHookIfMatches(entry.if, context.toolName, context.toolInput, options.pathFilter)) {
|
|
1029
1300
|
return { action: 'continue' };
|
|
@@ -1050,6 +1321,8 @@ export function createShellHook(event, configured, options) {
|
|
|
1050
1321
|
...(context.prompt !== undefined ? { prompt: context.prompt } : {}),
|
|
1051
1322
|
...(context.compaction !== undefined ? { compaction: context.compaction } : {}),
|
|
1052
1323
|
...(context.toolName !== undefined ? { toolName: context.toolName } : {}),
|
|
1324
|
+
...(entry.toolNames !== undefined ? { toolNames: entry.toolNames } : {}),
|
|
1325
|
+
...(context.toolCallId !== undefined ? { toolCallId: context.toolCallId } : {}),
|
|
1053
1326
|
...(context.toolInput !== undefined ? { toolInput: context.toolInput } : {}),
|
|
1054
1327
|
...(context.toolResult !== undefined
|
|
1055
1328
|
? {
|
|
@@ -1067,9 +1340,16 @@ export function createShellHook(event, configured, options) {
|
|
|
1067
1340
|
...(context.error !== undefined ? { error: context.error } : {}),
|
|
1068
1341
|
...(context.notification !== undefined ? { notification: context.notification } : {}),
|
|
1069
1342
|
...(context.stopHookActive === true ? { stopHookActive: true } : {}),
|
|
1343
|
+
...(context.lastAssistantMessage !== undefined
|
|
1344
|
+
? { lastAssistantMessage: context.lastAssistantMessage }
|
|
1345
|
+
: {}),
|
|
1070
1346
|
...(transcriptPath !== undefined ? { transcriptPath } : {}),
|
|
1071
1347
|
...(permissionMode !== undefined ? { permissionMode } : {}),
|
|
1072
1348
|
};
|
|
1349
|
+
const verdictCall = {
|
|
1350
|
+
...(context.toolName !== undefined ? { toolName: context.toolName } : {}),
|
|
1351
|
+
...(context.toolInput !== undefined ? { toolInput: context.toolInput } : {}),
|
|
1352
|
+
};
|
|
1073
1353
|
if (entry.type === 'http') {
|
|
1074
1354
|
const response = await postHookJson({
|
|
1075
1355
|
url: entry.url,
|
|
@@ -1087,7 +1367,7 @@ export function createShellHook(event, configured, options) {
|
|
|
1087
1367
|
stdout: ok ? response.body : '',
|
|
1088
1368
|
stderr: ok ? '' : response.body.slice(0, 500),
|
|
1089
1369
|
...(response.error !== undefined ? { spawnError: response.error } : {}),
|
|
1090
|
-
}, log);
|
|
1370
|
+
}, log, verdictCall);
|
|
1091
1371
|
}
|
|
1092
1372
|
if (entry.type === 'prompt') {
|
|
1093
1373
|
return judgePromptHook(event, entry, hookInput, options.judge, context.signal, log);
|
|
@@ -1097,14 +1377,21 @@ export function createShellHook(event, configured, options) {
|
|
|
1097
1377
|
// for it and its own failure is only written down.
|
|
1098
1378
|
void runShellHook(entry, hookInput)
|
|
1099
1379
|
.then((outcome) => {
|
|
1100
|
-
shellHookVerdict(event, entry, outcome, log);
|
|
1380
|
+
const verdict = shellHookVerdict(event, entry, outcome, log, verdictCall);
|
|
1381
|
+
if (verdict.action !== 'continue') {
|
|
1382
|
+
log.warn('an async hook answered, and its answer is not applied', {
|
|
1383
|
+
'namzu.hook.event': event,
|
|
1384
|
+
'namzu.hook.command': describeShellHook(entry),
|
|
1385
|
+
'namzu.hook.action': verdict.action,
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1101
1388
|
})
|
|
1102
1389
|
.catch(() => undefined);
|
|
1103
1390
|
return { action: 'continue' };
|
|
1104
1391
|
}
|
|
1105
1392
|
const outcome = await runShellHook(entry, hookInput, context.signal);
|
|
1106
1393
|
reportTimeout(outcome.timedOut);
|
|
1107
|
-
return shellHookVerdict(event, entry, outcome, log);
|
|
1394
|
+
return shellHookVerdict(event, entry, outcome, log, verdictCall);
|
|
1108
1395
|
},
|
|
1109
1396
|
};
|
|
1110
1397
|
}
|