@namzu/sdk 62.14.0 → 63.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/connector/mcp/stdio.d.ts.map +1 -1
  3. package/dist/connector/mcp/stdio.js +3 -0
  4. package/dist/connector/mcp/stdio.js.map +1 -1
  5. package/dist/eval/compare.d.ts +54 -0
  6. package/dist/eval/compare.d.ts.map +1 -1
  7. package/dist/eval/compare.js +146 -1
  8. package/dist/eval/compare.js.map +1 -1
  9. package/dist/eval/experiment.d.ts.map +1 -1
  10. package/dist/eval/experiment.js +46 -6
  11. package/dist/eval/experiment.js.map +1 -1
  12. package/dist/eval/index.d.ts +6 -2
  13. package/dist/eval/index.d.ts.map +1 -1
  14. package/dist/eval/index.js +3 -1
  15. package/dist/eval/index.js.map +1 -1
  16. package/dist/eval/report-stats.d.ts +6 -0
  17. package/dist/eval/report-stats.d.ts.map +1 -1
  18. package/dist/eval/report-stats.js +10 -0
  19. package/dist/eval/report-stats.js.map +1 -1
  20. package/dist/eval/run-options.d.ts +25 -0
  21. package/dist/eval/run-options.d.ts.map +1 -1
  22. package/dist/eval/run-options.js +36 -0
  23. package/dist/eval/run-options.js.map +1 -1
  24. package/dist/eval/run-summary.d.ts +8 -0
  25. package/dist/eval/run-summary.d.ts.map +1 -1
  26. package/dist/eval/run-summary.js +13 -3
  27. package/dist/eval/run-summary.js.map +1 -1
  28. package/dist/eval/settings.d.ts +74 -0
  29. package/dist/eval/settings.d.ts.map +1 -0
  30. package/dist/eval/settings.js +161 -0
  31. package/dist/eval/settings.js.map +1 -0
  32. package/dist/eval/split.d.ts +48 -0
  33. package/dist/eval/split.d.ts.map +1 -0
  34. package/dist/eval/split.js +41 -0
  35. package/dist/eval/split.js.map +1 -0
  36. package/dist/eval/types.d.ts +12 -0
  37. package/dist/eval/types.d.ts.map +1 -1
  38. package/dist/execution/local.d.ts.map +1 -1
  39. package/dist/execution/local.js +2 -1
  40. package/dist/execution/local.js.map +1 -1
  41. package/dist/peers/envelope.d.ts +11 -0
  42. package/dist/peers/envelope.d.ts.map +1 -1
  43. package/dist/peers/envelope.js +29 -0
  44. package/dist/peers/envelope.js.map +1 -1
  45. package/dist/peers/index.d.ts +3 -3
  46. package/dist/peers/index.d.ts.map +1 -1
  47. package/dist/peers/index.js +2 -2
  48. package/dist/peers/index.js.map +1 -1
  49. package/dist/peers/protocol.d.ts +26 -0
  50. package/dist/peers/protocol.d.ts.map +1 -1
  51. package/dist/peers/protocol.js +22 -2
  52. package/dist/peers/protocol.js.map +1 -1
  53. package/dist/peers/record.d.ts +2 -0
  54. package/dist/peers/record.d.ts.map +1 -1
  55. package/dist/plugin/hook-context.d.ts +30 -0
  56. package/dist/plugin/hook-context.d.ts.map +1 -0
  57. package/dist/plugin/hook-context.js +51 -0
  58. package/dist/plugin/hook-context.js.map +1 -0
  59. package/dist/plugin/index.d.ts +2 -1
  60. package/dist/plugin/index.d.ts.map +1 -1
  61. package/dist/plugin/index.js +1 -0
  62. package/dist/plugin/index.js.map +1 -1
  63. package/dist/plugin/lifecycle.d.ts +9 -0
  64. package/dist/plugin/lifecycle.d.ts.map +1 -1
  65. package/dist/plugin/lifecycle.js +13 -0
  66. package/dist/plugin/lifecycle.js.map +1 -1
  67. package/dist/plugin/shell-hook.d.ts +87 -12
  68. package/dist/plugin/shell-hook.d.ts.map +1 -1
  69. package/dist/plugin/shell-hook.js +224 -32
  70. package/dist/plugin/shell-hook.js.map +1 -1
  71. package/dist/process/session-child.d.ts +21 -0
  72. package/dist/process/session-child.d.ts.map +1 -0
  73. package/dist/process/session-child.js +32 -0
  74. package/dist/process/session-child.js.map +1 -0
  75. package/dist/public-runtime.d.ts +4 -3
  76. package/dist/public-runtime.d.ts.map +1 -1
  77. package/dist/public-runtime.js +4 -3
  78. package/dist/public-runtime.js.map +1 -1
  79. package/dist/public-types.d.ts +2 -2
  80. package/dist/public-types.d.ts.map +1 -1
  81. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  82. package/dist/runtime/query/executor/tool-call-admission.js +2 -3
  83. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  84. package/dist/runtime/query/executor.d.ts +2 -0
  85. package/dist/runtime/query/executor.d.ts.map +1 -1
  86. package/dist/runtime/query/executor.js +13 -1
  87. package/dist/runtime/query/executor.js.map +1 -1
  88. package/dist/runtime/query/index.d.ts.map +1 -1
  89. package/dist/runtime/query/index.js +3 -2
  90. package/dist/runtime/query/index.js.map +1 -1
  91. package/dist/runtime/system-events.d.ts +3 -3
  92. package/dist/runtime/system-events.d.ts.map +1 -1
  93. package/dist/runtime/system-events.js +2 -1
  94. package/dist/runtime/system-events.js.map +1 -1
  95. package/dist/sandbox/provider/local.d.ts +1 -0
  96. package/dist/sandbox/provider/local.d.ts.map +1 -1
  97. package/dist/sandbox/provider/local.js +5 -2
  98. package/dist/sandbox/provider/local.js.map +1 -1
  99. package/dist/sandbox/terminal.d.ts.map +1 -1
  100. package/dist/sandbox/terminal.js +2 -1
  101. package/dist/sandbox/terminal.js.map +1 -1
  102. package/dist/tools/command-shell.d.ts.map +1 -1
  103. package/dist/tools/command-shell.js +7 -1
  104. package/dist/tools/command-shell.js.map +1 -1
  105. package/dist/types/message/index.d.ts +1 -1
  106. package/dist/types/message/index.d.ts.map +1 -1
  107. package/dist/types/message/index.js +1 -0
  108. package/dist/types/message/index.js.map +1 -1
  109. package/dist/types/plugin/index.d.ts +37 -10
  110. package/dist/types/plugin/index.d.ts.map +1 -1
  111. package/dist/types/plugin/index.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/connector/mcp/stdio.ts +3 -0
  114. package/src/eval/compare.ts +245 -2
  115. package/src/eval/experiment.ts +58 -4
  116. package/src/eval/index.ts +32 -1
  117. package/src/eval/report-stats.ts +11 -0
  118. package/src/eval/run-options.ts +51 -0
  119. package/src/eval/run-summary.ts +19 -1
  120. package/src/eval/settings.ts +225 -0
  121. package/src/eval/split.ts +83 -0
  122. package/src/eval/types.ts +12 -0
  123. package/src/execution/local.ts +2 -1
  124. package/src/peers/envelope.ts +36 -0
  125. package/src/peers/index.ts +3 -1
  126. package/src/peers/protocol.ts +24 -2
  127. package/src/plugin/hook-context.ts +58 -0
  128. package/src/plugin/index.ts +9 -0
  129. package/src/plugin/lifecycle.ts +13 -0
  130. package/src/plugin/shell-hook.ts +290 -43
  131. package/src/process/session-child.ts +38 -0
  132. package/src/public-runtime.ts +27 -0
  133. package/src/public-types.ts +2 -0
  134. package/src/runtime/query/executor/tool-call-admission.ts +2 -4
  135. package/src/runtime/query/executor.ts +15 -3
  136. package/src/runtime/query/index.ts +3 -3
  137. package/src/runtime/system-events.ts +2 -1
  138. package/src/sandbox/provider/local.ts +5 -1
  139. package/src/sandbox/terminal.ts +3 -1
  140. package/src/tools/command-shell.ts +7 -1
  141. package/src/types/message/index.ts +1 -0
  142. package/src/types/plugin/index.ts +48 -16
@@ -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
@@ -106,6 +111,7 @@ import {
106
111
  SKILL_TOOL_NAME_ALIASES,
107
112
  permissionPatternToRegExpSource,
108
113
  } from '../authorization/skill-grant.js'
114
+ import { withSessionChildMarker } from '../process/session-child.js'
109
115
  import { spawnHostShell } from '../tools/command-shell.js'
110
116
  import type { PluginId } from '../types/ids/index.js'
111
117
  import type {
@@ -122,6 +128,7 @@ import { RENAMED_PLUGIN_HOOK_EVENTS } from '../types/plugin/index.js'
122
128
  import { asPluginId } from '../utils/id.js'
123
129
  import { NOOP_LOGGER } from '../utils/log/create-logger.js'
124
130
  import type { Logger } from '../utils/logger.js'
131
+ import { boundHookReplacement } from './hook-context.js'
125
132
  import type { PluginLifecycleManager } from './lifecycle.js'
126
133
  import { postHookJson } from './shell-hook-http.js'
127
134
 
@@ -180,8 +187,10 @@ interface ShellHookEntryBase {
180
187
  * What this applies to. On a tool event (and `permission_request`) the tool
181
188
  * name; on `notification` the notification type. `*` or absent matches
182
189
  * everything; a list of names (`a|b`, `a,b`, each optionally ending in `*`
183
- * as a prefix) matches those; anything else is a regular expression tested
184
- * 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).
185
194
  */
186
195
  readonly matcher?: string
187
196
  /** Deadline in milliseconds; default `DEFAULT_SHELL_HOOK_TIMEOUT_MS`, capped at `MAX_SHELL_HOOK_TIMEOUT_MS`. */
@@ -201,10 +210,19 @@ interface ShellHookEntryBase {
201
210
  * What the hook's own failure does: it could not start, it timed out, it
202
211
  * exited with a code other than `0` or `2`, or it printed JSON that is not
203
212
  * valid. `continue` (the default) reports it and lets the action go ahead;
204
- * `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).
205
217
  * Not read on a `prompt` hook or an `async` command.
206
218
  */
207
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
208
226
  }
209
227
 
210
228
  /** One shell command at one event. */
@@ -268,6 +286,51 @@ export const MAX_SHELL_HOOK_TIMEOUT_MS = 10 * 60_000
268
286
  /** Captured output bound per stream. */
269
287
  const MAX_CAPTURE_BYTES = 64 * 1024
270
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
+
271
334
  /**
272
335
  * `matcher` against a name (a tool's, or a notification's type).
273
336
  *
@@ -275,25 +338,55 @@ const MAX_CAPTURE_BYTES = 64 * 1024
275
338
  * matches exactly, each part optionally ending in `*` as a prefix (the
276
339
  * spelling this adapter has always taken). Anything else is a regular
277
340
  * expression, tested unanchored against the name; one that does not compile
278
- * matches nothing. Case-sensitive, because tool names are. A matcher on an
279
- * 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.
280
360
  */
281
361
  export function shellHookMatches(
282
362
  matcher: string | undefined,
283
363
  toolName: string | undefined,
364
+ options: { readonly tool?: boolean } = {},
284
365
  ): boolean {
285
366
  if (matcher === undefined || matcher.trim() === '' || matcher.trim() === '*') return true
286
367
  if (toolName === undefined) return false
368
+ const tool = options.tool === true
287
369
  const parts = matcher.split(/[|,]/).map((part) => part.trim())
288
370
  if (parts.every((part) => /^[A-Za-z0-9_.:-]+\*?$/.test(part))) {
289
- return parts.some((candidate) =>
290
- candidate.endsWith('*')
291
- ? toolName.startsWith(candidate.slice(0, -1))
292
- : toolName === candidate,
293
- )
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
294
385
  }
295
386
  try {
296
- 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))
297
390
  } catch {
298
391
  return false
299
392
  }
@@ -310,6 +403,7 @@ export function shellHookIfMatches(
310
403
  filter: string | undefined,
311
404
  toolName: string | undefined,
312
405
  toolInput: unknown,
406
+ pathFilter?: ShellHookPathFilter,
313
407
  ): boolean {
314
408
  if (filter === undefined || filter.trim() === '') return true
315
409
  if (toolName === undefined) return true
@@ -317,10 +411,17 @@ export function shellHookIfMatches(
317
411
  if (!shape) return true
318
412
  const written = (shape[1] ?? '').toLowerCase()
319
413
  const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written
320
- if (tool !== toolName.toLowerCase()) return false
321
414
  const specifier = shape[2]?.trim()
322
- if (specifier === undefined || specifier === '' || specifier.replaceAll('*', '') === '')
323
- 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
324
425
  if (tool !== 'bash') return true
325
426
  const line =
326
427
  toolInput !== null && typeof toolInput === 'object'
@@ -344,12 +445,28 @@ export function shellHookIfMatches(
344
445
  }
345
446
  }
346
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
+
347
462
  /** What one hook run came back with, before it is mapped to a verdict. */
348
463
  export interface ShellHookOutcome {
349
464
  readonly exitCode: number | null
350
465
  readonly timedOut: boolean
351
466
  readonly stdout: string
352
467
  readonly stderr: string
468
+ /** Set when the hook printed more on stdout than is kept (`MAX_CAPTURE_BYTES`). */
469
+ readonly stdoutTruncated?: boolean
353
470
  /** Set when the process could not be started at all. */
354
471
  readonly spawnError?: string
355
472
  }
@@ -361,6 +478,12 @@ export interface ShellHookInput {
361
478
  readonly sessionId: string
362
479
  /** The turn the hook fired in. Absent on `session_start` and `session_end`. */
363
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
364
487
  /** The delegating session, on `subagent_start` and `subagent_stop`. */
365
488
  readonly parentSessionId?: string
366
489
  /** The parent turn whose tool call spawned the child, on `subagent_start` and `subagent_stop`. */
@@ -402,6 +525,7 @@ export function shellHookPayload(input: ShellHookInput): string {
402
525
  ...(input.transcriptPath !== undefined ? { transcript_path: input.transcriptPath } : {}),
403
526
  ...(input.permissionMode !== undefined ? { permission_mode: input.permissionMode } : {}),
404
527
  ...(input.turnId !== undefined ? { turn_id: input.turnId } : {}),
528
+ ...(input.sessionSource !== undefined ? { source: input.sessionSource } : {}),
405
529
  ...(input.parentSessionId !== undefined ? { parent_session_id: input.parentSessionId } : {}),
406
530
  ...(input.parentTurnId !== undefined ? { parent_turn_id: input.parentTurnId } : {}),
407
531
  ...(input.agentType !== undefined
@@ -428,7 +552,11 @@ export function shellHookPayload(input: ShellHookInput): string {
428
552
  })),
429
553
  }
430
554
  : {}),
431
- ...(input.reason !== undefined ? { reason: input.reason } : {}),
555
+ ...(input.reason !== undefined
556
+ ? { reason: input.reason }
557
+ : input.endReason !== undefined
558
+ ? { reason: input.endReason }
559
+ : {}),
432
560
  ...(input.event === 'turn_failure' && input.error !== undefined ? { error: input.error } : {}),
433
561
  ...(input.notification !== undefined
434
562
  ? {
@@ -462,6 +590,7 @@ export function runShellHook(
462
590
  ...inherited,
463
591
  NAMZU_HOOK_EVENT: input.event,
464
592
  NAMZU_SESSION_ID: input.sessionId,
593
+ NAMZU_PROJECT_DIR: input.projectDir ?? input.cwd,
465
594
  ...(input.turnId !== undefined ? { NAMZU_TURN_ID: input.turnId } : {}),
466
595
  ...(input.toolName !== undefined ? { NAMZU_TOOL_NAME: input.toolName } : {}),
467
596
  ...(toolPath !== undefined ? { NAMZU_TOOL_PATH: toolPath } : {}),
@@ -478,7 +607,7 @@ export function runShellHook(
478
607
  ? // The exec form: no shell reads `command` or any argument.
479
608
  spawn(entry.command, [...entry.args], {
480
609
  cwd: input.cwd,
481
- env,
610
+ env: withSessionChildMarker(env),
482
611
  stdio: ['pipe', 'pipe', 'pipe'],
483
612
  detached,
484
613
  })
@@ -506,17 +635,24 @@ export function runShellHook(
506
635
  }
507
636
  let stdout = ''
508
637
  let stderr = ''
638
+ let stdoutTruncated = false
509
639
  let timedOut = false
510
640
  let settled = false
511
- const capture = (current: string, chunk: Buffer): string =>
512
- current.length >= MAX_CAPTURE_BYTES
513
- ? current
514
- : (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
+ }
515
649
  child.stdout?.on('data', (chunk: Buffer) => {
516
- stdout = capture(stdout, chunk)
650
+ const next = capture(stdout, chunk)
651
+ stdout = next.text
652
+ if (next.cut) stdoutTruncated = true
517
653
  })
518
654
  child.stderr?.on('data', (chunk: Buffer) => {
519
- stderr = capture(stderr, chunk)
655
+ stderr = capture(stderr, chunk).text
520
656
  })
521
657
  const finish = (outcome: ShellHookOutcome) => {
522
658
  if (settled) return
@@ -549,11 +685,18 @@ export function runShellHook(
549
685
  timedOut,
550
686
  stdout,
551
687
  stderr,
688
+ ...(stdoutTruncated ? { stdoutTruncated } : {}),
552
689
  spawnError: error.message,
553
690
  })
554
691
  })
555
692
  child.on('close', (code) => {
556
- finish({ exitCode: code, timedOut, stdout, stderr })
693
+ finish({
694
+ exitCode: code,
695
+ timedOut,
696
+ stdout,
697
+ stderr,
698
+ ...(stdoutTruncated ? { stdoutTruncated } : {}),
699
+ })
557
700
  })
558
701
  child.on('exit', () => {
559
702
  // After a kill, a survivor that still holds the pipes must not hold
@@ -609,6 +752,8 @@ interface ShellHookReading {
609
752
  context?: string
610
753
  halt?: { reason: string }
611
754
  notice?: string
755
+ /** Text that replaces a tool's result, on `post_tool_use`. */
756
+ replacement?: string
612
757
  /** The hook asked for its stdout to stay out of the debug log. */
613
758
  suppressOutput?: boolean
614
759
  /** Why the JSON was set aside, when it was. */
@@ -727,6 +872,12 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
727
872
  reading.updatedInput = specific.updatedInput
728
873
  }
729
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
+ }
730
881
  if (event === 'permission_request') {
731
882
  const decision = specific.decision
732
883
  if (!isRecord(decision)) {
@@ -764,15 +915,30 @@ export function shellHookVerdict(
764
915
  }
765
916
  // A failure of the hook itself is "carry on" unless the entry asked for
766
917
  // it to count as a refusal, which is exit 2 with the failure as the reason.
767
- const failed = (reason: string): PluginHookResult =>
768
- entry.onFailure === 'block' && entry.type !== 'prompt' && !isAsyncShellHook(entry)
769
- ? shellHookVerdict(
770
- event,
771
- { ...entry, onFailure: 'continue' },
772
- { exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` },
773
- log,
774
- )
775
- : { 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
+ }
776
942
  if (outcome.spawnError !== undefined) {
777
943
  log.warn('hook could not start', {
778
944
  ...attrs,
@@ -821,12 +987,20 @@ export function shellHookVerdict(
821
987
 
822
988
  // Exit 0.
823
989
  const text = outcome.stdout.trim()
824
- 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)
825
996
  if (reading === undefined) {
826
997
  // On the prompt event, what the hook printed is context for the
827
998
  // model — the branch, the time, a ticket. Everywhere else stdout is
828
999
  // the operator's to read and the model's business not at all.
829
- 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
+ )
830
1004
  return { action: 'annotate', text }
831
1005
  return { action: 'continue' }
832
1006
  }
@@ -835,7 +1009,9 @@ export function shellHookVerdict(
835
1009
  ...attrs,
836
1010
  'namzu.hook.problem': reading.invalid,
837
1011
  })
838
- 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])
839
1015
  }
840
1016
  if (!reading.suppressOutput) {
841
1017
  log.debug('hook output', {
@@ -884,11 +1060,34 @@ export function shellHookVerdict(
884
1060
  const context = [reading.block, reading.context].filter(Boolean).join('\n')
885
1061
  return context.length > 0 ? { action: 'annotate', text: context } : { action: 'continue' }
886
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
+ }
887
1074
  case 'post_tool_use':
888
1075
  case 'post_tool_use_failure': {
889
1076
  // The call has run, so a "block" cannot undo it: its reason is what the
890
1077
  // model is told about the result.
891
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
+ }
892
1091
  return {
893
1092
  action: 'continue',
894
1093
  ...(context.length > 0 ? { context } : {}),
@@ -985,6 +1184,28 @@ export interface ShellHookOptions {
985
1184
  readonly transcriptPath?: (sessionId: string) => string | undefined
986
1185
  /** The permission mode in force now, sent as `permission_mode`. Called at each run. */
987
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
988
1209
  }
989
1210
 
990
1211
  /** The deadline for `prompt` hooks when they name none. */
@@ -1086,10 +1307,19 @@ function readJudgement(reply: string): { ok: boolean; reason: string } | undefin
1086
1307
  /** One entry as a hook definition the lifecycle manager accepts. */
1087
1308
  export function createShellHook(
1088
1309
  event: ShellHookEvent,
1089
- entry: ShellHookHandler,
1310
+ configured: ShellHookHandler,
1090
1311
  options: ShellHookOptions,
1091
1312
  ): PluginHookDefinition {
1092
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
+ }
1093
1323
  // `once` is per definition, and a definition is per session: the manager
1094
1324
  // is built for one session and its hooks are attached to it.
1095
1325
  let spent = false
@@ -1101,14 +1331,26 @@ export function createShellHook(
1101
1331
  handler: async (context: PluginHookContext) => {
1102
1332
  if (spent) return { action: 'continue' }
1103
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
+ )
1104
1341
  const subject =
1105
1342
  event === 'notification'
1106
1343
  ? context.notification?.type
1107
1344
  : event === 'subagent_start' || event === 'subagent_stop'
1108
1345
  ? context.agentType
1109
- : context.toolName
1110
- if (!shellHookMatches(entry.matcher, subject)) return { action: 'continue' }
1111
- 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)) {
1112
1354
  return { action: 'continue' }
1113
1355
  }
1114
1356
  if (entry.once === true) spent = true
@@ -1117,7 +1359,10 @@ export function createShellHook(
1117
1359
  const hookInput: ShellHookInput = {
1118
1360
  event,
1119
1361
  cwd: options.cwd,
1362
+ ...(options.projectDir !== undefined ? { projectDir: options.projectDir } : {}),
1120
1363
  sessionId: String(context.sessionId),
1364
+ ...(context.source !== undefined ? { sessionSource: context.source } : {}),
1365
+ ...(context.endReason !== undefined ? { endReason: context.endReason } : {}),
1121
1366
  ...(context.turnId !== undefined ? { turnId: String(context.turnId) } : {}),
1122
1367
  ...(context.parentSessionId !== undefined
1123
1368
  ? { parentSessionId: String(context.parentSessionId) }
@@ -1158,6 +1403,7 @@ export function createShellHook(
1158
1403
  timeoutMs: hookTimeout(entry),
1159
1404
  ...(context.signal !== undefined ? { signal: context.signal } : {}),
1160
1405
  })
1406
+ reportTimeout(response.timedOut)
1161
1407
  const ok = response.status !== null && response.status >= 200 && response.status < 300
1162
1408
  return shellHookVerdict(
1163
1409
  event,
@@ -1186,6 +1432,7 @@ export function createShellHook(
1186
1432
  return { action: 'continue' }
1187
1433
  }
1188
1434
  const outcome = await runShellHook(entry, hookInput, context.signal)
1435
+ reportTimeout(outcome.timedOut)
1189
1436
  return shellHookVerdict(event, entry, outcome, log)
1190
1437
  },
1191
1438
  } as PluginHookDefinition
@@ -0,0 +1,38 @@
1
+ import { readEnvironmentEntry, setEnvironmentEntry } from '../utils/process-environment.js'
2
+
3
+ /**
4
+ * The name of the variable the kernel puts into the environment of every process it starts on a
5
+ * session's behalf: a command the model ran, a hook, a tool server, a delegated or background run's
6
+ * command, a terminal a session opened.
7
+ *
8
+ * It says one thing: this process, or one of its ancestors, was started by a running session, not by a
9
+ * person at their own terminal. `namzu send` reads it and refuses, because a message to another session
10
+ * is a person's, and a command a model ran is not a person.
11
+ *
12
+ * It is a marker and not a lock. Any process of the same account can start another with a cleaner
13
+ * environment (`env -i`), so the kernel does not rely on it alone: the command-line permission layer
14
+ * refuses the same command before it runs, and the receiving terminal checks the sender's process
15
+ * ancestry. What a process of the same account can do after hiding both is the account boundary's to
16
+ * hold, not this marker's.
17
+ */
18
+ export const SESSION_CHILD_ENV = 'NAMZU_STARTED_BY_SESSION'
19
+
20
+ /** A copy of `env` that carries the marker. Everywhere the kernel builds a child's environment ends here. */
21
+ export function withSessionChildMarker<T extends Readonly<Record<string, string | undefined>>>(
22
+ env: T,
23
+ platform: NodeJS.Platform = process.platform,
24
+ ): Record<string, string | undefined> {
25
+ const out: Record<string, string> = {}
26
+ for (const [name, value] of Object.entries(env)) if (value !== undefined) out[name] = value
27
+ setEnvironmentEntry(out, SESSION_CHILD_ENV, '1', platform)
28
+ return out
29
+ }
30
+
31
+ /** Whether `env` carries the marker: this process was started, directly or not, by a running session. */
32
+ export function startedBySession(
33
+ env: Readonly<Record<string, string | undefined>> = process.env,
34
+ platform: NodeJS.Platform = process.platform,
35
+ ): boolean {
36
+ const found = readEnvironmentEntry(env, SESSION_CHILD_ENV, platform)
37
+ return found !== undefined && found[1] !== '' && found[1] !== '0'
38
+ }