@namzu/sdk 51.1.0 → 52.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 (323) hide show
  1. package/CHANGELOG.md +100 -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/SupervisorAgent.d.ts +1 -1
  6. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/examples/SupervisorAgent.d.ts +42 -0
  9. package/dist/agents/examples/SupervisorAgent.d.ts.map +1 -1
  10. package/dist/agents/examples/SupervisorAgent.js +80 -4
  11. package/dist/agents/examples/SupervisorAgent.js.map +1 -1
  12. package/dist/agents/index.d.ts +1 -1
  13. package/dist/agents/index.d.ts.map +1 -1
  14. package/dist/agents/index.js.map +1 -1
  15. package/dist/authorization/protected-paths.d.ts +36 -0
  16. package/dist/authorization/protected-paths.d.ts.map +1 -0
  17. package/dist/authorization/protected-paths.js +107 -0
  18. package/dist/authorization/protected-paths.js.map +1 -0
  19. package/dist/authorization/readonly-commands.d.ts +39 -0
  20. package/dist/authorization/readonly-commands.d.ts.map +1 -0
  21. package/dist/authorization/readonly-commands.js +797 -0
  22. package/dist/authorization/readonly-commands.js.map +1 -0
  23. package/dist/authorization/readonly-flags.d.ts +19 -0
  24. package/dist/authorization/readonly-flags.d.ts.map +1 -0
  25. package/dist/authorization/readonly-flags.js +178 -0
  26. package/dist/authorization/readonly-flags.js.map +1 -0
  27. package/dist/authorization/removal.d.ts +52 -0
  28. package/dist/authorization/removal.d.ts.map +1 -0
  29. package/dist/authorization/removal.js +283 -0
  30. package/dist/authorization/removal.js.map +1 -0
  31. package/dist/authorization/shell-paths.d.ts +66 -0
  32. package/dist/authorization/shell-paths.d.ts.map +1 -0
  33. package/dist/authorization/shell-paths.js +697 -0
  34. package/dist/authorization/shell-paths.js.map +1 -0
  35. package/dist/bridge/acp/update.d.ts.map +1 -1
  36. package/dist/bridge/acp/update.js +1 -0
  37. package/dist/bridge/acp/update.js.map +1 -1
  38. package/dist/compaction/context-window.d.ts +24 -0
  39. package/dist/compaction/context-window.d.ts.map +1 -1
  40. package/dist/compaction/context-window.js +34 -0
  41. package/dist/compaction/context-window.js.map +1 -1
  42. package/dist/compaction/manual.d.ts.map +1 -1
  43. package/dist/compaction/manual.js +10 -2
  44. package/dist/compaction/manual.js.map +1 -1
  45. package/dist/compaction/plan.d.ts +4 -0
  46. package/dist/compaction/plan.d.ts.map +1 -1
  47. package/dist/compaction/plan.js +6 -2
  48. package/dist/compaction/plan.js.map +1 -1
  49. package/dist/compaction/verifier.d.ts +11 -1
  50. package/dist/compaction/verifier.d.ts.map +1 -1
  51. package/dist/compaction/verifier.js +19 -7
  52. package/dist/compaction/verifier.js.map +1 -1
  53. package/dist/config/runtime.d.ts +55 -7
  54. package/dist/config/runtime.d.ts.map +1 -1
  55. package/dist/config/runtime.js +23 -2
  56. package/dist/config/runtime.js.map +1 -1
  57. package/dist/connector/web/guarded-fetch.d.ts.map +1 -1
  58. package/dist/connector/web/guarded-fetch.js +24 -0
  59. package/dist/connector/web/guarded-fetch.js.map +1 -1
  60. package/dist/connector/web/index.d.ts +1 -1
  61. package/dist/connector/web/index.d.ts.map +1 -1
  62. package/dist/connector/web/page.d.ts +59 -0
  63. package/dist/connector/web/page.d.ts.map +1 -0
  64. package/dist/connector/web/page.js +112 -0
  65. package/dist/connector/web/page.js.map +1 -0
  66. package/dist/connector/web/types.d.ts +47 -0
  67. package/dist/connector/web/types.d.ts.map +1 -1
  68. package/dist/connector/web/types.js.map +1 -1
  69. package/dist/constants/continuation.d.ts +8 -0
  70. package/dist/constants/continuation.d.ts.map +1 -1
  71. package/dist/constants/continuation.js +8 -0
  72. package/dist/constants/continuation.js.map +1 -1
  73. package/dist/constants/plugin/index.d.ts +1 -1
  74. package/dist/constants/plugin/index.d.ts.map +1 -1
  75. package/dist/constants/plugin/index.js +1 -1
  76. package/dist/constants/plugin/index.js.map +1 -1
  77. package/dist/manager/agent/lifecycle.d.ts +7 -0
  78. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  79. package/dist/manager/agent/lifecycle.js +11 -2
  80. package/dist/manager/agent/lifecycle.js.map +1 -1
  81. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  82. package/dist/manager/session/turn-recorder.js +4 -2
  83. package/dist/manager/session/turn-recorder.js.map +1 -1
  84. package/dist/plugin/index.d.ts +1 -1
  85. package/dist/plugin/index.d.ts.map +1 -1
  86. package/dist/plugin/index.js +1 -1
  87. package/dist/plugin/index.js.map +1 -1
  88. package/dist/plugin/lifecycle.d.ts +6 -0
  89. package/dist/plugin/lifecycle.d.ts.map +1 -1
  90. package/dist/plugin/lifecycle.js +98 -114
  91. package/dist/plugin/lifecycle.js.map +1 -1
  92. package/dist/plugin/shell-hook.d.ts +90 -22
  93. package/dist/plugin/shell-hook.d.ts.map +1 -1
  94. package/dist/plugin/shell-hook.js +434 -41
  95. package/dist/plugin/shell-hook.js.map +1 -1
  96. package/dist/public-runtime.d.ts +4 -1
  97. package/dist/public-runtime.d.ts.map +1 -1
  98. package/dist/public-runtime.js +4 -1
  99. package/dist/public-runtime.js.map +1 -1
  100. package/dist/public-types.d.ts +2 -2
  101. package/dist/public-types.d.ts.map +1 -1
  102. package/dist/runtime/jobs/registry.d.ts +21 -0
  103. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  104. package/dist/runtime/jobs/registry.js +15 -2
  105. package/dist/runtime/jobs/registry.js.map +1 -1
  106. package/dist/runtime/query/executor/tool-call-admission.d.ts +10 -0
  107. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  108. package/dist/runtime/query/executor/tool-call-admission.js +74 -13
  109. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  110. package/dist/runtime/query/executor.d.ts +41 -2
  111. package/dist/runtime/query/executor.d.ts.map +1 -1
  112. package/dist/runtime/query/executor.js +190 -6
  113. package/dist/runtime/query/executor.js.map +1 -1
  114. package/dist/runtime/query/finalize-turn.d.ts +6 -0
  115. package/dist/runtime/query/finalize-turn.d.ts.map +1 -1
  116. package/dist/runtime/query/finalize-turn.js +1 -1
  117. package/dist/runtime/query/finalize-turn.js.map +1 -1
  118. package/dist/runtime/query/index.d.ts +29 -0
  119. package/dist/runtime/query/index.d.ts.map +1 -1
  120. package/dist/runtime/query/index.js +15 -3
  121. package/dist/runtime/query/index.js.map +1 -1
  122. package/dist/runtime/query/iteration/index.d.ts +31 -0
  123. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  124. package/dist/runtime/query/iteration/index.js +216 -7
  125. package/dist/runtime/query/iteration/index.js.map +1 -1
  126. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts +58 -0
  127. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts.map +1 -0
  128. package/dist/runtime/query/iteration/phases/compaction-breaker.js +91 -0
  129. package/dist/runtime/query/iteration/phases/compaction-breaker.js.map +1 -0
  130. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  131. package/dist/runtime/query/iteration/phases/compaction.js +89 -7
  132. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  133. package/dist/runtime/query/iteration/phases/context.d.ts +10 -0
  134. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  135. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  136. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  137. package/dist/runtime/query/iteration/phases/tool-review.js +165 -2
  138. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  139. package/dist/runtime/query/path-escalation.d.ts +36 -0
  140. package/dist/runtime/query/path-escalation.d.ts.map +1 -0
  141. package/dist/runtime/query/path-escalation.js +184 -0
  142. package/dist/runtime/query/path-escalation.js.map +1 -0
  143. package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
  144. package/dist/runtime/query/plugin-hooks.js +8 -0
  145. package/dist/runtime/query/plugin-hooks.js.map +1 -1
  146. package/dist/runtime/query/reminders.d.ts +35 -0
  147. package/dist/runtime/query/reminders.d.ts.map +1 -0
  148. package/dist/runtime/query/reminders.js +7 -0
  149. package/dist/runtime/query/reminders.js.map +1 -0
  150. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  151. package/dist/runtime/query/resume-pending.js +26 -0
  152. package/dist/runtime/query/resume-pending.js.map +1 -1
  153. package/dist/runtime/query/review-policy.d.ts +26 -0
  154. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  155. package/dist/runtime/query/review-policy.js +79 -5
  156. package/dist/runtime/query/review-policy.js.map +1 -1
  157. package/dist/runtime/query/tool-output-budget.d.ts +46 -0
  158. package/dist/runtime/query/tool-output-budget.d.ts.map +1 -1
  159. package/dist/runtime/query/tool-output-budget.js +85 -1
  160. package/dist/runtime/query/tool-output-budget.js.map +1 -1
  161. package/dist/runtime/query/tooling.d.ts +1 -0
  162. package/dist/runtime/query/tooling.d.ts.map +1 -1
  163. package/dist/runtime/query/tooling.js +5 -0
  164. package/dist/runtime/query/tooling.js.map +1 -1
  165. package/dist/session/paths.d.ts +5 -1
  166. package/dist/session/paths.d.ts.map +1 -1
  167. package/dist/session/paths.js +8 -0
  168. package/dist/session/paths.js.map +1 -1
  169. package/dist/store/memory/disk.d.ts.map +1 -1
  170. package/dist/store/memory/disk.js +2 -2
  171. package/dist/store/memory/disk.js.map +1 -1
  172. package/dist/store/memory/markdown.d.ts.map +1 -1
  173. package/dist/store/memory/markdown.js +2 -2
  174. package/dist/store/memory/markdown.js.map +1 -1
  175. package/dist/store/memory/naming.d.ts +9 -0
  176. package/dist/store/memory/naming.d.ts.map +1 -1
  177. package/dist/store/memory/naming.js +12 -0
  178. package/dist/store/memory/naming.js.map +1 -1
  179. package/dist/tools/builtins/bash.d.ts +30 -1
  180. package/dist/tools/builtins/bash.d.ts.map +1 -1
  181. package/dist/tools/builtins/bash.js +170 -26
  182. package/dist/tools/builtins/bash.js.map +1 -1
  183. package/dist/tools/builtins/content-fingerprint.d.ts +2 -0
  184. package/dist/tools/builtins/content-fingerprint.d.ts.map +1 -1
  185. package/dist/tools/builtins/content-fingerprint.js +4 -0
  186. package/dist/tools/builtins/content-fingerprint.js.map +1 -1
  187. package/dist/tools/builtins/edit.d.ts.map +1 -1
  188. package/dist/tools/builtins/edit.js +60 -18
  189. package/dist/tools/builtins/edit.js.map +1 -1
  190. package/dist/tools/builtins/grep.d.ts +8 -1
  191. package/dist/tools/builtins/grep.d.ts.map +1 -1
  192. package/dist/tools/builtins/grep.js +152 -33
  193. package/dist/tools/builtins/grep.js.map +1 -1
  194. package/dist/tools/builtins/read-file.d.ts +1 -0
  195. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  196. package/dist/tools/builtins/read-file.js +44 -5
  197. package/dist/tools/builtins/read-file.js.map +1 -1
  198. package/dist/tools/builtins/read-render.d.ts +52 -0
  199. package/dist/tools/builtins/read-render.d.ts.map +1 -1
  200. package/dist/tools/builtins/read-render.js +143 -0
  201. package/dist/tools/builtins/read-render.js.map +1 -1
  202. package/dist/tools/builtins/web.d.ts +13 -2
  203. package/dist/tools/builtins/web.d.ts.map +1 -1
  204. package/dist/tools/builtins/web.js +225 -32
  205. package/dist/tools/builtins/web.js.map +1 -1
  206. package/dist/tools/coordinator/ask-user-question.d.ts +2 -0
  207. package/dist/tools/coordinator/ask-user-question.d.ts.map +1 -1
  208. package/dist/tools/coordinator/ask-user-question.js +256 -92
  209. package/dist/tools/coordinator/ask-user-question.js.map +1 -1
  210. package/dist/tools/coordinator/question-options.d.ts +1 -0
  211. package/dist/tools/coordinator/question-options.d.ts.map +1 -1
  212. package/dist/tools/coordinator/question-options.js +6 -1
  213. package/dist/tools/coordinator/question-options.js.map +1 -1
  214. package/dist/tools/defineTool.d.ts +4 -1
  215. package/dist/tools/defineTool.d.ts.map +1 -1
  216. package/dist/tools/defineTool.js +6 -1
  217. package/dist/tools/defineTool.js.map +1 -1
  218. package/dist/tools/paths.d.ts +6 -0
  219. package/dist/tools/paths.d.ts.map +1 -1
  220. package/dist/tools/paths.js +1 -1
  221. package/dist/tools/paths.js.map +1 -1
  222. package/dist/types/agent/query.d.ts +3 -0
  223. package/dist/types/agent/query.d.ts.map +1 -1
  224. package/dist/types/errors/catalog.js +1 -1
  225. package/dist/types/errors/catalog.js.map +1 -1
  226. package/dist/types/hitl/index.d.ts +104 -0
  227. package/dist/types/hitl/index.d.ts.map +1 -1
  228. package/dist/types/hitl/index.js.map +1 -1
  229. package/dist/types/message/index.d.ts +1 -1
  230. package/dist/types/message/index.d.ts.map +1 -1
  231. package/dist/types/message/index.js +2 -0
  232. package/dist/types/message/index.js.map +1 -1
  233. package/dist/types/plugin/index.d.ts +93 -19
  234. package/dist/types/plugin/index.d.ts.map +1 -1
  235. package/dist/types/plugin/index.js +3 -0
  236. package/dist/types/plugin/index.js.map +1 -1
  237. package/dist/types/provider/errors.d.ts +19 -0
  238. package/dist/types/provider/errors.d.ts.map +1 -1
  239. package/dist/types/provider/errors.js +24 -0
  240. package/dist/types/provider/errors.js.map +1 -1
  241. package/dist/types/session/events.d.ts +10 -2
  242. package/dist/types/session/events.d.ts.map +1 -1
  243. package/dist/types/session/events.js.map +1 -1
  244. package/dist/types/session/records.d.ts +9 -9
  245. package/dist/types/session/records.d.ts.map +1 -1
  246. package/dist/types/session/records.js +1 -0
  247. package/dist/types/session/records.js.map +1 -1
  248. package/dist/types/session/stop-reason.d.ts +5 -0
  249. package/dist/types/session/stop-reason.d.ts.map +1 -1
  250. package/dist/types/tool/index.d.ts +52 -0
  251. package/dist/types/tool/index.d.ts.map +1 -1
  252. package/dist/types/tool/index.js.map +1 -1
  253. package/package.json +4 -2
  254. package/src/agents/QueryAgent.ts +5 -1
  255. package/src/agents/SupervisorAgent.ts +1 -1
  256. package/src/agents/examples/SupervisorAgent.ts +217 -106
  257. package/src/agents/index.ts +1 -1
  258. package/src/authorization/protected-paths.ts +110 -0
  259. package/src/authorization/readonly-commands.ts +771 -0
  260. package/src/authorization/readonly-flags.ts +196 -0
  261. package/src/authorization/removal.ts +326 -0
  262. package/src/authorization/shell-paths.ts +927 -0
  263. package/src/bridge/acp/update.ts +1 -0
  264. package/src/compaction/context-window.ts +41 -0
  265. package/src/compaction/manual.ts +10 -2
  266. package/src/compaction/plan.ts +11 -2
  267. package/src/compaction/verifier.ts +28 -3
  268. package/src/config/runtime.ts +23 -2
  269. package/src/connector/web/guarded-fetch.ts +26 -0
  270. package/src/connector/web/index.ts +3 -0
  271. package/src/connector/web/page.ts +130 -0
  272. package/src/connector/web/types.ts +47 -0
  273. package/src/constants/continuation.ts +10 -0
  274. package/src/constants/plugin/index.ts +1 -1
  275. package/src/manager/agent/lifecycle.ts +12 -4
  276. package/src/manager/session/turn-recorder.ts +10 -2
  277. package/src/plugin/index.ts +1 -0
  278. package/src/plugin/lifecycle.ts +107 -119
  279. package/src/plugin/shell-hook.ts +477 -43
  280. package/src/public-runtime.ts +8 -0
  281. package/src/public-types.ts +4 -0
  282. package/src/runtime/jobs/registry.ts +39 -2
  283. package/src/runtime/query/executor/tool-call-admission.ts +79 -13
  284. package/src/runtime/query/executor.ts +242 -8
  285. package/src/runtime/query/finalize-turn.ts +7 -1
  286. package/src/runtime/query/index.ts +45 -3
  287. package/src/runtime/query/iteration/index.ts +251 -12
  288. package/src/runtime/query/iteration/phases/compaction-breaker.ts +106 -0
  289. package/src/runtime/query/iteration/phases/compaction.ts +99 -12
  290. package/src/runtime/query/iteration/phases/context.ts +11 -0
  291. package/src/runtime/query/iteration/phases/tool-review.ts +177 -2
  292. package/src/runtime/query/path-escalation.ts +204 -0
  293. package/src/runtime/query/plugin-hooks.ts +8 -0
  294. package/src/runtime/query/reminders.ts +45 -0
  295. package/src/runtime/query/resume-pending.ts +27 -0
  296. package/src/runtime/query/review-policy.ts +106 -5
  297. package/src/runtime/query/tool-output-budget.ts +115 -1
  298. package/src/runtime/query/tooling.ts +6 -0
  299. package/src/session/paths.ts +11 -1
  300. package/src/store/memory/disk.ts +2 -1
  301. package/src/store/memory/markdown.ts +2 -1
  302. package/src/store/memory/naming.ts +13 -0
  303. package/src/tools/builtins/bash.ts +203 -34
  304. package/src/tools/builtins/content-fingerprint.ts +5 -0
  305. package/src/tools/builtins/edit.ts +69 -19
  306. package/src/tools/builtins/grep.ts +159 -38
  307. package/src/tools/builtins/read-file.ts +48 -5
  308. package/src/tools/builtins/read-render.ts +193 -0
  309. package/src/tools/builtins/web.ts +258 -33
  310. package/src/tools/coordinator/ask-user-question.ts +309 -103
  311. package/src/tools/coordinator/question-options.ts +8 -1
  312. package/src/tools/defineTool.ts +11 -2
  313. package/src/tools/paths.ts +1 -1
  314. package/src/types/agent/query.ts +3 -0
  315. package/src/types/errors/catalog.ts +1 -1
  316. package/src/types/hitl/index.ts +104 -0
  317. package/src/types/message/index.ts +2 -0
  318. package/src/types/plugin/index.ts +101 -18
  319. package/src/types/provider/errors.ts +33 -0
  320. package/src/types/session/events.ts +10 -2
  321. package/src/types/session/records.ts +1 -0
  322. package/src/types/session/stop-reason.ts +5 -0
  323. package/src/types/tool/index.ts +53 -2
@@ -0,0 +1,204 @@
1
+ /**
2
+ * What a call writes or removes that no mode may approve in advance.
3
+ *
4
+ * Three questions the kernel answers about a prepared call before it runs, all
5
+ * of them about the call's own arguments and none about the model's intent:
6
+ *
7
+ * - does it write a protected path (a git hook, a shell rc file, editor or
8
+ * tool configuration, the project's `.namzu` directory)?
9
+ * - does its shell line remove something catastrophic?
10
+ * - does its shell line reach a path outside the turn's roots, through a
11
+ * command whose operands are paths or through a redirection?
12
+ *
13
+ * The first two are floors: they hold in every mode, the way a sandbox escape
14
+ * does. The third is the existing outside-root boundary, extended from the
15
+ * file tools' one declared argument to the paths a shell line names.
16
+ */
17
+
18
+ import { homedir } from 'node:os'
19
+ import { isAbsolute, relative, resolve } from 'node:path'
20
+
21
+ import { isProtectedPath } from '../../authorization/protected-paths.js'
22
+ import { dangerousRemoval } from '../../authorization/removal.js'
23
+ import type { ShellDialect } from '../../authorization/shell-lexer.js'
24
+ import { readShellLine, shellPathAccess } from '../../authorization/shell-paths.js'
25
+ import { canonicalize, pathOutsideRoots } from '../../tools/paths.js'
26
+
27
+ /** What the escalation records carry. */
28
+ export interface PathEscalations {
29
+ readonly outsidePaths: readonly string[]
30
+ readonly protectedPaths: readonly string[]
31
+ readonly dangerousRemoval?: string
32
+ }
33
+
34
+ export interface PathEscalationContext {
35
+ /** The working directory first, then the added directories. */
36
+ readonly roots: readonly string[]
37
+ /** Whether to report paths outside the roots (`outsideRootAccess: 'review'`, no sandbox). */
38
+ readonly reviewOutside: boolean
39
+ }
40
+
41
+ /**
42
+ * The part of `absolute` that protected names are looked for in.
43
+ *
44
+ * Inside a root (the working directory or an added directory) that is the part
45
+ * below the root, not the whole path: the root is where the turn was put, and
46
+ * a root that itself sits under a protected name is not a reason to ask about
47
+ * every file in it. The case that matters is the managed worktree a sub-agent
48
+ * runs in, `~/.namzu/projects/<slug>/worktrees/<name>`, which lies under
49
+ * `.namzu` by construction; the reference terminal makes the same exception
50
+ * for its own worktrees directory. A path in no root is looked at whole.
51
+ */
52
+ function protectedScope(absolute: string, roots: readonly string[]): string {
53
+ let best: string | undefined
54
+ for (const root of roots) {
55
+ const base = resolve(root)
56
+ const below = relative(base, absolute)
57
+ if (below === '' || below.startsWith('..') || isAbsolute(below)) continue
58
+ if (best === undefined || below.length < best.length) best = below
59
+ }
60
+ return best ?? absolute
61
+ }
62
+
63
+ /** A write is protected when its spelling, or the place a link leads to, is. */
64
+ async function isProtectedWrite(
65
+ spelled: string,
66
+ absolute: string,
67
+ roots: readonly string[],
68
+ ): Promise<boolean> {
69
+ if (isProtectedPath(isAbsolute(spelled) ? protectedScope(absolute, roots) : spelled)) return true
70
+ if (isProtectedPath(protectedScope(absolute, roots))) return true
71
+ const real = await canonicalize(absolute)
72
+ const realRoots = await Promise.all(roots.map((root) => canonicalize(resolve(root))))
73
+ return isProtectedPath(protectedScope(real, [...roots, ...realRoots]))
74
+ }
75
+
76
+ /** `{a,b}` with plain comma lists, one level at a time, bounded. */
77
+ export function expandBraces(value: string, limit = 16): string[] {
78
+ const open = value.indexOf('{')
79
+ if (open < 0) return [value]
80
+ let depth = 0
81
+ let close = -1
82
+ const commas: number[] = []
83
+ for (let i = open; i < value.length; i += 1) {
84
+ const c = value[i]
85
+ if (c === '{') depth += 1
86
+ else if (c === '}') {
87
+ depth -= 1
88
+ if (depth === 0) {
89
+ close = i
90
+ break
91
+ }
92
+ } else if (c === ',' && depth === 1) commas.push(i)
93
+ }
94
+ if (close < 0 || commas.length === 0) return [value]
95
+ const head = value.slice(0, open)
96
+ const tail = value.slice(close + 1)
97
+ const parts: string[] = []
98
+ let start = open + 1
99
+ for (const comma of [...commas, close]) {
100
+ parts.push(value.slice(start, comma))
101
+ start = comma + 1
102
+ }
103
+ const out: string[] = []
104
+ for (const part of parts) {
105
+ for (const rest of expandBraces(head + part + tail, limit)) {
106
+ out.push(rest)
107
+ if (out.length >= limit) return out
108
+ }
109
+ }
110
+ return out
111
+ }
112
+
113
+ /** The path a use names with `~` and `$HOME` read as the home directory, or why it cannot be. */
114
+ function settle(
115
+ value: string,
116
+ home: string,
117
+ ): { readonly path: string } | { readonly unresolved: true } {
118
+ if (value === '~') return { path: home }
119
+ if (value.startsWith('~/')) return { path: resolve(home, value.slice(2)) }
120
+ if (value.startsWith('~')) return { unresolved: true }
121
+ const variable = /^\$(?:\{HOME\}|HOME)(?=\/|$)/.exec(value)
122
+ if (variable !== null) return { path: resolve(home, `.${value.slice(variable[0].length)}`) }
123
+ if (/[$`]/.test(value)) return { unresolved: true }
124
+ return { path: value }
125
+ }
126
+
127
+ /** The part of a glob path before the first segment that holds a glob character. */
128
+ function beforeGlob(path: string): string {
129
+ const at = path.search(/[*?[]/)
130
+ if (at < 0) return path
131
+ const cut = path.lastIndexOf('/', at)
132
+ return cut <= 0 ? '/' : path.slice(0, cut)
133
+ }
134
+
135
+ /** A write the file tool's own declared path argument names. */
136
+ export async function declaredWriteEscalation(
137
+ roots: readonly string[],
138
+ value: string,
139
+ ): Promise<string | undefined> {
140
+ const [first] = roots
141
+ if (first === undefined || value === '') return undefined
142
+ const absolute = resolve(resolve(first), value)
143
+ return (await isProtectedWrite(value, absolute, roots)) ? absolute : undefined
144
+ }
145
+
146
+ /** What a shell line reaches. */
147
+ export async function shellEscalations(
148
+ line: string,
149
+ dialect: ShellDialect,
150
+ context: PathEscalationContext,
151
+ ): Promise<PathEscalations> {
152
+ const reading = readShellLine(line, dialect)
153
+ const access = shellPathAccess(reading)
154
+ const [cwd, ...added] = context.roots
155
+ const outside = new Set<string>()
156
+ const protectedPaths = new Set<string>()
157
+ const home = homedir()
158
+ if (cwd !== undefined) {
159
+ for (const use of access.uses) {
160
+ for (const spelled of expandBraces(use.path)) {
161
+ const settled = settle(spelled, home)
162
+ const unresolved = use.unresolved || 'unresolved' in settled
163
+ const base = 'path' in settled ? settled.path : spelled
164
+ const absolute = isAbsolute(base) ? resolve(base) : resolve(cwd, base)
165
+ if (use.access === 'write' && (await isProtectedWrite(spelled, absolute, context.roots))) {
166
+ protectedPaths.add(absolute)
167
+ }
168
+ if (!context.reviewOutside) continue
169
+ if (unresolved) {
170
+ // A write whose target is decided at runtime cannot be checked; a
171
+ // read of one is left, as the reference leaves it.
172
+ if (use.access === 'write') outside.add(spelled)
173
+ continue
174
+ }
175
+ if (
176
+ access.changesDirectory &&
177
+ use.access === 'write' &&
178
+ !isAbsolute(base) &&
179
+ !spelled.startsWith('~')
180
+ ) {
181
+ // A `cd` earlier in the line moves a relative target somewhere the
182
+ // roots cannot be compared with.
183
+ outside.add(spelled)
184
+ continue
185
+ }
186
+ const reached = await pathOutsideRoots(context.roots, beforeGlob(absolute))
187
+ if (reached !== undefined) outside.add(reached)
188
+ }
189
+ }
190
+ }
191
+ const removal =
192
+ cwd === undefined
193
+ ? undefined
194
+ : dangerousRemoval(reading, access.changesDirectory, {
195
+ cwd,
196
+ additionalDirectories: added,
197
+ home,
198
+ })
199
+ return {
200
+ outsidePaths: [...outside],
201
+ protectedPaths: [...protectedPaths],
202
+ ...(removal !== undefined ? { dangerousRemoval: removal } : {}),
203
+ }
204
+ }
@@ -27,6 +27,14 @@ export function applyLifecycleHookResults(
27
27
  if (result.text.trim().length > 0) annotations.push(result.text.trim())
28
28
  continue
29
29
  case 'skip':
30
+ // `turn_end`, `subagent_stop` and `pre_compact` answer with `skip`
31
+ // to keep the turn going or to cancel a pass. The sites that can act
32
+ // on that read the results themselves; a site that cannot (the
33
+ // final `turn_end` of a turn that already ended) has nothing to
34
+ // refuse and carries on.
35
+ if (event === 'turn_end' || event === 'subagent_stop' || event === 'pre_compact') {
36
+ continue
37
+ }
30
38
  // The one lifecycle event with a verdict: a hook may refuse the
31
39
  // prompt before the model sees it, and the turn ends there.
32
40
  if (event === 'user_prompt_submit') {
@@ -0,0 +1,45 @@
1
+ import type { SessionId, TurnId } from '../../types/ids/index.js'
2
+ import type { Message } from '../../types/message/index.js'
3
+
4
+ /**
5
+ * What a reminder source is told when the kernel asks it for hidden reminders.
6
+ */
7
+ export interface ReminderContext {
8
+ readonly sessionId: SessionId
9
+ readonly turnId: TurnId
10
+ /** The model request about to be made; 1 for the first of the turn. */
11
+ readonly iteration: number
12
+ /**
13
+ * The conversation as it stands, reminders included. Read-only: a source
14
+ * derives its cadence from this (how many assistant turns since it last
15
+ * spoke), so a resumed or compacted conversation needs no counter of its own.
16
+ */
17
+ readonly messages: readonly Message[]
18
+ }
19
+
20
+ /**
21
+ * A host's hidden reminders for the next model request.
22
+ *
23
+ * The kernel calls it once at the start of every model request of a turn
24
+ * (never the closing one) and appends each returned text to the conversation
25
+ * as a user-role message with provenance `{ type: 'runtime-context', kind:
26
+ * 'reminder' }`. The message is part of the durable history, so it is not
27
+ * operator input, never counts as the latest user request, and is not shown as
28
+ * something the operator typed. The channel is opt-in
29
+ * (`QueryParams.reminders`): a turn without a source behaves as before.
30
+ *
31
+ * A source that throws is treated as having nothing to say; a reminder is
32
+ * context, and a fault in one must not end a turn.
33
+ */
34
+ export type ReminderSource = (
35
+ context: ReminderContext,
36
+ ) => readonly string[] | undefined | Promise<readonly string[] | undefined>
37
+
38
+ /** Whether a message is a reminder the kernel appended for a {@link ReminderSource}. */
39
+ export function isReminderMessage(message: Message): boolean {
40
+ return (
41
+ message.role === 'user' &&
42
+ message.source?.type === 'runtime-context' &&
43
+ message.source.kind === 'reminder'
44
+ )
45
+ }
@@ -517,6 +517,24 @@ export async function applyPendingResume(
517
517
  reason: "the turn's review approved this call, answered across a restart",
518
518
  })
519
519
  }
520
+ for (const path of call.escalation?.protectedPaths ?? []) {
521
+ await recorder.recordAudit({
522
+ what: { action: 'protected_path_write', tool: call.name, resource: path },
523
+ outcome: 'approved',
524
+ reason: "the turn's review approved this call, answered across a restart",
525
+ })
526
+ }
527
+ if (call.escalation?.dangerousRemoval !== undefined) {
528
+ await recorder.recordAudit({
529
+ what: {
530
+ action: 'dangerous_removal',
531
+ tool: call.name,
532
+ resource: call.escalation.dangerousRemoval,
533
+ },
534
+ outcome: 'approved',
535
+ reason: "the turn's review approved this call, answered across a restart",
536
+ })
537
+ }
520
538
  }
521
539
 
522
540
  // The parked assistant message is already in the log: it goes back into
@@ -562,6 +580,15 @@ export function escalationRefusal(
562
580
  for (const path of escalation.outsidePaths ?? []) {
563
581
  if (!shown?.outsidePaths?.includes(path)) return refusal
564
582
  }
583
+ for (const path of escalation.protectedPaths ?? []) {
584
+ if (!shown?.protectedPaths?.includes(path)) return refusal
585
+ }
586
+ if (
587
+ escalation.dangerousRemoval !== undefined &&
588
+ shown?.dangerousRemoval !== escalation.dangerousRemoval
589
+ ) {
590
+ return refusal
591
+ }
565
592
  if (escalation.sandboxEscape && (!shown?.sandboxEscape || !confirmed?.includes(id))) {
566
593
  return 'Refused after resume: running this command outside the sandbox needs a person to confirm it for this call, and the durable approval did not.'
567
594
  }
@@ -228,6 +228,26 @@ export const REQUIRES_APPROVAL_UNATTENDED_REFUSAL =
228
228
  export const UNKNOWN_PROGRAM_UNATTENDED_REFUSAL =
229
229
  'Refused: a call in this batch runs a command whose own program name is decided at runtime (a substitution or a variable stands for it), which needs a person to approve it each time, and nobody can be asked in this session. Nothing in this batch ran. Write the program name literally; compute what needs computing in the arguments instead.'
230
230
 
231
+ /**
232
+ * What the model is told when a batch writes a protected path (a git hook, a
233
+ * shell rc file, editor or tool configuration, the project's `.namzu`
234
+ * directory) and nobody can be asked.
235
+ *
236
+ * Refused in every mode, `auto` included: a write there runs later, outside
237
+ * the turn, with the operator's rights, so an answer given before the call
238
+ * existed (a mode, a rule, "approve all") is not an answer about it.
239
+ */
240
+ export const PROTECTED_PATH_UNATTENDED_REFUSAL =
241
+ "Refused: a call in this batch writes a protected path (a git hook or config, a shell startup file, editor or tool configuration, or the project's .namzu directory), which needs a person to approve it each time, and nobody can be asked in this session. Nothing in this batch ran. Leave those files alone, or tell the user what change you need so they can make it."
242
+
243
+ /**
244
+ * What the model is told when a batch removes something catastrophic (the
245
+ * root, the home directory, the working directory or a parent of it, a glob
246
+ * across directories, a target a variable may empty) and nobody can be asked.
247
+ */
248
+ export const DANGEROUS_REMOVAL_UNATTENDED_REFUSAL =
249
+ 'Refused: a call in this batch removes something this check treats as catastrophic (the file system root, a top-level or home directory, the working directory or a parent of it, a glob across directories, or a target a variable or substitution may leave empty), which needs a person to approve it each time, and nobody can be asked in this session. Nothing in this batch ran. Name the exact directory to remove with a literal path.'
250
+
231
251
  /**
232
252
  * What the model is told when a batch would show it the operator's screen for
233
253
  * the first time in a session and nobody can be asked.
@@ -250,6 +270,21 @@ export interface ScreenConsentRecord {
250
270
  readonly sessions: Set<string>
251
271
  }
252
272
 
273
+ /** Whether the call writes a protected path or removes something catastrophic. */
274
+ function reachesAFloor(tc: ToolCallSummary): boolean {
275
+ return (
276
+ (tc.escalation?.protectedPaths?.length ?? 0) > 0 ||
277
+ tc.escalation?.dangerousRemoval !== undefined
278
+ )
279
+ }
280
+
281
+ /** The refusal for a batch that reaches a floor and cannot be asked about it. */
282
+ function floorRefusal(toolCalls: readonly ToolCallSummary[]): string {
283
+ return toolCalls.some((tc) => (tc.escalation?.protectedPaths?.length ?? 0) > 0)
284
+ ? PROTECTED_PATH_UNATTENDED_REFUSAL
285
+ : DANGEROUS_REMOVAL_UNATTENDED_REFUSAL
286
+ }
287
+
253
288
  /** The batch a person is asked about. */
254
289
  export interface ToolReviewRequest {
255
290
  /** Originating session, preserved by createReviewHandler for host attribution. */
@@ -339,6 +374,16 @@ export interface ReviewPolicyOptions {
339
374
  * `capturesScreen` declaration, read from `registry`; nothing without one.
340
375
  */
341
376
  readonly capturesScreen?: (name: string, input: unknown) => boolean
377
+ /**
378
+ * The one write `plan` mode lets through: a host that gives plan mode a plan
379
+ * file says here which call writes it. In `plan`, a call this accepts is not
380
+ * a change — it is neither refused nor asked about — unless a rule asked to
381
+ * review it, in which case it is put to the person like a read outside the
382
+ * roots. Every other write stays refused. Omitted (the default), `plan`
383
+ * refuses every change, as it always has; the option does nothing in any
384
+ * other mode.
385
+ */
386
+ readonly planModeWrite?: (call: ToolCallSummary) => boolean
342
387
  }
343
388
 
344
389
  /**
@@ -358,6 +403,23 @@ function isSkillGranted(tc: ToolCallSummary): boolean {
358
403
  )
359
404
  }
360
405
 
406
+ /**
407
+ * Whether a `pre_tool_use` hook's `allow` may stand in for a person on this
408
+ * call. The same bar as a skill's grant, repeated here so that a mark on a
409
+ * persisted or host-built summary cannot carry more than the kernel gave it.
410
+ */
411
+ function isHookGranted(tc: ToolCallSummary): boolean {
412
+ return (
413
+ tc.hookGrant !== undefined &&
414
+ tc.hookPermission?.decision !== 'ask' &&
415
+ !tc.authorization?.explicitReview &&
416
+ tc.authorization?.decision !== 'deny' &&
417
+ !tc.isDestructive &&
418
+ tc.escalation === undefined &&
419
+ tc.requiresApproval !== true
420
+ )
421
+ }
422
+
361
423
  /** The handler behind `createReviewPolicy`, for a host that wants only the function. */
362
424
  export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHandler {
363
425
  const { prompt, registry } = options
@@ -422,7 +484,16 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
422
484
  if (answer.kind === 'approve-all') remembered.all = true
423
485
  answered = answer
424
486
  }
425
- if (!batchNeedsReview(request.toolCalls, exempt)) {
487
+ // The plan file, in `plan`: the one write that is not a change. A rule
488
+ // that asked for review keeps the call in the batch, so it is asked.
489
+ const isPlanFileWrite = (tc: ToolCallSummary): boolean =>
490
+ mode === 'plan' &&
491
+ options.planModeWrite?.(tc) === true &&
492
+ tc.authorization?.decision !== 'deny'
493
+ const reviewable = request.toolCalls.filter(
494
+ (tc) => !(isPlanFileWrite(tc) && !tc.authorization?.explicitReview),
495
+ )
496
+ if (!batchNeedsReview(reviewable, exempt)) {
426
497
  return { action: 'approve_tools' }
427
498
  }
428
499
  // A batch of nothing but non-destructive file edits is the case
@@ -450,7 +521,13 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
450
521
  // to do, and the question such a path gets is the same in every mode that
451
522
  // lets reads run. It goes on to that question below; `strict` still
452
523
  // refuses it, since no rule can approve a path outside the roots.
453
- if (mode === 'plan' && !onlyReadsOutsideRoots(request.toolCalls, exempt)) {
524
+ if (
525
+ mode === 'plan' &&
526
+ !onlyReadsOutsideRoots(
527
+ reviewable.filter((tc) => !isPlanFileWrite(tc)),
528
+ exempt,
529
+ )
530
+ ) {
454
531
  return { action: 'reject_tools', feedback: PLAN_MODE_REFUSAL }
455
532
  }
456
533
  if (mode === 'strict') return { action: 'reject_tools', feedback: STRICT_MODE_REFUSAL }
@@ -471,6 +548,9 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
471
548
  // This opt-in confirms only the sandbox escape. A different call in
472
549
  // the batch, or another boundary on this same call, still needs a
473
550
  // person; the early return must not approve that boundary too.
551
+ if (request.toolCalls.some(reachesAFloor)) {
552
+ return { action: 'reject_tools', feedback: floorRefusal(request.toolCalls) }
553
+ }
474
554
  if (request.toolCalls.some((tc) => (tc.escalation?.outsidePaths?.length ?? 0) > 0)) {
475
555
  return { action: 'reject_tools', feedback: OUTSIDE_ROOTS_UNATTENDED_REFUSAL }
476
556
  }
@@ -495,6 +575,24 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
495
575
  if (answer.kind === 'approve-all') remembered.all = true
496
576
  return { action: 'approve_tools', confirmedEscalations: escapes }
497
577
  }
578
+ // A write to a protected path, or a removal of something catastrophic,
579
+ // is a question for a person, every time, in every mode that got this
580
+ // far: `auto` and a remembered "approve all" were answers given before
581
+ // the call existed. With nobody to ask it is refused, not approved.
582
+ if (request.toolCalls.some(reachesAFloor)) {
583
+ if (!prompt) return { action: 'reject_tools', feedback: floorRefusal(request.toolCalls) }
584
+ const answer = await ask()
585
+ if (answer.kind === 'reject') {
586
+ return {
587
+ action: 'reject_tools',
588
+ feedback: answer.feedback ?? DECLINED_TOOL_CALL_FEEDBACK,
589
+ ...(answer.declined ? { declined: answer.declined } : {}),
590
+ }
591
+ }
592
+ // Latches for the ordinary calls that follow, never for the next floor.
593
+ if (answer.kind === 'approve-all') remembered.all = true
594
+ return { action: 'approve_tools' }
595
+ }
498
596
  // A path outside the roots is a question for a person, every time,
499
597
  // like an escape: `auto` and a remembered "approve all" were answers
500
598
  // given about tools, before this path was named, and neither saw it.
@@ -571,15 +669,18 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
571
669
  (mode === 'accept-edits' && ACCEPT_EDITS_TOOLS.has(tc.name))
572
670
  ),
573
671
  )
672
+ const skillsHonoured = options.skillGrants !== 'ignore'
574
673
  if (
575
674
  answered === undefined &&
576
- options.skillGrants !== 'ignore' &&
577
675
  needsPerson.length > 0 &&
578
- needsPerson.every(isSkillGranted)
676
+ needsPerson.every((tc) => isHookGranted(tc) || (skillsHonoured && isSkillGranted(tc)))
579
677
  ) {
678
+ const byHook = needsPerson.filter(isHookGranted).map((tc) => tc.id)
679
+ const bySkill = needsPerson.filter((tc) => !isHookGranted(tc)).map((tc) => tc.id)
580
680
  return {
581
681
  action: 'approve_tools',
582
- skillGranted: needsPerson.map((tc) => tc.id),
682
+ ...(bySkill.length > 0 ? { skillGranted: bySkill } : {}),
683
+ ...(byHook.length > 0 ? { hookGranted: byHook } : {}),
583
684
  }
584
685
  }
585
686
  const answer = await ask()
@@ -18,6 +18,19 @@ import { digest, spillManifest } from '../../store/evidence/format.js'
18
18
  */
19
19
  export const DEFAULT_MAX_TOOL_OUTPUT_CHARS = 40_000
20
20
 
21
+ /**
22
+ * Cap on the COMBINED model-visible text of every tool result one assistant
23
+ * message produced.
24
+ *
25
+ * The per-result cap alone lets eight parallel calls put 320,000 characters in
26
+ * front of the model at once. This is the bound on the whole batch; see
27
+ * {@link fitResultsToMessageBudget}.
28
+ */
29
+ export const DEFAULT_MAX_TOOL_OUTPUT_CHARS_PER_MESSAGE = 200_000
30
+
31
+ /** Size of the preview that stands in for a result replaced to fit the message budget. */
32
+ export const MESSAGE_BUDGET_PREVIEW_CHARS = 2_000
33
+
21
34
  /**
22
35
  * Opening of the line that points at a spilled output.
23
36
  *
@@ -298,7 +311,10 @@ function spill(
298
311
  writeFileSync(path, bytes, { flag: 'wx', mode: 0o600 })
299
312
  try {
300
313
  const manifest = spillManifest(bytes)
301
- writeFileSync(`${path}.manifest.json`, manifest, { flag: 'wx', mode: 0o600 })
314
+ writeFileSync(`${path}.manifest.json`, manifest, {
315
+ flag: 'wx',
316
+ mode: 0o600,
317
+ })
302
318
  return { path, integrity: digest(manifest) }
303
319
  } catch {
304
320
  onError?.('The full output was retained, but its integrity manifest could not be written.')
@@ -324,3 +340,101 @@ function spill(
324
340
  return undefined
325
341
  }
326
342
  }
343
+
344
+ export interface MessageBudgetItem {
345
+ readonly toolCallId: string
346
+ readonly toolName: string
347
+ readonly output: string
348
+ /** False for a result that must stay as the tool produced it. */
349
+ readonly replaceable: boolean
350
+ }
351
+
352
+ export interface MessageBudgetReplacement {
353
+ readonly output: string
354
+ readonly originalLength: number
355
+ readonly spillPath?: string
356
+ }
357
+
358
+ export interface FitResultsToMessageBudgetOptions {
359
+ readonly maxChars: number
360
+ readonly spillDir?: string | undefined
361
+ readonly onError?: (message: string) => void
362
+ }
363
+
364
+ const SPILL_LINE = new RegExp(`^${SPILL_MARKER} .+$`, 'm')
365
+
366
+ /**
367
+ * Hold one assistant message's combined tool results under a cap.
368
+ *
369
+ * Replaceable results are replaced largest first, each by a short preview that
370
+ * names where its full text is kept, until the total is within `maxChars`. A
371
+ * result already spilled keeps the path it was given; one that was not is
372
+ * spilled now. Results that are not replaceable are left alone and are not
373
+ * counted, because nothing here could reduce them.
374
+ *
375
+ * The caller writes the replacement into the result BEFORE the result reaches
376
+ * the history. From then on it is just the message's content, so it is the
377
+ * same on every later request and a prompt-cache prefix over it stays valid;
378
+ * nothing is decided again on a later turn.
379
+ *
380
+ * Returns only the results that changed, keyed by `toolCallId`. Ties keep the
381
+ * earlier call, so the same batch always yields the same replacements.
382
+ */
383
+ export function fitResultsToMessageBudget(
384
+ items: readonly MessageBudgetItem[],
385
+ opts: FitResultsToMessageBudgetOptions,
386
+ ): Map<string, MessageBudgetReplacement> {
387
+ const replaced = new Map<string, MessageBudgetReplacement>()
388
+ if (!Number.isFinite(opts.maxChars) || opts.maxChars <= 0) return replaced
389
+
390
+ const candidates = items.filter((item) => item.replaceable)
391
+ let total = candidates.reduce((sum, item) => sum + item.output.length, 0)
392
+ if (total <= opts.maxChars) return replaced
393
+
394
+ const bySize = candidates
395
+ .map((item, index) => ({ item, index }))
396
+ .sort((a, b) => b.item.output.length - a.item.output.length || a.index - b.index)
397
+
398
+ for (const { item } of bySize) {
399
+ if (total <= opts.maxChars) break
400
+ const replacement = previewForMessageBudget(item, opts)
401
+ if (replacement.output.length >= item.output.length) continue
402
+ total -= item.output.length - replacement.output.length
403
+ replaced.set(item.toolCallId, replacement)
404
+ }
405
+ return replaced
406
+ }
407
+
408
+ function previewForMessageBudget(
409
+ item: MessageBudgetItem,
410
+ opts: FitResultsToMessageBudgetOptions,
411
+ ): MessageBudgetReplacement {
412
+ const spilledAt = SPILL_LINE.exec(item.output)?.[0]
413
+ if (spilledAt !== undefined) {
414
+ const output = [
415
+ safeHead(item.output, MESSAGE_BUDGET_PREVIEW_CHARS / 2),
416
+ '',
417
+ `[... omitted: this message's tool results together were over the ${opts.maxChars.toLocaleString()}-character budget ...]`,
418
+ spilledAt,
419
+ ].join('\n')
420
+ return {
421
+ output,
422
+ originalLength: item.output.length,
423
+ spillPath: spilledAt.slice(SPILL_MARKER.length + 1),
424
+ }
425
+ }
426
+ const budgeted = applyToolOutputBudget({
427
+ toolName: item.toolName,
428
+ // A distinct name: the spill file is keyed by it and opened exclusively.
429
+ toolUseId: `${item.toolCallId}:message-budget`,
430
+ output: item.output,
431
+ maxChars: MESSAGE_BUDGET_PREVIEW_CHARS,
432
+ spillDir: opts.spillDir,
433
+ ...(opts.onError ? { onError: opts.onError } : {}),
434
+ })
435
+ return {
436
+ output: budgeted.output,
437
+ originalLength: item.output.length,
438
+ ...(budgeted.spillPath ? { spillPath: budgeted.spillPath } : {}),
439
+ }
440
+ }
@@ -54,6 +54,7 @@ export interface ToolingBootstrapConfig {
54
54
  maxToolCalls?: number
55
55
  readToolCallBudgetRecords?: () => Promise<readonly SessionRecord[]>
56
56
  maxToolOutputChars?: number
57
+ maxToolOutputCharsPerMessage?: number
57
58
  /** See `QueryParams.toolResultGuardrails`. Absent installs the shipped default; a registry's own win. */
58
59
  toolResultGuardrails?: readonly import('../../types/guardrail/index.js').ToolResultGuardrailSpec[]
59
60
  retainedToolPreviewChars?: number
@@ -119,6 +120,11 @@ export class ToolingBootstrap {
119
120
  ...(config.maxToolOutputChars !== undefined
120
121
  ? { maxToolOutputChars: config.maxToolOutputChars }
121
122
  : {}),
123
+ ...(config.maxToolOutputCharsPerMessage !== undefined
124
+ ? {
125
+ maxToolOutputCharsPerMessage: config.maxToolOutputCharsPerMessage,
126
+ }
127
+ : {}),
122
128
  ...(config.toolResultGuardrails !== undefined
123
129
  ? { toolResultGuardrails: config.toolResultGuardrails }
124
130
  : {}),
@@ -28,7 +28,7 @@ import { uuidv7 } from '../utils/uuidv7.js'
28
28
  * <session-id>.jsonl the session log (source of truth)
29
29
  * <session-id>/
30
30
  * subagents/<child-id>.jsonl, <child-id>.meta.json, <child-id>/subagents/…
31
- * tool-results/ checkpoints/ budgets/ tasks/ feedback/ goals/
31
+ * tool-results/ checkpoints/ budgets/ tasks/ plans/ feedback/ goals/
32
32
  * file-history/ lease.json
33
33
  * $TMPDIR/namzu-<user>/<slug>/<session-id>/scratchpad/
34
34
  * ```
@@ -412,6 +412,16 @@ export class SessionPaths {
412
412
  return join(this.sessionDir(locator), 'tasks')
413
413
  }
414
414
 
415
+ /** The session's plan directory; a host that has a plan mode keeps its one plan file here. */
416
+ plans(locator: SessionLocator): string {
417
+ return join(this.sessionDir(locator), 'plans')
418
+ }
419
+
420
+ /** The one file a session's plan mode may write: `<session-id>/plans/plan.md`. */
421
+ planFile(locator: SessionLocator): string {
422
+ return join(this.plans(locator), 'plan.md')
423
+ }
424
+
415
425
  taskFile(locator: SessionLocator, taskId: TaskId): string {
416
426
  return join(this.tasks(locator), `${checkId(taskId, 'task')}.json`)
417
427
  }
@@ -23,6 +23,7 @@ import { InMemoryMemoryIndex, searchMemoryEntries, selectMemorySearchCandidates
23
23
  import {
24
24
  MemoryNameConflictError,
25
25
  assertOptionalMemoryFields,
26
+ entryPath,
26
27
  isMemoryName,
27
28
  nameHolder,
28
29
  withOptionalFields,
@@ -228,7 +229,7 @@ export class DiskMemoryStore implements ConditionalMemoryStore {
228
229
 
229
230
  private contentPath(location: StorageLocation, id: MemoryId): string {
230
231
  assertStorageMemoryId(id, (reason) => invalidContent(id, reason, { field: 'id' }))
231
- const path = resolve(location.contentDir, `${id}.json`)
232
+ const path = resolve(entryPath(location.contentDir, id, '.json'))
232
233
  if (dirname(path) !== location.contentDir) {
233
234
  invalidContent(id, 'resolved path escapes the content directory', { field: 'id' })
234
235
  }