@namzu/sdk 57.1.0 → 58.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 (254) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +2 -0
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/agents/file-definitions.d.ts +35 -4
  6. package/dist/agents/file-definitions.d.ts.map +1 -1
  7. package/dist/agents/file-definitions.js +98 -8
  8. package/dist/agents/file-definitions.js.map +1 -1
  9. package/dist/authorization/protected-paths.d.ts +9 -0
  10. package/dist/authorization/protected-paths.d.ts.map +1 -1
  11. package/dist/authorization/protected-paths.js +29 -3
  12. package/dist/authorization/protected-paths.js.map +1 -1
  13. package/dist/compaction/context-window.d.ts +1 -1
  14. package/dist/compaction/context-window.d.ts.map +1 -1
  15. package/dist/compaction/context-window.js +8 -1
  16. package/dist/compaction/context-window.js.map +1 -1
  17. package/dist/compaction/plan.d.ts.map +1 -1
  18. package/dist/compaction/plan.js +2 -2
  19. package/dist/compaction/plan.js.map +1 -1
  20. package/dist/config/runtime.d.ts +10 -0
  21. package/dist/config/runtime.d.ts.map +1 -1
  22. package/dist/config/runtime.js +2 -0
  23. package/dist/config/runtime.js.map +1 -1
  24. package/dist/connector/mcp/client.d.ts +8 -0
  25. package/dist/connector/mcp/client.d.ts.map +1 -1
  26. package/dist/connector/mcp/client.js +75 -2
  27. package/dist/connector/mcp/client.js.map +1 -1
  28. package/dist/constants/mcp/index.d.ts +2 -0
  29. package/dist/constants/mcp/index.d.ts.map +1 -1
  30. package/dist/constants/mcp/index.js +2 -0
  31. package/dist/constants/mcp/index.js.map +1 -1
  32. package/dist/constants/plugin/index.d.ts +2 -0
  33. package/dist/constants/plugin/index.d.ts.map +1 -1
  34. package/dist/constants/plugin/index.js +2 -0
  35. package/dist/constants/plugin/index.js.map +1 -1
  36. package/dist/manager/agent/lifecycle.d.ts +1 -1
  37. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  38. package/dist/manager/agent/lifecycle.js +25 -4
  39. package/dist/manager/agent/lifecycle.js.map +1 -1
  40. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  41. package/dist/manager/session/turn-recorder.js +2 -1
  42. package/dist/manager/session/turn-recorder.js.map +1 -1
  43. package/dist/persona/assembler.d.ts.map +1 -1
  44. package/dist/persona/assembler.js +2 -2
  45. package/dist/persona/assembler.js.map +1 -1
  46. package/dist/plugin/index.d.ts +1 -1
  47. package/dist/plugin/index.d.ts.map +1 -1
  48. package/dist/plugin/lifecycle.d.ts +8 -1
  49. package/dist/plugin/lifecycle.d.ts.map +1 -1
  50. package/dist/plugin/lifecycle.js +9 -2
  51. package/dist/plugin/lifecycle.js.map +1 -1
  52. package/dist/plugin/shell-hook-http.d.ts +39 -0
  53. package/dist/plugin/shell-hook-http.d.ts.map +1 -0
  54. package/dist/plugin/shell-hook-http.js +163 -0
  55. package/dist/plugin/shell-hook-http.js.map +1 -0
  56. package/dist/plugin/shell-hook.d.ts +93 -11
  57. package/dist/plugin/shell-hook.d.ts.map +1 -1
  58. package/dist/plugin/shell-hook.js +223 -39
  59. package/dist/plugin/shell-hook.js.map +1 -1
  60. package/dist/public-runtime.d.ts +2 -2
  61. package/dist/public-runtime.d.ts.map +1 -1
  62. package/dist/public-runtime.js +2 -2
  63. package/dist/public-runtime.js.map +1 -1
  64. package/dist/public-tools.d.ts +1 -0
  65. package/dist/public-tools.d.ts.map +1 -1
  66. package/dist/public-tools.js +2 -0
  67. package/dist/public-tools.js.map +1 -1
  68. package/dist/public-types.d.ts +1 -1
  69. package/dist/public-types.d.ts.map +1 -1
  70. package/dist/runtime/query/executor.d.ts +1 -0
  71. package/dist/runtime/query/executor.d.ts.map +1 -1
  72. package/dist/runtime/query/executor.js +34 -9
  73. package/dist/runtime/query/executor.js.map +1 -1
  74. package/dist/runtime/query/index.d.ts +2 -0
  75. package/dist/runtime/query/index.d.ts.map +1 -1
  76. package/dist/runtime/query/index.js +25 -1
  77. package/dist/runtime/query/index.js.map +1 -1
  78. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  79. package/dist/runtime/query/iteration/index.js +47 -10
  80. package/dist/runtime/query/iteration/index.js.map +1 -1
  81. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts +18 -8
  82. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts.map +1 -1
  83. package/dist/runtime/query/iteration/phases/compaction-breaker.js +33 -9
  84. package/dist/runtime/query/iteration/phases/compaction-breaker.js.map +1 -1
  85. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  86. package/dist/runtime/query/iteration/phases/compaction.js +7 -5
  87. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  88. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  89. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  90. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  91. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  92. package/dist/runtime/query/iteration/phases/tool-review.js +70 -3
  93. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  94. package/dist/runtime/query/path-escalation.d.ts +2 -0
  95. package/dist/runtime/query/path-escalation.d.ts.map +1 -1
  96. package/dist/runtime/query/path-escalation.js +7 -4
  97. package/dist/runtime/query/path-escalation.js.map +1 -1
  98. package/dist/runtime/query/plugin-hooks.js +2 -2
  99. package/dist/runtime/query/plugin-hooks.js.map +1 -1
  100. package/dist/runtime/query/prepare-turn.d.ts +2 -0
  101. package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
  102. package/dist/runtime/query/prepare-turn.js +25 -0
  103. package/dist/runtime/query/prepare-turn.js.map +1 -1
  104. package/dist/runtime/query/review-policy.d.ts +3 -2
  105. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  106. package/dist/runtime/query/review-policy.js +32 -13
  107. package/dist/runtime/query/review-policy.js.map +1 -1
  108. package/dist/session/retention/archive.js +1 -1
  109. package/dist/session/retention/archive.js.map +1 -1
  110. package/dist/session/workspace/driver.d.ts +6 -0
  111. package/dist/session/workspace/driver.d.ts.map +1 -1
  112. package/dist/session/workspace/git-worktree.d.ts.map +1 -1
  113. package/dist/session/workspace/git-worktree.js +28 -0
  114. package/dist/session/workspace/git-worktree.js.map +1 -1
  115. package/dist/skills/index.d.ts +1 -1
  116. package/dist/skills/index.d.ts.map +1 -1
  117. package/dist/skills/index.js +1 -1
  118. package/dist/skills/index.js.map +1 -1
  119. package/dist/skills/loader.d.ts +7 -0
  120. package/dist/skills/loader.d.ts.map +1 -1
  121. package/dist/skills/loader.js +53 -0
  122. package/dist/skills/loader.js.map +1 -1
  123. package/dist/skills/registry.d.ts +1 -1
  124. package/dist/skills/registry.d.ts.map +1 -1
  125. package/dist/skills/registry.js +2 -1
  126. package/dist/skills/registry.js.map +1 -1
  127. package/dist/tools/builtins/bash-cwd.d.ts +52 -0
  128. package/dist/tools/builtins/bash-cwd.d.ts.map +1 -0
  129. package/dist/tools/builtins/bash-cwd.js +127 -0
  130. package/dist/tools/builtins/bash-cwd.js.map +1 -0
  131. package/dist/tools/builtins/bash.d.ts.map +1 -1
  132. package/dist/tools/builtins/bash.js +41 -6
  133. package/dist/tools/builtins/bash.js.map +1 -1
  134. package/dist/tools/builtins/bounded-file-read.d.ts +10 -0
  135. package/dist/tools/builtins/bounded-file-read.d.ts.map +1 -0
  136. package/dist/tools/builtins/bounded-file-read.js +67 -0
  137. package/dist/tools/builtins/bounded-file-read.js.map +1 -0
  138. package/dist/tools/builtins/grep.d.ts +6 -1
  139. package/dist/tools/builtins/grep.d.ts.map +1 -1
  140. package/dist/tools/builtins/grep.js +187 -24
  141. package/dist/tools/builtins/grep.js.map +1 -1
  142. package/dist/tools/builtins/read-file.d.ts +1 -0
  143. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  144. package/dist/tools/builtins/read-file.js +113 -28
  145. package/dist/tools/builtins/read-file.js.map +1 -1
  146. package/dist/tools/builtins/read-media.d.ts +68 -0
  147. package/dist/tools/builtins/read-media.d.ts.map +1 -0
  148. package/dist/tools/builtins/read-media.js +397 -0
  149. package/dist/tools/builtins/read-media.js.map +1 -0
  150. package/dist/tools/builtins/search-tools.js +1 -1
  151. package/dist/tools/builtins/search-tools.js.map +1 -1
  152. package/dist/tools/builtins/skill.d.ts +4 -2
  153. package/dist/tools/builtins/skill.d.ts.map +1 -1
  154. package/dist/tools/builtins/skill.js +61 -2
  155. package/dist/tools/builtins/skill.js.map +1 -1
  156. package/dist/tools/builtins/sleep-guard.d.ts +15 -0
  157. package/dist/tools/builtins/sleep-guard.d.ts.map +1 -0
  158. package/dist/tools/builtins/sleep-guard.js +33 -0
  159. package/dist/tools/builtins/sleep-guard.js.map +1 -0
  160. package/dist/tools/builtins/view-image.d.ts +23 -1
  161. package/dist/tools/builtins/view-image.d.ts.map +1 -1
  162. package/dist/tools/builtins/view-image.js +66 -58
  163. package/dist/tools/builtins/view-image.js.map +1 -1
  164. package/dist/tools/file-read-tracker.d.ts.map +1 -1
  165. package/dist/tools/file-read-tracker.js +31 -0
  166. package/dist/tools/file-read-tracker.js.map +1 -1
  167. package/dist/toolsets/manager.js +3 -3
  168. package/dist/toolsets/manager.js.map +1 -1
  169. package/dist/types/agent/query.d.ts +2 -0
  170. package/dist/types/agent/query.d.ts.map +1 -1
  171. package/dist/types/agent/task.d.ts +6 -2
  172. package/dist/types/agent/task.d.ts.map +1 -1
  173. package/dist/types/agent/task.js.map +1 -1
  174. package/dist/types/connector/mcp.d.ts +46 -0
  175. package/dist/types/connector/mcp.d.ts.map +1 -1
  176. package/dist/types/hitl/index.d.ts +1 -1
  177. package/dist/types/plugin/index.d.ts +72 -5
  178. package/dist/types/plugin/index.d.ts.map +1 -1
  179. package/dist/types/plugin/index.js +12 -1
  180. package/dist/types/plugin/index.js.map +1 -1
  181. package/dist/types/session/sub-session.d.ts +6 -2
  182. package/dist/types/session/sub-session.d.ts.map +1 -1
  183. package/dist/types/skills/index.d.ts +36 -0
  184. package/dist/types/skills/index.d.ts.map +1 -1
  185. package/dist/types/skills/index.js +9 -0
  186. package/dist/types/skills/index.js.map +1 -1
  187. package/dist/types/tool/index.d.ts +22 -0
  188. package/dist/types/tool/index.d.ts.map +1 -1
  189. package/dist/types/tool/index.js.map +1 -1
  190. package/dist/types/workspace/ref.d.ts +2 -0
  191. package/dist/types/workspace/ref.d.ts.map +1 -1
  192. package/dist/utils/frontmatter.d.ts +12 -0
  193. package/dist/utils/frontmatter.d.ts.map +1 -1
  194. package/dist/utils/frontmatter.js +6 -0
  195. package/dist/utils/frontmatter.js.map +1 -1
  196. package/package.json +1 -1
  197. package/src/agents/QueryAgent.ts +2 -0
  198. package/src/agents/file-definitions.ts +126 -6
  199. package/src/authorization/protected-paths.ts +30 -2
  200. package/src/compaction/context-window.ts +10 -0
  201. package/src/compaction/plan.ts +12 -2
  202. package/src/config/runtime.ts +2 -0
  203. package/src/connector/mcp/client.ts +87 -1
  204. package/src/constants/mcp/index.ts +3 -0
  205. package/src/constants/plugin/index.ts +2 -0
  206. package/src/manager/agent/lifecycle.ts +24 -5
  207. package/src/manager/session/turn-recorder.ts +2 -1
  208. package/src/persona/assembler.ts +2 -2
  209. package/src/plugin/index.ts +5 -0
  210. package/src/plugin/lifecycle.ts +16 -2
  211. package/src/plugin/shell-hook-http.ts +181 -0
  212. package/src/plugin/shell-hook.ts +386 -89
  213. package/src/public-runtime.ts +2 -0
  214. package/src/public-tools.ts +2 -0
  215. package/src/public-types.ts +5 -0
  216. package/src/runtime/query/executor.ts +40 -10
  217. package/src/runtime/query/index.ts +33 -0
  218. package/src/runtime/query/iteration/index.ts +51 -11
  219. package/src/runtime/query/iteration/phases/compaction-breaker.ts +38 -9
  220. package/src/runtime/query/iteration/phases/compaction.ts +27 -5
  221. package/src/runtime/query/iteration/phases/context.ts +2 -0
  222. package/src/runtime/query/iteration/phases/tool-review.ts +74 -3
  223. package/src/runtime/query/path-escalation.ts +11 -5
  224. package/src/runtime/query/plugin-hooks.ts +2 -2
  225. package/src/runtime/query/prepare-turn.ts +33 -0
  226. package/src/runtime/query/review-policy.ts +45 -16
  227. package/src/session/retention/archive.ts +1 -1
  228. package/src/session/workspace/driver.ts +6 -0
  229. package/src/session/workspace/git-worktree.ts +31 -0
  230. package/src/skills/index.ts +6 -1
  231. package/src/skills/loader.ts +54 -0
  232. package/src/skills/registry.ts +7 -6
  233. package/src/tools/builtins/bash-cwd.ts +156 -0
  234. package/src/tools/builtins/bash.ts +79 -34
  235. package/src/tools/builtins/bounded-file-read.ts +80 -0
  236. package/src/tools/builtins/grep.ts +198 -28
  237. package/src/tools/builtins/read-file.ts +151 -30
  238. package/src/tools/builtins/read-media.ts +483 -0
  239. package/src/tools/builtins/search-tools.ts +1 -1
  240. package/src/tools/builtins/skill.ts +91 -2
  241. package/src/tools/builtins/sleep-guard.ts +31 -0
  242. package/src/tools/builtins/view-image.ts +74 -62
  243. package/src/tools/file-read-tracker.ts +30 -0
  244. package/src/toolsets/manager.ts +3 -3
  245. package/src/types/agent/query.ts +2 -0
  246. package/src/types/agent/task.ts +6 -2
  247. package/src/types/connector/mcp.ts +48 -0
  248. package/src/types/hitl/index.ts +1 -1
  249. package/src/types/plugin/index.ts +102 -16
  250. package/src/types/session/sub-session.ts +6 -2
  251. package/src/types/skills/index.ts +48 -0
  252. package/src/types/tool/index.ts +22 -0
  253. package/src/types/workspace/ref.ts +2 -0
  254. package/src/utils/frontmatter.ts +21 -0
@@ -485,6 +485,7 @@ export {
485
485
  loadSkill,
486
486
  resolveSkillChain,
487
487
  SKILL_FRONTMATTER_KEYS,
488
+ SKILL_FRONTMATTER_TOLERATED_KEYS,
488
489
  SkillCollisionError,
489
490
  SkillRegistry,
490
491
  } from './skills/index.js'
@@ -1521,6 +1522,7 @@ export {
1521
1522
  SKILL_INVOCATION_DEFAULT,
1522
1523
  isInvocableBy,
1523
1524
  skillInvocation,
1525
+ skillModelDescription,
1524
1526
  } from './types/skills/index.js'
1525
1527
 
1526
1528
  // Reaching the web. Fetching ships guarded because its rules are about the
@@ -79,6 +79,8 @@ export {
79
79
  backgroundTasksDisabled,
80
80
  execHostShell,
81
81
  } from './tools/builtins/bash.js'
82
+ // Authorization and execution must agree after a session carries its shell cwd.
83
+ export { effectiveBashWorkingDirectory } from './tools/builtins/bash-cwd.js'
82
84
  export {
83
85
  CODE_ANALYSIS_TOOL_NAME,
84
86
  CodeAnalysisTool,
@@ -231,8 +231,13 @@ export type {
231
231
  SkippedAgentFile,
232
232
  } from './agents/file-definitions.js'
233
233
  export type {
234
+ HttpHookEntry,
235
+ PromptHookEntry,
234
236
  ShellHookEntry,
235
237
  ShellHookEvent,
238
+ ShellHookHandler,
239
+ ShellHookJudge,
240
+ ShellHookOnFailure,
236
241
  ShellHookInput,
237
242
  ShellHookOptions,
238
243
  ShellHookOutcome,
@@ -14,6 +14,11 @@ import { ProbeVetoError } from '../../probe/errors.js'
14
14
  import { probe as defaultProbeRegistry } from '../../probe/registry.js'
15
15
  import type { ProbeEnforcement } from '../../probe/registry.js'
16
16
  import type { ActivityStore } from '../../store/activity/memory.js'
17
+ import {
18
+ effectiveBashWorkingDirectory,
19
+ pinBashWorkingDirectory,
20
+ } from '../../tools/builtins/bash-cwd.js'
21
+ import { BashTool } from '../../tools/builtins/bash.js'
17
22
  import { fingerprintContent } from '../../tools/builtins/content-fingerprint.js'
18
23
  import { sandboxShellSpawn, withoutBashStartup } from '../../tools/command-shell.js'
19
24
  import { isAlwaysDestructive } from '../../tools/defineTool.js'
@@ -99,6 +104,9 @@ import {
99
104
  export type { EmitEvent } from './events.js'
100
105
  import type { EmitEvent, SessionEventDraft } from './events.js'
101
106
 
107
+ /** Input and identity needed by the shared direct/nested safety checks. */
108
+ type PreparedSafetyCall = Pick<PreparedDirectCall, 'kind' | 'toolName' | 'input'>
109
+
102
110
  export type PreparedDirectCall =
103
111
  | {
104
112
  readonly kind: 'ready'
@@ -708,6 +716,7 @@ export class ToolExecutor {
708
716
  private parentSpan?: Span
709
717
  private readonly toolCallBudget?: ToolCallBudget
710
718
  private readonly preparedBatches = new WeakSet<PreparedToolBatch>()
719
+ private readonly preparedBashDirectories = new WeakMap<PreparedSafetyCall, string>()
711
720
  /** Ephemeral proof of actual execution; restored messages do not acquire it. */
712
721
  private readonly recordedObservations = new Map<
713
722
  string,
@@ -1180,7 +1189,7 @@ export class ToolExecutor {
1180
1189
  * safety checks did not run is the one a person must look at.
1181
1190
  */
1182
1191
  private async escalationOrUnfinished(
1183
- call: PreparedDirectCall,
1192
+ call: PreparedSafetyCall,
1184
1193
  ): Promise<ToolCallEscalation | undefined> {
1185
1194
  try {
1186
1195
  return await this.escalationOf(call)
@@ -1212,12 +1221,19 @@ export class ToolExecutor {
1212
1221
  * roots is not mounted there to be approved. The escape is looked at only
1213
1222
  * WITH one, since without one there is nothing to escape.
1214
1223
  */
1215
- private async escalationOf(call: PreparedDirectCall): Promise<ToolCallEscalation | undefined> {
1224
+ private async escalationOf(call: PreparedSafetyCall): Promise<ToolCallEscalation | undefined> {
1216
1225
  if (call.kind === 'synthetic') return undefined
1217
1226
  const tool = this.config.tools.get(call.toolName)
1218
1227
  if (!tool || call.input === null || typeof call.input !== 'object') return undefined
1219
1228
  const input = call.input as Record<string, unknown>
1220
1229
  const sandboxed = this.sandboxed
1230
+ const shellCwd =
1231
+ tool.execute === BashTool.execute
1232
+ ? effectiveBashWorkingDirectory(this.config.sessionId, this.config.workingDirectory, {
1233
+ sandboxed,
1234
+ })
1235
+ : undefined
1236
+ if (shellCwd !== undefined) this.preparedBashDirectories.set(call, shellCwd)
1221
1237
  let outsidePaths: string[] | undefined
1222
1238
  if (
1223
1239
  this.config.outsideRootAccess === 'review' &&
@@ -1240,7 +1256,7 @@ export class ToolExecutor {
1240
1256
  tool.sandboxEscapeArgument !== undefined &&
1241
1257
  input[tool.sandboxEscapeArgument] === true
1242
1258
  const unknownProgram = this.unknownProgramOf(tool, input, sandboxed)
1243
- const reaches = await this.pathEscalationsOf(tool, input, sandboxed)
1259
+ const reaches = await this.pathEscalationsOf(tool, input, sandboxed, shellCwd)
1244
1260
  if (reaches.outsidePaths.length > 0) {
1245
1261
  outsidePaths = [...new Set([...(outsidePaths ?? []), ...reaches.outsidePaths])]
1246
1262
  }
@@ -1281,6 +1297,7 @@ export class ToolExecutor {
1281
1297
  >,
1282
1298
  input: Record<string, unknown>,
1283
1299
  sandboxed: boolean,
1300
+ shellCwd?: string,
1284
1301
  ): Promise<{
1285
1302
  outsidePaths: readonly string[]
1286
1303
  protectedPaths: readonly string[]
@@ -1303,6 +1320,7 @@ export class ToolExecutor {
1303
1320
  const dialect = tool.commandDialect?.({ sandboxed }) ?? 'sh'
1304
1321
  const found = await shellEscalations(line, dialect, {
1305
1322
  roots,
1323
+ ...(shellCwd !== undefined ? { cwd: shellCwd } : {}),
1306
1324
  reviewOutside: this.config.outsideRootAccess === 'review' && !sandboxed,
1307
1325
  })
1308
1326
  protectedPaths.push(...found.protectedPaths)
@@ -1598,6 +1616,10 @@ export class ToolExecutor {
1598
1616
  ...(this.config.toolPause ? { requestPause: this.config.toolPause(toolCall.id) } : {}),
1599
1617
  report: (message: string, fraction?: number) => progress.report(message, fraction),
1600
1618
  }
1619
+ if (preparedCall) {
1620
+ const cwd = this.preparedBashDirectories.get(preparedCall)
1621
+ if (cwd !== undefined) pinBashWorkingDirectory(ctx, cwd)
1622
+ }
1601
1623
  const run = async () => {
1602
1624
  try {
1603
1625
  results[i] = await this.executeSingle(
@@ -1849,7 +1871,10 @@ export class ToolExecutor {
1849
1871
  signal.throwIfAborted()
1850
1872
  // Preparation detaches input synchronously before its first await.
1851
1873
  // Admission must not add an earlier yield where the caller can mutate it.
1852
- const preparedCall = await this.prepareNestedCall(name, input, signal)
1874
+ const preparedCall = {
1875
+ ...(await this.prepareNestedCall(name, input, signal)),
1876
+ toolName: name,
1877
+ }
1853
1878
  signal.throwIfAborted()
1854
1879
  if (this.toolCallBudget) {
1855
1880
  const refusal = await this.toolCallBudget.admit(1, 'nested', signal)
@@ -1932,12 +1957,15 @@ export class ToolExecutor {
1932
1957
  } catch {
1933
1958
  needsApproval = true
1934
1959
  }
1935
- if (needsApproval || (gateResult && gateResult.decision !== 'allow')) {
1936
- const reason = needsApproval
1937
- ? `Blocked: "${name}" requires the person's approval for every call, and an approval cannot be requested from inside another tool. Call it directly.`
1938
- : gateResult?.decision === 'deny'
1939
- ? `Blocked by the authorization gate: ${gateResult.reason}`
1940
- : `Blocked by the authorization gate: this nested call requires an explicit allow rule because an operator review cannot be opened from inside another tool. ${gateResult?.reason}`
1960
+ const nestedEscalation = await this.escalationOrUnfinished(preparedCall)
1961
+ if (nestedEscalation || needsApproval || (gateResult && gateResult.decision !== 'allow')) {
1962
+ const reason = nestedEscalation
1963
+ ? 'Blocked: this nested call reaches a protected path, removal floor, outside-root boundary or another safety escalation. Call it directly so it can be reviewed.'
1964
+ : needsApproval
1965
+ ? `Blocked: "${name}" requires the person's approval for every call, and an approval cannot be requested from inside another tool. Call it directly.`
1966
+ : gateResult?.decision === 'deny'
1967
+ ? `Blocked by the authorization gate: ${gateResult.reason}`
1968
+ : `Blocked by the authorization gate: this nested call requires an explicit allow rule because an operator review cannot be opened from inside another tool. ${gateResult?.reason}`
1941
1969
  const output = deniedToolOutput(name, reason)
1942
1970
  // Same fail-closed durability rule as a direct gate denial: if the
1943
1971
  // configured session log cannot record the refusal, do not quietly carry
@@ -2010,6 +2038,8 @@ export class ToolExecutor {
2010
2038
  report: (message: string, fraction?: number) => progress.report(message, fraction),
2011
2039
  }
2012
2040
 
2041
+ const nestedCwd = this.preparedBashDirectories.get(preparedCall)
2042
+ if (nestedCwd !== undefined) pinBashWorkingDirectory(childContext, nestedCwd)
2013
2043
  await this.emitEvent({
2014
2044
  type: 'tool_executing',
2015
2045
  turnId: this.config.turnId,
@@ -553,6 +553,8 @@ export interface QueryParams {
553
553
  allowedTools?: string[]
554
554
  agentId: string
555
555
  agentName: string
556
+ /** Application-owned agent kind for lifecycle hooks; defaults to agentName. */
557
+ agentType?: string
556
558
  workingDirectory?: string
557
559
  /**
558
560
  * Directories besides the working directory the file tools may reach,
@@ -1071,6 +1073,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1071
1073
  approvalPolicy,
1072
1074
  eventTranslator,
1073
1075
  executeUserInterruptHooks,
1076
+ executeTurnFailureHooks,
1074
1077
  pendingHistoryRepairs,
1075
1078
  initialMessages,
1076
1079
  historyIds,
@@ -1877,6 +1880,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1877
1880
  pluginManager: params.pluginManager,
1878
1881
  ...(params.parentSessionId !== undefined ? { parentSessionId: params.parentSessionId } : {}),
1879
1882
  ...(params.parentTurnId !== undefined ? { parentTurnId: params.parentTurnId } : {}),
1883
+ agentType: params.agentType ?? params.agentName,
1880
1884
  })
1881
1885
 
1882
1886
  const tracer = getTracer()
@@ -2041,6 +2045,33 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
2041
2045
  'namzu.runtime.resume_from_checkpoint': params.resumeFromCheckpoint ?? null,
2042
2046
  })
2043
2047
 
2048
+ // A child session is starting: its hooks may add context for the
2049
+ // child's turn, before the child's first prompt is seen.
2050
+ if (params.pluginManager && params.parentSessionId !== undefined) {
2051
+ const startResults = await params.pluginManager.executeHooks(
2052
+ 'subagent_start',
2053
+ {
2054
+ turnId: ctx.turnId,
2055
+ sessionId: params.sessionId,
2056
+ parentSessionId: params.parentSessionId,
2057
+ ...(params.parentTurnId !== undefined ? { parentTurnId: params.parentTurnId } : {}),
2058
+ agentType: params.agentType ?? params.agentName,
2059
+ signal: ctx.abortController.signal,
2060
+ },
2061
+ eventTranslator.emitEvent,
2062
+ )
2063
+ const startAnnotations = applyLifecycleHookResults('subagent_start', startResults)
2064
+ if (startAnnotations.length > 0) {
2065
+ promptContributions.replace({
2066
+ id: 'hooks:subagent_start',
2067
+ placement: 'context',
2068
+ render: () =>
2069
+ `Context from the operator's hooks:\n\n${startAnnotations.join('\n\n')}`,
2070
+ })
2071
+ }
2072
+ yield* eventTranslator.drainPending()
2073
+ }
2074
+
2044
2075
  // The operator's prompt, before the model sees it. A hook may
2045
2076
  // refuse it — the turn ends here, before a prompt is built — or add
2046
2077
  // to what the model is told; the addition rides the prompt as a
@@ -2799,6 +2830,8 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
2799
2830
  ctx.recorder.setSteps(iterationOrchestrator.getSteps())
2800
2831
  await executeUserInterruptHooks(err)
2801
2832
  yield* eventTranslator.drainPending()
2833
+ await executeTurnFailureHooks(err)
2834
+ yield* eventTranslator.drainPending()
2802
2835
  yield* resultAssembler.handleError(err, rootSpan)
2803
2836
  } finally {
2804
2837
  // A pending acquisition is withdrawn and awaited first, so a handle
@@ -79,7 +79,7 @@ import {
79
79
  } from './outstanding-work.js'
80
80
  import { runAdvisoryPhase } from './phases/advisory.js'
81
81
  import { runIterationCheckpoint } from './phases/checkpoint.js'
82
- import { compactionBreaker } from './phases/compaction-breaker.js'
82
+ import { compactionBreaker, estimateRefillTokens } from './phases/compaction-breaker.js'
83
83
  import {
84
84
  AUTOMATIC_COMPACTION_FAILED,
85
85
  OVERFLOW_RELIEF_LIMIT,
@@ -319,7 +319,7 @@ export class IterationOrchestrator {
319
319
  const feedback: string[] = []
320
320
  const ask = async (
321
321
  event: 'turn_end' | 'subagent_stop',
322
- extra: { parentSessionId?: SessionId; parentTurnId?: TurnId },
322
+ extra: { parentSessionId?: SessionId; parentTurnId?: TurnId; agentType?: string },
323
323
  ): Promise<void> => {
324
324
  const results = await manager.executeHooks(
325
325
  event,
@@ -346,6 +346,7 @@ export class IterationOrchestrator {
346
346
  await ask('subagent_stop', {
347
347
  parentSessionId: this.ctx.parentSessionId,
348
348
  ...(this.ctx.parentTurnId !== undefined ? { parentTurnId: this.ctx.parentTurnId } : {}),
349
+ ...(this.ctx.agentType !== undefined ? { agentType: this.ctx.agentType } : {}),
349
350
  })
350
351
  yield* this.ctx.drainPending()
351
352
  }
@@ -1491,10 +1492,10 @@ export class IterationOrchestrator {
1491
1492
  // Auto-continuation after an output-ceiling cutoff.
1492
1493
  //
1493
1494
  // Guards:
1494
- // - `hasContent` so we don't loop forever on an
1495
- // empty cutoff (a provider occasionally emits
1496
- // `stop_reason: max_tokens` with no content
1497
- // when an injected pre-fill blocks the model).
1495
+ // - An empty cutoff with no exposed reasoning only retries
1496
+ // under an explicit cap. Compatible endpoints can spend
1497
+ // the output allowance without exposing any text; without
1498
+ // a cap the legacy empty-completion fallback stays bounded.
1498
1499
  // - `!forceFinalize` so the forced-finalize path
1499
1500
  // never auto-continues — that path is invoked
1500
1501
  // specifically to extract a closing summary.
@@ -1507,16 +1508,18 @@ export class IterationOrchestrator {
1507
1508
  !hasContent &&
1508
1509
  (response.message.toolCalls?.length ?? 0) === 0 &&
1509
1510
  (response.message.reasoning?.length ?? 0) > 0
1511
+ const cap = this.ctx.maxOutputContinuations
1512
+ const boundedEmptyCut =
1513
+ !hasContent && cap !== undefined && Number.isSafeInteger(cap) && cap >= 0
1510
1514
  if (
1511
1515
  !forceFinalize &&
1512
1516
  response.finishReason === 'length' &&
1513
1517
  response.finishDetail !== 'context_window' &&
1514
- (hasContent || cutDuringReasoning)
1518
+ (hasContent || cutDuringReasoning || boundedEmptyCut)
1515
1519
  ) {
1516
1520
  // A host that opted in gets a cap on the nudges: a model that
1517
1521
  // overruns its output limit every time would otherwise spend the
1518
1522
  // whole turn on it. Past the cap the turn stops, and says why.
1519
- const cap = this.ctx.maxOutputContinuations
1520
1523
  if (cap !== undefined && this.outputContinuations >= cap) {
1521
1524
  this.ctx.log.warn('LLM kept hitting max_tokens — stopping the turn', {
1522
1525
  [NAMZU.TURN_ID]: recorder.turnId,
@@ -1538,7 +1541,7 @@ export class IterationOrchestrator {
1538
1541
  }
1539
1542
  this.outputContinuations++
1540
1543
  this.ctx.log.info(
1541
- cutDuringReasoning
1544
+ cutDuringReasoning || boundedEmptyCut
1542
1545
  ? 'LLM hit max_tokens while reasoning — nudging to answer briefly'
1543
1546
  : 'LLM hit max_tokens mid-text — auto-continuing',
1544
1547
  {
@@ -1549,7 +1552,9 @@ export class IterationOrchestrator {
1549
1552
  )
1550
1553
  recorder.pushMessage(
1551
1554
  createRuntimeContextMessage(
1552
- cutDuringReasoning ? REASONING_CUT_USER_MESSAGE : AUTO_CONTINUATION_USER_MESSAGE,
1555
+ cutDuringReasoning || boundedEmptyCut
1556
+ ? REASONING_CUT_USER_MESSAGE
1557
+ : AUTO_CONTINUATION_USER_MESSAGE,
1553
1558
  'auto-continuation',
1554
1559
  ),
1555
1560
  )
@@ -2176,6 +2181,7 @@ export class IterationOrchestrator {
2176
2181
  }
2177
2182
  // A rung that sheds too little to change the verdict is not worth a
2178
2183
  // provider call; climb to the next one at once.
2184
+ const tokensBeforeRelief = estimateRefillTokens(recorder.messages)
2179
2185
  let shed = false
2180
2186
  while (!shed && overflowReliefs < OVERFLOW_RELIEF_LIMIT) {
2181
2187
  overflowReliefs++
@@ -2185,7 +2191,13 @@ export class IterationOrchestrator {
2185
2191
  // Clearing results is a compaction too: only summaries and reducers
2186
2192
  // recorded themselves, so a clear-only relief was invisible to the
2187
2193
  // breaker.
2188
- compactionBreaker(this.ctx)?.recordCompaction(iterationNum)
2194
+ // Record the whole ladder once, including any insufficient first
2195
+ // rung, against the same estimator the refill check uses.
2196
+ compactionBreaker(this.ctx)?.recordCompaction(
2197
+ iterationNum,
2198
+ tokensBeforeRelief,
2199
+ estimateRefillTokens(recorder.messages),
2200
+ )
2189
2201
  this.ctx.log.info('Retrying the turn after relieving a context overflow', {
2190
2202
  [NAMZU.TURN_ID]: recorder.turnId,
2191
2203
  [NAMZU.ITERATION]: iterationNum,
@@ -2993,6 +3005,34 @@ export class IterationOrchestrator {
2993
3005
  iteration: this.ctx.recorder.currentIteration,
2994
3006
  messageId: finalMessageId,
2995
3007
  })
3008
+ // This call was collected, so it produced no live deltas. Publish
3009
+ // its actual text before completion as the normal stream does;
3010
+ // otherwise a streaming host can show the turn ending with no answer.
3011
+ const parts = response.message.textParts
3012
+ if (parts?.length) {
3013
+ for (const part of parts) {
3014
+ if (!part.text) continue
3015
+ await this.ctx.emitEvent({
3016
+ type: 'text_delta',
3017
+ turnId: this.ctx.recorder.turnId,
3018
+ iteration: this.ctx.recorder.currentIteration,
3019
+ messageId: finalMessageId,
3020
+ text: part.text,
3021
+ textPart: {
3022
+ id: part.id,
3023
+ ...(part.phase ? { phase: part.phase } : {}),
3024
+ },
3025
+ })
3026
+ }
3027
+ } else if (response.message.content) {
3028
+ await this.ctx.emitEvent({
3029
+ type: 'text_delta',
3030
+ turnId: this.ctx.recorder.turnId,
3031
+ iteration: this.ctx.recorder.currentIteration,
3032
+ messageId: finalMessageId,
3033
+ text: response.message.content,
3034
+ })
3035
+ }
2996
3036
  await this.ctx.emitEvent({
2997
3037
  type: 'message_completed',
2998
3038
  turnId: this.ctx.recorder.turnId,
@@ -7,10 +7,10 @@
7
7
  * and the turn carries on, silently as far as the person is concerned (a log
8
8
  * line and a `breaker_open` event, no sentence); a pass the provider forces
9
9
  * by rejecting the prompt is not gated, so the reactive ladder still runs.
10
- * - Three compactions in a row, each followed by the context filling back up
11
- * within three iterations: something in the loop (a huge file read, a tool
12
- * that prints without limit) refills the window faster than any summary can
13
- * empty it. The turn stops, because carrying on only buys the same wall.
10
+ * - Three compactions in a row, each followed by the history regaining all
11
+ * space shed within three iterations: something in the loop (a huge file
12
+ * read, a tool that prints without limit) refills the window faster than a
13
+ * summary can empty it. The turn stops, because carrying on buys the same wall.
14
14
  *
15
15
  * Off unless `compactionConfig.circuitBreaker` is true: stopping compaction,
16
16
  * and above all stopping a turn, is not something a host had before.
@@ -19,11 +19,31 @@
19
19
  * share a count.
20
20
  */
21
21
 
22
+ import { estimateMessageTokens } from '../../../../compaction/token-estimate.js'
23
+ import { type Message, isTurnContextEntry } from '../../../../types/message/index.js'
24
+ import { isReminderMessage } from '../../reminders.js'
22
25
  import type { IterationContext } from './context.js'
23
26
 
27
+ /**
28
+ * Conversation growth for the refill guard. Compaction sheds host reminders
29
+ * and recorded context so the host can announce them again; their return is
30
+ * not evidence of a tool refilling the window. Budgeting still includes them.
31
+ * Use provenance, not text that an operator or tool could happen to quote.
32
+ */
33
+ export function estimateRefillTokens(messages: readonly Message[]): number {
34
+ return messages.reduce(
35
+ (total, message) =>
36
+ total +
37
+ (isReminderMessage(message) || isTurnContextEntry(message)
38
+ ? 0
39
+ : estimateMessageTokens(message)),
40
+ 0,
41
+ )
42
+ }
43
+
24
44
  /** Consecutive failed passes after which automatic compaction stops. */
25
45
  export const COMPACTION_FAILURE_LIMIT = 3
26
- /** A compaction followed by a trigger sooner than this many iterations is a refill. */
46
+ /** A full refill sooner than this many iterations counts toward the limit. */
27
47
  export const COMPACTION_REFILL_ITERATIONS = 3
28
48
  /** Consecutive refills that stop the turn. */
29
49
  export const COMPACTION_REFILL_LIMIT = 3
@@ -41,6 +61,8 @@ export class CompactionBreaker {
41
61
  private refills = 0
42
62
  private pendingRefills = 0
43
63
  private lastCompactedIteration: number | undefined
64
+ private lastTokensBefore = 0
65
+ private lastTokensAfter = 0
44
66
  private failureNoticeSent = false
45
67
  private stopReason: string | undefined
46
68
 
@@ -69,18 +91,25 @@ export class CompactionBreaker {
69
91
  * A summary or reducer pass is about to run at `iteration`. True when it
70
92
  * would be the third refill in a row, in which case it must not run.
71
93
  */
72
- wouldTrip(iteration: number): boolean {
94
+ wouldTrip(iteration: number, tokens: number): boolean {
95
+ // Iteration distance alone confuses continued pressure with a refill:
96
+ // a small reduction may leave the prompt above its trigger. Compare the
97
+ // same history estimator on both sides, never provider usage against an
98
+ // estimate. Count only when new context has replaced ALL the space shed.
99
+ const refilled = this.lastTokensAfter < this.lastTokensBefore && tokens >= this.lastTokensBefore
73
100
  const quick =
74
101
  this.lastCompactedIteration !== undefined &&
75
102
  iteration - this.lastCompactedIteration < COMPACTION_REFILL_ITERATIONS
76
- this.pendingRefills = quick ? this.refills + 1 : 0
103
+ this.pendingRefills = quick && refilled ? this.refills + 1 : 0
77
104
  return this.pendingRefills >= COMPACTION_REFILL_LIMIT
78
105
  }
79
106
 
80
- /** The pass that `wouldTrip` allowed completed at `iteration`. */
81
- recordCompaction(iteration: number): void {
107
+ /** The allowed pass completed; both sizes must use the same history estimator. */
108
+ recordCompaction(iteration: number, tokensBefore: number, tokensAfter: number): void {
82
109
  this.refills = this.pendingRefills
83
110
  this.lastCompactedIteration = iteration
111
+ this.lastTokensBefore = tokensBefore
112
+ this.lastTokensAfter = tokensAfter
84
113
  this.recordRelief()
85
114
  }
86
115
 
@@ -29,7 +29,11 @@ import { invariants } from '../../../../invariants/index.js'
29
29
  import { resolveTaskModel } from '../../../../model-router/task-router.js'
30
30
  import type { Message } from '../../../../types/message/index.js'
31
31
  import { applyLifecycleHookResults } from '../../plugin-hooks.js'
32
- import { COMPACTION_REFILL_MESSAGE, compactionBreaker } from './compaction-breaker.js'
32
+ import {
33
+ COMPACTION_REFILL_MESSAGE,
34
+ compactionBreaker,
35
+ estimateRefillTokens,
36
+ } from './compaction-breaker.js'
33
37
  import type { IterationContext } from './context.js'
34
38
  import { isWorkingMemoryMessage } from './working-memory.js'
35
39
 
@@ -443,7 +447,10 @@ export async function stopOnRefill(ctx: IterationContext): Promise<boolean> {
443
447
  if (!breaker) return false
444
448
  // Already announced: the turn is on its way out; do not say it twice.
445
449
  if (breaker.stop !== undefined) return true
446
- if (!breaker.wouldTrip(ctx.recorder.currentIteration)) return false
450
+ if (
451
+ !breaker.wouldTrip(ctx.recorder.currentIteration, estimateRefillTokens(ctx.recorder.messages))
452
+ )
453
+ return false
447
454
  ctx.log.warn('Compaction keeps refilling — stopping the turn', {
448
455
  [NAMZU.TURN_ID]: ctx.recorder.turnId,
449
456
  [NAMZU.ITERATION]: ctx.recorder.currentIteration,
@@ -509,6 +516,7 @@ async function applyReducer(
509
516
  const messages = ctx.recorder.messages
510
517
  const before = messages.length
511
518
  const beforeChars = totalChars(messages)
519
+ const beforeTokens = estimateRefillTokens(messages)
512
520
 
513
521
  let next: readonly Message[] | undefined
514
522
  try {
@@ -595,7 +603,11 @@ async function applyReducer(
595
603
  reduction.contextWindowTokens,
596
604
  measurement.windowSource,
597
605
  )
598
- compactionBreaker(ctx)?.recordCompaction(ctx.recorder.currentIteration)
606
+ compactionBreaker(ctx)?.recordCompaction(
607
+ ctx.recorder.currentIteration,
608
+ beforeTokens,
609
+ estimateRefillTokens(messages),
610
+ )
599
611
  }
600
612
 
601
613
  /**
@@ -716,7 +728,12 @@ async function runCompactionCheckInner(
716
728
  // well before the summary trigger; every other strategy waits for it.
717
729
  const salience = config.strategy === 'salience'
718
730
  const maxOutputTokens = ctx.providerCapabilities?.maxOutputTokens
719
- const trigger = resolveTriggerThreshold(config.triggerThreshold, budget, maxOutputTokens)
731
+ const trigger = resolveTriggerThreshold(
732
+ config.triggerThreshold,
733
+ budget,
734
+ maxOutputTokens,
735
+ config.autoCompactWindowTokens,
736
+ )
720
737
  const startAt = salience ? Math.min(config.softTarget ?? DEFAULT_SOFT_TARGET, trigger) : trigger
721
738
  if (!options?.force && usage < startAt) return
722
739
 
@@ -1122,6 +1139,7 @@ async function runCompactionCheckInner(
1122
1139
 
1123
1140
  const live = ctx.recorder.messages
1124
1141
  const oldCount = live.length
1142
+ const beforeTokens = estimateRefillTokens(live)
1125
1143
  // Retained history can survive in full while a summary adds another copy
1126
1144
  // of its facts. Even an unretained span may be smaller than the cumulative
1127
1145
  // summary. Compare both candidates with the same estimator, rather than
@@ -1196,5 +1214,9 @@ async function runCompactionCheckInner(
1196
1214
  reachedResetThreshold: reachedReset,
1197
1215
  })
1198
1216
  await emitContextUsageSnapshot(ctx, newEstimate, budget, window.source)
1199
- compactionBreaker(ctx)?.recordCompaction(ctx.recorder.currentIteration)
1217
+ compactionBreaker(ctx)?.recordCompaction(
1218
+ ctx.recorder.currentIteration,
1219
+ beforeTokens,
1220
+ estimateRefillTokens(live),
1221
+ )
1200
1222
  }
@@ -284,6 +284,8 @@ export interface IterationContext {
284
284
  /** The delegating session, when this turn is a child's: `subagent_stop` names it. */
285
285
  readonly parentSessionId?: import('../../../../types/ids/index.js').SessionId
286
286
  readonly parentTurnId?: import('../../../../types/ids/index.js').TurnId
287
+ /** The agent this turn runs, named to `subagent_stop` hooks of a child. */
288
+ readonly agentType?: string
287
289
 
288
290
  /**
289
291
  * Override for {@link PARK_RECORD_DELAY_MS}. Internal; tests set `0` to
@@ -16,7 +16,9 @@ import type {
16
16
  ReviewedExecution,
17
17
  ToolCallDeclines,
18
18
  ToolCallDenials,
19
+ ToolCallOutcome,
19
20
  } from '../../executor.js'
21
+ import { applyLifecycleHookResults } from '../../plugin-hooks.js'
20
22
  import {
21
23
  awaitProjectInstructionCallback,
22
24
  replaceProjectInstructionSnapshot,
@@ -212,11 +214,81 @@ export async function* runToolReview(
212
214
  /** Set only where a person's No is known, so a policy refusal never lands in it. */
213
215
  let declines: ToolCallDeclines | undefined
214
216
 
217
+ /**
218
+ * Tell `permission_denied` hooks about each call refused without a person's
219
+ * No: a rule, the mode's policy, a classifier or a hook. Observational, so
220
+ * a hook's answer changes nothing; one that throws is logged, not allowed to
221
+ * fail the step.
222
+ */
223
+ const announceRefusals = async (denials?: ToolCallDenials): Promise<void> => {
224
+ if (!ctx.pluginManager || !denials || denials.size === 0) return
225
+ for (const summary of toolCallSummaries) {
226
+ const reason = denials.get(summary.id)
227
+ if (reason === undefined || declines?.has(summary.id)) continue
228
+ if (ctx.abortController.signal.aborted) return
229
+ try {
230
+ await ctx.pluginManager.executeHooks(
231
+ 'permission_denied',
232
+ {
233
+ sessionId: ctx.recorder.sessionId,
234
+ turnId: ctx.recorder.turnId,
235
+ toolName: summary.name,
236
+ toolInput: summary.input,
237
+ reason,
238
+ signal: ctx.abortController.signal,
239
+ },
240
+ ctx.emitEvent,
241
+ )
242
+ } catch (error) {
243
+ if (ctx.abortController.signal.aborted) return
244
+ ctx.log.warn('A permission_denied hook failed', {
245
+ 'exception.message': error instanceof Error ? error.message : String(error),
246
+ })
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Tell `post_tool_batch` hooks that every call of this step has settled.
253
+ * Fires once per step, before the next model request. A hook may ask for the
254
+ * turn to stop once this step is done; an `error` fails the turn like any
255
+ * other lifecycle hook.
256
+ */
257
+ const announceBatch = async (outcomes: readonly ToolCallOutcome[]): Promise<void> => {
258
+ if (!ctx.pluginManager || ctx.abortController.signal.aborted) return
259
+ const inputs = new Map(toolCallSummaries.map((summary) => [summary.id, summary.input]))
260
+ const toolCalls = outcomes.map((outcome) => ({
261
+ toolCallId: outcome.toolCallId,
262
+ toolName: outcome.toolName,
263
+ toolInput: inputs.get(outcome.toolCallId),
264
+ output: outcome.output,
265
+ isError: outcome.isError === true,
266
+ }))
267
+ if (toolCalls.length === 0) return
268
+ const results = await ctx.pluginManager.executeHooks(
269
+ 'post_tool_batch',
270
+ {
271
+ sessionId: ctx.recorder.sessionId,
272
+ turnId: ctx.recorder.turnId,
273
+ toolCalls,
274
+ signal: ctx.abortController.signal,
275
+ },
276
+ ctx.emitEvent,
277
+ )
278
+ applyLifecycleHookResults('post_tool_batch', results)
279
+ for (const result of results) {
280
+ if (result.action === 'continue' && result.halt) {
281
+ ctx.toolExecutor.requestHalt?.(result.halt.reason)
282
+ }
283
+ }
284
+ }
285
+
215
286
  /** Executes the batch, answering every call, and appends the results. */
216
287
  const settleBatch = async (
217
288
  denials?: ToolCallDenials,
218
289
  review?: ReviewedExecution,
219
290
  ): Promise<void> => {
291
+ await announceRefusals(denials)
220
292
  const startedAt = Date.now()
221
293
  const batch = await ctx.toolExecutor.executeBatch(
222
294
  response,
@@ -228,6 +300,7 @@ export async function* runToolReview(
228
300
  )
229
301
  toolMs += Date.now() - startedAt
230
302
  executed = batch.results
303
+ await announceBatch(batch.results)
231
304
  // Registry receipts are ordered by actual completion, include nested
232
305
  // dispatches, and exclude denials/recovered results. A successful possible
233
306
  // mutation permits another experiment; unrelated reads cannot do so.
@@ -533,9 +606,7 @@ export async function* runToolReview(
533
606
  toolCall.authorization = {
534
607
  decision: gateResult.decision,
535
608
  ...(gateResult.reason ? { reason: gateResult.reason } : {}),
536
- ...(gateResult.decision === 'review' && (gateResult.matchedRule || hookAsked)
537
- ? { explicitReview: true as const }
538
- : {}),
609
+ ...(ruleAsked || hookAsked ? { explicitReview: true as const } : {}),
539
610
  ...(ruleAsked || hookAsked ? { askRule: true as const } : {}),
540
611
  ...(gateResult.decision === 'allow' && allowRuleScope(gateResult.matchedRule)
541
612
  ? { allowScope: allowRuleScope(gateResult.matchedRule) }