@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
@@ -38,6 +38,7 @@ import {
38
38
  MemoryContentRejectedError,
39
39
  MemoryNameConflictError,
40
40
  assertOptionalMemoryFields,
41
+ entryPath,
41
42
  isMemoryName,
42
43
  nameHolder,
43
44
  slugifyMemoryName,
@@ -327,7 +328,7 @@ export class MarkdownMemoryStore implements ConditionalMemoryStore {
327
328
 
328
329
  private memoryPath(dir: string, name: string): string {
329
330
  if (!isMemoryName(name)) invalidFile(join(dir, `${name}.md`), 'name is not a memory name')
330
- const path = resolve(dir, `${name}.md`)
331
+ const path = resolve(entryPath(dir, name, '.md'))
331
332
  if (dirname(path) !== dir) invalidFile(path, 'resolved path escapes the memory directory')
332
333
  return path
333
334
  }
@@ -1,3 +1,4 @@
1
+ import { sep } from 'node:path'
1
2
  import { NamzuError } from '../../types/errors/index.js'
2
3
  import type { MemoryId } from '../../types/ids/index.js'
3
4
  import type {
@@ -193,3 +194,15 @@ export function withOptionalFields(
193
194
  ...(type !== undefined ? { type } : {}),
194
195
  }
195
196
  }
197
+
198
+ /**
199
+ * The path of one entry file: `dir`, a separator, `stem`, `extension`, before
200
+ * the caller resolves it. Written as plain concatenation on purpose. A static
201
+ * file tracer (the kind standalone-output builds use) evaluates
202
+ * `path.resolve(dir, `${stem}.ext`)` with an unknown `dir` as a wildcard and
203
+ * lists every `.ext` file under the repository as a runtime asset; it does not
204
+ * evaluate string concatenation, so this form leaves it nothing to expand.
205
+ */
206
+ export function entryPath(dir: string, stem: string, extension: string): string {
207
+ return dir + sep + stem + extension
208
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { isReadOnlyCommandLine } from '../../authorization/readonly-commands.js'
2
3
  import { SANDBOX_KILL_GRACE_MS } from '../../constants/sandbox/index.js'
3
4
  import { DANGEROUS_PATTERNS } from '../../constants/tools/index.js'
4
5
  import { killTree } from '../../process/kill-tree.js'
@@ -51,12 +52,47 @@ const DEFAULT_BASH_MAX_BUFFER_BYTES = readPositiveIntEnv(
51
52
  *
52
53
  * So the tool declares a ceiling and the executor is given a deadline above
53
54
  * it (see `timeoutMs` on the definition), which makes this the only clock
54
- * that can fire in practice. A request past the ceiling is REFUSED rather
55
- * than quietly clamped: the model asked for something specific, and silently
56
- * giving it a different number is how it learns to distrust the answer.
55
+ * that can fire in practice. A request past the ceiling is CLAMPED to it, as
56
+ * the reference terminal does: the command still runs, and a timeout reports
57
+ * the deadline that was actually applied, so the number the model sees on a
58
+ * timeout is the one that fired.
57
59
  */
58
60
  const MAX_BASH_TIMEOUT_MS = readPositiveIntEnv('NAMZU_BASH_MAX_TIMEOUT_MS', 10 * 60 * 1000)
59
61
 
62
+ /**
63
+ * Background work switched off for this process: a command that outruns its
64
+ * timeout is killed, as it was before it could be moved to the background, and
65
+ * `run_in_background` is refused. Read at each call so a host can flip it.
66
+ */
67
+ export const DISABLE_BACKGROUND_TASKS_ENV = 'NAMZU_DISABLE_BACKGROUND_TASKS'
68
+
69
+ function backgroundTasksDisabled(): boolean {
70
+ const value = process.env[DISABLE_BACKGROUND_TASKS_ENV]?.trim().toLowerCase()
71
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false'
72
+ }
73
+
74
+ /**
75
+ * A command that is only waiting (`sleep 30`, also as the head of a chain)
76
+ * has nothing to keep running when its time is up, so it is still stopped.
77
+ */
78
+ function startsWithSleep(command: string): boolean {
79
+ const head = command
80
+ .split(/&&|\|\||;|\||\n/)[0]
81
+ ?.trim()
82
+ .split(/\s+/)[0]
83
+ return head === 'sleep'
84
+ }
85
+
86
+ /**
87
+ * What the model is told when a foreground command outran its timeout and was
88
+ * moved to the background instead of being stopped. Exported so the test
89
+ * states the same words.
90
+ */
91
+ export function movedToBackgroundMessage(jobId: string, timeoutMs: number): string {
92
+ const seconds = Math.max(1, Math.round(timeoutMs / 1000))
93
+ return `Command did not complete within its ${seconds}s timeout and was moved to the background as job ${jobId}; it was not stopped. You will be told when it ends. Await it with wait_for_job: {"id":"${jobId}"}, read its output with job action "read" (id "${jobId}"), or stop it with job action "kill". Its output so far is above.`
94
+ }
95
+
60
96
  const inputSchema = z.object({
61
97
  command: z
62
98
  .string()
@@ -73,10 +109,14 @@ const inputSchema = z.object({
73
109
  timeout: z
74
110
  .preprocess(
75
111
  (v) => (typeof v === 'string' ? Number(v) : v),
76
- z.number().positive().max(MAX_BASH_TIMEOUT_MS).default(DEFAULT_BASH_TIMEOUT_MS),
112
+ z
113
+ .number()
114
+ .positive()
115
+ .default(DEFAULT_BASH_TIMEOUT_MS)
116
+ .transform((ms) => Math.min(ms, MAX_BASH_TIMEOUT_MS)),
77
117
  )
78
118
  .describe(
79
- `Command timeout in milliseconds. Default: ${DEFAULT_BASH_TIMEOUT_MS}, maximum: ${MAX_BASH_TIMEOUT_MS}. For work that legitimately runs longer than the maximum, set run_in_background and await it with \`wait_for_job\`, rather than holding the turn open.`,
119
+ `Command timeout in milliseconds. Default: ${DEFAULT_BASH_TIMEOUT_MS}, maximum: ${MAX_BASH_TIMEOUT_MS}. For work that legitimately runs longer than the maximum, set run_in_background and await it with \`wait_for_job\`, rather than holding the turn open. A foreground command still running when the timeout elapses is moved to a background job (and reported as such) instead of being stopped, where the host has background jobs.`,
80
120
  ),
81
121
  run_in_background: z
82
122
  .boolean()
@@ -94,6 +134,21 @@ const inputSchema = z.object({
94
134
 
95
135
  type BashInput = z.infer<typeof inputSchema>
96
136
 
137
+ /**
138
+ * Whether this exact call only reads. A background job, or a command that
139
+ * leaves the sandbox, is never read-only whatever its line says.
140
+ */
141
+ function bashInputIsReadOnly(input: BashInput | undefined): boolean {
142
+ // Asked with no input to describe the tool in general (a roster, an export).
143
+ if (typeof input?.command !== 'string') return false
144
+ if (input.run_in_background === true || input.dangerously_disable_sandbox === true) return false
145
+ try {
146
+ return isReadOnlyCommandLine(input.command, hostCommandShell().dialect)
147
+ } catch {
148
+ return false
149
+ }
150
+ }
151
+
97
152
  const MAX_SHELL_PROGRESS_CHARS = 160
98
153
 
99
154
  /** Keep a bounded tail even when a process never writes a newline. */
@@ -147,7 +202,10 @@ function shellProgress(report?: (message: string) => void) {
147
202
  }
148
203
 
149
204
  /** A progress callback for {@link execHostShell}: one line, per stream, coalesced. */
150
- export type ExecHostShellProgress = (report: { stream: 'stdout' | 'stderr'; data: string }) => void
205
+ export type ExecHostShellProgress = (report: {
206
+ stream: 'stdout' | 'stderr'
207
+ data: string
208
+ }) => void
151
209
 
152
210
  /**
153
211
  * Keep the shell's process group until inherited pipes close. Node's exec
@@ -170,8 +228,22 @@ export function execHostShell(
170
228
  shell?: CommandShell
171
229
  signal?: AbortSignal
172
230
  onOutput?: ExecHostShellProgress
231
+ /**
232
+ * Asked once, at the deadline, instead of killing the command. Given the
233
+ * LIVE child and what it has written so far, it may take the process over
234
+ * and return a job id; the command then keeps running, this call settles
235
+ * with `backgroundedJobId` set, and nothing here touches the process
236
+ * again (not on a later Stop, not on its exit). Returning `undefined`, or
237
+ * throwing, leaves the deadline to kill the command as it always did.
238
+ */
239
+ onTimeout?: (running: {
240
+ child: ReturnType<typeof spawnHostShell>
241
+ initialOutput: readonly { stream: 'stdout' | 'stderr'; text: string }[]
242
+ }) => string | undefined
173
243
  },
174
244
  ): Promise<{
245
+ /** Set when `onTimeout` took the process over; the command was not stopped. */
246
+ backgroundedJobId?: string
175
247
  stdout: string
176
248
  stderr: string
177
249
  /** Bytes captured on each stream, before decoding; absent from a runner that does not count them. */
@@ -249,19 +321,31 @@ export function execHostShell(
249
321
  child.stdout?.on('data', onStdout)
250
322
  child.stderr?.on('data', onStderr)
251
323
  let spawned = false
252
- child.once('spawn', () => {
324
+ const onSpawn = () => {
253
325
  spawned = true
254
- })
255
- child.once('error', (error: NodeJS.ErrnoException) => {
326
+ }
327
+ child.once('spawn', onSpawn)
328
+ const onError = (error: NodeJS.ErrnoException) => {
256
329
  // Same words as a background job that cannot start.
257
330
  failure ??= spawned
258
331
  ? error
259
332
  : Object.assign(
260
- new Error(describeSpawnFailure(error, options.cwd), { cause: error }),
333
+ new Error(describeSpawnFailure(error, options.cwd), {
334
+ cause: error,
335
+ }),
261
336
  error.code === undefined ? {} : { code: error.code },
262
337
  )
263
- })
264
- child.once('close', (code, signal) => {
338
+ }
339
+ child.once('error', onError)
340
+ const decodeCapture = (stream: 'stdout' | 'stderr') => {
341
+ const state = captures[stream]
342
+ const bytes = Buffer.concat(state.chunks, state.bytes)
343
+ // Do not flush an incomplete character cut by our byte cap.
344
+ const decoder = createHostOutputDecoder()
345
+ const text = decoder.write(bytes)
346
+ return state.truncated ? text : text + decoder.end()
347
+ }
348
+ const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
265
349
  closed = true
266
350
  disposeAbort?.()
267
351
  clearTimeout(deadline)
@@ -277,14 +361,6 @@ export function execHostShell(
277
361
  const data = captures[stream].decoder.end()
278
362
  if (data) options.onOutput?.({ stream, data })
279
363
  }
280
- const decodeCapture = (stream: 'stdout' | 'stderr') => {
281
- const state = captures[stream]
282
- const bytes = Buffer.concat(state.chunks, state.bytes)
283
- // Do not flush an incomplete character cut by our byte cap.
284
- const decoder = createHostOutputDecoder()
285
- const text = decoder.write(bytes)
286
- return state.truncated ? text : text + decoder.end()
287
- }
288
364
  const stdout = decodeCapture('stdout')
289
365
  const stderr = decodeCapture('stderr')
290
366
  if (!failure && cause === undefined && code === 0 && signal === null) {
@@ -300,7 +376,9 @@ export function execHostShell(
300
376
  failure ??
301
377
  (cause === 'caller'
302
378
  ? Object.assign(
303
- new Error('The operation was aborted', { cause: options.signal?.reason }),
379
+ new Error('The operation was aborted', {
380
+ cause: options.signal?.reason,
381
+ }),
304
382
  {
305
383
  name: 'AbortError',
306
384
  code: 'ABORT_ERR',
@@ -321,8 +399,41 @@ export function execHostShell(
321
399
  timedOut: cause === 'timeout',
322
400
  }),
323
401
  )
324
- })
325
- const deadline = setTimeout(() => cancel('timeout'), options.timeout)
402
+ }
403
+ child.once('close', onClose)
404
+ const deadline = setTimeout(() => {
405
+ if (options.onTimeout && !closed && cause === undefined) {
406
+ let jobId: string | undefined
407
+ try {
408
+ jobId = options.onTimeout({
409
+ child,
410
+ initialOutput: (['stdout', 'stderr'] as const)
411
+ .map((stream) => ({ stream, text: decodeCapture(stream) }))
412
+ .filter((part) => part.text !== ''),
413
+ })
414
+ } catch {
415
+ // A host that cannot take the process over leaves the kill in place.
416
+ }
417
+ if (jobId !== undefined) {
418
+ // The registry owns the process from here: stop listening to it,
419
+ // so a later Stop or its exit is not this call's business.
420
+ closed = true
421
+ disposeAbort?.()
422
+ child.stdout?.off('data', onStdout)
423
+ child.stderr?.off('data', onStderr)
424
+ child.off('close', onClose)
425
+ child.off('error', onError)
426
+ child.off('spawn', onSpawn)
427
+ resolve({
428
+ backgroundedJobId: jobId,
429
+ stdout: decodeCapture('stdout'),
430
+ stderr: decodeCapture('stderr'),
431
+ })
432
+ return
433
+ }
434
+ }
435
+ cancel('timeout')
436
+ }, options.timeout)
326
437
  deadline.unref?.()
327
438
  if (options.signal) {
328
439
  disposeAbort = subscribeToAbort(options.signal, () => cancel('caller'))
@@ -372,9 +483,12 @@ const BashToolBase = defineTool({
372
483
  // confirms it only by id; see `ToolDefinition.sandboxEscapeArgument`.
373
484
  sandboxEscapeArgument: 'dangerously_disable_sandbox',
374
485
  permissions: ['shell_execute'],
375
- readOnly: false,
486
+ // A line made only of vetted read-only commands (`ls`, `cat`, `git status`,
487
+ // `git diff`, ...) needs no prompt and may run beside other reads; see
488
+ // `authorization/readonly-commands.ts`. Anything else is not read-only.
489
+ readOnly: (input: BashInput) => bashInputIsReadOnly(input),
376
490
  destructive: (input: BashInput) => isDangerousCommand(input.command),
377
- concurrencySafe: false,
491
+ concurrencySafe: (input: BashInput) => bashInputIsReadOnly(input),
378
492
  // Above the ceiling the input schema accepts, so the executor's deadline
379
493
  // is a backstop rather than a second clock racing this tool's own. It used
380
494
  // to be undefined, which meant the executor's generic default applied —
@@ -424,6 +538,14 @@ const BashToolBase = defineTool({
424
538
  }
425
539
  }
426
540
 
541
+ if (input.run_in_background && backgroundTasksDisabled()) {
542
+ return {
543
+ success: false,
544
+ output: '',
545
+ error: `Background jobs are switched off here (${DISABLE_BACKGROUND_TASKS_ENV}). Run the command in the foreground, or raise \`timeout\` up to the tool maximum.`,
546
+ }
547
+ }
548
+
427
549
  if (input.run_in_background) {
428
550
  // A sandboxed turn gets a registry only when its sandbox can start a
429
551
  // detached process inside the boundary (`Sandbox.spawnDetached`);
@@ -578,19 +700,66 @@ const BashToolBase = defineTool({
578
700
  // the sandbox, and for what it therefore does not catch.
579
701
  const inherited = scrubInheritedEnv()
580
702
 
703
+ // Where a job registry can take the process over, running out of time
704
+ // moves the command to the background instead of killing it: a build
705
+ // stopped at two minutes loses everything it did. Nowhere else (no
706
+ // registry, a sandboxed turn, a one-off sandbox escape, a plain wait)
707
+ // is there anything to hand the process to, so those keep the kill.
708
+ const adopt = context.backgroundJobs?.adopt?.bind(context.backgroundJobs)
709
+ const canMoveToBackground =
710
+ adopt !== undefined &&
711
+ context.sandbox === undefined &&
712
+ !backgroundTasksDisabled() &&
713
+ !startsWithSleep(input.command)
714
+
581
715
  try {
582
- const { stdout, stderr, stdoutBytes, stderrBytes } = await execHostShell(input.command, {
583
- cwd: context.workingDirectory,
584
- timeout: input.timeout,
585
- env: { ...inherited.env, ...context.env },
586
- maxBuffer: DEFAULT_BASH_MAX_BUFFER_BYTES,
587
- signal: context.abortSignal,
588
- onOutput,
589
- })
716
+ const { stdout, stderr, stdoutBytes, stderrBytes, backgroundedJobId } = await execHostShell(
717
+ input.command,
718
+ {
719
+ cwd: context.workingDirectory,
720
+ timeout: input.timeout,
721
+ env: { ...inherited.env, ...context.env },
722
+ maxBuffer: DEFAULT_BASH_MAX_BUFFER_BYTES,
723
+ signal: context.abortSignal,
724
+ onOutput,
725
+ ...(canMoveToBackground
726
+ ? {
727
+ onTimeout: ({ child, initialOutput }) =>
728
+ adopt({
729
+ command: input.command,
730
+ child,
731
+ workingDirectory: context.workingDirectory,
732
+ initialOutput,
733
+ }).id,
734
+ }
735
+ : {}),
736
+ },
737
+ )
738
+ if (backgroundedJobId !== undefined) {
739
+ return {
740
+ success: true,
741
+ output: [
742
+ formatShellOutput(stdout, stderr),
743
+ movedToBackgroundMessage(backgroundedJobId, input.timeout),
744
+ ]
745
+ .filter(Boolean)
746
+ .join('\n\n'),
747
+ data: {
748
+ jobId: backgroundedJobId,
749
+ background: true,
750
+ timedOut: true,
751
+ movedToBackground: true,
752
+ },
753
+ facts: { kind: 'command', command: input.command, background: true },
754
+ }
755
+ }
590
756
  return {
591
757
  success: true,
592
758
  output: formatShellOutput(stdout, stderr) || '(no output)',
593
- data: { exitCode: 0, ...(leaveSandbox ? { sandboxed: false, sandboxEscape: true } : {}) },
759
+ data: {
760
+ exitCode: 0,
761
+ ...(leaveSandbox ? { sandboxed: false, sandboxEscape: true } : {}),
762
+ },
594
763
  facts: commandFacts({
595
764
  command: input.command,
596
765
  exitCode: 0,
@@ -28,3 +28,8 @@ export function fingerprintContent(content: string): string {
28
28
  export function staleFileError(path: string, operation: 'edit' | 'write' = 'edit'): string {
29
29
  return `${path} changed on disk after you read it, so this ${operation} was based on a stale copy and was not applied. Read the file again and redo the ${operation} against its current contents. Nothing was written.`
30
30
  }
31
+
32
+ /** What a mutation tool tells the agent when the session never read the file. */
33
+ export function unreadFileError(path: string, operation: 'edit' | 'write' = 'edit'): string {
34
+ return `${path} has not been read yet. Read it first, then redo the ${operation}. Nothing was written.`
35
+ }
@@ -5,8 +5,8 @@ import type { ToolContext, ToolResult } from '../../types/tool/index.js'
5
5
  import { defineTool } from '../defineTool.js'
6
6
  import { resolveWithinAnyReal, toolRoots } from '../paths.js'
7
7
  import { atomicWriteFile } from './atomic-write-file.js'
8
- import { fingerprintContent, staleFileError } from './content-fingerprint.js'
9
- import { applyEdit, normalizeEditInput } from './edit-apply.js'
8
+ import { fingerprintContent, staleFileError, unreadFileError } from './content-fingerprint.js'
9
+ import { type NormalizedEditInput, applyEdit, normalizeEditInput } from './edit-apply.js'
10
10
  import { withFileMutationLock } from './file-mutation-lock.js'
11
11
  import { jsonStringEscapes } from './json-string-hint.js'
12
12
 
@@ -151,7 +151,10 @@ export function dryRunEdit(
151
151
  ): { success: true; content: string } | { success: false; error: string } {
152
152
  const parsed = inputSchema.safeParse(input)
153
153
  if (!parsed.success) {
154
- return { success: false, error: parsed.error.issues.map((issue) => issue.message).join('; ') }
154
+ return {
155
+ success: false,
156
+ error: parsed.error.issues.map((issue) => issue.message).join('; '),
157
+ }
155
158
  }
156
159
  const normalized = normalizeEditInput(parsed.data)
157
160
  if (!normalized.success) return normalized
@@ -161,7 +164,10 @@ export function dryRunEdit(
161
164
  operation.operation === 'replace' && operation.oldString === operation.newString,
162
165
  )
163
166
  ) {
164
- return { success: false, error: 'old_string and new_string are identical — no change needed' }
167
+ return {
168
+ success: false,
169
+ error: 'old_string and new_string are identical — no change needed',
170
+ }
165
171
  }
166
172
  const result = applyEdit(content, normalized.operations)
167
173
  return result.success ? { success: true, content: result.content } : result
@@ -249,7 +255,10 @@ const modelInputSchema: Record<string, unknown> = {
249
255
  type: 'string',
250
256
  description: 'Exact text from the file at this point in the sequence.',
251
257
  },
252
- new_string: { type: 'string', description: 'Exact replacement text.' },
258
+ new_string: {
259
+ type: 'string',
260
+ description: 'Exact replacement text.',
261
+ },
253
262
  replace_all: {
254
263
  type: 'boolean',
255
264
  description: 'Replace every occurrence of this old_string.',
@@ -322,6 +331,46 @@ function recordDrift(context: ToolContext, key: string): void {
322
331
  context.fileReadTracker?.recordDriftObserved?.(key)
323
332
  }
324
333
 
334
+ type EditAdmission =
335
+ | { success: false; output: ''; error: string }
336
+ | { success: true; result: ReturnType<typeof applyEdit> }
337
+
338
+ /**
339
+ * Decide whether this edit may run against the body now on disk, and apply it.
340
+ *
341
+ * Two rules, in this order:
342
+ *
343
+ * 1. A file the session never read is refused. The model is about to rewrite
344
+ * text it has not seen, which is the commonest source of a wrong patch. A
345
+ * host that supplies no tracker has no ledger to ask and is not refused.
346
+ * 2. A file that moved since the read is refused UNLESS every operation is a
347
+ * text replacement that still applies to the current body — uniquely,
348
+ * unless `replace_all` was asked for. A formatter run between the read and
349
+ * the edit then costs nothing. An insertion by line number is never
350
+ * recovered: the line it named may now be a different line.
351
+ */
352
+ function admitAgainstRead(
353
+ context: ToolContext,
354
+ key: string,
355
+ content: string,
356
+ operations: readonly NormalizedEditInput[],
357
+ ): EditAdmission {
358
+ const tracker = context.fileReadTracker
359
+ if (tracker && !tracker.hasRead(key)) {
360
+ return { success: false, output: '', error: unreadFileError(key, 'edit') }
361
+ }
362
+ const seen = tracker?.fingerprint?.(key)
363
+ const applied = applyEdit(content, operations)
364
+ if (seen !== undefined && seen !== fingerprintContent(content)) {
365
+ const recoverable = applied.success && operations.every((o) => o.operation === 'replace')
366
+ if (!recoverable) {
367
+ recordDrift(context, key)
368
+ return { success: false, output: '', error: staleFileError(key) }
369
+ }
370
+ }
371
+ return { success: true, result: applied }
372
+ }
373
+
325
374
  const EDIT_SHAPES =
326
375
  'Three shapes. Replace: {"path":"file.md","old_string":"exact unique text","new_string":"replacement text"} (optional "replace_all": true). Insert: {"path":"file.md","insertLine":"end","new_string":"text to add"} where insertLine is a non-negative line number or "end". Batch: {"path":"file.md","edits":[{"old_string":"a","new_string":"b"},{"old_string":"c","new_string":"d"}]} applied in order, all or nothing. Exactly one of old_string, insertLine or edits — a call carrying more than one of them is refused rather than resolved.'
327
376
 
@@ -381,7 +430,10 @@ export const EditTool = defineTool({
381
430
  * decide this by matching two names.
382
431
  */
383
432
  presentResult(_input: EditInput, result: ToolResult) {
384
- return { kind: 'generic' as const, label: result.output?.split('\n')[0] ?? '' }
433
+ return {
434
+ kind: 'generic' as const,
435
+ label: result.output?.split('\n')[0] ?? '',
436
+ }
385
437
  },
386
438
 
387
439
  async execute(input: EditInput, context) {
@@ -439,12 +491,9 @@ export const EditTool = defineTool({
439
491
  if (context.sandbox) {
440
492
  const buffer = await context.sandbox.readFile(parsed.data.path)
441
493
  const content = buffer.toString('utf-8')
442
- const seen = context.fileReadTracker?.fingerprint?.(parsed.data.path)
443
- if (seen !== undefined && seen !== fingerprintContent(content)) {
444
- recordDrift(context, parsed.data.path)
445
- return { success: false as const, output: '', error: staleFileError(parsed.data.path) }
446
- }
447
- const result = applyEdit(content, normalized.operations)
494
+ const admitted = admitAgainstRead(context, parsed.data.path, content, normalized.operations)
495
+ if (!admitted.success) return admitted
496
+ const result = admitted.result
448
497
  if (!result.success) {
449
498
  return { success: false as const, output: '', error: result.error }
450
499
  }
@@ -454,7 +503,11 @@ export const EditTool = defineTool({
454
503
  return {
455
504
  success: true as const,
456
505
  output: `Edited ${parsed.data.path}: ${result.replacements} replacement(s) [sandboxed]`,
457
- data: { path: parsed.data.path, replacements: result.replacements, sandboxed: true },
506
+ data: {
507
+ path: parsed.data.path,
508
+ replacements: result.replacements,
509
+ sandboxed: true,
510
+ },
458
511
  facts: {
459
512
  kind: 'edit' as const,
460
513
  path: parsed.data.path,
@@ -466,12 +519,9 @@ export const EditTool = defineTool({
466
519
  const hostPath = filePath as string
467
520
  const content = await readFile(hostPath, 'utf-8')
468
521
 
469
- const seen = context.fileReadTracker?.fingerprint?.(hostPath)
470
- if (seen !== undefined && seen !== fingerprintContent(content)) {
471
- recordDrift(context, hostPath)
472
- return { success: false as const, output: '', error: staleFileError(hostPath) }
473
- }
474
- const result = applyEdit(content, normalized.operations)
522
+ const admitted = admitAgainstRead(context, hostPath, content, normalized.operations)
523
+ if (!admitted.success) return admitted
524
+ const result = admitted.result
475
525
  if (!result.success) return { success: false as const, output: '', error: result.error }
476
526
 
477
527
  // Temp file, fsync, rename — a reader sees the old body or the new