@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
package/src/plugin/shell-hook.ts
CHANGED
|
@@ -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`
|
|
@@ -130,7 +131,9 @@ import { NOOP_LOGGER } from '../utils/log/create-logger.js'
|
|
|
130
131
|
import type { Logger } from '../utils/logger.js'
|
|
131
132
|
import { boundHookReplacement } from './hook-context.js'
|
|
132
133
|
import type { PluginLifecycleManager } from './lifecycle.js'
|
|
134
|
+
import { readHookCommand } from './shell-hook-command.js'
|
|
133
135
|
import { postHookJson } from './shell-hook-http.js'
|
|
136
|
+
import { type ShellHookToolNames, shellHookToolName } from './shell-hook-tool-names.js'
|
|
134
137
|
|
|
135
138
|
/**
|
|
136
139
|
* The events a shell hook may attach to: the ones an operator can act on
|
|
@@ -223,6 +226,15 @@ interface ShellHookEntryBase {
|
|
|
223
226
|
* here are the same entry.
|
|
224
227
|
*/
|
|
225
228
|
readonly source?: string
|
|
229
|
+
/**
|
|
230
|
+
* Which spelling of a tool's name the hook is sent as `tool_name` (and
|
|
231
|
+
* `NAMZU_TOOL_NAME`): `namzu`, the name the tool is registered under
|
|
232
|
+
* (`bash`; the default), or `written`, the capitalised name a person writes
|
|
233
|
+
* in a matcher (`Bash`). A tool with no written name, and every tool-server
|
|
234
|
+
* tool, keeps its own. `matcher` and `if` read either spelling whatever this
|
|
235
|
+
* says. An entry read from the common hook contract is `written`.
|
|
236
|
+
*/
|
|
237
|
+
readonly toolNames?: ShellHookToolNames
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
/** One shell command at one event. */
|
|
@@ -318,12 +330,29 @@ const WRITTEN_TOOL_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
|
|
318
330
|
tasklist: 'TaskList',
|
|
319
331
|
})
|
|
320
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The tools a written name stands for. The skill alias table sends `Task` and
|
|
335
|
+
* `Agent` to `create_task`, but the command line's sub-agent tool is named
|
|
336
|
+
* `Agent`, and a hook written for either spelling means it: both name it.
|
|
337
|
+
*/
|
|
338
|
+
function toolsNamedBy(written: string): string[] {
|
|
339
|
+
const alias = SKILL_TOOL_NAME_ALIASES[written]
|
|
340
|
+
const names = alias !== undefined ? [alias] : []
|
|
341
|
+
if (written === 'task' || written === 'agent') names.push('agent')
|
|
342
|
+
return names
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** A name with the separators dropped: `ViewImage` and `view_image` are one tool. */
|
|
346
|
+
function squashed(name: string): string {
|
|
347
|
+
return name.toLowerCase().replace(/[_-]/g, '')
|
|
348
|
+
}
|
|
349
|
+
|
|
321
350
|
/** The names a skill writes for the tool called `toolName` here. */
|
|
322
351
|
function writtenNamesFor(toolName: string): string[] {
|
|
323
352
|
const lower = toolName.toLowerCase()
|
|
324
353
|
const names: string[] = []
|
|
325
|
-
for (const
|
|
326
|
-
if (
|
|
354
|
+
for (const written of new Set([...Object.keys(SKILL_TOOL_NAME_ALIASES), 'agent', 'task'])) {
|
|
355
|
+
if (!toolsNamedBy(written).includes(lower)) continue
|
|
327
356
|
names.push(written)
|
|
328
357
|
const spelled = WRITTEN_TOOL_NAMES[written]
|
|
329
358
|
if (spelled !== undefined) names.push(spelled)
|
|
@@ -331,27 +360,73 @@ function writtenNamesFor(toolName: string): string[] {
|
|
|
331
360
|
return names
|
|
332
361
|
}
|
|
333
362
|
|
|
363
|
+
/** A glob over names: `*` matches any run of characters, the rest is literal. */
|
|
364
|
+
function globMatches(pattern: string, subject: string): boolean {
|
|
365
|
+
const source = pattern
|
|
366
|
+
.split('*')
|
|
367
|
+
.map((piece) => piece.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
368
|
+
.join('.*')
|
|
369
|
+
return new RegExp(`^${source}$`, 'i').test(subject)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether a list part (`Edit`, `Web*`, `mcp__*__search`) names `subject`, as a
|
|
374
|
+
* tool name or not. A `*` at the end is a prefix; a `*` anywhere else is a
|
|
375
|
+
* glob. For a tool, a bare tool-server name (`mcp__github`) names every tool
|
|
376
|
+
* of that server.
|
|
377
|
+
*/
|
|
378
|
+
function listPartNames(part: string, subject: string, tool: boolean): boolean {
|
|
379
|
+
const prefix = part.endsWith('*')
|
|
380
|
+
const core = prefix ? part.slice(0, -1) : part
|
|
381
|
+
const written = core.toLowerCase()
|
|
382
|
+
const lower = subject.toLowerCase()
|
|
383
|
+
if (core.includes('*')) {
|
|
384
|
+
if (globMatches(part, subject)) return true
|
|
385
|
+
return tool && writtenNamesFor(subject).some((name) => globMatches(part, name))
|
|
386
|
+
}
|
|
387
|
+
const spellings = [written]
|
|
388
|
+
if (tool) spellings.push(...toolsNamedBy(written))
|
|
389
|
+
return spellings.some((name) => {
|
|
390
|
+
if (prefix ? lower.startsWith(name) : lower === name) return true
|
|
391
|
+
if (!tool) return false
|
|
392
|
+
// A tool's name also compares without its `_` and `-`.
|
|
393
|
+
if (prefix ? squashed(lower).startsWith(squashed(name)) : squashed(lower) === squashed(name)) {
|
|
394
|
+
return true
|
|
395
|
+
}
|
|
396
|
+
return !prefix && name.startsWith('mcp__') && !name.slice(5).includes('__')
|
|
397
|
+
? lower.startsWith(`${name}__`)
|
|
398
|
+
: false
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const MATCHER_LIST_PART = /^[A-Za-z0-9_.:*-]+$/
|
|
403
|
+
|
|
334
404
|
/**
|
|
335
405
|
* `matcher` against a name (a tool's, or a notification's type).
|
|
336
406
|
*
|
|
337
407
|
* Empty or `*` matches everything. A list of names separated by `|` or `,`
|
|
338
408
|
* matches exactly, each part optionally ending in `*` as a prefix (the
|
|
339
409
|
* spelling this adapter has always taken). Anything else is a regular
|
|
340
|
-
* expression, tested unanchored against the name
|
|
341
|
-
* matches
|
|
342
|
-
*
|
|
410
|
+
* expression, tested unanchored against the name. This function called with no
|
|
411
|
+
* name matches only `*`; a hook on an event that has no name (`turn_end`)
|
|
412
|
+
* ignores its matcher and fires (see `createShellHook`).
|
|
413
|
+
*
|
|
414
|
+
* A text that reads both ways (it is a list, and it holds a `.*`
|
|
415
|
+
* that a regular expression reads differently: `Bash.*`,
|
|
416
|
+
* `mcp__.*__search`) fires when either reading matches. A pattern that does
|
|
417
|
+
* not compile cannot be read, and a guard that silently matches nothing is
|
|
418
|
+
* the failure to avoid, so it matches every name; `/hooks` and
|
|
419
|
+
* `namzu doctor` say that it cannot be read (`shellHookMatcherProblem`).
|
|
343
420
|
*
|
|
344
421
|
* Names in a list compare without regard to case. With `tool: true` (the
|
|
345
422
|
* event's name is a tool: the tool events and `permission_request`) they also
|
|
346
|
-
* take the names skills write, the way an entry's `if` does
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* A list part ending in `.*` is also tried as a regular expression.
|
|
423
|
+
* take the names skills write, the way an entry's `if` does, and a tool name
|
|
424
|
+
* compares without its `_` and `-`: `Bash` matches the tool `bash`,
|
|
425
|
+
* `MultiEdit` matches `edit`, `ViewImage` matches `view_image`, `Task` and
|
|
426
|
+
* `Agent` match the sub-agent tool `Agent`, and a prefix such as `Web*`
|
|
427
|
+
* matches `web_fetch`. Where the name is not a tool (a notification's type,
|
|
428
|
+
* an agent's name, a session's `source` or `reason`) no tool alias applies,
|
|
429
|
+
* since those words are not tool names; case is still ignored there.
|
|
355
430
|
*
|
|
356
431
|
* A regular expression is tested, case-sensitively as written, against the
|
|
357
432
|
* real name and, with `tool: true`, against each name skills write for that
|
|
@@ -367,37 +442,65 @@ export function shellHookMatches(
|
|
|
367
442
|
if (toolName === undefined) return false
|
|
368
443
|
const tool = options.tool === true
|
|
369
444
|
const parts = matcher.split(/[|,]/).map((part) => part.trim())
|
|
370
|
-
if (parts.every((part) =>
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const spellings = [written]
|
|
376
|
-
if (tool) {
|
|
377
|
-
const alias = SKILL_TOOL_NAME_ALIASES[written]
|
|
378
|
-
if (alias !== undefined) spellings.push(alias)
|
|
379
|
-
}
|
|
380
|
-
return spellings.some((name) => (prefix ? subject.startsWith(name) : subject === name))
|
|
381
|
-
})
|
|
382
|
-
// `Bash.*` reads as the prefix `bash.` above, which no tool has. A part
|
|
383
|
-
// ending in `.*` is also a regular expression, so it is tried as one.
|
|
384
|
-
if (listed || !matcher.includes('.*')) return listed
|
|
445
|
+
if (parts.every((part) => MATCHER_LIST_PART.test(part))) {
|
|
446
|
+
if (parts.some((part) => listPartNames(part, toolName, tool))) return true
|
|
447
|
+
// A list of names is exact (a `*` in it is a prefix or a glob). Only `.*` reads differently
|
|
448
|
+
// as a regular expression, so only then is the second reading tried.
|
|
449
|
+
if (!matcher.includes('.*')) return false
|
|
385
450
|
}
|
|
386
451
|
try {
|
|
387
452
|
const expression = new RegExp(matcher)
|
|
388
453
|
if (expression.test(toolName)) return true
|
|
389
454
|
return tool && writtenNamesFor(toolName).some((name) => expression.test(name))
|
|
390
455
|
} catch {
|
|
391
|
-
return
|
|
456
|
+
return true
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Why a `matcher` cannot be read, or `undefined` when it can. A matcher that
|
|
462
|
+
* is not a list of names and does not compile as a regular expression fires
|
|
463
|
+
* for every name (see {@link shellHookMatches}).
|
|
464
|
+
*/
|
|
465
|
+
export function shellHookMatcherProblem(matcher: string | undefined): string | undefined {
|
|
466
|
+
if (matcher === undefined || matcher.trim() === '' || matcher.trim() === '*') return undefined
|
|
467
|
+
const parts = matcher.split(/[|,]/).map((part) => part.trim())
|
|
468
|
+
if (parts.every((part) => MATCHER_LIST_PART.test(part))) return undefined
|
|
469
|
+
// `Bash(git push *)` is a tool and a pattern, which is what an `if` reads. As a
|
|
470
|
+
// matcher it is a regular expression that no tool name satisfies, so the hook
|
|
471
|
+
// would never fire; say so, and name the `if`.
|
|
472
|
+
if (/^[A-Za-z0-9_.:*-]+\([\s\S]*\)$/.test(matcher.trim())) {
|
|
473
|
+
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`
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
new RegExp(matcher)
|
|
477
|
+
return undefined
|
|
478
|
+
} catch (error) {
|
|
479
|
+
return `the matcher "${matcher}" is not a list of names and is not a regular expression (${(error as Error).message}), so it cannot be read and the hook fires for every call`
|
|
392
480
|
}
|
|
393
481
|
}
|
|
394
482
|
|
|
483
|
+
const IF_SHAPE = /^([A-Za-z0-9_.:*|, -]+)(?:\(([\s\S]*)\))?$/
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Why an `if` cannot be read, or `undefined` when it can. A filter that
|
|
487
|
+
* cannot be read runs the hook.
|
|
488
|
+
*/
|
|
489
|
+
export function shellHookIfProblem(filter: string | undefined): string | undefined {
|
|
490
|
+
if (filter === undefined || filter.trim() === '') return undefined
|
|
491
|
+
return IF_SHAPE.test(filter.trim())
|
|
492
|
+
? undefined
|
|
493
|
+
: `the if "${filter}" is not Tool or Tool(pattern), so it cannot be read and the hook runs for every call`
|
|
494
|
+
}
|
|
495
|
+
|
|
395
496
|
/**
|
|
396
497
|
* A hook's `if` against one tool call: `Tool` or `Tool(pattern)`. The tool
|
|
397
|
-
* part
|
|
398
|
-
* `Read`)
|
|
399
|
-
*
|
|
400
|
-
*
|
|
498
|
+
* part takes every spelling a `matcher` takes as a list: names skills write
|
|
499
|
+
* (`Bash`, `Read`, `Agent`), separated by `|` or `,`, a prefix ending in `*`,
|
|
500
|
+
* case ignored; a regular expression is not read here (use `matcher`). A
|
|
501
|
+
* pattern is matched against a shell tool's command line (one command at a
|
|
502
|
+
* time) or, for a file tool, a path the host reads; for a tool with neither
|
|
503
|
+
* the filter cannot be read, which runs the hook.
|
|
401
504
|
*/
|
|
402
505
|
export function shellHookIfMatches(
|
|
403
506
|
filter: string | undefined,
|
|
@@ -407,22 +510,36 @@ export function shellHookIfMatches(
|
|
|
407
510
|
): boolean {
|
|
408
511
|
if (filter === undefined || filter.trim() === '') return true
|
|
409
512
|
if (toolName === undefined) return true
|
|
410
|
-
const shape =
|
|
513
|
+
const shape = IF_SHAPE.exec(filter.trim())
|
|
411
514
|
if (!shape) return true
|
|
412
|
-
const written = (shape[1] ?? '').toLowerCase()
|
|
413
|
-
const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written
|
|
414
515
|
const specifier = shape[2]?.trim()
|
|
415
516
|
const hasPattern =
|
|
416
517
|
specifier !== undefined && specifier !== '' && specifier.replaceAll('*', '') !== ''
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
518
|
+
const parts = (shape[1] ?? '')
|
|
519
|
+
.split(/[|,]/)
|
|
520
|
+
.map((part) => part.trim())
|
|
521
|
+
.filter((part) => part !== '')
|
|
522
|
+
if (parts.length === 0) return true
|
|
523
|
+
if (parts.some((part) => part === '*')) return true
|
|
524
|
+
const callTool = toolName.toLowerCase()
|
|
525
|
+
return parts.some((part) => {
|
|
526
|
+
const written = part.replace(/\*$/, '').toLowerCase()
|
|
527
|
+
const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written
|
|
528
|
+
// A path pattern is the permission rules' own: the host that has them says
|
|
529
|
+
// whether this call's path is named. It decides for the tools it knows.
|
|
530
|
+
if (hasPattern && pathFilter !== undefined && !part.endsWith('*')) {
|
|
531
|
+
const answer = pathFilter(tool, specifier, toolName, toolInput)
|
|
532
|
+
if (answer !== undefined) return answer
|
|
533
|
+
}
|
|
534
|
+
if (!listPartNames(part, toolName, true)) return false
|
|
535
|
+
if (!hasPattern) return true
|
|
536
|
+
if (callTool !== 'bash') return true
|
|
537
|
+
return commandMatches(specifier, toolInput)
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** A shell tool's command line against a permission-rule pattern, per command. */
|
|
542
|
+
function commandMatches(specifier: string, toolInput: unknown): boolean {
|
|
426
543
|
const line =
|
|
427
544
|
toolInput !== null && typeof toolInput === 'object'
|
|
428
545
|
? (toolInput as Record<string, unknown>).command
|
|
@@ -436,9 +553,16 @@ export function shellHookIfMatches(
|
|
|
436
553
|
// filter names, so the hook runs. A filter only ever narrows when the
|
|
437
554
|
// whole line is visible and none of its commands matches.
|
|
438
555
|
if (decomposed.opaque) return true
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
556
|
+
// Wrappers, a program's full path and git's global options do not change
|
|
557
|
+
// which command runs; a command whose name is only known at run time
|
|
558
|
+
// cannot be compared, so the hook runs.
|
|
559
|
+
const reading = readHookCommand(line)
|
|
560
|
+
if (reading.unknowable) return true
|
|
561
|
+
const candidates = [
|
|
562
|
+
...decomposed.segments,
|
|
563
|
+
...decodedCommands(line, 'sh'),
|
|
564
|
+
...reading.candidates,
|
|
565
|
+
].map((each) => each.trim())
|
|
442
566
|
return candidates.some((each) => matcher.test(each))
|
|
443
567
|
} catch {
|
|
444
568
|
return true
|
|
@@ -493,6 +617,10 @@ export interface ShellHookInput {
|
|
|
493
617
|
readonly prompt?: string
|
|
494
618
|
readonly compaction?: PluginCompactionInfo
|
|
495
619
|
readonly toolName?: string
|
|
620
|
+
/** The spelling `toolName` (and each `toolCalls` name) is sent in; absent is `namzu`. */
|
|
621
|
+
readonly toolNames?: ShellHookToolNames
|
|
622
|
+
/** The call's id, on the single-call tool events. Sent as `tool_use_id`. */
|
|
623
|
+
readonly toolCallId?: string
|
|
496
624
|
readonly toolInput?: unknown
|
|
497
625
|
readonly toolResult?: {
|
|
498
626
|
readonly success: boolean
|
|
@@ -509,6 +637,8 @@ export interface ShellHookInput {
|
|
|
509
637
|
readonly notification?: PluginNotificationInfo
|
|
510
638
|
/** On `turn_end` and `subagent_stop`: an earlier answer already kept this turn going. */
|
|
511
639
|
readonly stopHookActive?: boolean
|
|
640
|
+
/** On `turn_end` and `subagent_stop`: the model's last message of the turn. Sent as `last_assistant_message`. */
|
|
641
|
+
readonly lastAssistantMessage?: string
|
|
512
642
|
/** The session's transcript file, when the host has one. Sent as `transcript_path`. */
|
|
513
643
|
readonly transcriptPath?: string
|
|
514
644
|
/** How tool calls are being decided right now. Sent as `permission_mode`. */
|
|
@@ -519,7 +649,7 @@ export interface ShellHookInput {
|
|
|
519
649
|
export function shellHookPayload(input: ShellHookInput): string {
|
|
520
650
|
return JSON.stringify({
|
|
521
651
|
event: input.event,
|
|
522
|
-
hook_event_name:
|
|
652
|
+
hook_event_name: SHELL_HOOK_PASCAL_EVENT_NAMES[input.event] ?? input.event,
|
|
523
653
|
cwd: input.cwd,
|
|
524
654
|
session_id: input.sessionId,
|
|
525
655
|
...(input.transcriptPath !== undefined ? { transcript_path: input.transcriptPath } : {}),
|
|
@@ -532,9 +662,14 @@ export function shellHookPayload(input: ShellHookInput): string {
|
|
|
532
662
|
? { agent_id: input.sessionId, agent_type: input.agentType }
|
|
533
663
|
: {}),
|
|
534
664
|
...(input.prompt !== undefined ? { prompt: input.prompt } : {}),
|
|
535
|
-
...(input.compaction !== undefined ?
|
|
536
|
-
...(input.toolName !== undefined
|
|
537
|
-
|
|
665
|
+
...(input.compaction !== undefined ? compactionFields(input.event, input.compaction) : {}),
|
|
666
|
+
...(input.toolName !== undefined
|
|
667
|
+
? { tool_name: shellHookToolName(input.toolName, input.toolNames) }
|
|
668
|
+
: {}),
|
|
669
|
+
...(input.toolCallId !== undefined ? { tool_use_id: input.toolCallId } : {}),
|
|
670
|
+
...(input.toolInput !== undefined
|
|
671
|
+
? { tool_input: withFilePathKey(input.toolName, input.toolInput) }
|
|
672
|
+
: {}),
|
|
538
673
|
...(input.toolResult !== undefined
|
|
539
674
|
? { tool_result: input.toolResult, tool_response: input.toolResult }
|
|
540
675
|
: {}),
|
|
@@ -544,8 +679,8 @@ export function shellHookPayload(input: ShellHookInput): string {
|
|
|
544
679
|
...(input.toolCalls !== undefined
|
|
545
680
|
? {
|
|
546
681
|
tool_calls: input.toolCalls.map((call) => ({
|
|
547
|
-
tool_name: call.toolName,
|
|
548
|
-
tool_input: call.toolInput,
|
|
682
|
+
tool_name: shellHookToolName(call.toolName, input.toolNames),
|
|
683
|
+
tool_input: withFilePathKey(call.toolName, call.toolInput),
|
|
549
684
|
tool_use_id: call.toolCallId,
|
|
550
685
|
tool_response: call.output,
|
|
551
686
|
is_error: call.isError,
|
|
@@ -565,9 +700,31 @@ export function shellHookPayload(input: ShellHookInput): string {
|
|
|
565
700
|
}
|
|
566
701
|
: {}),
|
|
567
702
|
...(input.stopHookActive === true ? { stop_hook_active: true } : {}),
|
|
703
|
+
...(input.lastAssistantMessage !== undefined
|
|
704
|
+
? { last_assistant_message: input.lastAssistantMessage }
|
|
705
|
+
: {}),
|
|
568
706
|
})
|
|
569
707
|
}
|
|
570
708
|
|
|
709
|
+
/**
|
|
710
|
+
* A compaction hook's fields. `compaction` keeps what it always had and gains
|
|
711
|
+
* `trigger`; the person's instructions and the summary are top-level
|
|
712
|
+
* (`custom_instructions`, null when there are none, and `compact_summary`),
|
|
713
|
+
* where a script reads them without opening the object.
|
|
714
|
+
*/
|
|
715
|
+
function compactionFields(
|
|
716
|
+
event: ShellHookEvent,
|
|
717
|
+
compaction: PluginCompactionInfo,
|
|
718
|
+
): Record<string, unknown> {
|
|
719
|
+
const { instructions, summary, ...rest } = compaction
|
|
720
|
+
return {
|
|
721
|
+
compaction: rest,
|
|
722
|
+
...(compaction.trigger !== undefined ? { trigger: compaction.trigger } : {}),
|
|
723
|
+
...(event === 'pre_compact' ? { custom_instructions: instructions ?? null } : {}),
|
|
724
|
+
...(event === 'post_compact' && summary !== undefined ? { compact_summary: summary } : {}),
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
571
728
|
/** Run one hook command to completion, bounded by its deadline and capture. */
|
|
572
729
|
export function runShellHook(
|
|
573
730
|
entry: ShellHookEntry,
|
|
@@ -592,7 +749,9 @@ export function runShellHook(
|
|
|
592
749
|
NAMZU_SESSION_ID: input.sessionId,
|
|
593
750
|
NAMZU_PROJECT_DIR: input.projectDir ?? input.cwd,
|
|
594
751
|
...(input.turnId !== undefined ? { NAMZU_TURN_ID: input.turnId } : {}),
|
|
595
|
-
...(input.toolName !== undefined
|
|
752
|
+
...(input.toolName !== undefined
|
|
753
|
+
? { NAMZU_TOOL_NAME: shellHookToolName(input.toolName, input.toolNames) }
|
|
754
|
+
: {}),
|
|
596
755
|
...(toolPath !== undefined ? { NAMZU_TOOL_PATH: toolPath } : {}),
|
|
597
756
|
}
|
|
598
757
|
// Its own process group, so the deadline can kill everything the
|
|
@@ -713,6 +872,56 @@ export function runShellHook(
|
|
|
713
872
|
})
|
|
714
873
|
}
|
|
715
874
|
|
|
875
|
+
/**
|
|
876
|
+
* The tools whose input names a file or folder `path`. A moved script reads
|
|
877
|
+
* that file under the key `file_path`, so the input a hook receives carries
|
|
878
|
+
* both, with the same value.
|
|
879
|
+
*/
|
|
880
|
+
const FILE_PATH_TOOLS: ReadonlySet<string> = new Set([
|
|
881
|
+
'read',
|
|
882
|
+
'write',
|
|
883
|
+
'edit',
|
|
884
|
+
'grep',
|
|
885
|
+
'glob',
|
|
886
|
+
'ls',
|
|
887
|
+
'view_image',
|
|
888
|
+
'lsp',
|
|
889
|
+
])
|
|
890
|
+
|
|
891
|
+
/** `toolInput` as a hook receives it: a file tool's `path` is also `file_path`. */
|
|
892
|
+
function withFilePathKey(toolName: string | undefined, toolInput: unknown): unknown {
|
|
893
|
+
if (toolName === undefined || !FILE_PATH_TOOLS.has(toolName.toLowerCase())) return toolInput
|
|
894
|
+
if (!isRecord(toolInput)) return toolInput
|
|
895
|
+
const path = toolInput.path
|
|
896
|
+
if (typeof path !== 'string' || 'file_path' in toolInput) return toolInput
|
|
897
|
+
return { ...toolInput, file_path: path }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* A rewritten input read back. An echoed `file_path` is never a reason to
|
|
902
|
+
* refuse the rewrite: when only one of the two keys was changed that change is
|
|
903
|
+
* the new path; when both were changed to different values the answer cannot be
|
|
904
|
+
* read (`undefined`). The tool never sees `file_path`.
|
|
905
|
+
*/
|
|
906
|
+
function readRewrittenInput(
|
|
907
|
+
toolName: string | undefined,
|
|
908
|
+
original: unknown,
|
|
909
|
+
rewritten: Record<string, unknown>,
|
|
910
|
+
): Record<string, unknown> | undefined {
|
|
911
|
+
if (toolName === undefined || !FILE_PATH_TOOLS.has(toolName.toLowerCase())) return rewritten
|
|
912
|
+
if (!('file_path' in rewritten)) return rewritten
|
|
913
|
+
const { file_path: echoed, ...rest } = rewritten
|
|
914
|
+
const before = isRecord(original) ? original.path : undefined
|
|
915
|
+
const pathChanged = 'path' in rest && rest.path !== before
|
|
916
|
+
const echoChanged = echoed !== before
|
|
917
|
+
if (pathChanged && echoChanged && echoed !== rest.path) return undefined
|
|
918
|
+
if (!pathChanged && echoChanged) {
|
|
919
|
+
if (typeof echoed !== 'string') return undefined
|
|
920
|
+
return { ...rest, path: echoed }
|
|
921
|
+
}
|
|
922
|
+
return rest
|
|
923
|
+
}
|
|
924
|
+
|
|
716
925
|
function pathOf(toolInput: unknown): string | undefined {
|
|
717
926
|
if (toolInput === null || typeof toolInput !== 'object') return undefined
|
|
718
927
|
const record = toolInput as Record<string, unknown>
|
|
@@ -724,7 +933,7 @@ function pathOf(toolInput: unknown): string | undefined {
|
|
|
724
933
|
}
|
|
725
934
|
|
|
726
935
|
/** The PascalCase spelling of each event name, which a hook's JSON may use. */
|
|
727
|
-
const
|
|
936
|
+
export const SHELL_HOOK_PASCAL_EVENT_NAMES: Readonly<Partial<Record<ShellHookEvent, string>>> = {
|
|
728
937
|
user_prompt_submit: 'UserPromptSubmit',
|
|
729
938
|
session_start: 'SessionStart',
|
|
730
939
|
session_end: 'SessionEnd',
|
|
@@ -760,6 +969,40 @@ interface ShellHookReading {
|
|
|
760
969
|
invalid?: string
|
|
761
970
|
}
|
|
762
971
|
|
|
972
|
+
/** The keys that carry a replacement for a tool's result. */
|
|
973
|
+
const REPLACEMENT_KEYS = ['updatedToolOutput', 'updatedMCPToolOutput'] as const
|
|
974
|
+
|
|
975
|
+
/** The tool tied to an answer: what a rewrite or a replacement is read against. */
|
|
976
|
+
export interface ShellHookCall {
|
|
977
|
+
readonly toolName?: string
|
|
978
|
+
readonly toolInput?: unknown
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* A replacement for a tool's result, whatever its JSON type. A string is the
|
|
983
|
+
* text; an object that has the shape of the result object a hook is sent
|
|
984
|
+
* (`output` a string, `success` a boolean and `error` a string when present)
|
|
985
|
+
* is read as that; anything else (another object, an array, a number, a
|
|
986
|
+
* boolean) is shown as its JSON text. The call's own success is not changed.
|
|
987
|
+
*/
|
|
988
|
+
function replacementText(value: unknown): string {
|
|
989
|
+
if (typeof value === 'string') return value
|
|
990
|
+
if (
|
|
991
|
+
isRecord(value) &&
|
|
992
|
+
typeof value.output === 'string' &&
|
|
993
|
+
(value.success === undefined || typeof value.success === 'boolean') &&
|
|
994
|
+
(value.error === undefined || typeof value.error === 'string')
|
|
995
|
+
) {
|
|
996
|
+
if (value.success === false && typeof value.error === 'string' && value.error !== '') {
|
|
997
|
+
return value.output === ''
|
|
998
|
+
? `Error: ${value.error}`
|
|
999
|
+
: `${value.output}\n\nError: ${value.error}`
|
|
1000
|
+
}
|
|
1001
|
+
return value.output
|
|
1002
|
+
}
|
|
1003
|
+
return JSON.stringify(value)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
763
1006
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
764
1007
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
765
1008
|
}
|
|
@@ -770,7 +1013,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
770
1013
|
* sets the whole object aside rather than half-obeying it, so a typo in a
|
|
771
1014
|
* guard's `decision` is reported instead of silently becoming "continue".
|
|
772
1015
|
*/
|
|
773
|
-
function readShellHookJson(
|
|
1016
|
+
function readShellHookJson(
|
|
1017
|
+
event: ShellHookEvent,
|
|
1018
|
+
stdout: string,
|
|
1019
|
+
call: ShellHookCall = {},
|
|
1020
|
+
): ShellHookReading | undefined {
|
|
774
1021
|
const text = stdout.trim()
|
|
775
1022
|
if (!text.startsWith('{')) return undefined
|
|
776
1023
|
let parsed: unknown
|
|
@@ -815,6 +1062,24 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
|
|
|
815
1062
|
return { invalid: '`decision` must be "approve" or "block"' }
|
|
816
1063
|
}
|
|
817
1064
|
|
|
1065
|
+
// A replacement written where it is not read (at the top level of the answer
|
|
1066
|
+
// instead of under `hookSpecificOutput`) is a hook that clearly tried to say
|
|
1067
|
+
// something we cannot read: set the answer aside as unreadable, so
|
|
1068
|
+
// `onFailure: block` withholds the result rather than showing the original.
|
|
1069
|
+
if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
|
|
1070
|
+
const misplaced = REPLACEMENT_KEYS.find((key) => parsed[key] !== undefined)
|
|
1071
|
+
const inSpecific =
|
|
1072
|
+
isRecord(parsed.hookSpecificOutput) &&
|
|
1073
|
+
REPLACEMENT_KEYS.some(
|
|
1074
|
+
(key) => (parsed.hookSpecificOutput as Record<string, unknown>)[key] !== undefined,
|
|
1075
|
+
)
|
|
1076
|
+
if (misplaced !== undefined && !inSpecific) {
|
|
1077
|
+
return {
|
|
1078
|
+
invalid: `\`${misplaced}\` is read under \`hookSpecificOutput\`, not at the top level of the answer`,
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
818
1083
|
const reading: ShellHookReading = {}
|
|
819
1084
|
if (parsed.suppressOutput === true) reading.suppressOutput = true
|
|
820
1085
|
const systemMessage = optionalString('systemMessage')
|
|
@@ -836,7 +1101,7 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
|
|
|
836
1101
|
if (typeof named !== 'string') {
|
|
837
1102
|
return { invalid: '`hookSpecificOutput` is missing its `hookEventName`' }
|
|
838
1103
|
}
|
|
839
|
-
if (named !== event && named !==
|
|
1104
|
+
if (named !== event && named !== SHELL_HOOK_PASCAL_EVENT_NAMES[event]) {
|
|
840
1105
|
return {
|
|
841
1106
|
invalid: `\`hookSpecificOutput.hookEventName\` is "${named}" but this hook ran for ${event}`,
|
|
842
1107
|
}
|
|
@@ -869,14 +1134,27 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
|
|
|
869
1134
|
}
|
|
870
1135
|
if (specific.updatedInput !== undefined) {
|
|
871
1136
|
if (!isRecord(specific.updatedInput)) return { invalid: '`updatedInput` must be an object' }
|
|
872
|
-
|
|
1137
|
+
const rewritten = readRewrittenInput(call.toolName, call.toolInput, specific.updatedInput)
|
|
1138
|
+
if (rewritten === undefined) {
|
|
1139
|
+
return {
|
|
1140
|
+
invalid:
|
|
1141
|
+
'`updatedInput` changes both `path` and `file_path`, to different values: say which file is meant',
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
reading.updatedInput = rewritten
|
|
873
1145
|
}
|
|
874
1146
|
}
|
|
875
|
-
if (event === 'post_tool_use'
|
|
876
|
-
|
|
877
|
-
|
|
1147
|
+
if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
|
|
1148
|
+
// For a tool-server tool the common contract has a second field, read
|
|
1149
|
+
// when `updatedToolOutput` is absent or null. `updatedToolOutput` wins when
|
|
1150
|
+
// both carry a value: it is the field for every tool, so a script that
|
|
1151
|
+
// sets both has said the general thing last.
|
|
1152
|
+
const toolServer = call.toolName?.startsWith('mcp__') === true
|
|
1153
|
+
const chosen =
|
|
1154
|
+
specific.updatedToolOutput ?? (toolServer ? specific.updatedMCPToolOutput : undefined)
|
|
1155
|
+
if (chosen !== undefined && chosen !== null) {
|
|
1156
|
+
reading.replacement = boundHookReplacement(replacementText(chosen))
|
|
878
1157
|
}
|
|
879
|
-
reading.replacement = boundHookReplacement(specific.updatedToolOutput)
|
|
880
1158
|
}
|
|
881
1159
|
if (event === 'permission_request') {
|
|
882
1160
|
const decision = specific.decision
|
|
@@ -907,6 +1185,7 @@ export function shellHookVerdict(
|
|
|
907
1185
|
entry: ShellHookHandler,
|
|
908
1186
|
outcome: ShellHookOutcome,
|
|
909
1187
|
log: Logger = NOOP_LOGGER,
|
|
1188
|
+
call: ShellHookCall = {},
|
|
910
1189
|
): PluginHookResult {
|
|
911
1190
|
const label = describeShellHook(entry)
|
|
912
1191
|
const attrs = {
|
|
@@ -937,6 +1216,7 @@ export function shellHookVerdict(
|
|
|
937
1216
|
{ ...entry, onFailure: 'continue' },
|
|
938
1217
|
{ exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` },
|
|
939
1218
|
log,
|
|
1219
|
+
call,
|
|
940
1220
|
)
|
|
941
1221
|
}
|
|
942
1222
|
if (outcome.spawnError !== undefined) {
|
|
@@ -992,13 +1272,31 @@ export function shellHookVerdict(
|
|
|
992
1272
|
? {
|
|
993
1273
|
invalid: `answer is too large: the hook printed more than the ${MAX_CAPTURE_BYTES} characters of stdout that are read`,
|
|
994
1274
|
}
|
|
995
|
-
: readShellHookJson(event, outcome.stdout)
|
|
1275
|
+
: readShellHookJson(event, outcome.stdout, call)
|
|
996
1276
|
if (reading === undefined) {
|
|
1277
|
+
// A post-tool hook whose plain output (or whose stderr) names a replacement
|
|
1278
|
+
// key tried to replace the result and put the answer where it is not read
|
|
1279
|
+
// (a log line before the JSON, the JSON on stderr). That is an answer that
|
|
1280
|
+
// does not read, so it follows `onFailure` like one.
|
|
1281
|
+
if (
|
|
1282
|
+
(event === 'post_tool_use' || event === 'post_tool_use_failure') &&
|
|
1283
|
+
REPLACEMENT_KEYS.some((key) => outcome.stdout.includes(key) || outcome.stderr.includes(key))
|
|
1284
|
+
) {
|
|
1285
|
+
log.warn('hook output ignored', {
|
|
1286
|
+
...attrs,
|
|
1287
|
+
'namzu.hook.problem':
|
|
1288
|
+
'a replacement is named but the answer is not a JSON object on stdout',
|
|
1289
|
+
})
|
|
1290
|
+
return failed('its answer names a replacement but is not a JSON object that starts stdout')
|
|
1291
|
+
}
|
|
997
1292
|
// On the prompt event, what the hook printed is context for the
|
|
998
1293
|
// model — the branch, the time, a ticket. Everywhere else stdout is
|
|
999
1294
|
// the operator's to read and the model's business not at all.
|
|
1000
1295
|
if (
|
|
1001
|
-
(event === 'user_prompt_submit' ||
|
|
1296
|
+
(event === 'user_prompt_submit' ||
|
|
1297
|
+
event === 'subagent_start' ||
|
|
1298
|
+
event === 'session_start' ||
|
|
1299
|
+
event === 'pre_compact') &&
|
|
1002
1300
|
text.length > 0
|
|
1003
1301
|
)
|
|
1004
1302
|
return { action: 'annotate', text }
|
|
@@ -1118,6 +1416,8 @@ export function shellHookVerdict(
|
|
|
1118
1416
|
}
|
|
1119
1417
|
}
|
|
1120
1418
|
case 'pre_compact':
|
|
1419
|
+
// What the hook said is for the summariser: it rides as `context` on
|
|
1420
|
+
// either answer (a refusal a forced pass overrides is still heard).
|
|
1121
1421
|
return reading.block !== undefined
|
|
1122
1422
|
? { action: 'skip', reason: reading.block, ...notes }
|
|
1123
1423
|
: { action: 'continue', ...notes }
|
|
@@ -1332,6 +1632,8 @@ export function createShellHook(
|
|
|
1332
1632
|
if (spent) return { action: 'continue' }
|
|
1333
1633
|
// A notification is matched on its type; every other event on the tool.
|
|
1334
1634
|
const namesATool = !(
|
|
1635
|
+
event === 'pre_compact' ||
|
|
1636
|
+
event === 'post_compact' ||
|
|
1335
1637
|
event === 'notification' ||
|
|
1336
1638
|
event === 'subagent_start' ||
|
|
1337
1639
|
event === 'subagent_stop' ||
|
|
@@ -1341,14 +1643,21 @@ export function createShellHook(
|
|
|
1341
1643
|
const subject =
|
|
1342
1644
|
event === 'notification'
|
|
1343
1645
|
? context.notification?.type
|
|
1344
|
-
: event === '
|
|
1345
|
-
? context.
|
|
1346
|
-
: event === '
|
|
1347
|
-
? context.
|
|
1348
|
-
: event === '
|
|
1349
|
-
? context.
|
|
1350
|
-
:
|
|
1351
|
-
|
|
1646
|
+
: event === 'pre_compact' || event === 'post_compact'
|
|
1647
|
+
? context.compaction?.trigger
|
|
1648
|
+
: event === 'subagent_start' || event === 'subagent_stop'
|
|
1649
|
+
? context.agentType
|
|
1650
|
+
: event === 'session_start'
|
|
1651
|
+
? context.source
|
|
1652
|
+
: event === 'session_end'
|
|
1653
|
+
? context.endReason
|
|
1654
|
+
: context.toolName
|
|
1655
|
+
// An event that has no name to match (`turn_end`, `user_prompt_submit`,
|
|
1656
|
+
// `turn_start`, `post_tool_batch`) ignores the matcher and the hook
|
|
1657
|
+
// fires: a matcher cannot be read against a name that is not there, and
|
|
1658
|
+
// a guard that never fires is the failure to avoid. The compaction
|
|
1659
|
+
// events are matched on `trigger`.
|
|
1660
|
+
if (subject !== undefined && !shellHookMatches(entry.matcher, subject, { tool: namesATool }))
|
|
1352
1661
|
return { action: 'continue' }
|
|
1353
1662
|
if (!shellHookIfMatches(entry.if, context.toolName, context.toolInput, options.pathFilter)) {
|
|
1354
1663
|
return { action: 'continue' }
|
|
@@ -1374,6 +1683,8 @@ export function createShellHook(
|
|
|
1374
1683
|
...(context.prompt !== undefined ? { prompt: context.prompt } : {}),
|
|
1375
1684
|
...(context.compaction !== undefined ? { compaction: context.compaction } : {}),
|
|
1376
1685
|
...(context.toolName !== undefined ? { toolName: context.toolName } : {}),
|
|
1686
|
+
...(entry.toolNames !== undefined ? { toolNames: entry.toolNames } : {}),
|
|
1687
|
+
...(context.toolCallId !== undefined ? { toolCallId: context.toolCallId } : {}),
|
|
1377
1688
|
...(context.toolInput !== undefined ? { toolInput: context.toolInput } : {}),
|
|
1378
1689
|
...(context.toolResult !== undefined
|
|
1379
1690
|
? {
|
|
@@ -1391,9 +1702,16 @@ export function createShellHook(
|
|
|
1391
1702
|
...(context.error !== undefined ? { error: context.error } : {}),
|
|
1392
1703
|
...(context.notification !== undefined ? { notification: context.notification } : {}),
|
|
1393
1704
|
...(context.stopHookActive === true ? { stopHookActive: true } : {}),
|
|
1705
|
+
...(context.lastAssistantMessage !== undefined
|
|
1706
|
+
? { lastAssistantMessage: context.lastAssistantMessage }
|
|
1707
|
+
: {}),
|
|
1394
1708
|
...(transcriptPath !== undefined ? { transcriptPath } : {}),
|
|
1395
1709
|
...(permissionMode !== undefined ? { permissionMode } : {}),
|
|
1396
1710
|
}
|
|
1711
|
+
const verdictCall: ShellHookCall = {
|
|
1712
|
+
...(context.toolName !== undefined ? { toolName: context.toolName } : {}),
|
|
1713
|
+
...(context.toolInput !== undefined ? { toolInput: context.toolInput } : {}),
|
|
1714
|
+
}
|
|
1397
1715
|
if (entry.type === 'http') {
|
|
1398
1716
|
const response = await postHookJson({
|
|
1399
1717
|
url: entry.url,
|
|
@@ -1416,6 +1734,7 @@ export function createShellHook(
|
|
|
1416
1734
|
...(response.error !== undefined ? { spawnError: response.error } : {}),
|
|
1417
1735
|
},
|
|
1418
1736
|
log,
|
|
1737
|
+
verdictCall,
|
|
1419
1738
|
)
|
|
1420
1739
|
}
|
|
1421
1740
|
if (entry.type === 'prompt') {
|
|
@@ -1426,14 +1745,21 @@ export function createShellHook(
|
|
|
1426
1745
|
// for it and its own failure is only written down.
|
|
1427
1746
|
void runShellHook(entry, hookInput)
|
|
1428
1747
|
.then((outcome) => {
|
|
1429
|
-
shellHookVerdict(event, entry, outcome, log)
|
|
1748
|
+
const verdict = shellHookVerdict(event, entry, outcome, log, verdictCall)
|
|
1749
|
+
if (verdict.action !== 'continue') {
|
|
1750
|
+
log.warn('an async hook answered, and its answer is not applied', {
|
|
1751
|
+
'namzu.hook.event': event,
|
|
1752
|
+
'namzu.hook.command': describeShellHook(entry),
|
|
1753
|
+
'namzu.hook.action': verdict.action,
|
|
1754
|
+
})
|
|
1755
|
+
}
|
|
1430
1756
|
})
|
|
1431
1757
|
.catch(() => undefined)
|
|
1432
1758
|
return { action: 'continue' }
|
|
1433
1759
|
}
|
|
1434
1760
|
const outcome = await runShellHook(entry, hookInput, context.signal)
|
|
1435
1761
|
reportTimeout(outcome.timedOut)
|
|
1436
|
-
return shellHookVerdict(event, entry, outcome, log)
|
|
1762
|
+
return shellHookVerdict(event, entry, outcome, log, verdictCall)
|
|
1437
1763
|
},
|
|
1438
1764
|
} as PluginHookDefinition
|
|
1439
1765
|
}
|