@namzu/sdk 57.1.0 → 58.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +2 -0
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/agents/file-definitions.d.ts +35 -4
  6. package/dist/agents/file-definitions.d.ts.map +1 -1
  7. package/dist/agents/file-definitions.js +98 -8
  8. package/dist/agents/file-definitions.js.map +1 -1
  9. package/dist/authorization/protected-paths.d.ts +9 -0
  10. package/dist/authorization/protected-paths.d.ts.map +1 -1
  11. package/dist/authorization/protected-paths.js +29 -3
  12. package/dist/authorization/protected-paths.js.map +1 -1
  13. package/dist/compaction/context-window.d.ts +1 -1
  14. package/dist/compaction/context-window.d.ts.map +1 -1
  15. package/dist/compaction/context-window.js +8 -1
  16. package/dist/compaction/context-window.js.map +1 -1
  17. package/dist/compaction/plan.d.ts.map +1 -1
  18. package/dist/compaction/plan.js +2 -2
  19. package/dist/compaction/plan.js.map +1 -1
  20. package/dist/config/runtime.d.ts +10 -0
  21. package/dist/config/runtime.d.ts.map +1 -1
  22. package/dist/config/runtime.js +2 -0
  23. package/dist/config/runtime.js.map +1 -1
  24. package/dist/connector/mcp/client.d.ts +8 -0
  25. package/dist/connector/mcp/client.d.ts.map +1 -1
  26. package/dist/connector/mcp/client.js +75 -2
  27. package/dist/connector/mcp/client.js.map +1 -1
  28. package/dist/constants/mcp/index.d.ts +2 -0
  29. package/dist/constants/mcp/index.d.ts.map +1 -1
  30. package/dist/constants/mcp/index.js +2 -0
  31. package/dist/constants/mcp/index.js.map +1 -1
  32. package/dist/constants/plugin/index.d.ts +2 -0
  33. package/dist/constants/plugin/index.d.ts.map +1 -1
  34. package/dist/constants/plugin/index.js +2 -0
  35. package/dist/constants/plugin/index.js.map +1 -1
  36. package/dist/manager/agent/lifecycle.d.ts +1 -1
  37. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  38. package/dist/manager/agent/lifecycle.js +25 -4
  39. package/dist/manager/agent/lifecycle.js.map +1 -1
  40. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  41. package/dist/manager/session/turn-recorder.js +2 -1
  42. package/dist/manager/session/turn-recorder.js.map +1 -1
  43. package/dist/persona/assembler.d.ts.map +1 -1
  44. package/dist/persona/assembler.js +2 -2
  45. package/dist/persona/assembler.js.map +1 -1
  46. package/dist/plugin/index.d.ts +1 -1
  47. package/dist/plugin/index.d.ts.map +1 -1
  48. package/dist/plugin/lifecycle.d.ts +8 -1
  49. package/dist/plugin/lifecycle.d.ts.map +1 -1
  50. package/dist/plugin/lifecycle.js +9 -2
  51. package/dist/plugin/lifecycle.js.map +1 -1
  52. package/dist/plugin/shell-hook-http.d.ts +39 -0
  53. package/dist/plugin/shell-hook-http.d.ts.map +1 -0
  54. package/dist/plugin/shell-hook-http.js +163 -0
  55. package/dist/plugin/shell-hook-http.js.map +1 -0
  56. package/dist/plugin/shell-hook.d.ts +93 -11
  57. package/dist/plugin/shell-hook.d.ts.map +1 -1
  58. package/dist/plugin/shell-hook.js +223 -39
  59. package/dist/plugin/shell-hook.js.map +1 -1
  60. package/dist/public-runtime.d.ts +2 -2
  61. package/dist/public-runtime.d.ts.map +1 -1
  62. package/dist/public-runtime.js +2 -2
  63. package/dist/public-runtime.js.map +1 -1
  64. package/dist/public-tools.d.ts +1 -0
  65. package/dist/public-tools.d.ts.map +1 -1
  66. package/dist/public-tools.js +2 -0
  67. package/dist/public-tools.js.map +1 -1
  68. package/dist/public-types.d.ts +1 -1
  69. package/dist/public-types.d.ts.map +1 -1
  70. package/dist/runtime/query/executor.d.ts +1 -0
  71. package/dist/runtime/query/executor.d.ts.map +1 -1
  72. package/dist/runtime/query/executor.js +34 -9
  73. package/dist/runtime/query/executor.js.map +1 -1
  74. package/dist/runtime/query/index.d.ts +2 -0
  75. package/dist/runtime/query/index.d.ts.map +1 -1
  76. package/dist/runtime/query/index.js +25 -1
  77. package/dist/runtime/query/index.js.map +1 -1
  78. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  79. package/dist/runtime/query/iteration/index.js +47 -10
  80. package/dist/runtime/query/iteration/index.js.map +1 -1
  81. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts +18 -8
  82. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts.map +1 -1
  83. package/dist/runtime/query/iteration/phases/compaction-breaker.js +33 -9
  84. package/dist/runtime/query/iteration/phases/compaction-breaker.js.map +1 -1
  85. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  86. package/dist/runtime/query/iteration/phases/compaction.js +7 -5
  87. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  88. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  89. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  90. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  91. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  92. package/dist/runtime/query/iteration/phases/tool-review.js +70 -3
  93. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  94. package/dist/runtime/query/path-escalation.d.ts +2 -0
  95. package/dist/runtime/query/path-escalation.d.ts.map +1 -1
  96. package/dist/runtime/query/path-escalation.js +7 -4
  97. package/dist/runtime/query/path-escalation.js.map +1 -1
  98. package/dist/runtime/query/plugin-hooks.js +2 -2
  99. package/dist/runtime/query/plugin-hooks.js.map +1 -1
  100. package/dist/runtime/query/prepare-turn.d.ts +2 -0
  101. package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
  102. package/dist/runtime/query/prepare-turn.js +25 -0
  103. package/dist/runtime/query/prepare-turn.js.map +1 -1
  104. package/dist/runtime/query/review-policy.d.ts +3 -2
  105. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  106. package/dist/runtime/query/review-policy.js +32 -13
  107. package/dist/runtime/query/review-policy.js.map +1 -1
  108. package/dist/session/retention/archive.js +1 -1
  109. package/dist/session/retention/archive.js.map +1 -1
  110. package/dist/session/workspace/driver.d.ts +6 -0
  111. package/dist/session/workspace/driver.d.ts.map +1 -1
  112. package/dist/session/workspace/git-worktree.d.ts.map +1 -1
  113. package/dist/session/workspace/git-worktree.js +28 -0
  114. package/dist/session/workspace/git-worktree.js.map +1 -1
  115. package/dist/skills/index.d.ts +1 -1
  116. package/dist/skills/index.d.ts.map +1 -1
  117. package/dist/skills/index.js +1 -1
  118. package/dist/skills/index.js.map +1 -1
  119. package/dist/skills/loader.d.ts +7 -0
  120. package/dist/skills/loader.d.ts.map +1 -1
  121. package/dist/skills/loader.js +53 -0
  122. package/dist/skills/loader.js.map +1 -1
  123. package/dist/skills/registry.d.ts +1 -1
  124. package/dist/skills/registry.d.ts.map +1 -1
  125. package/dist/skills/registry.js +2 -1
  126. package/dist/skills/registry.js.map +1 -1
  127. package/dist/tools/builtins/bash-cwd.d.ts +52 -0
  128. package/dist/tools/builtins/bash-cwd.d.ts.map +1 -0
  129. package/dist/tools/builtins/bash-cwd.js +127 -0
  130. package/dist/tools/builtins/bash-cwd.js.map +1 -0
  131. package/dist/tools/builtins/bash.d.ts.map +1 -1
  132. package/dist/tools/builtins/bash.js +41 -6
  133. package/dist/tools/builtins/bash.js.map +1 -1
  134. package/dist/tools/builtins/bounded-file-read.d.ts +10 -0
  135. package/dist/tools/builtins/bounded-file-read.d.ts.map +1 -0
  136. package/dist/tools/builtins/bounded-file-read.js +67 -0
  137. package/dist/tools/builtins/bounded-file-read.js.map +1 -0
  138. package/dist/tools/builtins/grep.d.ts +6 -1
  139. package/dist/tools/builtins/grep.d.ts.map +1 -1
  140. package/dist/tools/builtins/grep.js +187 -24
  141. package/dist/tools/builtins/grep.js.map +1 -1
  142. package/dist/tools/builtins/read-file.d.ts +1 -0
  143. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  144. package/dist/tools/builtins/read-file.js +113 -28
  145. package/dist/tools/builtins/read-file.js.map +1 -1
  146. package/dist/tools/builtins/read-media.d.ts +68 -0
  147. package/dist/tools/builtins/read-media.d.ts.map +1 -0
  148. package/dist/tools/builtins/read-media.js +397 -0
  149. package/dist/tools/builtins/read-media.js.map +1 -0
  150. package/dist/tools/builtins/search-tools.js +1 -1
  151. package/dist/tools/builtins/search-tools.js.map +1 -1
  152. package/dist/tools/builtins/skill.d.ts +4 -2
  153. package/dist/tools/builtins/skill.d.ts.map +1 -1
  154. package/dist/tools/builtins/skill.js +61 -2
  155. package/dist/tools/builtins/skill.js.map +1 -1
  156. package/dist/tools/builtins/sleep-guard.d.ts +15 -0
  157. package/dist/tools/builtins/sleep-guard.d.ts.map +1 -0
  158. package/dist/tools/builtins/sleep-guard.js +33 -0
  159. package/dist/tools/builtins/sleep-guard.js.map +1 -0
  160. package/dist/tools/builtins/view-image.d.ts +23 -1
  161. package/dist/tools/builtins/view-image.d.ts.map +1 -1
  162. package/dist/tools/builtins/view-image.js +66 -58
  163. package/dist/tools/builtins/view-image.js.map +1 -1
  164. package/dist/tools/file-read-tracker.d.ts.map +1 -1
  165. package/dist/tools/file-read-tracker.js +31 -0
  166. package/dist/tools/file-read-tracker.js.map +1 -1
  167. package/dist/toolsets/manager.js +3 -3
  168. package/dist/toolsets/manager.js.map +1 -1
  169. package/dist/types/agent/query.d.ts +2 -0
  170. package/dist/types/agent/query.d.ts.map +1 -1
  171. package/dist/types/agent/task.d.ts +6 -2
  172. package/dist/types/agent/task.d.ts.map +1 -1
  173. package/dist/types/agent/task.js.map +1 -1
  174. package/dist/types/connector/mcp.d.ts +46 -0
  175. package/dist/types/connector/mcp.d.ts.map +1 -1
  176. package/dist/types/hitl/index.d.ts +1 -1
  177. package/dist/types/plugin/index.d.ts +72 -5
  178. package/dist/types/plugin/index.d.ts.map +1 -1
  179. package/dist/types/plugin/index.js +12 -1
  180. package/dist/types/plugin/index.js.map +1 -1
  181. package/dist/types/session/sub-session.d.ts +6 -2
  182. package/dist/types/session/sub-session.d.ts.map +1 -1
  183. package/dist/types/skills/index.d.ts +36 -0
  184. package/dist/types/skills/index.d.ts.map +1 -1
  185. package/dist/types/skills/index.js +9 -0
  186. package/dist/types/skills/index.js.map +1 -1
  187. package/dist/types/tool/index.d.ts +22 -0
  188. package/dist/types/tool/index.d.ts.map +1 -1
  189. package/dist/types/tool/index.js.map +1 -1
  190. package/dist/types/workspace/ref.d.ts +2 -0
  191. package/dist/types/workspace/ref.d.ts.map +1 -1
  192. package/dist/utils/frontmatter.d.ts +12 -0
  193. package/dist/utils/frontmatter.d.ts.map +1 -1
  194. package/dist/utils/frontmatter.js +6 -0
  195. package/dist/utils/frontmatter.js.map +1 -1
  196. package/package.json +1 -1
  197. package/src/agents/QueryAgent.ts +2 -0
  198. package/src/agents/file-definitions.ts +126 -6
  199. package/src/authorization/protected-paths.ts +30 -2
  200. package/src/compaction/context-window.ts +10 -0
  201. package/src/compaction/plan.ts +12 -2
  202. package/src/config/runtime.ts +2 -0
  203. package/src/connector/mcp/client.ts +87 -1
  204. package/src/constants/mcp/index.ts +3 -0
  205. package/src/constants/plugin/index.ts +2 -0
  206. package/src/manager/agent/lifecycle.ts +24 -5
  207. package/src/manager/session/turn-recorder.ts +2 -1
  208. package/src/persona/assembler.ts +2 -2
  209. package/src/plugin/index.ts +5 -0
  210. package/src/plugin/lifecycle.ts +16 -2
  211. package/src/plugin/shell-hook-http.ts +181 -0
  212. package/src/plugin/shell-hook.ts +386 -89
  213. package/src/public-runtime.ts +2 -0
  214. package/src/public-tools.ts +2 -0
  215. package/src/public-types.ts +5 -0
  216. package/src/runtime/query/executor.ts +40 -10
  217. package/src/runtime/query/index.ts +33 -0
  218. package/src/runtime/query/iteration/index.ts +51 -11
  219. package/src/runtime/query/iteration/phases/compaction-breaker.ts +38 -9
  220. package/src/runtime/query/iteration/phases/compaction.ts +27 -5
  221. package/src/runtime/query/iteration/phases/context.ts +2 -0
  222. package/src/runtime/query/iteration/phases/tool-review.ts +74 -3
  223. package/src/runtime/query/path-escalation.ts +11 -5
  224. package/src/runtime/query/plugin-hooks.ts +2 -2
  225. package/src/runtime/query/prepare-turn.ts +33 -0
  226. package/src/runtime/query/review-policy.ts +45 -16
  227. package/src/session/retention/archive.ts +1 -1
  228. package/src/session/workspace/driver.ts +6 -0
  229. package/src/session/workspace/git-worktree.ts +31 -0
  230. package/src/skills/index.ts +6 -1
  231. package/src/skills/loader.ts +54 -0
  232. package/src/skills/registry.ts +7 -6
  233. package/src/tools/builtins/bash-cwd.ts +156 -0
  234. package/src/tools/builtins/bash.ts +79 -34
  235. package/src/tools/builtins/bounded-file-read.ts +80 -0
  236. package/src/tools/builtins/grep.ts +198 -28
  237. package/src/tools/builtins/read-file.ts +151 -30
  238. package/src/tools/builtins/read-media.ts +483 -0
  239. package/src/tools/builtins/search-tools.ts +1 -1
  240. package/src/tools/builtins/skill.ts +91 -2
  241. package/src/tools/builtins/sleep-guard.ts +31 -0
  242. package/src/tools/builtins/view-image.ts +74 -62
  243. package/src/tools/file-read-tracker.ts +30 -0
  244. package/src/toolsets/manager.ts +3 -3
  245. package/src/types/agent/query.ts +2 -0
  246. package/src/types/agent/task.ts +6 -2
  247. package/src/types/connector/mcp.ts +48 -0
  248. package/src/types/hitl/index.ts +1 -1
  249. package/src/types/plugin/index.ts +102 -16
  250. package/src/types/session/sub-session.ts +6 -2
  251. package/src/types/skills/index.ts +48 -0
  252. package/src/types/tool/index.ts +22 -0
  253. package/src/types/workspace/ref.ts +2 -0
  254. package/src/utils/frontmatter.ts +21 -0
@@ -1,3 +1,4 @@
1
+ import { rm } from 'node:fs/promises'
1
2
  import { z } from 'zod'
2
3
  import { isReadOnlyCommandLine } from '../../authorization/readonly-commands.js'
3
4
  import { SANDBOX_KILL_GRACE_MS } from '../../constants/sandbox/index.js'
@@ -24,8 +25,15 @@ import { defineTool } from '../defineTool.js'
24
25
  import { scrubInheritedEnv, withheldEnvNoticeText } from '../env-scrub.js'
25
26
  import { createHostOutputDecoder } from '../host-output.js'
26
27
  import { looksBinary, windowText } from '../text-window.js'
28
+ import {
29
+ bashWorkingDirectoryForExecution,
30
+ newCwdFile,
31
+ recordDirectory,
32
+ withCwdCapture,
33
+ } from './bash-cwd.js'
27
34
  import { judgeExit } from './exit-semantics.js'
28
35
  import { jsonStringEscapes } from './json-string-hint.js'
36
+ import { leadingLongSleep, longSleepRefusal } from './sleep-guard.js'
29
37
 
30
38
  // Namzu owns its own bash timeout knob — `NAMZU_BASH_TIMEOUT_MS` — and
31
39
  // reads no consumer's env name. A consumer that wants one knob aliases
@@ -526,7 +534,7 @@ export const SANDBOX_ESCAPE_NOT_APPROVED =
526
534
  'dangerously_disable_sandbox was not approved for this call, so the command did not run. Leaving the sandbox needs the user to confirm it each time, and this turn either cannot ask or does not allow it. Run the command inside the sandbox, or tell the user what it needs and let them run it.'
527
535
 
528
536
  const BASH_DESCRIPTION =
529
- 'Executes a command line using the execution shell disclosed in the system context and returns stdout/stderr output. Command timeout is configurable. The `command` parameter is required, so every call carries one. Long file content goes through `write`, not a heredoc, so it cannot hit the output token limit mid-stream.'
537
+ 'Executes a command line using the execution shell disclosed in the system context and returns stdout/stderr output. The working directory persists between calls, but shell state does not. Command timeout is configurable. The `command` parameter is required, so every call carries one. Long file content goes through `write`, not a heredoc, so it cannot hit the output token limit mid-stream.'
530
538
 
531
539
  const BashToolBase = defineTool({
532
540
  name: 'bash',
@@ -579,6 +587,7 @@ const BashToolBase = defineTool({
579
587
  },
580
588
 
581
589
  async execute(input, context) {
590
+ const startDirectory = bashWorkingDirectoryForExecution(context)
582
591
  if (isDangerousCommand(input.command)) {
583
592
  return {
584
593
  success: false,
@@ -588,6 +597,15 @@ const BashToolBase = defineTool({
588
597
  }
589
598
  }
590
599
 
600
+ // Where a job registry exists to wait on, a long leading sleep only
601
+ // holds the turn; it is refused with the better ways to wait.
602
+ if (!input.run_in_background && context.backgroundJobs && !backgroundTasksDisabled()) {
603
+ const longSleep = leadingLongSleep(input.command)
604
+ if (longSleep !== null) {
605
+ return { success: false, output: '', error: longSleepRefusal(longSleep) }
606
+ }
607
+ }
608
+
591
609
  // Asked to leave the sandbox. Honoured only on the executor's word that
592
610
  // a reviewer confirmed THIS call (`sandboxEscapeApproved`); the input
593
611
  // alone is the model's request, never the permission. Without a
@@ -648,7 +666,7 @@ const BashToolBase = defineTool({
648
666
  try {
649
667
  const job = context.backgroundJobs.start({
650
668
  command: input.command,
651
- workingDirectory: context.workingDirectory,
669
+ workingDirectory: startDirectory,
652
670
  })
653
671
  // `start` returns before the process exists, and a spawn that
654
672
  // fails (no such shell, bad directory) reports a tick later.
@@ -799,38 +817,57 @@ const BashToolBase = defineTool({
799
817
  let withdrawOffer: (() => void) | undefined
800
818
  const offer = context.backgroundJobs?.registerForeground?.bind(context.backgroundJobs)
801
819
 
820
+ // The directory carries over between calls of a session on a POSIX host
821
+ // shell; the shell records where it ended, read back in `finally`.
822
+ const hostShell = hostCommandShell()
823
+ const carryDirectory =
824
+ context.sandbox === undefined &&
825
+ typeof context.sessionId === 'string' &&
826
+ hostShell.path !== undefined &&
827
+ hostShell.windows !== true
828
+ const cwdFile = carryDirectory ? newCwdFile() : undefined
829
+ let settled = false
830
+ const settle = async (): Promise<string | undefined> => {
831
+ if (cwdFile === undefined) return undefined
832
+ settled = true
833
+ return recordDirectory(context.sessionId, cwdFile, context)
834
+ }
835
+
802
836
  try {
803
837
  const { stdout, stderr, stdoutBytes, stderrBytes, backgroundedJobId, backgroundedBy } =
804
- await execHostShell(input.command, {
805
- cwd: context.workingDirectory,
806
- timeout: input.timeout,
807
- env: { ...inherited.env, ...context.env },
808
- maxBuffer: DEFAULT_BASH_MAX_BUFFER_BYTES,
809
- signal: context.abortSignal,
810
- onOutput,
811
- ...(canMoveOnTimeout ? { onTimeout: adoptRunning } : {}),
812
- ...(canHandOff
813
- ? {
814
- adopt: adoptRunning,
815
- adoptOnInterrupt: true,
816
- ...(offer
817
- ? {
818
- onRunning: (control: {
819
- background(reason: 'user' | 'message'): boolean
820
- }) => {
821
- offerTimer = setTimeout(() => {
822
- withdrawOffer = offer({
823
- command: input.command,
824
- background: (reason) => control.background(reason),
825
- })
826
- }, FOREGROUND_HANDOFF_AFTER_MS)
827
- offerTimer.unref?.()
828
- },
829
- }
830
- : {}),
831
- }
832
- : {}),
833
- })
838
+ await execHostShell(
839
+ cwdFile === undefined ? input.command : withCwdCapture(input.command, cwdFile),
840
+ {
841
+ cwd: startDirectory,
842
+ timeout: input.timeout,
843
+ env: { ...inherited.env, ...context.env },
844
+ maxBuffer: DEFAULT_BASH_MAX_BUFFER_BYTES,
845
+ signal: context.abortSignal,
846
+ onOutput,
847
+ ...(canMoveOnTimeout ? { onTimeout: adoptRunning } : {}),
848
+ ...(canHandOff
849
+ ? {
850
+ adopt: adoptRunning,
851
+ adoptOnInterrupt: true,
852
+ ...(offer
853
+ ? {
854
+ onRunning: (control: {
855
+ background(reason: 'user' | 'message'): boolean
856
+ }) => {
857
+ offerTimer = setTimeout(() => {
858
+ withdrawOffer = offer({
859
+ command: input.command,
860
+ background: (reason) => control.background(reason),
861
+ })
862
+ }, FOREGROUND_HANDOFF_AFTER_MS)
863
+ offerTimer.unref?.()
864
+ },
865
+ }
866
+ : {}),
867
+ }
868
+ : {}),
869
+ },
870
+ )
834
871
  if (backgroundedJobId !== undefined) {
835
872
  const timedOut = backgroundedBy === 'timeout'
836
873
  return {
@@ -853,9 +890,10 @@ const BashToolBase = defineTool({
853
890
  facts: { kind: 'command', command: input.command, background: true, continued: true },
854
891
  }
855
892
  }
893
+ const reset = await settle()
856
894
  return {
857
895
  success: true,
858
- output: formatShellOutput(stdout, stderr) || '(no output)',
896
+ output: withResetNotice(formatShellOutput(stdout, stderr) || '(no output)', reset),
859
897
  data: {
860
898
  exitCode: 0,
861
899
  ...(leaveSandbox ? { sandboxed: false, sandboxEscape: true } : {}),
@@ -903,6 +941,7 @@ const BashToolBase = defineTool({
903
941
  const judged =
904
942
  !timedOut && exitCode !== undefined ? judgeExit(input.command, exitCode) : undefined
905
943
  const answered = judged !== undefined && !judged.isError
944
+ const reset = await settle()
906
945
  const output = [
907
946
  formatShellOutput(failure.stdout, failure.stderr),
908
947
  clipped.length > 0
@@ -914,7 +953,7 @@ const BashToolBase = defineTool({
914
953
 
915
954
  return {
916
955
  success: answered,
917
- output: output || '(no output)',
956
+ output: withResetNotice(output || '(no output)', reset),
918
957
  data: {
919
958
  ...(exitCode !== undefined ? { exitCode } : {}),
920
959
  ...(judged?.note ? { exitNote: judged.note } : {}),
@@ -949,6 +988,7 @@ const BashToolBase = defineTool({
949
988
  }),
950
989
  }
951
990
  } finally {
991
+ if (cwdFile !== undefined && !settled) await rm(cwdFile, { force: true })
952
992
  if (offerTimer !== undefined) clearTimeout(offerTimer)
953
993
  withdrawOffer?.()
954
994
  }
@@ -1002,6 +1042,11 @@ function formatShellOutput(stdout: string | undefined, stderr: string | undefine
1002
1042
  .join('\n\n')
1003
1043
  }
1004
1044
 
1045
+ /** The result text with the directory-reset line, when there is one, on its own last line. */
1046
+ function withResetNotice(output: string, notice: string | undefined): string {
1047
+ return notice === undefined ? output : `${output}\n${notice}`
1048
+ }
1049
+
1005
1050
  /**
1006
1051
  * The notice text for the credential-shaped variables left out of a command's
1007
1052
  * environment (see {@link withheldEnvNoticeText}). It is sent as a notice after
@@ -0,0 +1,80 @@
1
+ import { constants } from 'node:fs'
2
+ import { open } from 'node:fs/promises'
3
+
4
+ const CHUNK_BYTES = 64 * 1024
5
+
6
+ /** A hard input ceiling, independent of output windows and allow_large. */
7
+ export const TEXT_MAX_INPUT_BYTES = 32 * 1024 * 1024
8
+
9
+ /**
10
+ * Read at most the limit plus one sentinel byte, even if the file grows after
11
+ * stat. No allocation or request is derived from an untrusted file size.
12
+ * The first bounded chunk can choose a stricter limit from the file signature.
13
+ */
14
+ export async function readBoundedChunks(
15
+ read: (offset: number, length: number) => Promise<Buffer>,
16
+ maximum: number,
17
+ signal?: AbortSignal,
18
+ selectLimit?: (prefix: Buffer) => number,
19
+ ): Promise<Buffer> {
20
+ const chunks: Buffer[] = []
21
+ let offset = 0
22
+ let limit = maximum
23
+ for (;;) {
24
+ signal?.throwIfAborted()
25
+ const length = Math.min(CHUNK_BYTES, limit + 1 - offset)
26
+ const chunk = await read(offset, length)
27
+ signal?.throwIfAborted()
28
+ if (chunk.length > length)
29
+ throw new Error('The file backend returned more bytes than the requested bounded range.')
30
+ if (offset === 0 && selectLimit) limit = Math.min(maximum, selectLimit(chunk))
31
+ offset += chunk.length
32
+ if (offset > limit) throw inputTooLarge(limit)
33
+ chunks.push(chunk)
34
+ if (chunk.length < length) return Buffer.concat(chunks, offset)
35
+ }
36
+ }
37
+
38
+ export async function readBoundedFile(
39
+ path: string,
40
+ maximum: number,
41
+ signal?: AbortSignal,
42
+ selectLimit?: (prefix: Buffer) => number,
43
+ ): Promise<Buffer> {
44
+ // O_NONBLOCK prevents a substituted FIFO from blocking open before fstat.
45
+ signal?.throwIfAborted()
46
+ const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK)
47
+ try {
48
+ const info = await file.stat()
49
+ if (!info.isFile()) throw new Error('Read requires a regular file.')
50
+ if (info.size > maximum) throw inputTooLarge(maximum)
51
+ return await readBoundedChunks(
52
+ async (offset, length) => {
53
+ const buffer = Buffer.allocUnsafe(length)
54
+ let used = 0
55
+ while (used < length) {
56
+ signal?.throwIfAborted()
57
+ const { bytesRead } = await file.read(buffer, used, length - used, offset + used)
58
+ if (bytesRead === 0) break
59
+ used += bytesRead
60
+ }
61
+ return buffer.subarray(0, used)
62
+ },
63
+ maximum,
64
+ signal,
65
+ (prefix) => {
66
+ const limit = selectLimit?.(prefix) ?? maximum
67
+ if (info.size > limit) throw inputTooLarge(limit)
68
+ return limit
69
+ },
70
+ )
71
+ } finally {
72
+ await file.close()
73
+ }
74
+ }
75
+
76
+ function inputTooLarge(limit: number): Error {
77
+ return new Error(
78
+ `File exceeds the bounded read limit (${limit} bytes). Extract a smaller file with a shell command.`,
79
+ )
80
+ }
@@ -1,8 +1,9 @@
1
- import { readFile, stat } from 'node:fs/promises'
1
+ import { stat } from 'node:fs/promises'
2
2
  import { isAbsolute, join, relative, sep } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { walkFilesLocally } from '../../sandbox/file-walk.js'
5
5
  import type { SandboxWalkFilesOptions } from '../../types/sandbox/index.js'
6
+ import type { ToolContext } from '../../types/tool/index.js'
6
7
  import { subscribeToAbort } from '../../utils/abort.js'
7
8
  import { defineTool } from '../defineTool.js'
8
9
  import {
@@ -13,6 +14,7 @@ import {
13
14
  toolRoots,
14
15
  } from '../paths.js'
15
16
  import { relativePosix, resolveWithinPosix } from '../posix-path.js'
17
+ import { readBoundedChunks, readBoundedFile } from './bounded-file-read.js'
16
18
 
17
19
  /** A remote read has no signal parameter; stop consuming it when the turn ends. */
18
20
  async function readWithSignal(read: () => Promise<Buffer>, signal?: AbortSignal): Promise<Buffer> {
@@ -57,6 +59,46 @@ export const DEFAULT_HEAD_LIMIT = 250
57
59
  /** A matching or context line longer than this is replaced by a notice, not printed. */
58
60
  export const MAX_LINE_COLUMNS = 500
59
61
 
62
+ /** File kinds for the `type` argument: extensions (without the dot) and exact file names. */
63
+ const FILE_TYPES: Record<string, { ext: string[]; names?: string[] }> = {
64
+ js: { ext: ['js', 'jsx', 'mjs', 'cjs'] },
65
+ ts: { ext: ['ts', 'tsx', 'mts', 'cts'] },
66
+ py: { ext: ['py', 'pyi'] },
67
+ rust: { ext: ['rs'] },
68
+ go: { ext: ['go'] },
69
+ java: { ext: ['java'] },
70
+ kotlin: { ext: ['kt', 'kts'] },
71
+ swift: { ext: ['swift'] },
72
+ c: { ext: ['c', 'h'] },
73
+ cpp: { ext: ['cc', 'cpp', 'cxx', 'hpp', 'hh', 'hxx', 'h'] },
74
+ cs: { ext: ['cs'] },
75
+ rb: { ext: ['rb', 'rake', 'gemspec'] },
76
+ php: { ext: ['php'] },
77
+ sh: { ext: ['sh', 'bash', 'zsh'] },
78
+ md: { ext: ['md', 'markdown', 'mdx'] },
79
+ json: { ext: ['json', 'jsonc'] },
80
+ yaml: { ext: ['yml', 'yaml'] },
81
+ toml: { ext: ['toml'] },
82
+ html: { ext: ['html', 'htm'] },
83
+ css: { ext: ['css', 'scss', 'sass', 'less'] },
84
+ sql: { ext: ['sql'] },
85
+ xml: { ext: ['xml'] },
86
+ make: { ext: ['mk', 'mak'], names: ['Makefile', 'makefile', 'GNUmakefile'] },
87
+ docker: { ext: [], names: ['Dockerfile'] },
88
+ }
89
+ FILE_TYPES.javascript = FILE_TYPES.js as { ext: string[] }
90
+ FILE_TYPES.typescript = FILE_TYPES.ts as { ext: string[] }
91
+ FILE_TYPES.python = FILE_TYPES.py as { ext: string[] }
92
+ FILE_TYPES.rs = FILE_TYPES.rust as { ext: string[] }
93
+ FILE_TYPES.markdown = FILE_TYPES.md as { ext: string[] }
94
+
95
+ function matchesType(path: string, kind: { ext: string[]; names?: string[] }): boolean {
96
+ const name = path.slice(path.lastIndexOf('/') + 1)
97
+ if (kind.names?.includes(name)) return true
98
+ const dot = name.lastIndexOf('.')
99
+ return dot > 0 && kind.ext.includes(name.slice(dot + 1))
100
+ }
101
+
60
102
  const inputSchema = z.object({
61
103
  pattern: z.string().describe('Regular expression pattern to search for in file contents'),
62
104
  path: z
@@ -74,6 +116,24 @@ const inputSchema = z.object({
74
116
  .min(0)
75
117
  .default(0)
76
118
  .describe('Number of lines to show before and after each match'),
119
+ before_context: z.coerce
120
+ .number()
121
+ .int()
122
+ .min(0)
123
+ .optional()
124
+ .describe('Lines before each content match; overrides context_lines for this side.'),
125
+ after_context: z.coerce
126
+ .number()
127
+ .int()
128
+ .min(0)
129
+ .optional()
130
+ .describe('Lines after each content match; overrides context_lines for this side.'),
131
+ only_matching: z
132
+ .boolean()
133
+ .optional()
134
+ .describe(
135
+ 'In content mode, print each matched substring instead of the whole line. Ignores context.',
136
+ ),
77
137
  output_mode: z
78
138
  .enum(['files_with_matches', 'content', 'count'])
79
139
  .default('files_with_matches')
@@ -94,6 +154,18 @@ const inputSchema = z.object({
94
154
  .min(0)
95
155
  .default(0)
96
156
  .describe('Skip this many entries before applying head_limit, to page through results.'),
157
+ multiline: z
158
+ .boolean()
159
+ .optional()
160
+ .describe(
161
+ 'Let a pattern span lines: "." also matches a newline, and "^" and "$" match at line starts and ends. Content mode prints every line a match covers.',
162
+ ),
163
+ type: z
164
+ .string()
165
+ .optional()
166
+ .describe(
167
+ 'Only search files of this kind, e.g. "js", "ts", "py", "rust", "go", "java", "md". Narrows what include selects.',
168
+ ),
97
169
  max_results: z.coerce
98
170
  .number()
99
171
  .int()
@@ -117,7 +189,7 @@ function isBinaryContent(buffer: Buffer): boolean {
117
189
  export const GrepTool = defineTool({
118
190
  name: 'grep',
119
191
  description:
120
- 'Searches file contents using a regular expression. By default returns the paths of matching files, newest first; output_mode "content" returns matching lines with file paths, line numbers and optional context lines, and "count" returns the number of matching lines per file. Results are paged: head_limit (default 250, 0 for none) and offset. A line over 500 characters is replaced by a notice. Searches with a bounded traversal and identifies incomplete searches. Skips binary files, files over 5 MB, and symlinks. Choose the narrowest relevant directory.',
192
+ 'Searches file contents using a regular expression. By default returns the paths of matching files, newest first; output_mode "content" returns matching lines with file paths, line numbers and optional context lines, and "count" returns the number of matching lines per file. Set multiline for a pattern that spans lines and type (js, py, rust, ...) to search one kind of file. In content mode, before_context and after_context override symmetric context_lines; only_matching returns matched substrings without context. Results are paged: head_limit (default 250, 0 for none) and offset. A line over 500 characters is replaced by a notice. Searches with a bounded traversal and identifies incomplete searches. Skips binary files, files over 5 MB, and symlinks. Choose the narrowest relevant directory.',
121
193
  inputSchema,
122
194
  category: 'analysis',
123
195
  // Declared so a path outside the turn's roots can be reviewed before the
@@ -146,7 +218,18 @@ export const GrepTool = defineTool({
146
218
 
147
219
  async execute(input, context) {
148
220
  context.abortSignal?.throwIfAborted()
149
- const flags = input.case_sensitive ? 'g' : 'gi'
221
+ const multiline = input.multiline === true
222
+ const flags = `${(input.case_sensitive ?? true) ? 'g' : 'gi'}${multiline ? 'ms' : ''}`
223
+ const fileType = input.type
224
+ ? FILE_TYPES[input.type.toLowerCase().replace(/^\./, '')]
225
+ : undefined
226
+ if (input.type && !fileType) {
227
+ return {
228
+ success: false,
229
+ output: '',
230
+ error: `Unknown file type "${input.type}". Known types: ${Object.keys(FILE_TYPES).sort().join(', ')}. Use include for another pattern.`,
231
+ }
232
+ }
150
233
  let regex: RegExp
151
234
  try {
152
235
  regex = new RegExp(input.pattern, flags)
@@ -189,6 +272,8 @@ export const GrepTool = defineTool({
189
272
  // `max_results` predates `head_limit`; it keeps meaning "at most this many".
190
273
  const headLimit = input.head_limit ?? input.max_results ?? DEFAULT_HEAD_LIMIT
191
274
  const offset = input.offset ?? 0
275
+ const beforeContext = input.before_context ?? input.context_lines ?? 0
276
+ const afterContext = input.after_context ?? input.context_lines ?? 0
192
277
  // Content lines in output order. Only content mode uses them as the
193
278
  // result; the other modes build from `matchedFiles`.
194
279
  const results: string[] = []
@@ -213,6 +298,7 @@ export const GrepTool = defineTool({
213
298
  )
214
299
  }
215
300
  filesSearched++
301
+ if (fileType && !matchesType(entry.path, fileType)) continue
216
302
  if (entry.size > MAX_FILE_SIZE) continue
217
303
 
218
304
  let content: string
@@ -221,10 +307,15 @@ export const GrepTool = defineTool({
221
307
  const buffer = await readWithSignal(
222
308
  () =>
223
309
  sandbox
224
- ? sandbox.readFile(filePath)
310
+ ? readBoundedChunks(
311
+ (offset, length) =>
312
+ sandbox.readFile(filePath, { offset, length, signal: context.abortSignal }),
313
+ MAX_FILE_SIZE,
314
+ context.abortSignal,
315
+ )
225
316
  : resolveWithinReal(root, filePath).then((path) => {
226
317
  contained = path
227
- return readFile(path, { signal: context.abortSignal })
318
+ return readBoundedFile(path, MAX_FILE_SIZE, context.abortSignal)
228
319
  }),
229
320
  context.abortSignal,
230
321
  )
@@ -245,36 +336,47 @@ export const GrepTool = defineTool({
245
336
  .split(sep)
246
337
  .join('/')}`
247
338
 
248
- for (let i = 0; i < lines.length; i++) {
249
- context.abortSignal?.throwIfAborted()
250
- const line = lines[i] ?? ''
251
- regex.lastIndex = 0
252
- if (!regex.test(line)) continue
339
+ if (mode === 'content' && input.only_matching) {
340
+ for (const fragment of matchingFragments(content, regex, multiline)) {
341
+ context.abortSignal?.throwIfAborted()
342
+ if (fileMatches++ === 0) filesMatched++
343
+ totalMatches++
344
+ results.push(`${relPath}:${fragment.line}:${clipLine(fragment.text, 'matching')}`)
345
+ if (headLimit > 0 && results.length >= offset + headLimit) {
346
+ pageHasMore = true
347
+ break
348
+ }
349
+ }
350
+ if (pageHasMore) break
351
+ continue
352
+ }
253
353
 
354
+ // Each hit is the first and last line (0-based) a match covers.
355
+ const hits = multiline ? multilineHits(content, regex) : lineHits(lines, regex, context)
356
+ // Count mode reports matching lines, so a line covered twice counts once.
357
+ const countedLines = new Set<number>()
358
+ for (const [first, last] of hits) {
359
+ context.abortSignal?.throwIfAborted()
254
360
  if (fileMatches === 0) filesMatched++
255
361
  fileMatches++
256
362
  totalMatches++
257
363
  // A file list only needs to know that the file matches.
258
364
  if (mode === 'files_with_matches') break
259
- if (mode === 'count') continue
260
-
261
- if (input.context_lines > 0) {
262
- const start = Math.max(0, i - input.context_lines)
263
- const end = Math.min(lines.length - 1, i + input.context_lines)
264
-
265
- if (results.length > 0) {
266
- results.push('--')
267
- }
365
+ if (mode === 'count') {
366
+ for (let j = first; j <= last; j++) countedLines.add(j)
367
+ continue
368
+ }
268
369
 
269
- for (let j = start; j <= end; j++) {
270
- const prefix = j === i ? ':' : '-'
271
- const text = lines[j] ?? ''
272
- results.push(
273
- `${relPath}${prefix}${j + 1}${prefix}${clipLine(text, j === i ? 'matching' : 'context')}`,
274
- )
275
- }
276
- } else {
277
- results.push(`${relPath}:${i + 1}:${clipLine(line, 'matching')}`)
370
+ const before = Math.max(0, first - beforeContext)
371
+ const after = Math.min(lines.length - 1, last + afterContext)
372
+ if ((beforeContext > 0 || afterContext > 0) && results.length > 0) results.push('--')
373
+ for (let j = before; j <= after; j++) {
374
+ if (headLimit > 0 && results.length >= offset + headLimit) break
375
+ const hit = j >= first && j <= last
376
+ const prefix = hit ? ':' : '-'
377
+ results.push(
378
+ `${relPath}${prefix}${j + 1}${prefix}${clipLine(lines[j] ?? '', hit ? 'matching' : 'context')}`,
379
+ )
278
380
  }
279
381
 
280
382
  // A full page ends the search: another file is not read just to
@@ -285,6 +387,7 @@ export const GrepTool = defineTool({
285
387
  break
286
388
  }
287
389
  }
390
+ if (mode === 'count' && fileMatches > 0) fileMatches = countedLines.size
288
391
 
289
392
  if (fileMatches > 0 && mode !== 'content') {
290
393
  matchedFiles.push({
@@ -404,3 +507,70 @@ async function sortByNewest<T extends { relPath: string; host?: string }>(
404
507
  .sort((a, b) => b.mtime - a.mtime || a.file.relPath.localeCompare(b.file.relPath))
405
508
  .map((entry) => entry.file)
406
509
  }
510
+
511
+ /** One hit per matching line. */
512
+ function lineHits(
513
+ lines: readonly string[],
514
+ regex: RegExp,
515
+ context: ToolContext,
516
+ ): [number, number][] {
517
+ const hits: [number, number][] = []
518
+ for (let i = 0; i < lines.length; i++) {
519
+ context.abortSignal?.throwIfAborted()
520
+ regex.lastIndex = 0
521
+ if (regex.test(lines[i] ?? '')) hits.push([i, i])
522
+ }
523
+ return hits
524
+ }
525
+
526
+ /** One hit per match over the whole text, from the line it starts on to the line it ends on. */
527
+ function multilineHits(content: string, regex: RegExp): [number, number][] {
528
+ const hits: [number, number][] = []
529
+ const starts = [0]
530
+ for (let i = content.indexOf('\n'); i !== -1; i = content.indexOf('\n', i + 1)) starts.push(i + 1)
531
+ const lineOf = (offset: number): number => {
532
+ let lo = 0
533
+ let hi = starts.length - 1
534
+ while (lo < hi) {
535
+ const mid = (lo + hi + 1) >> 1
536
+ if ((starts[mid] ?? 0) <= offset) lo = mid
537
+ else hi = mid - 1
538
+ }
539
+ return lo
540
+ }
541
+ regex.lastIndex = 0
542
+ for (let m = regex.exec(content); m !== null; m = regex.exec(content)) {
543
+ const text = m[0]
544
+ if (text.length === 0) {
545
+ regex.lastIndex++
546
+ continue
547
+ }
548
+ // A match that ends on a newline covers the lines before it, not the next one.
549
+ const end = m.index + text.length - (text.endsWith('\n') ? 1 : 0)
550
+ hits.push([lineOf(m.index), lineOf(Math.max(m.index, end))])
551
+ }
552
+ return hits
553
+ }
554
+
555
+ /** Lazy fragments: a dense file never allocates every match before paging. */
556
+ function* matchingFragments(content: string, regex: RegExp, multiline: boolean) {
557
+ const subjects = multiline ? [content] : content.split('\n')
558
+ let line = 1
559
+ for (const subject of subjects) {
560
+ regex.lastIndex = 0
561
+ let previous = 0
562
+ for (let match = regex.exec(subject); match !== null; match = regex.exec(subject)) {
563
+ if (multiline) {
564
+ for (let i = previous; i < match.index; i++) if (subject[i] === '\n') line++
565
+ previous = match.index
566
+ }
567
+ if (match[0].length === 0) {
568
+ regex.lastIndex++
569
+ continue
570
+ }
571
+ // Keep a multiline substring on one output row, never forge path rows.
572
+ yield { line, text: match[0].replace(/\n/g, '\\n') }
573
+ }
574
+ if (!multiline) line++
575
+ }
576
+ }