@namzu/sdk 25.0.0 → 26.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 (62) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +1 -1
  3. package/dist/connector/mcp/index.d.ts +1 -0
  4. package/dist/connector/mcp/index.d.ts.map +1 -1
  5. package/dist/connector/mcp/index.js +3 -0
  6. package/dist/connector/mcp/index.js.map +1 -1
  7. package/dist/connector/mcp/server-stdio.d.ts +62 -0
  8. package/dist/connector/mcp/server-stdio.d.ts.map +1 -0
  9. package/dist/connector/mcp/server-stdio.js +121 -0
  10. package/dist/connector/mcp/server-stdio.js.map +1 -0
  11. package/dist/connector/mcp/stdio.d.ts +11 -0
  12. package/dist/connector/mcp/stdio.d.ts.map +1 -1
  13. package/dist/connector/mcp/stdio.js +106 -1
  14. package/dist/connector/mcp/stdio.js.map +1 -1
  15. package/dist/constants/telemetry/index.d.ts +15 -1
  16. package/dist/constants/telemetry/index.d.ts.map +1 -1
  17. package/dist/constants/telemetry/index.js +15 -1
  18. package/dist/constants/telemetry/index.js.map +1 -1
  19. package/dist/provider/fallback.d.ts.map +1 -1
  20. package/dist/provider/fallback.js +8 -2
  21. package/dist/provider/fallback.js.map +1 -1
  22. package/dist/provider/retry.d.ts.map +1 -1
  23. package/dist/provider/retry.js +10 -2
  24. package/dist/provider/retry.js.map +1 -1
  25. package/dist/registry/tool/execute.d.ts.map +1 -1
  26. package/dist/registry/tool/execute.js +12 -0
  27. package/dist/registry/tool/execute.js.map +1 -1
  28. package/dist/session/workspace/git-worktree.d.ts +15 -0
  29. package/dist/session/workspace/git-worktree.d.ts.map +1 -1
  30. package/dist/session/workspace/git-worktree.js +55 -1
  31. package/dist/session/workspace/git-worktree.js.map +1 -1
  32. package/dist/tools/builtins/bash.d.ts.map +1 -1
  33. package/dist/tools/builtins/bash.js +35 -0
  34. package/dist/tools/builtins/bash.js.map +1 -1
  35. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  36. package/dist/tools/coordinator/agent.js +12 -0
  37. package/dist/tools/coordinator/agent.js.map +1 -1
  38. package/dist/tools/coordinator/index.d.ts.map +1 -1
  39. package/dist/tools/coordinator/index.js +134 -6
  40. package/dist/tools/coordinator/index.js.map +1 -1
  41. package/dist/types/connector/mcp.d.ts +17 -0
  42. package/dist/types/connector/mcp.d.ts.map +1 -1
  43. package/dist/types/provider/interface.d.ts +23 -2
  44. package/dist/types/provider/interface.d.ts.map +1 -1
  45. package/dist/types/sandbox/index.d.ts +23 -0
  46. package/dist/types/sandbox/index.d.ts.map +1 -1
  47. package/dist/types/sandbox/index.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/connector/mcp/index.ts +3 -0
  50. package/src/connector/mcp/server-stdio.ts +137 -0
  51. package/src/connector/mcp/stdio.ts +106 -1
  52. package/src/constants/telemetry/index.ts +16 -1
  53. package/src/provider/fallback.ts +8 -2
  54. package/src/provider/retry.ts +10 -2
  55. package/src/registry/tool/execute.ts +12 -0
  56. package/src/session/workspace/git-worktree.ts +55 -1
  57. package/src/tools/builtins/bash.ts +34 -0
  58. package/src/tools/coordinator/agent.ts +12 -0
  59. package/src/tools/coordinator/index.ts +144 -8
  60. package/src/types/connector/mcp.ts +17 -0
  61. package/src/types/provider/interface.ts +23 -2
  62. package/src/types/sandbox/index.ts +23 -0
@@ -13,6 +13,111 @@ import { type Logger, getRootLogger } from '../../utils/logger.js'
13
13
  */
14
14
  const TERMINATE_GRACE_MS = 2_000
15
15
 
16
+ /**
17
+ * The variables a child needs in order to be a working process at all.
18
+ *
19
+ * This spawn used to pass `{ ...process.env, ...config.env }`, so a connected
20
+ * server received every credential the host happened to hold — measured at 119
21
+ * variables on a developer machine, including a planted secret the server had
22
+ * no reason to see. A server that needs one token was handed all of them, and
23
+ * nothing in the config said so.
24
+ *
25
+ * The list below is process plumbing, not secrets: where to find executables,
26
+ * where the home and temp directories are, what the locale is. Dropping any of
27
+ * it does not harden anything and does break servers — a child with no `PATH`
28
+ * cannot resolve its own interpreter.
29
+ *
30
+ * Anything else a server needs is now named: `env` gives it a literal value,
31
+ * and `inheritEnv` names a parent variable to pass through. Naming is the
32
+ * point — a grant that has to be written down is a grant somebody can review.
33
+ *
34
+ * Windows spellings are matched case-insensitively, because its environment is
35
+ * case-insensitive and a lookup for `Path` against a key stored as `PATH` would
36
+ * silently drop it — which would present as "the server does not start on
37
+ * Windows" rather than as anything to do with this list.
38
+ */
39
+ const BASE_ENV_KEYS: readonly string[] = [
40
+ // Everywhere.
41
+ 'PATH',
42
+ 'LANG',
43
+ 'LC_ALL',
44
+ 'LC_CTYPE',
45
+ 'TZ',
46
+ // POSIX.
47
+ 'HOME',
48
+ 'SHELL',
49
+ 'TMPDIR',
50
+ 'USER',
51
+ 'LOGNAME',
52
+ // Windows. `SystemRoot` and `ComSpec` are load-bearing: without them a
53
+ // child cannot resolve system DLLs or the command interpreter.
54
+ 'PATHEXT',
55
+ 'SystemRoot',
56
+ 'SystemDrive',
57
+ 'ComSpec',
58
+ 'WINDIR',
59
+ 'TEMP',
60
+ 'TMP',
61
+ 'USERPROFILE',
62
+ 'HOMEDRIVE',
63
+ 'HOMEPATH',
64
+ 'APPDATA',
65
+ 'LOCALAPPDATA',
66
+ 'PROGRAMDATA',
67
+ 'PROGRAMFILES',
68
+ 'NUMBER_OF_PROCESSORS',
69
+ 'PROCESSOR_ARCHITECTURE',
70
+ ]
71
+
72
+ /**
73
+ * Read one variable from the parent, honouring the platform's own casing rules.
74
+ *
75
+ * Node exposes `process.env` on Windows through a case-insensitive proxy, so a
76
+ * direct lookup already works there — but the KEY this returns has to be the
77
+ * one the parent actually uses, or a child comparing key names sees a spelling
78
+ * the host never set.
79
+ */
80
+ function readParentVar(source: NodeJS.ProcessEnv, name: string): [string, string] | undefined {
81
+ const direct = source[name]
82
+ if (direct !== undefined) return [name, direct]
83
+ if (process.platform !== 'win32') return undefined
84
+ const lowered = name.toLowerCase()
85
+ for (const [key, value] of Object.entries(source)) {
86
+ if (key.toLowerCase() === lowered && value !== undefined) return [key, value]
87
+ }
88
+ return undefined
89
+ }
90
+
91
+ /**
92
+ * What the child is handed: plumbing, then the named inheritances, then the
93
+ * literal values.
94
+ *
95
+ * Later wins, and the order is the precedence an operator would guess: a
96
+ * literal `env` entry overrides an inherited one, and both override the base.
97
+ * Exported for the tests, which assert on the ENV rather than on the spawn —
98
+ * a test that only checked the config was accepted would have passed against
99
+ * the version this replaces.
100
+ */
101
+ export function buildChildEnv(
102
+ config: Pick<MCPStdioTransportConfig, 'env' | 'inheritEnv'>,
103
+ source: NodeJS.ProcessEnv = process.env,
104
+ ): Record<string, string> {
105
+ const env: Record<string, string> = {}
106
+ for (const name of BASE_ENV_KEYS) {
107
+ const found = readParentVar(source, name)
108
+ if (found) env[found[0]] = found[1]
109
+ }
110
+ for (const name of config.inheritEnv ?? []) {
111
+ const found = readParentVar(source, name)
112
+ // A named variable the parent does not hold is simply absent. Refusing
113
+ // the spawn would turn an optional credential into a startup failure,
114
+ // and inventing an empty string would tell the server it has one.
115
+ if (found) env[found[0]] = found[1]
116
+ }
117
+ for (const [name, value] of Object.entries(config.env ?? {})) env[name] = value
118
+ return env
119
+ }
120
+
16
121
  export class StdioTransport implements MCPTransport {
17
122
  private process: ChildProcess | null = null
18
123
  private messageHandlers: Array<(message: MCPJsonRpcMessage) => void> = []
@@ -32,7 +137,7 @@ export class StdioTransport implements MCPTransport {
32
137
  if (this.connected) return
33
138
 
34
139
  this.process = spawn(this.config.command, this.config.args ?? [], {
35
- env: { ...process.env, ...this.config.env },
140
+ env: buildChildEnv(this.config),
36
141
  cwd: this.config.cwd,
37
142
  stdio: ['pipe', 'pipe', 'pipe'],
38
143
  })
@@ -23,7 +23,22 @@ export const GENAI = {
23
23
 
24
24
  TOOL_NAME: 'gen_ai.tool.name',
25
25
  TOOL_TYPE: 'gen_ai.tool.type',
26
- TOOL_CALL_ID: 'gen_ai.tool.call_id',
26
+
27
+ /**
28
+ * The id of the tool call this span is about.
29
+ *
30
+ * Spelled `call.id`, matching the registry and matching the two
31
+ * neighbours above — this read `gen_ai.tool.call_id`, one underscore
32
+ * where the convention has a dot, so a consumer grouping by the
33
+ * conventional name found nothing under it. Nothing errored, because a
34
+ * span attribute is a free-form key and a wrong one is simply a key
35
+ * nobody asked for.
36
+ *
37
+ * Pinned by `telemetry/__tests__/tool-call-id-attribute.test.ts`, which
38
+ * also drives the emitter: the spelling was only half the defect, and
39
+ * the constant had no writer at all.
40
+ */
41
+ TOOL_CALL_ID: 'gen_ai.tool.call.id',
27
42
 
28
43
  AGENT_NAME: 'gen_ai.agent.name',
29
44
  AGENT_ID: 'gen_ai.agent.id',
@@ -382,7 +382,13 @@ export function withProviderFallback(
382
382
  },
383
383
  chatStream,
384
384
  ...(first.provider.listModels ? { listModels: () => first.provider.listModels?.() } : {}),
385
- ...(first.provider.healthCheck ? { healthCheck: () => first.provider.healthCheck?.() } : {}),
386
- ...(first.provider.doctorCheck ? { doctorCheck: () => first.provider.doctorCheck?.() } : {}),
385
+ // Forwarded for the reason `retry.ts` forwards it: a wrapper that drops
386
+ // the model turns a probe the caller could answer into one it cannot.
387
+ ...(first.provider.healthCheck
388
+ ? { healthCheck: (model?: string) => first.provider.healthCheck?.(model) }
389
+ : {}),
390
+ ...(first.provider.doctorCheck
391
+ ? { doctorCheck: (model?: string) => first.provider.doctorCheck?.(model) }
392
+ : {}),
387
393
  } as LLMProvider
388
394
  }
@@ -231,7 +231,15 @@ export function withProviderRetry(
231
231
  },
232
232
  chatStream,
233
233
  ...(provider.listModels ? { listModels: () => provider.listModels?.() } : {}),
234
- ...(provider.healthCheck ? { healthCheck: () => provider.healthCheck?.() } : {}),
235
- ...(provider.doctorCheck ? { doctorCheck: () => provider.doctorCheck?.() } : {}),
234
+ // The model is forwarded, not dropped. A wrapper that swallowed it would
235
+ // leave the wrapped driver probing whatever it probes with no argument
236
+ // — which for at least one driver is "nothing", so the check would come
237
+ // back unanswerable purely because it was wrapped.
238
+ ...(provider.healthCheck
239
+ ? { healthCheck: (model?: string) => provider.healthCheck?.(model) }
240
+ : {}),
241
+ ...(provider.doctorCheck
242
+ ? { doctorCheck: (model?: string) => provider.doctorCheck?.(model) }
243
+ : {}),
236
244
  } as LLMProvider
237
245
  }
@@ -415,9 +415,21 @@ Executable tool names, descriptions, and JSON input schemas are attached through
415
415
 
416
416
  return tracer.startActiveSpan(toolSpanName(toolName), {}, parentCtx, async (span) => {
417
417
  try {
418
+ // The call id joins this span to the assistant block that asked
419
+ // for it. Without it a trace shows that `Bash` ran four times
420
+ // this turn and cannot say which span answers which
421
+ // `tool_use` — and the id was already in hand here, threaded
422
+ // through `ToolContext` for the tools that reply
423
+ // asynchronously.
424
+ //
425
+ // Conditional because `toolUseId` is optional: a host calling
426
+ // a tool directly, outside a run, has no call to correlate to,
427
+ // and an attribute set to `undefined` is worse than an absent
428
+ // one — it reaches the exporter as a key with no value.
418
429
  span.setAttributes({
419
430
  [GENAI.TOOL_NAME]: toolName,
420
431
  [GENAI.TOOL_TYPE]: 'function',
432
+ ...(context.toolUseId !== undefined ? { [GENAI.TOOL_CALL_ID]: context.toolUseId } : {}),
421
433
  })
422
434
 
423
435
  const tool = this.getOrThrow(toolName)
@@ -89,7 +89,26 @@ export class GitWorktreeDriver implements WorkspaceBackendDriver {
89
89
  try {
90
90
  await this.exec('git', argv)
91
91
  } catch (cause) {
92
- throw new WorkspaceBackendError({ op: 'create', kind: this.kind, cause })
92
+ // A non-zero exit here does not mean the worktree was not created.
93
+ // `git worktree add` runs the repository's post-checkout hook AFTER
94
+ // the checkout has completed, so a hook that fails — or that a
95
+ // timeout kills — reports failure over a worktree that is finished
96
+ // and usable. Treating the status as the answer throws away a good
97
+ // checkout AND leaks it: the path stays registered, and the next
98
+ // attempt fails differently, with "already exists".
99
+ //
100
+ // So the exit code is a hint and the repository is the evidence.
101
+ // The bar is deliberately high: registered under this exact path
102
+ // AND carrying the branch this call asked for. A registered path
103
+ // alone can be a half-finished checkout, or somebody else's.
104
+ if (!(await this.createdDespite(worktreePath, branch))) {
105
+ throw new WorkspaceBackendError({ op: 'create', kind: this.kind, cause })
106
+ }
107
+ this.log.warn('git-worktree add reported failure but the worktree is present', {
108
+ branch,
109
+ worktreePath,
110
+ cause: cause instanceof Error ? cause.message : String(cause),
111
+ })
93
112
  }
94
113
 
95
114
  const meta: GitWorktreeBackendMeta = {
@@ -142,6 +161,41 @@ export class GitWorktreeDriver implements WorkspaceBackendDriver {
142
161
  }
143
162
  }
144
163
 
164
+ /**
165
+ * Did the worktree arrive despite the command reporting failure?
166
+ *
167
+ * Answers only for the branch this call created. A path registered
168
+ * without that branch is not this call's worktree — it is a leftover
169
+ * from a killed attempt, or a checkout somebody else owns, and the two
170
+ * are indistinguishable from here. Claiming either would mean handing
171
+ * a caller a workspace whose contents nobody vouched for, so both are
172
+ * left to surface as the failure they are.
173
+ *
174
+ * Any error while checking is itself a "no". This runs on a path that
175
+ * has already gone wrong once, and guessing optimistically there is how
176
+ * a recovery turns a bad situation into a wrong one.
177
+ */
178
+ private async createdDespite(worktreePath: string, branch: string): Promise<boolean> {
179
+ try {
180
+ const { stdout } = await this.exec('git', [
181
+ '-C',
182
+ this.repoRoot,
183
+ 'worktree',
184
+ 'list',
185
+ '--porcelain',
186
+ ])
187
+ const entry = parseWorktreeList(stdout, worktreePath)
188
+ // `--porcelain` writes the branch as a full ref (`refs/heads/x`),
189
+ // and `branch` here is the short name this call passed to `-b`.
190
+ // Comparing them directly is a check that can never pass, which
191
+ // would make this whole recovery path silently dead — the exact
192
+ // shape it exists to catch.
193
+ return entry?.branch === `refs/heads/${branch}`
194
+ } catch {
195
+ return false
196
+ }
197
+ }
198
+
145
199
  async inspect(ref: WorkspaceRef): Promise<WorkspaceInspection> {
146
200
  let listStdout: string
147
201
  try {
@@ -60,6 +60,27 @@ const inputSchema = z.object({
60
60
 
61
61
  type BashInput = z.infer<typeof inputSchema>
62
62
 
63
+ /**
64
+ * The last line worth showing from one chunk of streamed output.
65
+ *
66
+ * A progress line is a status, not a log: the host renders one line and
67
+ * replaces it as the next arrives, so sending a whole chunk sends a wall
68
+ * of text into a slot that shows one line of it. A chunk usually ends
69
+ * mid-line and usually ends with a newline, so the last NON-EMPTY line is
70
+ * the most recent complete thing the command actually said.
71
+ *
72
+ * Progress is capped rather than truncated with an ellipsis: this is
73
+ * glanced at, and a marker in a line nobody reads to the end is noise.
74
+ */
75
+ function lastNonEmptyLine(chunk: string): string | undefined {
76
+ const lines = chunk.split('\n')
77
+ for (let i = lines.length - 1; i >= 0; i--) {
78
+ const line = lines[i]?.trim()
79
+ if (line) return line.length > 160 ? line.slice(0, 160) : line
80
+ }
81
+ return undefined
82
+ }
83
+
63
84
  function isDangerousCommand(command: string): boolean {
64
85
  return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command))
65
86
  }
@@ -114,6 +135,19 @@ export const BashTool = defineTool({
114
135
  // Same reason as the host path below: a Stop must reach the
115
136
  // process, not just the promise waiting on it.
116
137
  signal: context.abortSignal,
138
+ // The worker has always streamed its output; nothing asked for
139
+ // it, so a command that ran for minutes said nothing until it
140
+ // exited. `report` is ephemeral by design — it answers "is it
141
+ // still working?" for a live view and is excluded from the
142
+ // durable transcript — so this is a progress signal, not a
143
+ // second copy of the output. `result.stdout` remains the
144
+ // answer the model is given.
145
+ onOutput: context.report
146
+ ? ({ data }) => {
147
+ const line = lastNonEmptyLine(data)
148
+ if (line) context.report?.(line)
149
+ }
150
+ : undefined,
117
151
  })
118
152
 
119
153
  if (result.timedOut) {
@@ -7,6 +7,7 @@ import { defineTool } from '../defineTool.js'
7
7
  import { wrapUntrusted } from '../untrusted-envelope.js'
8
8
  import { failureLabel, taskSucceeded } from './outcome.js'
9
9
 
10
+ import { DELEGATION_TIMEOUT_MS } from './index.js'
10
11
  import type { TaskLaunchedCallback } from './index.js'
11
12
 
12
13
  /**
@@ -107,6 +108,17 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
107
108
  readOnly: false,
108
109
  destructive: false,
109
110
  concurrencySafe: true,
111
+ // Declaring nothing here does not mean "no deadline"; it means the
112
+ // executor's 120-second default, which is a bound for a tool call and
113
+ // absurd for a whole agent run. `create_task` in the sibling module
114
+ // carries the same reasoning and the same hour, and the measurement
115
+ // behind that number is in its docblock: three delegated children took
116
+ // 4m21s, 5m58s and 8m04s, and all three parents gave up at 120s.
117
+ //
118
+ // This surface did not get that fix when its twin did, and the file's
119
+ // own note above records the pair doing exactly this before. The two
120
+ // tools are twins; a bound applied to one of them is not applied.
121
+ timeoutMs: DELEGATION_TIMEOUT_MS,
110
122
  ...(opts.terminal !== undefined ? { terminal: opts.terminal } : {}),
111
123
  async execute({ description, prompt, subagent_type }, context) {
112
124
  // With a single registered subagent the type is optional — default to
@@ -157,6 +157,73 @@ const askUserQuestionModelInputSchema: Record<string, unknown> = {
157
157
  additionalProperties: false,
158
158
  }
159
159
 
160
+ /** One well-formed tag token: `<step>`, `</step>`, `<a href="…">`, `<br/>`. */
161
+ const TAG_TOKEN = /<\/?[A-Za-z][\w-]*(?:\s[^<>]*)?\/?>/g
162
+ const DESCRIPTION_BLOCK = /<description>([\s\S]*?)<\/description>/gi
163
+
164
+ /**
165
+ * Remove every tag token, including the ones removing a tag creates.
166
+ *
167
+ * One pass is not enough and the reason is not obvious: deleting an inner
168
+ * tag can splice its neighbours into a new one. `<<step>step>` loses the
169
+ * inner `<step>` and the halves close up into `<step>` again, so a line
170
+ * that is nothing but markup comes back non-empty and is offered to a
171
+ * human as a step to approve — which is the exact outcome this whole path
172
+ * exists to prevent.
173
+ *
174
+ * Repeating to a fixed point terminates: every pass that changes the
175
+ * string removes at least one token and so strictly shortens it.
176
+ *
177
+ * Only ever used to ANSWER "is there anything here besides markup". The
178
+ * result is never shown to anyone, so this is a test rather than a
179
+ * sanitiser, and it does not have to defend against every way a tag can
180
+ * be spelled.
181
+ */
182
+ function withoutTags(text: string): string {
183
+ let current = text
184
+ for (;;) {
185
+ const next = current.replace(TAG_TOKEN, '')
186
+ if (next === current) return current
187
+ current = next
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Peel tag wrappers off the ENDS of one line, and nowhere else — a step
193
+ * that legitimately says "wrap it in a <div>" keeps its sentence.
194
+ */
195
+ function unwrapStepLine(line: string): string {
196
+ let text = line.trim()
197
+ for (;;) {
198
+ const next = text
199
+ .replace(/^<[A-Za-z][\w-]*(?:\s[^<>]*)?>\s*/, '')
200
+ .replace(/\s*<\/[A-Za-z][\w-]*>$/, '')
201
+ .trim()
202
+ if (next === text) break
203
+ text = next
204
+ }
205
+ return text
206
+ }
207
+
208
+ /**
209
+ * A step list the model serialized instead of building.
210
+ *
211
+ * The line-splitting fallback below is the general case, and it had one
212
+ * shape badly wrong. A model that serializes this array tends to reach for
213
+ * MARKUP, not for prose:
214
+ *
215
+ * <steps>
216
+ * <step>
217
+ * <description>Convert the document to Word</description>
218
+ * </step>
219
+ * </steps>
220
+ *
221
+ * Split on newlines, that is seven "steps", five of which are tags. A host
222
+ * then numbered them in an approval card and asked a person to approve
223
+ * `</steps>` — reported from a real run. The descriptions the model named
224
+ * are right there, so read them; fall back to lines only when there are
225
+ * none, and drop the lines that carry no words at all.
226
+ */
160
227
  function normalizeApprovePlanSteps(value: unknown): unknown {
161
228
  if (typeof value !== 'string') return value
162
229
 
@@ -171,19 +238,84 @@ function normalizeApprovePlanSteps(value: unknown): unknown {
171
238
  }
172
239
  }
173
240
 
241
+ const described = [...trimmed.matchAll(DESCRIPTION_BLOCK)]
242
+ .map((match) => (match[1] ?? '').trim())
243
+ .filter(Boolean)
244
+ if (described.length > 0) {
245
+ return described.map((description) => ({ description }))
246
+ }
247
+
174
248
  const lines = trimmed
175
249
  .split(/\r?\n+/)
176
250
  .map((line) =>
177
- line
178
- .trim()
179
- .replace(/^(?:[-*•]|\d+[.)])\s*/, '')
180
- .trim(),
251
+ unwrapStepLine(
252
+ line
253
+ .trim()
254
+ .replace(/^(?:[-*•]|\d+[.)])\s*/, '')
255
+ .trim(),
256
+ ),
181
257
  )
182
- .filter(Boolean)
258
+ .filter((line) => line.length > 0 && withoutTags(line).trim().length > 0)
259
+
260
+ // Every line was markup: there is no plan in this string, and inventing
261
+ // one step reading `<steps>` is worse than saying so.
262
+ if (lines.length === 0) {
263
+ return withoutTags(unwrapStepLine(trimmed)).trim()
264
+ ? [{ description: unwrapStepLine(trimmed) }]
265
+ : []
266
+ }
183
267
 
184
- return (lines.length ? lines : [trimmed]).map((description) => ({
185
- description,
186
- }))
268
+ return lines.map((description) => ({ description }))
269
+ }
270
+
271
+ /**
272
+ * The single closed shape a capable provider constrains this call to —
273
+ * the same instrument `ask_user_question` carries, for the same failure.
274
+ *
275
+ * `steps` arriving as a STRING is what everything above exists to survive,
276
+ * and surviving it is not the same as preventing it: the normalizer can
277
+ * only guess at a structure the model already threw away. Advertising the
278
+ * closed shape turns the guess into a refusal at generation time.
279
+ */
280
+ const approvePlanModelInputSchema: Record<string, unknown> = {
281
+ type: 'object',
282
+ properties: {
283
+ title: {
284
+ type: 'string',
285
+ description: 'Short title for the plan (e.g. "TypeScript Security & Performance Review").',
286
+ },
287
+ summary: {
288
+ type: 'string',
289
+ description: '1-3 sentence summary of what you plan to do.',
290
+ },
291
+ steps: {
292
+ type: 'array',
293
+ description:
294
+ 'A JSON array of ordered step objects. Never a string, and never markup — no <step> or <description> tags.',
295
+ items: {
296
+ type: 'object',
297
+ properties: {
298
+ description: {
299
+ type: 'string',
300
+ description: 'What this step does, as one plain sentence a person can read.',
301
+ },
302
+ agent_id: {
303
+ type: 'string',
304
+ description: 'Which agent handles this; omit for steps you carry out yourself.',
305
+ },
306
+ depends_on: {
307
+ type: 'array',
308
+ items: { type: 'string' },
309
+ description: 'Descriptions of the steps that must finish before this one.',
310
+ },
311
+ },
312
+ required: ['description'],
313
+ additionalProperties: false,
314
+ },
315
+ },
316
+ },
317
+ required: ['title', 'summary', 'steps'],
318
+ additionalProperties: false,
187
319
  }
188
320
 
189
321
  /**
@@ -1013,6 +1145,10 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
1013
1145
  .preprocess(normalizeApprovePlanSteps, z.array(approvePlanStepSchema))
1014
1146
  .describe('Ordered list of planned steps'),
1015
1147
  }),
1148
+ modelInputSchema: structuredClone(approvePlanModelInputSchema),
1149
+ enforceModelInput: true,
1150
+ validationErrorHint:
1151
+ 'Required shape: {"title":"…","summary":"…","steps":[{"description":"One plain sentence"}]}. "steps" must be a JSON array of objects — never a string, and never markup such as <step> or <description>.',
1016
1152
  category: 'custom',
1017
1153
  permissions: [],
1018
1154
  readOnly: true,
@@ -18,7 +18,24 @@ export interface MCPStdioTransportConfig extends MCPTransportConfigBase {
18
18
  type: 'stdio'
19
19
  command: string
20
20
  args?: string[]
21
+ /** Literal values for the child. Highest precedence. */
21
22
  env?: Record<string, string>
23
+ /**
24
+ * Parent variables the child may have, named one at a time.
25
+ *
26
+ * The spawn used to pass the whole parent environment, so a server that
27
+ * needed one token received every credential the host held. It now gets
28
+ * process plumbing plus what is named here and in `env`, which is what
29
+ * makes the grant reviewable: the config says which secrets cross the
30
+ * boundary instead of the answer being "all of them".
31
+ *
32
+ * Use this rather than `env` for a credential — `env` puts the value in the
33
+ * config file, and this keeps it in the environment where it already lives.
34
+ *
35
+ * A name the parent does not hold is absent from the child rather than
36
+ * empty, and does not fail the spawn.
37
+ */
38
+ inheritEnv?: readonly string[]
22
39
  cwd?: string
23
40
  }
24
41
 
@@ -73,7 +73,23 @@ export interface LLMProvider {
73
73
  */
74
74
  probeCredential?(): Promise<void>
75
75
 
76
- healthCheck?(): Promise<boolean>
76
+ /**
77
+ * Is this driver able to serve traffic? A summary bit; `doctorCheck` is
78
+ * the same probe with its reasoning intact.
79
+ *
80
+ * `model` is the model the CALLER intends to run, and it is a parameter
81
+ * rather than something the driver reads off its own config because at
82
+ * least one driver's config does not carry a model at all. That driver
83
+ * hardcoded an id instead, which is how its check came to probe a model
84
+ * nobody used — and, once the id went stale, could not pass at any
85
+ * credential, region or service state. A health check against a model the
86
+ * operator does not run tests the wrong thing even while the id is valid.
87
+ *
88
+ * Optional, and a driver may ignore it: one that probes an endpoint rather
89
+ * than a model has nothing to do with the argument. Passing it is always
90
+ * safe.
91
+ */
92
+ healthCheck?(model?: string): Promise<boolean>
77
93
 
78
94
  /**
79
95
  * Optional structured health probe used by `runDoctor()`.
@@ -82,8 +98,13 @@ export interface LLMProvider {
82
98
  * (latency, model availability, auth status, …). Providers that
83
99
  * cannot be cheaply probed should return `{ status: 'inconclusive' }`
84
100
  * so the doctor doesn't mark them as failing — see ses_007 Q6.4.
101
+ *
102
+ * Takes `model` for the reason `healthCheck` does, and a driver is free to
103
+ * return a SUBTYPE of `DoctorCheckResult` carrying its own machine-readable
104
+ * detail. `status` is what `runDoctor()` reads; a caller holding the
105
+ * concrete driver can read more.
85
106
  */
86
- doctorCheck?(): Promise<DoctorCheckResult>
107
+ doctorCheck?(model?: string): Promise<DoctorCheckResult>
87
108
 
88
109
  /**
89
110
  * Which {@link ChatCompletionParams.effort} levels this model accepts,
@@ -103,6 +103,29 @@ export interface SandboxExecOptions {
103
103
  readonly timeout?: number
104
104
  readonly env?: Record<string, string>
105
105
  readonly cwd?: string
106
+ /**
107
+ * Called as output arrives, before the command has finished.
108
+ *
109
+ * Every container-tier worker already streams its output a chunk at a
110
+ * time — the wire carries `stdout_delta` and `stderr_delta` events —
111
+ * and every backend concatenated them into a string and returned that
112
+ * when the process exited. So a command that takes eight minutes said
113
+ * nothing for eight minutes, on a transport that had been reporting
114
+ * the whole time.
115
+ *
116
+ * Additive and optional: a backend that cannot stream simply never
117
+ * calls it, and `SandboxExecResult.stdout` still carries the complete
118
+ * output either way. A caller that wants only the result ignores this
119
+ * and behaves exactly as before.
120
+ *
121
+ * The callback must not throw and must not be awaited — it is on the
122
+ * read path of a running process, so a slow or failing consumer would
123
+ * otherwise become a slow or failing command.
124
+ */
125
+ readonly onOutput?: (chunk: {
126
+ readonly stream: 'stdout' | 'stderr'
127
+ readonly data: string
128
+ }) => void
106
129
  /**
107
130
  * Cancellation for the command. A backend that honours it kills the
108
131
  * process; one that does not simply ignores it, so this is additive.