@namzu/sdk 50.1.0 → 51.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 (161) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  3. package/dist/bridge/sse/mapper.js +1 -0
  4. package/dist/bridge/sse/mapper.js.map +1 -1
  5. package/dist/execution/code-runtime/types.d.ts +35 -0
  6. package/dist/execution/code-runtime/types.d.ts.map +1 -1
  7. package/dist/execution/code-runtime/types.js.map +1 -1
  8. package/dist/execution/code-runtime/worker-program.d.ts +1 -1
  9. package/dist/execution/code-runtime/worker-program.d.ts.map +1 -1
  10. package/dist/execution/code-runtime/worker-program.js +72 -13
  11. package/dist/execution/code-runtime/worker-program.js.map +1 -1
  12. package/dist/execution/code-runtime/worker.d.ts.map +1 -1
  13. package/dist/execution/code-runtime/worker.js +35 -1
  14. package/dist/execution/code-runtime/worker.js.map +1 -1
  15. package/dist/execution/workflow/errors.d.ts +5 -0
  16. package/dist/execution/workflow/errors.d.ts.map +1 -0
  17. package/dist/execution/workflow/errors.js +8 -0
  18. package/dist/execution/workflow/errors.js.map +1 -0
  19. package/dist/execution/workflow/index.d.ts +9 -0
  20. package/dist/execution/workflow/index.d.ts.map +1 -0
  21. package/dist/execution/workflow/index.js +6 -0
  22. package/dist/execution/workflow/index.js.map +1 -0
  23. package/dist/execution/workflow/journal.d.ts +18 -0
  24. package/dist/execution/workflow/journal.d.ts.map +1 -0
  25. package/dist/execution/workflow/journal.js +43 -0
  26. package/dist/execution/workflow/journal.js.map +1 -0
  27. package/dist/execution/workflow/meta.d.ts +14 -0
  28. package/dist/execution/workflow/meta.d.ts.map +1 -0
  29. package/dist/execution/workflow/meta.js +314 -0
  30. package/dist/execution/workflow/meta.js.map +1 -0
  31. package/dist/execution/workflow/phases.d.ts +32 -0
  32. package/dist/execution/workflow/phases.d.ts.map +1 -0
  33. package/dist/execution/workflow/phases.js +57 -0
  34. package/dist/execution/workflow/phases.js.map +1 -0
  35. package/dist/execution/workflow/prelude.d.ts +15 -0
  36. package/dist/execution/workflow/prelude.d.ts.map +1 -0
  37. package/dist/execution/workflow/prelude.js +128 -0
  38. package/dist/execution/workflow/prelude.js.map +1 -0
  39. package/dist/execution/workflow/runtime.d.ts +31 -0
  40. package/dist/execution/workflow/runtime.d.ts.map +1 -0
  41. package/dist/execution/workflow/runtime.js +682 -0
  42. package/dist/execution/workflow/runtime.js.map +1 -0
  43. package/dist/execution/workflow/schema.d.ts +10 -0
  44. package/dist/execution/workflow/schema.d.ts.map +1 -0
  45. package/dist/execution/workflow/schema.js +145 -0
  46. package/dist/execution/workflow/schema.js.map +1 -0
  47. package/dist/execution/workflow/types.d.ts +267 -0
  48. package/dist/execution/workflow/types.d.ts.map +1 -0
  49. package/dist/execution/workflow/types.js +7 -0
  50. package/dist/execution/workflow/types.js.map +1 -0
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/plugin/shell-hook.d.ts.map +1 -1
  56. package/dist/plugin/shell-hook.js +2 -1
  57. package/dist/plugin/shell-hook.js.map +1 -1
  58. package/dist/process/kill-tree.d.ts +7 -0
  59. package/dist/process/kill-tree.d.ts.map +1 -1
  60. package/dist/process/kill-tree.js +41 -0
  61. package/dist/process/kill-tree.js.map +1 -1
  62. package/dist/provider/errors.d.ts +10 -0
  63. package/dist/provider/errors.d.ts.map +1 -1
  64. package/dist/provider/errors.js +16 -0
  65. package/dist/provider/errors.js.map +1 -1
  66. package/dist/provider/retry.d.ts +27 -0
  67. package/dist/provider/retry.d.ts.map +1 -1
  68. package/dist/provider/retry.js +19 -3
  69. package/dist/provider/retry.js.map +1 -1
  70. package/dist/provider/token-budget.d.ts.map +1 -1
  71. package/dist/provider/token-budget.js +31 -2
  72. package/dist/provider/token-budget.js.map +1 -1
  73. package/dist/public-runtime.d.ts +1 -0
  74. package/dist/public-runtime.d.ts.map +1 -1
  75. package/dist/public-runtime.js +3 -0
  76. package/dist/public-runtime.js.map +1 -1
  77. package/dist/public-tools.d.ts +1 -1
  78. package/dist/public-tools.d.ts.map +1 -1
  79. package/dist/public-tools.js +1 -1
  80. package/dist/public-tools.js.map +1 -1
  81. package/dist/public-types.d.ts +3 -0
  82. package/dist/public-types.d.ts.map +1 -1
  83. package/dist/runtime/jobs/job-id.d.ts +21 -0
  84. package/dist/runtime/jobs/job-id.d.ts.map +1 -0
  85. package/dist/runtime/jobs/job-id.js +66 -0
  86. package/dist/runtime/jobs/job-id.js.map +1 -0
  87. package/dist/runtime/jobs/registry.d.ts +0 -1
  88. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  89. package/dist/runtime/jobs/registry.js +2 -3
  90. package/dist/runtime/jobs/registry.js.map +1 -1
  91. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  92. package/dist/runtime/query/iteration/stream-turn.js +24 -1
  93. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  94. package/dist/runtime/query/prompt.d.ts.map +1 -1
  95. package/dist/runtime/query/prompt.js +5 -1
  96. package/dist/runtime/query/prompt.js.map +1 -1
  97. package/dist/tools/builtins/bash.d.ts +8 -1
  98. package/dist/tools/builtins/bash.d.ts.map +1 -1
  99. package/dist/tools/builtins/bash.js +20 -3
  100. package/dist/tools/builtins/bash.js.map +1 -1
  101. package/dist/tools/builtins/job.d.ts.map +1 -1
  102. package/dist/tools/builtins/job.js +4 -0
  103. package/dist/tools/builtins/job.js.map +1 -1
  104. package/dist/tools/builtins/wait-for-job.d.ts.map +1 -1
  105. package/dist/tools/builtins/wait-for-job.js +4 -0
  106. package/dist/tools/builtins/wait-for-job.js.map +1 -1
  107. package/dist/tools/command-shell.d.ts +55 -0
  108. package/dist/tools/command-shell.d.ts.map +1 -1
  109. package/dist/tools/command-shell.js +174 -13
  110. package/dist/tools/command-shell.js.map +1 -1
  111. package/dist/types/provider/error.d.ts +6 -0
  112. package/dist/types/provider/error.d.ts.map +1 -1
  113. package/dist/types/provider/errors.d.ts.map +1 -1
  114. package/dist/types/provider/errors.js +15 -1
  115. package/dist/types/provider/errors.js.map +1 -1
  116. package/dist/types/provider/stream.d.ts +6 -0
  117. package/dist/types/provider/stream.d.ts.map +1 -1
  118. package/dist/types/session/events.d.ts +5 -0
  119. package/dist/types/session/events.d.ts.map +1 -1
  120. package/dist/types/session/events.js.map +1 -1
  121. package/dist/utils/backoff.d.ts +12 -0
  122. package/dist/utils/backoff.d.ts.map +1 -1
  123. package/dist/utils/backoff.js +15 -0
  124. package/dist/utils/backoff.js.map +1 -1
  125. package/package.json +1 -1
  126. package/src/agents/AGENTS.md +134 -46
  127. package/src/bridge/sse/mapper.ts +1 -0
  128. package/src/execution/code-runtime/types.ts +37 -1
  129. package/src/execution/code-runtime/worker-program.ts +72 -13
  130. package/src/execution/code-runtime/worker.ts +40 -1
  131. package/src/execution/workflow/errors.ts +7 -0
  132. package/src/execution/workflow/index.ts +8 -0
  133. package/src/execution/workflow/journal.ts +46 -0
  134. package/src/execution/workflow/meta.ts +316 -0
  135. package/src/execution/workflow/phases.ts +91 -0
  136. package/src/execution/workflow/prelude.ts +135 -0
  137. package/src/execution/workflow/runtime.ts +786 -0
  138. package/src/execution/workflow/schema.ts +137 -0
  139. package/src/execution/workflow/types.ts +300 -0
  140. package/src/index.ts +4 -0
  141. package/src/plugin/shell-hook.ts +2 -1
  142. package/src/process/kill-tree.ts +43 -0
  143. package/src/provider/errors.ts +18 -0
  144. package/src/provider/retry.ts +53 -3
  145. package/src/provider/token-budget.ts +31 -1
  146. package/src/public-runtime.ts +11 -0
  147. package/src/public-tools.ts +6 -0
  148. package/src/public-types.ts +3 -0
  149. package/src/runtime/jobs/job-id.ts +66 -0
  150. package/src/runtime/jobs/registry.ts +2 -3
  151. package/src/runtime/query/iteration/stream-turn.ts +24 -1
  152. package/src/runtime/query/prompt.ts +5 -1
  153. package/src/tools/builtins/bash.ts +33 -3
  154. package/src/tools/builtins/job.ts +4 -0
  155. package/src/tools/builtins/wait-for-job.ts +4 -0
  156. package/src/tools/command-shell.ts +194 -11
  157. package/src/types/provider/error.ts +6 -0
  158. package/src/types/provider/errors.ts +16 -1
  159. package/src/types/provider/stream.ts +6 -0
  160. package/src/types/session/events.ts +5 -0
  161. package/src/utils/backoff.ts +21 -0
@@ -1,8 +1,10 @@
1
+ import { estimateMessagesTokens } from '../compaction/token-estimate.js'
1
2
  import { EMPTY_TOKEN_USAGE } from '../constants/limits.js'
2
3
  import type { SessionTokenBudget } from '../store/budget/ledger.js'
3
4
  import { type TokenUsage, mergeTokenUsage } from '../types/common/index.js'
4
5
  import { ProviderError, classifyProviderError } from '../types/provider/errors.js'
5
6
  import type { LLMProvider, StreamChunk } from '../types/provider/index.js'
7
+ import { cancelCauseOf } from '../types/session/cancel-cause.js'
6
8
  import { isProviderRequestError } from './errors.js'
7
9
 
8
10
  export class TokenBudgetAdmissionError extends ProviderError {
@@ -49,6 +51,7 @@ export function withTokenBudget(provider: LLMProvider, budget: SessionTokenBudge
49
51
  let completed = false
50
52
  let outputObserved = false
51
53
  let uncertainRetry = false
54
+ let streamedChars = 0
52
55
  let contacted = false
53
56
  let abandoned = false
54
57
  let iterator: AsyncIterator<StreamChunk> | undefined
@@ -107,6 +110,7 @@ export function withTokenBudget(provider: LLMProvider, budget: SessionTokenBudge
107
110
  })
108
111
  }
109
112
  if (Object.keys(chunk.delta).length > 0) outputObserved = true
113
+ if (typeof chunk.delta.content === 'string') streamedChars += chunk.delta.content.length
110
114
  if (chunk.error) throw new Error(chunk.error)
111
115
  yield chunk
112
116
  }
@@ -142,7 +146,33 @@ export function withTokenBudget(provider: LLMProvider, budget: SessionTokenBudge
142
146
  } catch {
143
147
  // Cleanup cannot replace the provider outcome.
144
148
  }
145
- if (!completed) await budget.failRequest(requestId, usage)
149
+ if (!completed) {
150
+ // A request the operator deliberately cancelled (a `user` cancel cause,
151
+ // not a parent or a runtime tearing the tree down) before any usage frame
152
+ // arrived is closed on an estimate (the prompt and what streamed), not
153
+ // left as unknown spend. Left open it poisons the whole tree: every
154
+ // sibling and every later child is refused for the rest of the turn,
155
+ // because of work somebody chose to stop. The estimate can under-count
156
+ // what the provider billed for the part it never reported; a failure
157
+ // that was not a cancellation keeps its marker as before.
158
+ if (
159
+ usage === undefined &&
160
+ signal?.aborted === true &&
161
+ cancelCauseOf(signal.reason) === 'user' &&
162
+ !uncertainRetry
163
+ ) {
164
+ const prompt = estimateMessagesTokens(params.messages)
165
+ const completion = Math.ceil(streamedChars / 4)
166
+ await budget.finishRequest(requestId, {
167
+ ...EMPTY_TOKEN_USAGE,
168
+ promptTokens: prompt,
169
+ completionTokens: completion,
170
+ totalTokens: prompt + completion,
171
+ })
172
+ } else {
173
+ await budget.failRequest(requestId, usage)
174
+ }
175
+ }
146
176
  }
147
177
  }
148
178
 
@@ -20,6 +20,17 @@ export { BackgroundJobOutputWaitLimitError } from './runtime/jobs/registry.js'
20
20
  export { HostCallDeniedError } from './execution/code-runtime/types.js'
21
21
  export { WorkerCodeRuntime } from './execution/code-runtime/worker.js'
22
22
 
23
+ // The workflow runtime: runs a model-authored orchestration script on the
24
+ // interpreter above, with `agent()` as its only host call.
25
+ export {
26
+ FileWorkflowJournal,
27
+ parseWorkflowMeta,
28
+ resolveWorkflowLimits,
29
+ summarizeWorkflowPhases,
30
+ WorkflowRuntime,
31
+ WorkflowScriptError,
32
+ } from './execution/workflow/index.js'
33
+
23
34
  export {
24
35
  CompactionConfigSchema,
25
36
  PluginRuntimeConfigSchema,
@@ -54,9 +54,15 @@ export { builtinCommandArguments, commandArgumentOf } from './tools/command-argu
54
54
  export {
55
55
  type CommandShell,
56
56
  type CommandShellProbe,
57
+ WINDOWS_BASH_GUIDANCE,
58
+ WINDOWS_CMD_FALLBACK_NOTICE,
57
59
  findCommandShell,
58
60
  findCommandShellForDialect,
61
+ findGitBash,
59
62
  hostCommandShell,
63
+ isWindowsBash,
64
+ rewriteWindowsNullRedirect,
65
+ windowsShellNotice,
60
66
  describeSpawnFailure,
61
67
  hostShellSpawn,
62
68
  installedCommandShellForDialect,
@@ -32,6 +32,9 @@ export type { CoalesceOptions } from './streaming/coalesce.js'
32
32
  export type * from './types/bidi/index.js'
33
33
  export type * from './types/tool/index.js'
34
34
  export type * from './types/job/index.js'
35
+ export type * from './execution/workflow/types.js'
36
+ export type { PhaseAgentView, PhaseRecord } from './execution/workflow/phases.js'
37
+ export type { WorkflowRuntimeOptions } from './execution/workflow/runtime.js'
35
38
  export type { WorkerCodeRuntimeOptions } from './execution/code-runtime/worker.js'
36
39
  export type {
37
40
  CodeRunOutcome,
@@ -0,0 +1,66 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ /**
4
+ * Background job ids: `b` and eight characters of `0-9a-z`.
5
+ *
6
+ * Random rather than counted. A counter restarts with every registry, so a
7
+ * resumed conversation that still says `job_1` about a dead job would be
8
+ * answered by whatever job happens to be `job_1` in the new session. A random
9
+ * id from a space of 36^8 does not collide across sessions in practice, and
10
+ * the registry additionally never hands out one it already holds.
11
+ *
12
+ * Ids are opaque to callers: nothing may parse them or rely on the shape.
13
+ */
14
+ const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
15
+ const ID_BODY_LENGTH = 8
16
+ const ID_PATTERN = /^b[0-9a-z]{8}$/
17
+
18
+ /**
19
+ * Whether `id` looks like one this SDK minted (`b` + 8 base36 characters).
20
+ * A helper only: the tools do not gate on it, because a host's own registry
21
+ * may use other ids. Ids are opaque; a host must not parse them.
22
+ */
23
+ export function isBackgroundJobId(id: unknown): id is string {
24
+ return typeof id === 'string' && ID_PATTERN.test(id)
25
+ }
26
+
27
+ /**
28
+ * A fresh id. Bytes are mapped onto the alphabet by rejection so every
29
+ * character is uniform (256 is not a multiple of 36).
30
+ */
31
+ export function createBackgroundJobId(isTaken: (id: string) => boolean = () => false): string {
32
+ for (;;) {
33
+ let body = ''
34
+ while (body.length < ID_BODY_LENGTH) {
35
+ for (const byte of randomBytes(ID_BODY_LENGTH * 2)) {
36
+ if (byte >= 252) continue // 252 = 36 * 7
37
+ body += ALPHABET[byte % 36]
38
+ if (body.length === ID_BODY_LENGTH) break
39
+ }
40
+ }
41
+ const id = `b${body}`
42
+ if (!isTaken(id)) return id
43
+ }
44
+ }
45
+
46
+ const MAX_JOB_ID_INPUT_LENGTH = 128
47
+
48
+ /**
49
+ * The tools' only gate on an id a caller passes in. A host may supply its own
50
+ * registry with its own id format, so shape is NOT checked here: anything that
51
+ * is a sane single token goes on to the registry, whose "No background job
52
+ * <id>." answers for an unknown one. Returns a plain refusal for input that
53
+ * cannot be an id at all (not a string, empty, over 128 characters, or
54
+ * containing whitespace or control characters), otherwise undefined.
55
+ */
56
+ export function invalidBackgroundJobIdInput(id: unknown): string | undefined {
57
+ if (typeof id !== 'string' || id.length === 0) return 'A background job id is required.'
58
+ if (id.length > MAX_JOB_ID_INPUT_LENGTH) {
59
+ return `That is not a background job id: it is longer than ${MAX_JOB_ID_INPUT_LENGTH} characters.`
60
+ }
61
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control characters is the point.
62
+ if (/[\s\u0000-\u001f\u007f-\u009f]/u.test(id)) {
63
+ return 'That is not a background job id: ids contain no spaces or control characters.'
64
+ }
65
+ return undefined
66
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  BackgroundJobOutputWaitResult,
11
11
  } from '../../types/job/index.js'
12
12
  import { subscribeToAbort } from '../../utils/abort.js'
13
+ import { createBackgroundJobId } from './job-id.js'
13
14
 
14
15
  /**
15
16
  * Work that outlives a tool call, owned by whoever started it.
@@ -213,7 +214,6 @@ const GROUP_POLL_MS = 250
213
214
 
214
215
  export class BackgroundJobRegistry {
215
216
  private readonly jobs = new Map<string, JobEntry>()
216
- private counter = 0
217
217
  private readonly exitListeners = new Set<(job: BackgroundJob) => void>()
218
218
  private outputWaiters = 0
219
219
  private readonly outputWaitersByOwner = new Map<string, number>()
@@ -303,8 +303,7 @@ export class BackgroundJobRegistry {
303
303
  throw new BackgroundJobLimitError({ owner: params.owner, limit: this.maxJobs })
304
304
  }
305
305
 
306
- this.counter += 1
307
- const id = `job_${this.counter}`
306
+ const id = createBackgroundJobId((candidate) => this.jobs.has(candidate))
308
307
  // Same scrub as the foreground path: inheritance is implicit, so the
309
308
  // operator's provider credentials do not travel into a command nobody
310
309
  // decided should see them. A background job outlives the call that
@@ -268,7 +268,7 @@ export async function* streamProviderTurn(
268
268
 
269
269
  let id = ''
270
270
  const model = ''
271
- const text = new StreamTextAccumulator()
271
+ let text = new StreamTextAccumulator()
272
272
  let finishReason: ChatCompletionResponse['finishReason'] = 'stop'
273
273
  // What the stream itself reported, if anything. `finishReason` above
274
274
  // defaults to 'stop' for the response; classifying unreadable tool input
@@ -413,8 +413,31 @@ export async function* streamProviderTurn(
413
413
  code: chunk.retry.code,
414
414
  ...(chunk.retry.status !== undefined ? { status: chunk.retry.status } : {}),
415
415
  serverDirected: chunk.retry.serverDirected,
416
+ ...(chunk.retry.restart ? { restart: true } : {}),
416
417
  })
417
418
  yield* drainPending()
419
+ if (chunk.retry.restart) {
420
+ // The failed attempt's text and reasoning are void: the request
421
+ // starts over under the same message. The decorator never
422
+ // restarts once a tool call began, so no bucket is left behind.
423
+ text = new StreamTextAccumulator()
424
+ reasoningBuckets.clear()
425
+ citations.length = 0
426
+ replayState = undefined
427
+ id = ''
428
+ finishReason = 'stop'
429
+ reportedFinishReason = undefined
430
+ finishDetail = undefined
431
+ streamedLength = 0
432
+ lastOutputCall = undefined
433
+ usage = {
434
+ promptTokens: 0,
435
+ completionTokens: 0,
436
+ totalTokens: 0,
437
+ cachedTokens: 0,
438
+ cacheWriteTokens: 0,
439
+ }
440
+ }
418
441
  continue
419
442
  }
420
443
 
@@ -5,6 +5,7 @@ import {
5
5
  type PromptContributionRegistry,
6
6
  SKILLS_CONTRIBUTION_ID,
7
7
  } from '../../prompt/contributions.js'
8
+ import { isWindowsBash } from '../../tools/command-shell.js'
8
9
  import type { ToolManager } from '../../toolsets/manager.js'
9
10
  import type { AgentRuntimeContext } from '../../types/agent/base.js'
10
11
  import type { AgentContextLevel } from '../../types/agent/factory.js'
@@ -135,8 +136,11 @@ function buildShellContext(config: PromptBuilderConfig): string | undefined {
135
136
  )
136
137
  continue
137
138
  const dialect = tool.commandDialect({ sandboxed: config.sandboxed === true })
139
+ // Git Bash on a Windows host is named, so the line does not read as the
140
+ // Linux shell it is not. The syntax rules are in the tool's description.
141
+ const gitBash = !config.sandboxed && dialect === 'bash' && isWindowsBash()
138
142
  lines.push(
139
- `Execution shell for ${name}: ${dialect}${config.sandboxed ? ' (sandbox guest)' : ' (host)'}.`,
143
+ `Execution shell for ${name}: ${dialect}${config.sandboxed ? ' (sandbox guest)' : gitBash ? ' (Git Bash on Windows, host)' : ' (host)'}.`,
140
144
  )
141
145
  if (dialect === 'cmd')
142
146
  lines.push(
@@ -8,8 +8,12 @@ import { subscribeToAbort } from '../../utils/abort.js'
8
8
  import { readPositiveIntEnv } from '../../utils/env.js'
9
9
  import {
10
10
  type CommandShell,
11
+ WINDOWS_BASH_GUIDANCE,
11
12
  bashToolDialect,
12
13
  describeSpawnFailure,
14
+ hostCommandShell,
15
+ isWindowsBash,
16
+ rewriteWindowsNullRedirect,
13
17
  sandboxShellSpawn,
14
18
  spawnHostShell,
15
19
  withoutBashStartup,
@@ -57,6 +61,12 @@ const inputSchema = z.object({
57
61
  command: z
58
62
  .string()
59
63
  .min(1)
64
+ // Git Bash on Windows: a model that writes `>nul` meant the null device.
65
+ // Rewritten here, before the permission rules read the line and before
66
+ // it runs, so the two see the same text.
67
+ .transform((command) =>
68
+ isWindowsBash(hostCommandShell()) ? rewriteWindowsNullRedirect(command) : command,
69
+ )
60
70
  .describe(
61
71
  'The bash command to execute. Required, non-empty. Single command per call (use `&&` / `;` chaining for compound commands). Avoid heredocs that span more than a few hundred bytes — large content should be created with `write`, then extended with `edit` insertLine: "end", not piped into bash.',
62
72
  ),
@@ -336,10 +346,12 @@ export const SANDBOX_CANNOT_DETACH =
336
346
  export const SANDBOX_ESCAPE_NOT_APPROVED =
337
347
  '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.'
338
348
 
339
- export const BashTool = defineTool({
349
+ const BASH_DESCRIPTION =
350
+ '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 — never call this tool with empty arguments. For very long content (e.g. building a large file), prefer `write` for the opening and `edit` with insertLine: "end" for follow-up chunks over a heredoc to avoid hitting the output token limit mid-stream.'
351
+
352
+ const BashToolBase = defineTool({
340
353
  name: 'bash',
341
- description:
342
- '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 — never call this tool with empty arguments. For very long content (e.g. building a large file), prefer `write` for the opening and `edit` with insertLine: "end" for follow-up chunks over a heredoc to avoid hitting the output token limit mid-stream.',
354
+ description: BASH_DESCRIPTION,
343
355
  inputSchema,
344
356
  // A command carries quotes and backslashes of its own, and a heredoc
345
357
  // carries newlines: copied into the JSON string raw, they end it.
@@ -721,3 +733,21 @@ function describeWithheldEnv(dropped: readonly string[]): string {
721
733
  const names = rest > 0 ? `${shown}, and ${rest} more` : shown
722
734
  return `NOTE: ${dropped.length} credential-shaped environment variable(s) were withheld from this command and are unset rather than empty: ${names}. A host that means this command to have one passes it explicitly.`
723
735
  }
736
+
737
+ /**
738
+ * The same tool, whose description gains the Windows guidance when the host
739
+ * shell is Git Bash. Resolved on first read, not at import: finding Git Bash
740
+ * runs `where.exe`, which a program that only imports the SDK must not pay.
741
+ */
742
+ export const BashTool: typeof BashToolBase = Object.defineProperty(
743
+ { ...BashToolBase },
744
+ 'description',
745
+ {
746
+ enumerable: true,
747
+ configurable: true,
748
+ get: () =>
749
+ isWindowsBash(hostCommandShell())
750
+ ? `${BASH_DESCRIPTION} ${WINDOWS_BASH_GUIDANCE}`
751
+ : BASH_DESCRIPTION,
752
+ },
753
+ )
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { invalidBackgroundJobIdInput } from '../../runtime/jobs/job-id.js'
3
4
  import { defineTool } from '../defineTool.js'
4
5
 
5
6
  /**
@@ -90,6 +91,9 @@ export const JobTool = defineTool({
90
91
  }
91
92
  }
92
93
 
94
+ const badId = invalidBackgroundJobIdInput(input.id)
95
+ if (badId) return { success: false, output: '', error: badId }
96
+
93
97
  try {
94
98
  if (input.action === 'kill') {
95
99
  const killed = await jobs.kill(input.id)
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { invalidBackgroundJobIdInput } from '../../runtime/jobs/job-id.js'
3
4
  import { readPositiveIntEnv } from '../../utils/env.js'
4
5
  import { defineTool } from '../defineTool.js'
5
6
  import { describeJobWaitTimeout, waitForJobWithBounds } from './wait-for-job-bounds.js'
@@ -131,6 +132,9 @@ export const WaitForJobTool = defineTool({
131
132
  }
132
133
  const jobs = context.backgroundJobs
133
134
 
135
+ const badId = invalidBackgroundJobIdInput(input.id)
136
+ if (badId) return { success: false, output: '', error: badId }
137
+
134
138
  try {
135
139
  jobs.get(input.id)
136
140
  } catch (err) {
@@ -32,12 +32,30 @@
32
32
  * those would change what a command line means after the rules read it, so
33
33
  * they are removed from the environment of the spawned bash.
34
34
  * `NAMZU_BASH_SHELL=/bin/sh` restores the old shell exactly.
35
+ *
36
+ * ## Windows
37
+ *
38
+ * `cmd.exe` is a dialect the permission lexer cannot read: every command
39
+ * line comes back opaque, so every call needs a person in every mode, and a
40
+ * model writes POSIX syntax that cmd cannot run. So on Windows the same rule
41
+ * applies as everywhere else: run bash where it exists, and read the line as
42
+ * bash. The bash that exists on a Windows machine is Git for Windows'
43
+ * (`findGitBash`). Without it the tool falls back to cmd, as before, and
44
+ * {@link windowsShellNotice} says so once. `NAMZU_BASH_SHELL=cmd` keeps cmd
45
+ * on a machine that has Git Bash.
35
46
  */
36
47
 
37
- import { type ChildProcess, type SpawnOptions, spawn as nodeSpawn } from 'node:child_process'
48
+ import {
49
+ type ChildProcess,
50
+ type SpawnOptions,
51
+ execFileSync,
52
+ spawn as nodeSpawn,
53
+ } from 'node:child_process'
54
+ import { randomUUID } from 'node:crypto'
38
55
  import { constants, accessSync, existsSync } from 'node:fs'
39
- import { delimiter, isAbsolute, join } from 'node:path'
56
+ import { delimiter, isAbsolute, join, win32 } from 'node:path'
40
57
 
58
+ import { MSYS_TAG_VARIABLE, registerMsysShell } from '../process/kill-tree.js'
41
59
  import type { ShellDialect } from '../types/tool/index.js'
42
60
 
43
61
  /** The shell a host-side command runs in. */
@@ -48,6 +66,8 @@ export interface CommandShell {
48
66
  readonly dialect: ShellDialect
49
67
  /** Where the choice came from, for diagnostics. */
50
68
  readonly source: 'override' | 'bash' | 'sh' | 'platform'
69
+ /** True when this is a Windows executable (Git Bash), which needs Windows-only spawn details. */
70
+ readonly windows?: true
51
71
  }
52
72
 
53
73
  /** What resolution looks at. Injected by tests to simulate a host without bash. */
@@ -55,6 +75,13 @@ export interface CommandShellProbe {
55
75
  readonly env: NodeJS.ProcessEnv
56
76
  readonly platform: NodeJS.Platform
57
77
  readonly isExecutable: (path: string) => boolean
78
+ /**
79
+ * Windows only: the paths `where.exe git` prints, one per entry, in order.
80
+ * Absent or throwing means git was not found on PATH.
81
+ */
82
+ readonly whereGit?: () => readonly string[]
83
+ /** Windows only: the working directory, whose own programs are never trusted. Default: `process.cwd()`. */
84
+ readonly cwd?: string
58
85
  }
59
86
 
60
87
  const WELL_KNOWN_BASH = ['/bin/bash', '/usr/bin/bash']
@@ -63,6 +90,66 @@ const WELL_KNOWN_SH = ['/bin/sh', '/usr/bin/sh']
63
90
  /** Environment variables that change what a bash command line means. */
64
91
  const BASH_STARTUP_VARIABLES = new Set(['BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS'])
65
92
 
93
+ /** A variable of a Windows environment, whose names are not case-sensitive. */
94
+ function windowsEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
95
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase())
96
+ return key === undefined ? undefined : env[key]
97
+ }
98
+
99
+ /** Is `path` the working directory or inside it? Windows paths compare without case. */
100
+ function insideDirectory(path: string, directory: string): boolean {
101
+ const relative = win32.relative(directory, path)
102
+ return relative === '' || (!relative.startsWith('..') && !win32.isAbsolute(relative))
103
+ }
104
+
105
+ /**
106
+ * Git for Windows' bash.exe, found in this order:
107
+ *
108
+ * 1. `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`;
109
+ * 2. `%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe`, a per-user install;
110
+ * 3. the install that holds the first `git` that `where.exe git` lists: its
111
+ * `bin\bash.exe`, one or two directories up from the `git.exe` it printed.
112
+ * A hit inside the working directory is skipped, because a repository can
113
+ * carry a `git.exe` of its own and the search must not run it.
114
+ *
115
+ * There is no registry lookup, and `where bash` is never asked: on a machine
116
+ * with WSL, `System32\bash.exe` is the launcher of another system.
117
+ */
118
+ export function findGitBash(probe: CommandShellProbe): string | undefined {
119
+ const programFiles = windowsEnv(probe.env, 'ProgramFiles') ?? 'C:\\Program Files'
120
+ const programFilesX86 = windowsEnv(probe.env, 'ProgramFiles(x86)') ?? 'C:\\Program Files (x86)'
121
+ for (const root of [programFiles, programFilesX86]) {
122
+ const candidate = win32.join(root, 'Git', 'bin', 'bash.exe')
123
+ if (probe.isExecutable(candidate)) return candidate
124
+ }
125
+ // A per-user install (the installer's "just me" choice) lives under LOCALAPPDATA.
126
+ const localAppData = windowsEnv(probe.env, 'LOCALAPPDATA')
127
+ if (localAppData !== undefined && localAppData !== '') {
128
+ const candidate = win32.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe')
129
+ if (probe.isExecutable(candidate)) return candidate
130
+ }
131
+ let listed: readonly string[] = []
132
+ try {
133
+ listed = probe.whereGit?.() ?? []
134
+ } catch {
135
+ return undefined
136
+ }
137
+ const cwd = probe.cwd ?? process.cwd()
138
+ for (const line of listed) {
139
+ const git = line.trim()
140
+ if (!win32.isAbsolute(git) || insideDirectory(git, cwd)) continue
141
+ const directory = win32.dirname(git)
142
+ // <Git>\cmd\git.exe and <Git>\bin\git.exe, then <Git>\mingw64\bin\git.exe.
143
+ for (const candidate of [
144
+ win32.join(directory, '..', 'bin', 'bash.exe'),
145
+ win32.join(directory, '..', '..', 'bin', 'bash.exe'),
146
+ ]) {
147
+ if (probe.isExecutable(candidate)) return candidate
148
+ }
149
+ }
150
+ return undefined
151
+ }
152
+
66
153
  export function findCommandShell(probe: CommandShellProbe): CommandShell {
67
154
  const override = probe.env.NAMZU_BASH_SHELL
68
155
  if (override !== undefined && override !== '') {
@@ -76,11 +163,27 @@ export function findCommandShell(probe: CommandShellProbe): CommandShell {
76
163
  ?.toLowerCase()
77
164
  .replace(/\.exe$/, '')
78
165
  : override.slice(override.lastIndexOf('/') + 1)
79
- return { path: override, dialect: name === 'bash' ? 'bash' : 'sh', source: 'override' }
166
+ // cmd is not a POSIX shell and takes no `-c`: naming it keeps Node's
167
+ // own cmd path, which is how a Windows machine with Git Bash runs the
168
+ // tool as it did before bash was preferred.
169
+ if (probe.platform === 'win32' && name === 'cmd')
170
+ return { path: undefined, dialect: 'cmd', source: 'override' }
171
+ return {
172
+ path: override,
173
+ dialect: name === 'bash' ? 'bash' : 'sh',
174
+ source: 'override',
175
+ ...(probe.platform === 'win32' ? { windows: true as const } : {}),
176
+ }
177
+ }
178
+ // Looking `bash` up on the Windows PATH can find WSL's launcher, which
179
+ // runs the command in another system, so only Git for Windows' own
180
+ // install is used. Without it Node's platform shell (cmd) runs the line.
181
+ if (probe.platform === 'win32') {
182
+ const gitBash = findGitBash(probe)
183
+ return gitBash === undefined
184
+ ? { path: undefined, dialect: 'cmd', source: 'platform' }
185
+ : { path: gitBash, dialect: 'bash', source: 'bash', windows: true }
80
186
  }
81
- // Windows keeps Node's platform shell. Looking `bash` up on its PATH can
82
- // find WSL's launcher, which runs the command in another system.
83
- if (probe.platform === 'win32') return { path: undefined, dialect: 'cmd', source: 'platform' }
84
187
  for (const directory of (probe.env.PATH ?? '').split(delimiter)) {
85
188
  if (directory === '' || !directory.startsWith('/')) continue
86
189
  const candidate = join(directory, 'bash')
@@ -92,6 +195,55 @@ export function findCommandShell(probe: CommandShellProbe): CommandShell {
92
195
  return { path: '/bin/sh', dialect: 'sh', source: 'sh' }
93
196
  }
94
197
 
198
+ /**
199
+ * Said once, in the session, when a Windows machine has no Git Bash and the
200
+ * `bash` tool runs cmd.exe. Undefined everywhere else.
201
+ */
202
+ export const WINDOWS_CMD_FALLBACK_NOTICE =
203
+ 'Git for Windows not found — shell commands run in cmd.exe and each one asks for approval. Install Git for Windows for full support.'
204
+
205
+ export function windowsShellNotice(shell: CommandShell = hostCommandShell()): string | undefined {
206
+ return shell.source === 'platform' && shell.path === undefined && shell.dialect === 'cmd'
207
+ ? WINDOWS_CMD_FALLBACK_NOTICE
208
+ : undefined
209
+ }
210
+
211
+ /**
212
+ * Is the host `bash` tool Git Bash on Windows? The model is then told so, and
213
+ * the Windows-only spawn details apply.
214
+ */
215
+ export function isWindowsBash(shell: CommandShell = hostCommandShell()): boolean {
216
+ return shell.windows === true && shell.dialect === 'bash'
217
+ }
218
+
219
+ /** What a model needs to know when the `bash` tool is Git Bash on Windows, appended to the tool's description. */
220
+ export const WINDOWS_BASH_GUIDANCE =
221
+ 'This tool runs Git Bash (POSIX sh), not cmd.exe or PowerShell. Use Unix shell syntax: `/dev/null` not `NUL`, forward slashes, `$VAR` not `%VAR%` or `$env:VAR`. To run PowerShell, call `powershell -NoProfile -Command "…"`.'
222
+
223
+ /**
224
+ * Rewrite a redirect to Windows' `NUL` device (`>nul`, `2>NUL`, `&>nul`) to
225
+ * `/dev/null`. In bash `nul` is an ordinary file name, so the line would
226
+ * create a file called `nul` that Windows then cannot delete. A line with
227
+ * `<`, `$` or a backtick is left alone: it may be a heredoc or a string that
228
+ * only mentions the text.
229
+ */
230
+ export function rewriteWindowsNullRedirect(command: string): string {
231
+ if (/[<$`]/.test(command)) return command
232
+ return command.replace(/(\d?&?>+[ \t]*)nul(?=\s|$|[|&;)\n])/gi, '$1/dev/null')
233
+ }
234
+
235
+ /** `where.exe git`, run from the system directory by its absolute path, so PATH cannot stand in for it. */
236
+ function whereGit(): readonly string[] {
237
+ const root = process.env.SystemRoot ?? process.env.windir ?? 'C:\\Windows'
238
+ const output = execFileSync(win32.join(root, 'System32', 'where.exe'), ['git'], {
239
+ encoding: 'utf8',
240
+ windowsHide: true,
241
+ stdio: ['ignore', 'pipe', 'ignore'],
242
+ timeout: 5000,
243
+ })
244
+ return output.split(/\r?\n/)
245
+ }
246
+
95
247
  /**
96
248
  * An explicitly requested interpreter for a host script. Unlike the live
97
249
  * `bash` tool's automatic choice, this never substitutes bash for sh (or sh
@@ -144,7 +296,12 @@ let resolved: CommandShell | undefined
144
296
  */
145
297
  export function hostCommandShell(): CommandShell {
146
298
  if (resolved === undefined) {
147
- resolved = findCommandShell({ env: process.env, platform: process.platform, isExecutable })
299
+ resolved = findCommandShell({
300
+ env: process.env,
301
+ platform: process.platform,
302
+ isExecutable,
303
+ whereGit,
304
+ })
148
305
  }
149
306
  return resolved
150
307
  }
@@ -180,7 +337,17 @@ export function hostShellSpawn(
180
337
  } {
181
338
  if (shell.path === undefined) return { file: undefined, args: [command], env }
182
339
  if (shell.dialect !== 'bash') return { file: shell.path, args: ['-c', command], env }
183
- return { file: shell.path, args: ['-c', command], env: withoutBashStartup(env) }
340
+ const clean = withoutBashStartup(env)
341
+ // Git for Windows: the line is read as bash, so `>nul` must already have
342
+ // become `/dev/null` (the tool's input does that before the rules read it;
343
+ // this covers a caller that did not), and git must never open an editor.
344
+ if (shell.windows === true)
345
+ return {
346
+ file: shell.path,
347
+ args: ['-c', rewriteWindowsNullRedirect(command)],
348
+ env: { ...clean, GIT_EDITOR: 'true' },
349
+ }
350
+ return { file: shell.path, args: ['-c', command], env: clean }
184
351
  }
185
352
 
186
353
  /** Why a command could not be started, in the words a model or operator reads. */
@@ -283,15 +450,31 @@ export function spawnHostShell(
283
450
  },
284
451
  ): ChildProcess {
285
452
  const spawnFn = options.spawnImpl ?? nodeSpawn
286
- const resolved = hostShellSpawn(command, options.env, options.shell)
453
+ const shell = options.shell ?? hostCommandShell()
454
+ const spawned = hostShellSpawn(command, options.env, shell)
455
+ // Git Bash: tag the shell so a kill can find what a forked command left.
456
+ const tag = shell.windows === true && shell.path !== undefined ? randomUUID() : undefined
457
+ const resolved =
458
+ tag === undefined ? spawned : { ...spawned, env: { ...spawned.env, [MSYS_TAG_VARIABLE]: tag } }
287
459
  const common: SpawnOptions = {
288
460
  cwd: options.cwd,
289
461
  env: resolved.env,
290
462
  detached: options.detached ?? (options.platform ?? process.platform) !== 'win32',
291
463
  windowsHide: true,
292
- ...(options.stdio === undefined ? {} : { stdio: options.stdio }),
464
+ // Git Bash reads no terminal: a command that asks for input gets
465
+ // end-of-file (the null device) instead of waiting for a timeout.
466
+ ...(options.stdio === undefined
467
+ ? resolved.file !== undefined && shell.windows === true
468
+ ? { stdio: ['ignore', 'pipe', 'pipe'] as SpawnOptions['stdio'] }
469
+ : {}
470
+ : { stdio: options.stdio }),
471
+ }
472
+ if (resolved.file !== undefined) {
473
+ const child = spawnFn(resolved.file, [...resolved.args], common)
474
+ if (tag !== undefined && child !== null && typeof child === 'object')
475
+ registerMsysShell(child, { bash: resolved.file, tag })
476
+ return child
293
477
  }
294
- if (resolved.file !== undefined) return spawnFn(resolved.file, [...resolved.args], common)
295
478
  const windows = windowsCmdSpawn(command, resolved.env)
296
479
  return spawnFn(windows.file, [...windows.args], {
297
480
  ...common,
@@ -45,4 +45,10 @@ export interface ProviderRequestErrorInit extends ProviderErrorInfo {
45
45
  * still welcome; what is not welcome is a raw body passed straight in.
46
46
  */
47
47
  readonly detail?: string
48
+ /**
49
+ * The provider's own `x-should-retry` verdict, when it sent one. Only `false`
50
+ * is acted on: a provider that says the same request must not be sent again
51
+ * is believed over the status code, so the retry loop leaves it alone.
52
+ */
53
+ readonly shouldRetry?: boolean
48
54
  }