@namzu/sdk 26.0.0 → 27.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/dist/compaction/manager.d.ts +22 -0
  3. package/dist/compaction/manager.d.ts.map +1 -1
  4. package/dist/compaction/manager.js +31 -3
  5. package/dist/compaction/manager.js.map +1 -1
  6. package/dist/connector/mcp/adapter.d.ts +7 -1
  7. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  8. package/dist/connector/mcp/adapter.js +12 -1
  9. package/dist/connector/mcp/adapter.js.map +1 -1
  10. package/dist/connector/mcp/index.d.ts +1 -0
  11. package/dist/connector/mcp/index.d.ts.map +1 -1
  12. package/dist/connector/mcp/index.js +3 -0
  13. package/dist/connector/mcp/index.js.map +1 -1
  14. package/dist/connector/mcp/prompt-adapter.d.ts +10 -4
  15. package/dist/connector/mcp/prompt-adapter.d.ts.map +1 -1
  16. package/dist/connector/mcp/prompt-adapter.js +10 -4
  17. package/dist/connector/mcp/prompt-adapter.js.map +1 -1
  18. package/dist/connector/mcp/server-stdio.d.ts +62 -0
  19. package/dist/connector/mcp/server-stdio.d.ts.map +1 -0
  20. package/dist/connector/mcp/server-stdio.js +121 -0
  21. package/dist/connector/mcp/server-stdio.js.map +1 -0
  22. package/dist/eval/experiment.d.ts +0 -6
  23. package/dist/eval/experiment.d.ts.map +1 -1
  24. package/dist/eval/experiment.js +28 -0
  25. package/dist/eval/experiment.js.map +1 -1
  26. package/dist/eval/index.d.ts +2 -0
  27. package/dist/eval/index.d.ts.map +1 -1
  28. package/dist/eval/index.js +1 -0
  29. package/dist/eval/index.js.map +1 -1
  30. package/dist/eval/types.d.ts +17 -0
  31. package/dist/eval/types.d.ts.map +1 -1
  32. package/dist/eval/uncertainty.d.ts +64 -0
  33. package/dist/eval/uncertainty.d.ts.map +1 -0
  34. package/dist/eval/uncertainty.js +92 -0
  35. package/dist/eval/uncertainty.js.map +1 -0
  36. package/dist/public-runtime.d.ts +1 -0
  37. package/dist/public-runtime.d.ts.map +1 -1
  38. package/dist/public-runtime.js +1 -0
  39. package/dist/public-runtime.js.map +1 -1
  40. package/dist/registry/tool/execute.d.ts.map +1 -1
  41. package/dist/registry/tool/execute.js +2 -1
  42. package/dist/registry/tool/execute.js.map +1 -1
  43. package/dist/session/workspace/git-worktree.d.ts +15 -0
  44. package/dist/session/workspace/git-worktree.d.ts.map +1 -1
  45. package/dist/session/workspace/git-worktree.js +55 -1
  46. package/dist/session/workspace/git-worktree.js.map +1 -1
  47. package/dist/tools/builtins/bash.d.ts.map +1 -1
  48. package/dist/tools/builtins/bash.js +35 -0
  49. package/dist/tools/builtins/bash.js.map +1 -1
  50. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  51. package/dist/tools/coordinator/agent.js +12 -0
  52. package/dist/tools/coordinator/agent.js.map +1 -1
  53. package/dist/tools/coordinator/index.d.ts.map +1 -1
  54. package/dist/tools/coordinator/index.js +134 -6
  55. package/dist/tools/coordinator/index.js.map +1 -1
  56. package/dist/tools/trusted-read-only.d.ts +42 -0
  57. package/dist/tools/trusted-read-only.d.ts.map +1 -0
  58. package/dist/tools/trusted-read-only.js +51 -0
  59. package/dist/tools/trusted-read-only.js.map +1 -0
  60. package/dist/tools/untrusted-envelope.d.ts +22 -2
  61. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  62. package/dist/tools/untrusted-envelope.js +22 -2
  63. package/dist/tools/untrusted-envelope.js.map +1 -1
  64. package/dist/types/sandbox/index.d.ts +23 -0
  65. package/dist/types/sandbox/index.d.ts.map +1 -1
  66. package/dist/types/sandbox/index.js.map +1 -1
  67. package/dist/types/tool/index.d.ts +28 -0
  68. package/dist/types/tool/index.d.ts.map +1 -1
  69. package/dist/types/tool/index.js.map +1 -1
  70. package/dist/verification/rules.d.ts.map +1 -1
  71. package/dist/verification/rules.js +5 -1
  72. package/dist/verification/rules.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/compaction/manager.ts +39 -3
  75. package/src/connector/mcp/adapter.ts +11 -0
  76. package/src/connector/mcp/index.ts +3 -0
  77. package/src/connector/mcp/prompt-adapter.ts +10 -4
  78. package/src/connector/mcp/server-stdio.ts +137 -0
  79. package/src/eval/experiment.ts +30 -0
  80. package/src/eval/index.ts +2 -0
  81. package/src/eval/types.ts +17 -0
  82. package/src/eval/uncertainty.ts +124 -0
  83. package/src/public-runtime.ts +1 -0
  84. package/src/registry/tool/execute.ts +2 -1
  85. package/src/session/workspace/git-worktree.ts +55 -1
  86. package/src/tools/builtins/bash.ts +34 -0
  87. package/src/tools/coordinator/agent.ts +12 -0
  88. package/src/tools/coordinator/index.ts +144 -8
  89. package/src/tools/trusted-read-only.ts +52 -0
  90. package/src/tools/untrusted-envelope.ts +22 -2
  91. package/src/types/sandbox/index.ts +23 -0
  92. package/src/types/tool/index.ts +30 -0
  93. package/src/verification/rules.ts +5 -1
@@ -34,10 +34,16 @@ import type { MCPClient } from './client.js'
34
34
  /**
35
35
  * Marks where a remote party's words begin and end.
36
36
  *
37
- * A prompt is composed by a SERVER. Untrusted content arriving through a
38
- * tool result is the standard prompt-injection surface, and the mitigation
39
- * that survives contact is saying plainly whose words these are an
40
- * unlabelled block reads exactly like the agent's own instructions.
37
+ * A prompt is composed by a SERVER. Untrusted content arriving this way is
38
+ * the standard prompt-injection surface, and an unlabelled block reads
39
+ * exactly like the agent's own instructions so this says whose words
40
+ * they are.
41
+ *
42
+ * Marking, not stopping. See `tools/untrusted-envelope.ts` for the
43
+ * measurement: delimiting reports near-zero attack success on a static
44
+ * benchmark and above 95% once the attacker adapts (arXiv:2510.09023).
45
+ * This paragraph used to call it "the mitigation that survives contact",
46
+ * which was the same overstatement in a second file.
41
47
  */
42
48
  export function renderPromptMessages(
43
49
  serverName: string,
@@ -0,0 +1,137 @@
1
+ import type { Readable, Writable } from 'node:stream'
2
+
3
+ import type { MCPJsonRpcMessage, MCPTransport } from '../../types/connector/mcp.js'
4
+ import { type Logger, getRootLogger } from '../../utils/logger.js'
5
+
6
+ /**
7
+ * The server half of stdio, so `MCPServer` has something to run on.
8
+ *
9
+ * `MCPServer` is a complete implementation — `initialize`, `tools/list`,
10
+ * `tools/call`, resource and prompt providers — and nothing anywhere
11
+ * constructed one, because every transport in this directory is the
12
+ * CLIENT side. `StdioTransport` spawns a child and talks to its streams;
13
+ * this reads the streams THIS process was given. Same interface, opposite
14
+ * end of the pipe.
15
+ *
16
+ * Stdio is the first transport rather than an afterthought: a client
17
+ * spawns the server as a child process, so there is no port, no bind
18
+ * address and no inbound authentication question to get wrong. The
19
+ * boundary is the process, and the process was started by the client.
20
+ *
21
+ * **stdout belongs to the protocol.** A single stray `console.log`
22
+ * anywhere in the process corrupts the message stream, and the symptom is
23
+ * a client that reports malformed JSON rather than anything naming the
24
+ * culprit. This repository's logger already writes to stderr, which is
25
+ * what makes this transport safe to add; keep it that way.
26
+ */
27
+ export class ServerStdioTransport implements MCPTransport {
28
+ private readonly input: Readable
29
+ private readonly output: Writable
30
+ private buffer = ''
31
+ private connected = false
32
+ private readonly log: Logger
33
+
34
+ private messageHandler?: (message: MCPJsonRpcMessage) => void
35
+ private closeHandler?: () => void
36
+ private errorHandler?: (error: Error) => void
37
+
38
+ private readonly onData = (chunk: Buffer | string): void => this.consume(chunk)
39
+ private readonly onEnd = (): void => {
40
+ this.connected = false
41
+ this.closeHandler?.()
42
+ }
43
+ private readonly onStreamError = (err: Error): void => this.errorHandler?.(err)
44
+
45
+ /**
46
+ * Streams are injected rather than read from `process` directly so a
47
+ * test can drive this without owning the process's own stdio — a test
48
+ * that replaced `process.stdin` would break every other test sharing
49
+ * the runner.
50
+ */
51
+ constructor(streams?: { input?: Readable; output?: Writable }) {
52
+ this.input = streams?.input ?? process.stdin
53
+ this.output = streams?.output ?? process.stdout
54
+ this.log = getRootLogger().child({ component: 'ServerStdioTransport' })
55
+ }
56
+
57
+ async connect(): Promise<void> {
58
+ if (this.connected) return
59
+ this.input.setEncoding?.('utf8')
60
+ this.input.on('data', this.onData)
61
+ this.input.on('end', this.onEnd)
62
+ this.input.on('error', this.onStreamError)
63
+ this.connected = true
64
+ this.log.info('MCP stdio server listening on this process')
65
+ }
66
+
67
+ async close(): Promise<void> {
68
+ if (!this.connected) return
69
+ this.input.off('data', this.onData)
70
+ this.input.off('end', this.onEnd)
71
+ this.input.off('error', this.onStreamError)
72
+ this.connected = false
73
+ this.closeHandler?.()
74
+ }
75
+
76
+ async send(message: MCPJsonRpcMessage): Promise<void> {
77
+ // Newline-delimited JSON, one message per line. A message carrying a
78
+ // literal newline would split into two unparseable halves, so it is
79
+ // stripped by `JSON.stringify` escaping rather than by trusting the
80
+ // payload.
81
+ this.output.write(`${JSON.stringify(message)}\n`)
82
+ }
83
+
84
+ onMessage(handler: (message: MCPJsonRpcMessage) => void): void {
85
+ this.messageHandler = handler
86
+ }
87
+
88
+ onClose(handler: () => void): void {
89
+ this.closeHandler = handler
90
+ }
91
+
92
+ onError(handler: (error: Error) => void): void {
93
+ this.errorHandler = handler
94
+ }
95
+
96
+ isConnected(): boolean {
97
+ return this.connected
98
+ }
99
+
100
+ /**
101
+ * A chunk is not a message. Reads arrive at whatever size the pipe
102
+ * hands over, so one read can carry half a message, several messages,
103
+ * or the tail of one and the head of the next. Buffering until a
104
+ * newline is the whole protocol framing.
105
+ */
106
+ private consume(chunk: Buffer | string): void {
107
+ this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
108
+
109
+ let newline = this.buffer.indexOf('\n')
110
+ while (newline !== -1) {
111
+ const line = this.buffer.slice(0, newline).trim()
112
+ this.buffer = this.buffer.slice(newline + 1)
113
+ if (line) this.deliver(line)
114
+ newline = this.buffer.indexOf('\n')
115
+ }
116
+ }
117
+
118
+ private deliver(line: string): void {
119
+ let message: MCPJsonRpcMessage
120
+ try {
121
+ message = JSON.parse(line) as MCPJsonRpcMessage
122
+ } catch (err) {
123
+ // Reported, not thrown, and not fatal. A client that sends one bad
124
+ // line has not ended the session, and killing the transport over it
125
+ // would take down every other conversation on this pipe. The
126
+ // alternative — silence — is worse: a request that vanishes looks
127
+ // to the client exactly like a server that hung.
128
+ this.errorHandler?.(
129
+ new Error(
130
+ `MCP stdio server could not parse a message: ${err instanceof Error ? err.message : String(err)}`,
131
+ ),
132
+ )
133
+ return
134
+ }
135
+ this.messageHandler?.(message)
136
+ }
137
+ }
@@ -7,6 +7,8 @@ import type {
7
7
  Score,
8
8
  Scorer,
9
9
  } from './types.js'
10
+ import type { ScoreUncertainty } from './uncertainty.js'
11
+ import { describeUncertainty, uncertaintyOf } from './uncertainty.js'
10
12
 
11
13
  export interface ExperimentConfig<TInput = unknown> {
12
14
  name: string
@@ -151,6 +153,10 @@ export async function runExperiment<TInput>(
151
153
  name: config.name,
152
154
  cases: settled,
153
155
  mean,
156
+ // Over the same cases the mean is over. Computing spread across a
157
+ // different denominator than the average it qualifies would produce
158
+ // an interval that does not belong to the number beside it.
159
+ uncertainty: uncertaintyOf(scored.map((r) => r.mean)),
154
160
  passed: settled.filter((r) => r.status === 'passed').length,
155
161
  failed: settled.filter((r) => r.status === 'failed').length,
156
162
  inconclusive: settled.filter((r) => r.status === 'inconclusive').length,
@@ -278,9 +284,33 @@ function meanByScorer(results: readonly CaseResult[]): Record<string, number> {
278
284
  * Failures print their scorer reasons, because a CI log that says
279
285
  * "0.62" is a log that sends someone back to reproduce it by hand.
280
286
  */
287
+ /**
288
+ * Uncertainty for a report that did not carry its own.
289
+ *
290
+ * A suite file is loaded at runtime and may be plain JavaScript, so a
291
+ * report can reach here hand-built — the type cannot stop it. Deriving
292
+ * from the cases it does carry is better than either alternative:
293
+ * printing the mean alone leaves the reader where they started, and
294
+ * refusing to format would turn a missing convenience into a broken
295
+ * command.
296
+ *
297
+ * Uses the same exclusion `runExperiment` uses, so a derived interval and
298
+ * a carried one are the same number rather than two conventions.
299
+ */
300
+ function derivedUncertainty(report: ExperimentReport): ScoreUncertainty {
301
+ return uncertaintyOf(report.cases.filter((c) => c.status !== 'inconclusive').map((c) => c.mean))
302
+ }
303
+
281
304
  export function formatReport(report: ExperimentReport): string {
282
305
  const lines: string[] = [
283
306
  `${report.name}: ${report.passed}/${report.cases.length} passed (mean ${report.mean.toFixed(2)}) in ${report.durationMs}ms`,
307
+ // On its own line and always printed, including when the interval is
308
+ // undefined. A mean printed alone is the thing that has been
309
+ // over-read: two runs three points apart look like a difference, and
310
+ // at the n a hand-built suite has they are usually the same run
311
+ // twice. Computing the interval and not showing it would leave the
312
+ // reader exactly where they started.
313
+ ` ${describeUncertainty(report.mean, report.uncertainty ?? derivedUncertainty(report))}`,
284
314
  '',
285
315
  ]
286
316
 
package/src/eval/index.ts CHANGED
@@ -10,6 +10,8 @@ export {
10
10
  stepBudgetScorer,
11
11
  trajectoryScorer,
12
12
  } from './scorers.js'
13
+ export { describeUncertainty, uncertaintyOf } from './uncertainty.js'
14
+ export type { ScoreUncertainty } from './uncertainty.js'
13
15
  export type {
14
16
  CaseResult,
15
17
  CaseStatus,
package/src/eval/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { StepResult } from '../types/run/step.js'
2
+ import type { ScoreUncertainty } from './uncertainty.js'
2
3
 
3
4
  /**
4
5
  * One case in a dataset: an input, and what a good run looks like.
@@ -143,6 +144,22 @@ export interface ExperimentReport {
143
144
  cases: readonly CaseResult[]
144
145
  /** Mean score across the cases that produced one. */
145
146
  mean: number
147
+ /**
148
+ * How much of {@link mean} is signal.
149
+ *
150
+ * A mean on its own has been read as a result, and at the n a
151
+ * hand-built suite has it usually is not one: two runs three points
152
+ * apart are normally the same run twice.
153
+ *
154
+ * Optional, and deliberately so after trying it the other way. A suite
155
+ * file is loaded at runtime and may be plain JavaScript, so a required
156
+ * field is not enforced at the boundary that matters — it buys type
157
+ * safety for one kind of consumer and a crash for the other. Producers
158
+ * that go through `runExperiment` always set it; `formatReport` derives
159
+ * it from {@link cases} when a hand-built report does not, so no report
160
+ * is printed without an interval either way.
161
+ */
162
+ uncertainty?: ScoreUncertainty
146
163
  passed: number
147
164
  failed: number
148
165
  /** Cases where no scorer could produce a judgement. */
@@ -0,0 +1,124 @@
1
+ /**
2
+ * How much of a score is signal.
3
+ *
4
+ * A suite reported a mean and nothing else, so two runs differing by three
5
+ * points read as a difference. At the n a hand-built suite has, that is
6
+ * usually noise, and there was no number on the page that would have said
7
+ * so.
8
+ *
9
+ * Evan Miller, "Adding Error Bars to Evals" (arXiv:2411.00640), is the
10
+ * reference. Two of its results shape what is and is not computed here.
11
+ */
12
+
13
+ /**
14
+ * Two-sided 95% critical values of Student's t, by degrees of freedom.
15
+ *
16
+ * The normal approximation (1.96) is what most harnesses use and it is
17
+ * wrong in the direction that matters: at n=5 the true multiplier is 2.78,
18
+ * so a normal interval is nearly 30% too narrow exactly where a suite is
19
+ * small enough for that to mislead. Eval suites are small; this table is
20
+ * the difference between an interval that covers and one that flatters.
21
+ */
22
+ const T_95: readonly number[] = [
23
+ 12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, 2.201, 2.179, 2.16, 2.145,
24
+ 2.131, 2.12, 2.11, 2.101, 2.093, 2.086, 2.08, 2.074, 2.069, 2.064, 2.06, 2.056, 2.052, 2.048,
25
+ 2.045, 2.042,
26
+ ]
27
+
28
+ function critical95(df: number): number {
29
+ if (df < 1) return Number.NaN
30
+ // Beyond 30 the t value is within ~1% of the normal, and pretending
31
+ // otherwise would imply a precision the rest of this does not have.
32
+ return T_95[df - 1] ?? 1.96
33
+ }
34
+
35
+ export interface ScoreUncertainty {
36
+ /** Cases that produced a score. Not the number of cases run. */
37
+ readonly n: number
38
+ /** Sample standard deviation, Bessel-corrected. */
39
+ readonly stdDev: number
40
+ /** Standard error of the mean. */
41
+ readonly stdError: number
42
+ /** Half-width of the 95% interval: the mean plus or minus this. */
43
+ readonly margin95: number
44
+ /**
45
+ * The 95% interval, clamped to the score range.
46
+ *
47
+ * Clamped because a mean of 0.95 with a wide interval otherwise reports
48
+ * an upper bound above 1, which is not a possible score and makes a
49
+ * reader distrust the whole figure. The clamp is cosmetic and the
50
+ * margin above is not — read that one for the width.
51
+ */
52
+ readonly ci95: readonly [low: number, high: number]
53
+ /**
54
+ * True when there is not enough data for an interval at all.
55
+ *
56
+ * One case has no spread to measure. Reporting `±0` there would be the
57
+ * most confident-looking output the suite can produce, from the least
58
+ * evidence it can have.
59
+ */
60
+ readonly undefinedInterval: boolean
61
+ }
62
+
63
+ /**
64
+ * Spread of a set of scores, with the interval a reader should apply.
65
+ *
66
+ * **Assumes the cases are independent, and they may not be.** Miller's
67
+ * clustered standard errors run up to 3x the naive figure when cases come
68
+ * in related groups — several cases derived from one scenario, or one
69
+ * document, or one seed. This harness has no grouping key on a case, so
70
+ * there is nothing here to cluster on and this returns the naive figure.
71
+ * Where a suite does build several cases from one source, treat the
72
+ * interval below as a floor rather than as the answer.
73
+ *
74
+ * Stated rather than silently assumed because a too-narrow interval is
75
+ * worse than none: it turns "we cannot tell" into a number that looks
76
+ * like we can.
77
+ */
78
+ export function uncertaintyOf(scores: readonly number[]): ScoreUncertainty {
79
+ const n = scores.length
80
+ if (n < 2) {
81
+ return {
82
+ n,
83
+ stdDev: 0,
84
+ stdError: 0,
85
+ margin95: Number.NaN,
86
+ ci95: [Number.NaN, Number.NaN],
87
+ undefinedInterval: true,
88
+ }
89
+ }
90
+
91
+ const mean = scores.reduce((a, b) => a + b, 0) / n
92
+ // Bessel-corrected: dividing by n estimates the spread of THESE cases,
93
+ // and the question is about the suite they were drawn from.
94
+ const variance = scores.reduce((acc, s) => acc + (s - mean) ** 2, 0) / (n - 1)
95
+ const stdDev = Math.sqrt(variance)
96
+ const stdError = stdDev / Math.sqrt(n)
97
+ const margin95 = critical95(n - 1) * stdError
98
+
99
+ return {
100
+ n,
101
+ stdDev,
102
+ stdError,
103
+ margin95,
104
+ ci95: [Math.max(0, mean - margin95), Math.min(1, mean + margin95)],
105
+ undefinedInterval: false,
106
+ }
107
+ }
108
+
109
+ /**
110
+ * One line a reader can act on, for a surface that prints a score.
111
+ *
112
+ * Names the interval rather than only the mean, because the mean alone is
113
+ * the thing that has been over-read. An interval spanning most of the
114
+ * scale says the suite cannot currently tell two runs apart, and that is
115
+ * the most useful sentence such a suite can produce.
116
+ */
117
+ export function describeUncertainty(mean: number, u: ScoreUncertainty): string {
118
+ if (u.undefinedInterval) {
119
+ return u.n === 0
120
+ ? 'no scored cases, so no score'
121
+ : `${mean.toFixed(3)} from a single case — no interval, and one case cannot show spread`
122
+ }
123
+ return `${mean.toFixed(3)} ±${u.margin95.toFixed(3)} (95% CI ${u.ci95[0].toFixed(3)}–${u.ci95[1].toFixed(3)}, n=${u.n}); assumes cases are independent`
124
+ }
@@ -63,6 +63,7 @@ export {
63
63
  export { normaliseModelId, resolveModelPricing, VENDOR_RATES } from './pricing/index.js'
64
64
  export { toErrorMessage } from './utils/error.js'
65
65
  export { configureLogger, getRootLogger, Logger } from './utils/logger.js'
66
+ export { isTrustedReadOnly } from './tools/trusted-read-only.js'
66
67
  export { buildToolResultHashes, hashToolResult } from './utils/hash.js'
67
68
  export {
68
69
  compressShellOutput,
@@ -3,6 +3,7 @@ import { assertStrictSchema } from '../../provider/strict-schema.js'
3
3
  import { GENAI, NAMZU, toolSpanName } from '../../telemetry/attributes.js'
4
4
  import { recordToolCall } from '../../telemetry/metrics.js'
5
5
  import { getTracer } from '../../telemetry/runtime-accessors.js'
6
+ import { isTrustedReadOnly } from '../../tools/trusted-read-only.js'
6
7
  import type {
7
8
  LLMToolSchema,
8
9
  ToolAvailability,
@@ -487,7 +488,7 @@ Executable tool names, descriptions, and JSON input schemas are attached through
487
488
 
488
489
  const mode = context.permissionContext?.mode ?? 'auto'
489
490
  if (mode === 'plan') {
490
- const isReadOnly = tool.isReadOnly ? tool.isReadOnly(rawInput) : false
491
+ const isReadOnly = isTrustedReadOnly(tool, rawInput)
491
492
  if (!isReadOnly) {
492
493
  const msg = `plan mode: non-read-only tool "${toolName}" blocked`
493
494
  span.setAttributes({
@@ -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