@namzu/sdk 62.15.0 → 63.1.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/plugin/hook-context.d.ts +30 -0
  3. package/dist/plugin/hook-context.d.ts.map +1 -0
  4. package/dist/plugin/hook-context.js +51 -0
  5. package/dist/plugin/hook-context.js.map +1 -0
  6. package/dist/plugin/index.d.ts +2 -1
  7. package/dist/plugin/index.d.ts.map +1 -1
  8. package/dist/plugin/index.js +1 -0
  9. package/dist/plugin/index.js.map +1 -1
  10. package/dist/plugin/lifecycle.d.ts +9 -0
  11. package/dist/plugin/lifecycle.d.ts.map +1 -1
  12. package/dist/plugin/lifecycle.js +13 -0
  13. package/dist/plugin/lifecycle.js.map +1 -1
  14. package/dist/plugin/shell-hook.d.ts +87 -12
  15. package/dist/plugin/shell-hook.d.ts.map +1 -1
  16. package/dist/plugin/shell-hook.js +222 -31
  17. package/dist/plugin/shell-hook.js.map +1 -1
  18. package/dist/public-runtime.d.ts +1 -1
  19. package/dist/public-runtime.d.ts.map +1 -1
  20. package/dist/public-runtime.js +1 -1
  21. package/dist/public-runtime.js.map +1 -1
  22. package/dist/public-types.d.ts +1 -1
  23. package/dist/public-types.d.ts.map +1 -1
  24. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  25. package/dist/runtime/query/executor/tool-call-admission.js +2 -3
  26. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  27. package/dist/runtime/query/executor.d.ts +10 -0
  28. package/dist/runtime/query/executor.d.ts.map +1 -1
  29. package/dist/runtime/query/executor.js +25 -5
  30. package/dist/runtime/query/executor.js.map +1 -1
  31. package/dist/runtime/query/index.d.ts.map +1 -1
  32. package/dist/runtime/query/index.js +3 -2
  33. package/dist/runtime/query/index.js.map +1 -1
  34. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  35. package/dist/runtime/query/iteration/index.js +2 -0
  36. package/dist/runtime/query/iteration/index.js.map +1 -1
  37. package/dist/runtime/query/turn/outcome-ledger.d.ts +4 -0
  38. package/dist/runtime/query/turn/outcome-ledger.d.ts.map +1 -1
  39. package/dist/runtime/query/turn/outcome-ledger.js +2 -1
  40. package/dist/runtime/query/turn/outcome-ledger.js.map +1 -1
  41. package/dist/tools/builtins/bash.d.ts.map +1 -1
  42. package/dist/tools/builtins/bash.js +10 -2
  43. package/dist/tools/builtins/bash.js.map +1 -1
  44. package/dist/tools/coordinator/index.d.ts.map +1 -1
  45. package/dist/tools/coordinator/index.js +1 -0
  46. package/dist/tools/coordinator/index.js.map +1 -1
  47. package/dist/types/plugin/index.d.ts +37 -10
  48. package/dist/types/plugin/index.d.ts.map +1 -1
  49. package/dist/types/plugin/index.js.map +1 -1
  50. package/dist/types/session/events.d.ts +2 -0
  51. package/dist/types/session/events.d.ts.map +1 -1
  52. package/dist/types/session/events.js.map +1 -1
  53. package/dist/types/tool/index.d.ts +13 -1
  54. package/dist/types/tool/index.d.ts.map +1 -1
  55. package/dist/types/tool/index.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/plugin/hook-context.ts +58 -0
  58. package/src/plugin/index.ts +9 -0
  59. package/src/plugin/lifecycle.ts +13 -0
  60. package/src/plugin/shell-hook.ts +288 -42
  61. package/src/public-runtime.ts +6 -0
  62. package/src/public-types.ts +1 -0
  63. package/src/runtime/query/executor/tool-call-admission.ts +2 -4
  64. package/src/runtime/query/executor.ts +43 -9
  65. package/src/runtime/query/index.ts +3 -3
  66. package/src/runtime/query/iteration/index.ts +2 -0
  67. package/src/runtime/query/turn/outcome-ledger.ts +6 -1
  68. package/src/tools/builtins/bash.ts +16 -3
  69. package/src/tools/coordinator/index.ts +1 -0
  70. package/src/types/plugin/index.ts +48 -16
  71. package/src/types/session/events.ts +2 -0
  72. package/src/types/tool/index.ts +14 -1
@@ -22,7 +22,10 @@
22
22
  * `permission_mode`. The Namzu names stay, so no existing script changes.
23
23
  * The same facts go out as
24
24
  * same facts as `NAMZU_HOOK_EVENT`, `NAMZU_SESSION_ID`, `NAMZU_TURN_ID`,
25
- * `NAMZU_TOOL_NAME` and, when the input names a file, `NAMZU_TOOL_PATH`.
25
+ * `NAMZU_TOOL_NAME` and, when the input names a file, `NAMZU_TOOL_PATH`;
26
+ * `NAMZU_PROJECT_DIR` is the folder the session was started for.
27
+ * `session_start` also sends `source` (`startup` or `resume`) and `session_end`
28
+ * `reason` (`exit` or `replaced`).
26
29
  *
27
30
  * `session_start` and `session_end` run outside any turn, so they carry no
28
31
  * `turn_id` on stdin and no `NAMZU_TURN_ID` in the environment — not even
@@ -32,8 +35,8 @@
32
35
  * Its answer is the exit code and, on exit `0`, an optional JSON object on
33
36
  * stdout. The exit codes:
34
37
  *
35
- * - `0` — carry on. On `user_prompt_submit`, plain (non-JSON) stdout becomes
36
- * context for the model.
38
+ * - `0` — carry on. On `user_prompt_submit`, `subagent_start` and
39
+ * `session_start`, plain (non-JSON) stdout becomes context for the model.
37
40
  * - `2` — **block**, read per event: before a tool (`pre_tool_use`) the call is
38
41
  * skipped and the model is told why, with the hook's stderr as the reason;
39
42
  * on `user_prompt_submit` the turn ends; on `turn_end` and `subagent_stop`
@@ -68,7 +71,9 @@
68
71
  * `permissionDecisionReason`, `updatedInput` and `additionalContext` for
69
72
  * `pre_tool_use`; `additionalContext` for `user_prompt_submit`,
70
73
  * `post_tool_use`, `post_tool_use_failure`, `turn_end`, `subagent_stop`,
71
- * `session_start`, `notification`; and for `permission_request` a `decision`
74
+ * `session_start` (context for the whole session), `notification`;
75
+ * `updatedToolOutput` for `post_tool_use` (the text the model reads instead
76
+ * of the tool's result); and for `permission_request` a `decision`
72
77
  * `{ behavior: "allow" }` or `{ behavior: "deny", message, interrupt }`.
73
78
  *
74
79
  * ## What a hook may decide, and what it may not
@@ -123,6 +128,7 @@ import { RENAMED_PLUGIN_HOOK_EVENTS } from '../types/plugin/index.js'
123
128
  import { asPluginId } from '../utils/id.js'
124
129
  import { NOOP_LOGGER } from '../utils/log/create-logger.js'
125
130
  import type { Logger } from '../utils/logger.js'
131
+ import { boundHookReplacement } from './hook-context.js'
126
132
  import type { PluginLifecycleManager } from './lifecycle.js'
127
133
  import { postHookJson } from './shell-hook-http.js'
128
134
 
@@ -181,8 +187,10 @@ interface ShellHookEntryBase {
181
187
  * What this applies to. On a tool event (and `permission_request`) the tool
182
188
  * name; on `notification` the notification type. `*` or absent matches
183
189
  * everything; a list of names (`a|b`, `a,b`, each optionally ending in `*`
184
- * as a prefix) matches those; anything else is a regular expression tested
185
- * against the name.
190
+ * as a prefix) matches those without regard to case, and on a tool event
191
+ * also by the names skills write (`Bash`, `Edit`); anything else is a
192
+ * regular expression tested against the name (and, on a tool event, the
193
+ * names skills write for that tool).
186
194
  */
187
195
  readonly matcher?: string
188
196
  /** Deadline in milliseconds; default `DEFAULT_SHELL_HOOK_TIMEOUT_MS`, capped at `MAX_SHELL_HOOK_TIMEOUT_MS`. */
@@ -202,10 +210,19 @@ interface ShellHookEntryBase {
202
210
  * What the hook's own failure does: it could not start, it timed out, it
203
211
  * exited with a code other than `0` or `2`, or it printed JSON that is not
204
212
  * valid. `continue` (the default) reports it and lets the action go ahead;
205
- * `block` counts it as exit `2`, so the action the event guards is refused.
213
+ * `block` counts it as exit `2`, so the action the event guards is refused;
214
+ * on `post_tool_use` and `post_tool_use_failure`, where the call has already
215
+ * run, it withholds the result from the model instead (set it on a hook that
216
+ * redacts).
206
217
  * Not read on a `prompt` hook or an `async` command.
207
218
  */
208
219
  readonly onFailure?: ShellHookOnFailure
220
+ /**
221
+ * Where the entry was declared, as a label a host shows (a file's path).
222
+ * It has no effect on how the hook runs, and two entries that differ only
223
+ * here are the same entry.
224
+ */
225
+ readonly source?: string
209
226
  }
210
227
 
211
228
  /** One shell command at one event. */
@@ -269,6 +286,51 @@ export const MAX_SHELL_HOOK_TIMEOUT_MS = 10 * 60_000
269
286
  /** Captured output bound per stream. */
270
287
  const MAX_CAPTURE_BYTES = 64 * 1024
271
288
 
289
+ /**
290
+ * How a skill writes each name in `SKILL_TOOL_NAME_ALIASES` (the alias table
291
+ * is keyed lower-case). A regular-expression matcher is also tested against
292
+ * these, so `^(Edit|Write)$` written for those names still fires.
293
+ */
294
+ const WRITTEN_TOOL_NAMES: Readonly<Record<string, string>> = Object.freeze({
295
+ read: 'Read',
296
+ write: 'Write',
297
+ edit: 'Edit',
298
+ multiedit: 'MultiEdit',
299
+ bash: 'Bash',
300
+ grep: 'Grep',
301
+ glob: 'Glob',
302
+ ls: 'LS',
303
+ webfetch: 'WebFetch',
304
+ websearch: 'WebSearch',
305
+ skill: 'Skill',
306
+ askuserquestion: 'AskUserQuestion',
307
+ task: 'Task',
308
+ agent: 'Agent',
309
+ lsp: 'LSP',
310
+ toolsearch: 'ToolSearch',
311
+ bashoutput: 'BashOutput',
312
+ taskoutput: 'TaskOutput',
313
+ killshell: 'KillShell',
314
+ killbash: 'KillBash',
315
+ taskstop: 'TaskStop',
316
+ taskcreate: 'TaskCreate',
317
+ taskupdate: 'TaskUpdate',
318
+ tasklist: 'TaskList',
319
+ })
320
+
321
+ /** The names a skill writes for the tool called `toolName` here. */
322
+ function writtenNamesFor(toolName: string): string[] {
323
+ const lower = toolName.toLowerCase()
324
+ const names: string[] = []
325
+ for (const [written, tool] of Object.entries(SKILL_TOOL_NAME_ALIASES)) {
326
+ if (tool !== lower) continue
327
+ names.push(written)
328
+ const spelled = WRITTEN_TOOL_NAMES[written]
329
+ if (spelled !== undefined) names.push(spelled)
330
+ }
331
+ return names
332
+ }
333
+
272
334
  /**
273
335
  * `matcher` against a name (a tool's, or a notification's type).
274
336
  *
@@ -276,25 +338,55 @@ const MAX_CAPTURE_BYTES = 64 * 1024
276
338
  * matches exactly, each part optionally ending in `*` as a prefix (the
277
339
  * spelling this adapter has always taken). Anything else is a regular
278
340
  * expression, tested unanchored against the name; one that does not compile
279
- * matches nothing. Case-sensitive, because tool names are. A matcher on an
280
- * event that has no name (`turn_start`) matches nothing unless it is `*`.
341
+ * matches nothing. A matcher on an event that has no name (`turn_start`)
342
+ * matches nothing unless it is `*`.
343
+ *
344
+ * Names in a list compare without regard to case. With `tool: true` (the
345
+ * 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: `Bash` matches
347
+ * the tool `bash`, `MultiEdit` matches `edit`, and a prefix such as `Web*`
348
+ * matches `web_fetch` by its written name too. Where the name is not a tool
349
+ * (a notification's type, an agent's name, a session's `source` or `reason`)
350
+ * no tool alias applies, since those words are not tool names; case is still
351
+ * ignored there, because a guard that silently never runs over a capital
352
+ * letter is worse than one that also runs for a name differing only in case.
353
+ *
354
+ * A list part ending in `.*` is also tried as a regular expression.
355
+ *
356
+ * A regular expression is tested, case-sensitively as written, against the
357
+ * real name and, with `tool: true`, against each name skills write for that
358
+ * tool, so one written for those names still fires. It never matches less
359
+ * than it did against the real name alone.
281
360
  */
282
361
  export function shellHookMatches(
283
362
  matcher: string | undefined,
284
363
  toolName: string | undefined,
364
+ options: { readonly tool?: boolean } = {},
285
365
  ): boolean {
286
366
  if (matcher === undefined || matcher.trim() === '' || matcher.trim() === '*') return true
287
367
  if (toolName === undefined) return false
368
+ const tool = options.tool === true
288
369
  const parts = matcher.split(/[|,]/).map((part) => part.trim())
289
370
  if (parts.every((part) => /^[A-Za-z0-9_.:-]+\*?$/.test(part))) {
290
- return parts.some((candidate) =>
291
- candidate.endsWith('*')
292
- ? toolName.startsWith(candidate.slice(0, -1))
293
- : toolName === candidate,
294
- )
371
+ const subject = toolName.toLowerCase()
372
+ const listed = parts.some((candidate) => {
373
+ const prefix = candidate.endsWith('*')
374
+ const written = (prefix ? candidate.slice(0, -1) : candidate).toLowerCase()
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
295
385
  }
296
386
  try {
297
- return new RegExp(matcher).test(toolName)
387
+ const expression = new RegExp(matcher)
388
+ if (expression.test(toolName)) return true
389
+ return tool && writtenNamesFor(toolName).some((name) => expression.test(name))
298
390
  } catch {
299
391
  return false
300
392
  }
@@ -311,6 +403,7 @@ export function shellHookIfMatches(
311
403
  filter: string | undefined,
312
404
  toolName: string | undefined,
313
405
  toolInput: unknown,
406
+ pathFilter?: ShellHookPathFilter,
314
407
  ): boolean {
315
408
  if (filter === undefined || filter.trim() === '') return true
316
409
  if (toolName === undefined) return true
@@ -318,10 +411,17 @@ export function shellHookIfMatches(
318
411
  if (!shape) return true
319
412
  const written = (shape[1] ?? '').toLowerCase()
320
413
  const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written
321
- if (tool !== toolName.toLowerCase()) return false
322
414
  const specifier = shape[2]?.trim()
323
- if (specifier === undefined || specifier === '' || specifier.replaceAll('*', '') === '')
324
- return true
415
+ const hasPattern =
416
+ specifier !== undefined && specifier !== '' && specifier.replaceAll('*', '') !== ''
417
+ // A path pattern is the permission rules' own: the host that has them says
418
+ // whether this call's path is named. It decides for the tools it knows.
419
+ if (hasPattern && pathFilter !== undefined) {
420
+ const answer = pathFilter(tool, specifier, toolName, toolInput)
421
+ if (answer !== undefined) return answer
422
+ }
423
+ if (tool !== toolName.toLowerCase()) return false
424
+ if (!hasPattern) return true
325
425
  if (tool !== 'bash') return true
326
426
  const line =
327
427
  toolInput !== null && typeof toolInput === 'object'
@@ -345,12 +445,28 @@ export function shellHookIfMatches(
345
445
  }
346
446
  }
347
447
 
448
+ /**
449
+ * A host's reading of a path pattern in a hook's `if`: whether the call's path
450
+ * is named by `pattern` the way the host's permission rules read it. `tool` is
451
+ * the filter's tool (`edit`, `read`), `callTool` the tool being called.
452
+ * `undefined` means the host does not read this tool's paths, and the
453
+ * filter is then read as it is for any other tool.
454
+ */
455
+ export type ShellHookPathFilter = (
456
+ tool: string,
457
+ pattern: string,
458
+ callTool: string,
459
+ toolInput: unknown,
460
+ ) => boolean | undefined
461
+
348
462
  /** What one hook run came back with, before it is mapped to a verdict. */
349
463
  export interface ShellHookOutcome {
350
464
  readonly exitCode: number | null
351
465
  readonly timedOut: boolean
352
466
  readonly stdout: string
353
467
  readonly stderr: string
468
+ /** Set when the hook printed more on stdout than is kept (`MAX_CAPTURE_BYTES`). */
469
+ readonly stdoutTruncated?: boolean
354
470
  /** Set when the process could not be started at all. */
355
471
  readonly spawnError?: string
356
472
  }
@@ -362,6 +478,12 @@ export interface ShellHookInput {
362
478
  readonly sessionId: string
363
479
  /** The turn the hook fired in. Absent on `session_start` and `session_end`. */
364
480
  readonly turnId?: string
481
+ /** Why the session started, on `session_start`. Sent as `source`. */
482
+ readonly sessionSource?: string
483
+ /** Why the session closed, on `session_end`. Sent as `reason`. */
484
+ readonly endReason?: string
485
+ /** The folder the session was started for. Sent as `NAMZU_PROJECT_DIR`; absent, `cwd`. */
486
+ readonly projectDir?: string
365
487
  /** The delegating session, on `subagent_start` and `subagent_stop`. */
366
488
  readonly parentSessionId?: string
367
489
  /** The parent turn whose tool call spawned the child, on `subagent_start` and `subagent_stop`. */
@@ -403,6 +525,7 @@ export function shellHookPayload(input: ShellHookInput): string {
403
525
  ...(input.transcriptPath !== undefined ? { transcript_path: input.transcriptPath } : {}),
404
526
  ...(input.permissionMode !== undefined ? { permission_mode: input.permissionMode } : {}),
405
527
  ...(input.turnId !== undefined ? { turn_id: input.turnId } : {}),
528
+ ...(input.sessionSource !== undefined ? { source: input.sessionSource } : {}),
406
529
  ...(input.parentSessionId !== undefined ? { parent_session_id: input.parentSessionId } : {}),
407
530
  ...(input.parentTurnId !== undefined ? { parent_turn_id: input.parentTurnId } : {}),
408
531
  ...(input.agentType !== undefined
@@ -429,7 +552,11 @@ export function shellHookPayload(input: ShellHookInput): string {
429
552
  })),
430
553
  }
431
554
  : {}),
432
- ...(input.reason !== undefined ? { reason: input.reason } : {}),
555
+ ...(input.reason !== undefined
556
+ ? { reason: input.reason }
557
+ : input.endReason !== undefined
558
+ ? { reason: input.endReason }
559
+ : {}),
433
560
  ...(input.event === 'turn_failure' && input.error !== undefined ? { error: input.error } : {}),
434
561
  ...(input.notification !== undefined
435
562
  ? {
@@ -463,6 +590,7 @@ export function runShellHook(
463
590
  ...inherited,
464
591
  NAMZU_HOOK_EVENT: input.event,
465
592
  NAMZU_SESSION_ID: input.sessionId,
593
+ NAMZU_PROJECT_DIR: input.projectDir ?? input.cwd,
466
594
  ...(input.turnId !== undefined ? { NAMZU_TURN_ID: input.turnId } : {}),
467
595
  ...(input.toolName !== undefined ? { NAMZU_TOOL_NAME: input.toolName } : {}),
468
596
  ...(toolPath !== undefined ? { NAMZU_TOOL_PATH: toolPath } : {}),
@@ -507,17 +635,24 @@ export function runShellHook(
507
635
  }
508
636
  let stdout = ''
509
637
  let stderr = ''
638
+ let stdoutTruncated = false
510
639
  let timedOut = false
511
640
  let settled = false
512
- const capture = (current: string, chunk: Buffer): string =>
513
- current.length >= MAX_CAPTURE_BYTES
514
- ? current
515
- : (current + chunk.toString('utf8')).slice(0, MAX_CAPTURE_BYTES)
641
+ const capture = (current: string, chunk: Buffer): { text: string; cut: boolean } => {
642
+ if (current.length >= MAX_CAPTURE_BYTES) return { text: current, cut: chunk.length > 0 }
643
+ const joined = current + chunk.toString('utf8')
644
+ return {
645
+ text: joined.slice(0, MAX_CAPTURE_BYTES),
646
+ cut: joined.length > MAX_CAPTURE_BYTES,
647
+ }
648
+ }
516
649
  child.stdout?.on('data', (chunk: Buffer) => {
517
- stdout = capture(stdout, chunk)
650
+ const next = capture(stdout, chunk)
651
+ stdout = next.text
652
+ if (next.cut) stdoutTruncated = true
518
653
  })
519
654
  child.stderr?.on('data', (chunk: Buffer) => {
520
- stderr = capture(stderr, chunk)
655
+ stderr = capture(stderr, chunk).text
521
656
  })
522
657
  const finish = (outcome: ShellHookOutcome) => {
523
658
  if (settled) return
@@ -550,11 +685,18 @@ export function runShellHook(
550
685
  timedOut,
551
686
  stdout,
552
687
  stderr,
688
+ ...(stdoutTruncated ? { stdoutTruncated } : {}),
553
689
  spawnError: error.message,
554
690
  })
555
691
  })
556
692
  child.on('close', (code) => {
557
- finish({ exitCode: code, timedOut, stdout, stderr })
693
+ finish({
694
+ exitCode: code,
695
+ timedOut,
696
+ stdout,
697
+ stderr,
698
+ ...(stdoutTruncated ? { stdoutTruncated } : {}),
699
+ })
558
700
  })
559
701
  child.on('exit', () => {
560
702
  // After a kill, a survivor that still holds the pipes must not hold
@@ -610,6 +752,8 @@ interface ShellHookReading {
610
752
  context?: string
611
753
  halt?: { reason: string }
612
754
  notice?: string
755
+ /** Text that replaces a tool's result, on `post_tool_use`. */
756
+ replacement?: string
613
757
  /** The hook asked for its stdout to stay out of the debug log. */
614
758
  suppressOutput?: boolean
615
759
  /** Why the JSON was set aside, when it was. */
@@ -728,6 +872,12 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
728
872
  reading.updatedInput = specific.updatedInput
729
873
  }
730
874
  }
875
+ if (event === 'post_tool_use' && specific.updatedToolOutput !== undefined) {
876
+ if (typeof specific.updatedToolOutput !== 'string') {
877
+ return { invalid: 'hookSpecificOutput: `updatedToolOutput` must be a string' }
878
+ }
879
+ reading.replacement = boundHookReplacement(specific.updatedToolOutput)
880
+ }
731
881
  if (event === 'permission_request') {
732
882
  const decision = specific.decision
733
883
  if (!isRecord(decision)) {
@@ -765,15 +915,30 @@ export function shellHookVerdict(
765
915
  }
766
916
  // A failure of the hook itself is "carry on" unless the entry asked for
767
917
  // it to count as a refusal, which is exit 2 with the failure as the reason.
768
- const failed = (reason: string): PluginHookResult =>
769
- entry.onFailure === 'block' && entry.type !== 'prompt' && !isAsyncShellHook(entry)
770
- ? shellHookVerdict(
771
- event,
772
- { ...entry, onFailure: 'continue' },
773
- { exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` },
774
- log,
775
- )
776
- : { action: 'continue' }
918
+ const failed = (reason: string, shown: string = reason): PluginHookResult => {
919
+ if (entry.onFailure !== 'block' || entry.type === 'prompt' || isAsyncShellHook(entry)) {
920
+ return { action: 'continue' }
921
+ }
922
+ // The call has run and a refusal cannot undo it, but the hook may have
923
+ // been the one thing keeping something out of the result (a redaction),
924
+ // so a failed guard withholds the result instead of passing it on. The
925
+ // kernel marks the record as it does for any replaced result.
926
+ if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
927
+ const line = `A hook guarding this result failed, so the result is not shown (${shown}). The tool call itself has already run.`
928
+ return {
929
+ action: 'replace',
930
+ output: line,
931
+ content: [{ type: 'text', text: line }],
932
+ disclose: true,
933
+ }
934
+ }
935
+ return shellHookVerdict(
936
+ event,
937
+ { ...entry, onFailure: 'continue' },
938
+ { exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` },
939
+ log,
940
+ )
941
+ }
777
942
  if (outcome.spawnError !== undefined) {
778
943
  log.warn('hook could not start', {
779
944
  ...attrs,
@@ -822,12 +987,20 @@ export function shellHookVerdict(
822
987
 
823
988
  // Exit 0.
824
989
  const text = outcome.stdout.trim()
825
- const reading = readShellHookJson(event, outcome.stdout)
990
+ const reading: ShellHookReading | undefined =
991
+ outcome.stdoutTruncated === true && text.startsWith('{')
992
+ ? {
993
+ invalid: `answer is too large: the hook printed more than the ${MAX_CAPTURE_BYTES} characters of stdout that are read`,
994
+ }
995
+ : readShellHookJson(event, outcome.stdout)
826
996
  if (reading === undefined) {
827
997
  // On the prompt event, what the hook printed is context for the
828
998
  // model — the branch, the time, a ticket. Everywhere else stdout is
829
999
  // the operator's to read and the model's business not at all.
830
- if ((event === 'user_prompt_submit' || event === 'subagent_start') && text.length > 0)
1000
+ if (
1001
+ (event === 'user_prompt_submit' || event === 'subagent_start' || event === 'session_start') &&
1002
+ text.length > 0
1003
+ )
831
1004
  return { action: 'annotate', text }
832
1005
  return { action: 'continue' }
833
1006
  }
@@ -836,7 +1009,9 @@ export function shellHookVerdict(
836
1009
  ...attrs,
837
1010
  'namzu.hook.problem': reading.invalid,
838
1011
  })
839
- return failed(reading.invalid)
1012
+ // The parser's own detail can quote a piece of the answer; the line the
1013
+ // model reads keeps only the lead.
1014
+ return failed(reading.invalid, reading.invalid.split(' (')[0])
840
1015
  }
841
1016
  if (!reading.suppressOutput) {
842
1017
  log.debug('hook output', {
@@ -885,11 +1060,34 @@ export function shellHookVerdict(
885
1060
  const context = [reading.block, reading.context].filter(Boolean).join('\n')
886
1061
  return context.length > 0 ? { action: 'annotate', text: context } : { action: 'continue' }
887
1062
  }
1063
+ case 'session_start': {
1064
+ // Nothing here can be refused. What the hook said for the model becomes
1065
+ // the session's context; a stop request and a warning still count.
1066
+ return reading.context !== undefined
1067
+ ? { action: 'annotate', text: reading.context }
1068
+ : {
1069
+ action: 'continue',
1070
+ ...(reading.halt ? { halt: reading.halt } : {}),
1071
+ ...(reading.notice ? { notice: reading.notice } : {}),
1072
+ }
1073
+ }
888
1074
  case 'post_tool_use':
889
1075
  case 'post_tool_use_failure': {
890
1076
  // The call has run, so a "block" cannot undo it: its reason is what the
891
1077
  // model is told about the result.
892
1078
  const context = [reading.block, reading.context].filter(Boolean).join('\n')
1079
+ // A replacement is the hook's text in place of the tool's: the record
1080
+ // says so beside it (the kernel adds the mark).
1081
+ if (reading.replacement !== undefined) {
1082
+ return {
1083
+ action: 'replace',
1084
+ output: reading.replacement,
1085
+ disclose: true,
1086
+ ...(context.length > 0 ? { context } : {}),
1087
+ ...(reading.halt ? { halt: reading.halt } : {}),
1088
+ ...(reading.notice ? { notice: reading.notice } : {}),
1089
+ }
1090
+ }
893
1091
  return {
894
1092
  action: 'continue',
895
1093
  ...(context.length > 0 ? { context } : {}),
@@ -986,6 +1184,28 @@ export interface ShellHookOptions {
986
1184
  readonly transcriptPath?: (sessionId: string) => string | undefined
987
1185
  /** The permission mode in force now, sent as `permission_mode`. Called at each run. */
988
1186
  readonly permissionMode?: () => string | undefined
1187
+ /**
1188
+ * The folder the session was started for, sent to every command as
1189
+ * `NAMZU_PROJECT_DIR`. Absent, `cwd`.
1190
+ */
1191
+ readonly projectDir?: string
1192
+ /** How a path pattern in an entry's `if` is read. Absent, such a filter runs the hook. */
1193
+ readonly pathFilter?: ShellHookPathFilter
1194
+ /**
1195
+ * A deadline, in milliseconds, for entries of an event that set no
1196
+ * `timeoutMs` of their own (an entry's own is honoured as written, up to
1197
+ * the cap). Absent, or an event not listed, the ordinary default applies.
1198
+ */
1199
+ readonly defaultTimeoutMs?: { readonly [event in ShellHookEvent]?: number }
1200
+ /**
1201
+ * Called when a hook of this adapter ran out its deadline and was ended.
1202
+ * `hook` is the command, URL or prompt text the entry names.
1203
+ */
1204
+ readonly onTimeout?: (info: {
1205
+ readonly event: ShellHookEvent
1206
+ readonly hook: string
1207
+ readonly timeoutMs: number
1208
+ }) => void
989
1209
  }
990
1210
 
991
1211
  /** The deadline for `prompt` hooks when they name none. */
@@ -1087,10 +1307,19 @@ function readJudgement(reply: string): { ok: boolean; reason: string } | undefin
1087
1307
  /** One entry as a hook definition the lifecycle manager accepts. */
1088
1308
  export function createShellHook(
1089
1309
  event: ShellHookEvent,
1090
- entry: ShellHookHandler,
1310
+ configured: ShellHookHandler,
1091
1311
  options: ShellHookOptions,
1092
1312
  ): PluginHookDefinition {
1093
1313
  const log = options.log ?? NOOP_LOGGER
1314
+ const eventDefault = options.defaultTimeoutMs?.[event]
1315
+ const entry: ShellHookHandler =
1316
+ configured.timeoutMs === undefined && eventDefault !== undefined
1317
+ ? { ...configured, timeoutMs: eventDefault }
1318
+ : configured
1319
+ const reportTimeout = (timedOut: boolean): void => {
1320
+ if (!timedOut) return
1321
+ options.onTimeout?.({ event, hook: describeShellHook(entry), timeoutMs: hookTimeout(entry) })
1322
+ }
1094
1323
  // `once` is per definition, and a definition is per session: the manager
1095
1324
  // is built for one session and its hooks are attached to it.
1096
1325
  let spent = false
@@ -1102,14 +1331,26 @@ export function createShellHook(
1102
1331
  handler: async (context: PluginHookContext) => {
1103
1332
  if (spent) return { action: 'continue' }
1104
1333
  // A notification is matched on its type; every other event on the tool.
1334
+ const namesATool = !(
1335
+ event === 'notification' ||
1336
+ event === 'subagent_start' ||
1337
+ event === 'subagent_stop' ||
1338
+ event === 'session_start' ||
1339
+ event === 'session_end'
1340
+ )
1105
1341
  const subject =
1106
1342
  event === 'notification'
1107
1343
  ? context.notification?.type
1108
1344
  : event === 'subagent_start' || event === 'subagent_stop'
1109
1345
  ? context.agentType
1110
- : context.toolName
1111
- if (!shellHookMatches(entry.matcher, subject)) return { action: 'continue' }
1112
- if (!shellHookIfMatches(entry.if, context.toolName, context.toolInput)) {
1346
+ : event === 'session_start'
1347
+ ? context.source
1348
+ : event === 'session_end'
1349
+ ? context.endReason
1350
+ : context.toolName
1351
+ if (!shellHookMatches(entry.matcher, subject, { tool: namesATool }))
1352
+ return { action: 'continue' }
1353
+ if (!shellHookIfMatches(entry.if, context.toolName, context.toolInput, options.pathFilter)) {
1113
1354
  return { action: 'continue' }
1114
1355
  }
1115
1356
  if (entry.once === true) spent = true
@@ -1118,7 +1359,10 @@ export function createShellHook(
1118
1359
  const hookInput: ShellHookInput = {
1119
1360
  event,
1120
1361
  cwd: options.cwd,
1362
+ ...(options.projectDir !== undefined ? { projectDir: options.projectDir } : {}),
1121
1363
  sessionId: String(context.sessionId),
1364
+ ...(context.source !== undefined ? { sessionSource: context.source } : {}),
1365
+ ...(context.endReason !== undefined ? { endReason: context.endReason } : {}),
1122
1366
  ...(context.turnId !== undefined ? { turnId: String(context.turnId) } : {}),
1123
1367
  ...(context.parentSessionId !== undefined
1124
1368
  ? { parentSessionId: String(context.parentSessionId) }
@@ -1159,6 +1403,7 @@ export function createShellHook(
1159
1403
  timeoutMs: hookTimeout(entry),
1160
1404
  ...(context.signal !== undefined ? { signal: context.signal } : {}),
1161
1405
  })
1406
+ reportTimeout(response.timedOut)
1162
1407
  const ok = response.status !== null && response.status >= 200 && response.status < 300
1163
1408
  return shellHookVerdict(
1164
1409
  event,
@@ -1187,6 +1432,7 @@ export function createShellHook(
1187
1432
  return { action: 'continue' }
1188
1433
  }
1189
1434
  const outcome = await runShellHook(entry, hookInput, context.signal)
1435
+ reportTimeout(outcome.timedOut)
1190
1436
  return shellHookVerdict(event, entry, outcome, log)
1191
1437
  },
1192
1438
  } as PluginHookDefinition
@@ -697,9 +697,15 @@ export {
697
697
 
698
698
  export {
699
699
  attachShellHooks,
700
+ boundHookContext,
701
+ boundHookReplacement,
700
702
  createShellHook,
701
703
  DEFAULT_SHELL_HOOK_TIMEOUT_MS,
702
704
  definePlugin,
705
+ frameOperatorHookContext,
706
+ MAX_HOOK_CONTEXT_CHARS,
707
+ MAX_HOOK_REPLACEMENT_CHARS,
708
+ sessionStartHookContext,
703
709
  discoverAllPluginDirs,
704
710
  discoverPlugins,
705
711
  assertEnableable,
@@ -244,6 +244,7 @@ export type {
244
244
  ShellHookOnFailure,
245
245
  ShellHookInput,
246
246
  ShellHookOptions,
247
+ ShellHookPathFilter,
247
248
  ShellHookOutcome,
248
249
  ShellHooksConfig,
249
250
  } from './plugin/shell-hook.js'
@@ -1,4 +1,5 @@
1
1
  import { GENAI, NAMZU } from '../../../constants/telemetry/index.js'
2
+ import { boundHookContext } from '../../../plugin/hook-context.js'
2
3
  import { callableToolNames, formatToolNames } from '../../../registry/tool/callable.js'
3
4
  import { renderToolSchema } from '../../../registry/tool/schema.js'
4
5
  import type { ToolCall, ToolInputError } from '../../../types/message/index.js'
@@ -433,12 +434,9 @@ function interpretPreToolResults(
433
434
  */
434
435
  export function hookNote(texts: readonly string[]): string {
435
436
  const joined = texts.join('\n')
436
- return `[Hook note]\n${joined.length > MAX_HOOK_NOTE_CHARS ? `${joined.slice(0, MAX_HOOK_NOTE_CHARS)}\n[truncated]` : joined}`
437
+ return `[Hook note]\n${boundHookContext(joined)}`
437
438
  }
438
439
 
439
- /** A hook's text is one more thing for the model to read; it gets a bound. */
440
- const MAX_HOOK_NOTE_CHARS = 8_000
441
-
442
440
  /**
443
441
  * Turn the call the model issued into a name and a parsed input, giving
444
442
  * a configured repairer one chance to fix it first.