@namzu/sdk 48.0.0 → 48.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 (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  3. package/dist/connector/mcp/adapter.js +11 -0
  4. package/dist/connector/mcp/adapter.js.map +1 -1
  5. package/dist/connector/mcp/client.d.ts +19 -0
  6. package/dist/connector/mcp/client.d.ts.map +1 -1
  7. package/dist/connector/mcp/client.js +363 -2
  8. package/dist/connector/mcp/client.js.map +1 -1
  9. package/dist/connector/mcp/era.d.ts.map +1 -1
  10. package/dist/connector/mcp/era.js +7 -0
  11. package/dist/connector/mcp/era.js.map +1 -1
  12. package/dist/connector/mcp/streamable-http.d.ts +15 -0
  13. package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
  14. package/dist/connector/mcp/streamable-http.js +281 -6
  15. package/dist/connector/mcp/streamable-http.js.map +1 -1
  16. package/dist/manager/agent/lifecycle.d.ts +1 -0
  17. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  18. package/dist/manager/agent/lifecycle.js +137 -18
  19. package/dist/manager/agent/lifecycle.js.map +1 -1
  20. package/dist/manager/resident/initiative.d.ts +6 -6
  21. package/dist/manager/resident/outbox.d.ts +4 -4
  22. package/dist/scheduler/local.d.ts.map +1 -1
  23. package/dist/scheduler/local.js +2 -0
  24. package/dist/scheduler/local.js.map +1 -1
  25. package/dist/session/retention/archive.js +1 -1
  26. package/dist/session/retention/archive.js.map +1 -1
  27. package/dist/store/session/disk.d.ts.map +1 -1
  28. package/dist/store/session/disk.js +2 -0
  29. package/dist/store/session/disk.js.map +1 -1
  30. package/dist/tools/builtins/browser.js +2 -2
  31. package/dist/tools/builtins/browser.js.map +1 -1
  32. package/dist/types/agent/scheduler.d.ts +6 -1
  33. package/dist/types/agent/scheduler.d.ts.map +1 -1
  34. package/dist/types/agent/task.d.ts +19 -1
  35. package/dist/types/agent/task.d.ts.map +1 -1
  36. package/dist/types/agent/task.js.map +1 -1
  37. package/dist/types/connector/mcp.d.ts +13 -0
  38. package/dist/types/connector/mcp.d.ts.map +1 -1
  39. package/dist/types/session/sub-session.d.ts +2 -0
  40. package/dist/types/session/sub-session.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/connector/mcp/adapter.ts +15 -0
  43. package/src/connector/mcp/client.ts +405 -2
  44. package/src/connector/mcp/era.ts +7 -0
  45. package/src/connector/mcp/streamable-http.ts +302 -5
  46. package/src/manager/agent/lifecycle.ts +158 -19
  47. package/src/scheduler/local.ts +2 -0
  48. package/src/session/retention/archive.ts +1 -1
  49. package/src/store/session/disk.ts +3 -0
  50. package/src/tools/builtins/browser.ts +2 -2
  51. package/src/types/agent/scheduler.ts +6 -1
  52. package/src/types/agent/task.ts +21 -1
  53. package/src/types/connector/mcp.ts +14 -0
  54. package/src/types/session/sub-session.ts +2 -0
@@ -7,7 +7,11 @@ import type {
7
7
  } from '../../types/connector/index.js'
8
8
  import { SCOPE_ATTRIBUTE } from '../../utils/log/types.js'
9
9
  import { type Logger, resolveLogger } from '../../utils/logger.js'
10
- import { ConnectorHttpOperation, validateConnectorTimeoutMs } from '../http-operation.js'
10
+ import {
11
+ ConnectorHttpOperation,
12
+ readConnectorResponseBody,
13
+ validateConnectorTimeoutMs,
14
+ } from '../http-operation.js'
11
15
  import { MCPHttpStatusError } from './errors.js'
12
16
  import { refuseMcpHttpRedirect } from './http-redirect.js'
13
17
 
@@ -21,6 +25,23 @@ const DEFAULT_TIMEOUT_MS = 30_000
21
25
  * an unresponsive peer does not accumulate as a dangling request forever.
22
26
  */
23
27
  const SESSION_DELETE_TIMEOUT_MS = 5_000
28
+ /** SSE streams may live for hours; bound each event and read rather than their lifetime. */
29
+ const MAX_SSE_EVENT_CHARS = 1_048_576
30
+ const MAX_SSE_CHUNK_BYTES = 8_388_608
31
+ /** A single event may contain a JSON-RPC batch; bound its dispatch work too. */
32
+ const MAX_RESPONSE_SSE_MESSAGES_PER_EVENT = 256
33
+ const MAX_SUBSCRIPTION_ERROR_BODY_BYTES = 65_536
34
+
35
+ /** A successful HTTP status can still carry a JSON-RPC listen refusal. */
36
+ export class MCPSubscriptionResponseError extends Error {
37
+ readonly bodyText: string
38
+
39
+ constructor(bodyText: string) {
40
+ super('MCP subscriptions/listen returned JSON instead of an SSE response stream')
41
+ this.name = 'MCPSubscriptionResponseError'
42
+ this.bodyText = bodyText
43
+ }
44
+ }
24
45
 
25
46
  export class StreamableHttpTransport implements MCPTransport {
26
47
  private messageHandlers: Array<(message: MCPJsonRpcMessage) => void> = []
@@ -202,7 +223,7 @@ export class StreamableHttpTransport implements MCPTransport {
202
223
  // POST capable of poisoning every later request on this connection.
203
224
  if (message.method === 'initialize') this.captureSessionId(response)
204
225
 
205
- await this.dispatchResponseMessages(response, operation, owned.generation)
226
+ await this.dispatchResponseMessages(response, operation, owned.generation, message.id)
206
227
  } finally {
207
228
  // A streamable HTTP send owns one POST; its rejection is delivered to
208
229
  // that caller. It is not evidence that the logical MCP session died.
@@ -213,6 +234,124 @@ export class StreamableHttpTransport implements MCPTransport {
213
234
  }
214
235
  }
215
236
 
237
+ /**
238
+ * Modern MCP subscriptions are long-lived POST response streams. The ordinary
239
+ * `send` has a request deadline and stops at its terminal reply; a
240
+ * subscription may remain open indefinitely. The client owns the initial
241
+ * acknowledgment deadline and this stream's abort signal.
242
+ */
243
+ async sendSubscription(
244
+ message: MCPJsonRpcMessage,
245
+ options: MCPTransportSendOptions,
246
+ onMessage?: (message: MCPJsonRpcMessage) => void,
247
+ ): Promise<void> {
248
+ if (message.method !== 'subscriptions/listen' || message.id === undefined) {
249
+ throw new Error('sendSubscription requires a subscriptions/listen request')
250
+ }
251
+ if (!this.connected) throw new Error('StreamableHttpTransport: not connected')
252
+ const owned = this.beginSend(options.signal)
253
+ try {
254
+ const response = await this.fetchImpl(this.config.url, {
255
+ method: 'POST',
256
+ headers: this.buildHeaders(options.headers),
257
+ body: JSON.stringify(message),
258
+ redirect: 'manual',
259
+ signal: owned.controller.signal,
260
+ })
261
+ refuseMcpHttpRedirect(response, message.method)
262
+ if (!response.ok) {
263
+ throw new MCPHttpStatusError(
264
+ 'StreamableHttpTransport',
265
+ response.status,
266
+ response.statusText,
267
+ await readSubscriptionErrorBody(response, owned.controller.signal, this.timeoutMs),
268
+ )
269
+ }
270
+ const mediaType = (response.headers.get('content-type') ?? '')
271
+ .split(';', 1)[0]
272
+ ?.trim()
273
+ .toLowerCase()
274
+ if (mediaType !== 'text/event-stream' || !response.body) {
275
+ // A 200 JSON response can be a valid in-band JSON-RPC refusal.
276
+ // Read it under the same byte and time bounds as a failed HTTP
277
+ // response, then release its body before retry classification.
278
+ if (mediaType === 'application/json' && response.body) {
279
+ throw new MCPSubscriptionResponseError(
280
+ await readSubscriptionErrorBody(response, owned.controller.signal, this.timeoutMs),
281
+ )
282
+ }
283
+ // Unknown response media types still own a body. Never leave a
284
+ // held POST open while the client schedules its next attempt.
285
+ void response.body?.cancel().catch(() => undefined)
286
+ throw new Error('MCP subscriptions/listen did not return an SSE response stream')
287
+ }
288
+
289
+ const reader = response.body.getReader()
290
+ const decoder = new TextDecoder()
291
+ let buffer = ''
292
+ let previousChunkEndedWithCr = false
293
+ const cancelReader = (): void => {
294
+ void reader.cancel().catch(() => undefined)
295
+ }
296
+ owned.controller.signal.addEventListener('abort', cancelReader, { once: true })
297
+ if (owned.controller.signal.aborted) cancelReader()
298
+ try {
299
+ while (this.connected && owned.generation === this.generation) {
300
+ const { done, value } = await reader.read()
301
+ if (done) break
302
+ if (
303
+ !this.connected ||
304
+ owned.generation !== this.generation ||
305
+ owned.controller.signal.aborted
306
+ )
307
+ break
308
+ if (value.byteLength > MAX_SSE_CHUNK_BYTES) {
309
+ throw new Error('MCP subscription SSE chunk exceeds its size limit')
310
+ }
311
+ const oldLength = buffer.length
312
+ let chunk = decoder.decode(value, { stream: true })
313
+ // SSE permits LF, CRLF, and bare CR. A CRLF split across chunks
314
+ // still counts as one line break, not an empty line.
315
+ if (previousChunkEndedWithCr && chunk.startsWith('\n')) chunk = chunk.slice(1)
316
+ previousChunkEndedWithCr = chunk.endsWith('\r')
317
+ buffer += chunk.replace(/\r\n|\r/g, '\n')
318
+ let searchFrom = Math.max(0, oldLength - 1)
319
+ while (true) {
320
+ const boundary = findSseEventBoundary(buffer, searchFrom)
321
+ if (!boundary) break
322
+ if (boundary.start > MAX_SSE_EVENT_CHARS) {
323
+ throw new Error('MCP subscription SSE event exceeds its size limit')
324
+ }
325
+ const event = buffer.slice(0, boundary.start)
326
+ buffer = buffer.slice(boundary.end)
327
+ searchFrom = 0
328
+ for (const frame of parseSseMessages(event).messages) {
329
+ if (
330
+ !this.connected ||
331
+ owned.generation !== this.generation ||
332
+ owned.controller.signal.aborted
333
+ )
334
+ return
335
+ if (onMessage) onMessage(frame)
336
+ else for (const handler of [...this.messageHandlers]) handler(frame)
337
+ }
338
+ }
339
+ if (buffer.length > MAX_SSE_EVENT_CHARS) {
340
+ throw new Error('MCP subscription SSE event exceeds its size limit')
341
+ }
342
+ }
343
+ // A dangling partial event is not a notification. It cannot be
344
+ // forwarded as a complete JSON-RPC frame when the stream closes.
345
+ } finally {
346
+ owned.controller.signal.removeEventListener('abort', cancelReader)
347
+ cancelReader()
348
+ reader.releaseLock()
349
+ }
350
+ } finally {
351
+ owned.dispose()
352
+ }
353
+ }
354
+
216
355
  onMessage(handler: (message: MCPJsonRpcMessage) => void): void {
217
356
  this.messageHandlers.push(handler)
218
357
  }
@@ -300,22 +439,119 @@ export class StreamableHttpTransport implements MCPTransport {
300
439
  response: Response,
301
440
  operation: ConnectorHttpOperation,
302
441
  generation: number,
442
+ requestId: string | number | undefined,
303
443
  ): Promise<void> {
444
+ const contentType = response.headers.get('content-type') ?? ''
445
+ if (contentType.includes('text/event-stream') && response.body) {
446
+ await this.dispatchSseResponseMessages(response.body, operation, generation, requestId)
447
+ return
448
+ }
304
449
  const text = await operation.run(() => response.text())
305
450
  this.assertCurrent(generation, operation)
306
451
  if (text.trim().length === 0) return
307
452
 
308
- const contentType = response.headers.get('content-type') ?? ''
309
453
  const messages = contentType.includes('text/event-stream')
310
454
  ? this.parseSseAndCaptureEventId(text)
311
455
  : parseJsonMessages(text)
312
456
 
313
457
  for (const message of messages) {
458
+ this.dispatchMessage(message, operation, generation)
459
+ }
460
+ }
461
+
462
+ private dispatchMessage(
463
+ message: MCPJsonRpcMessage,
464
+ operation: ConnectorHttpOperation,
465
+ generation: number,
466
+ ): void {
467
+ this.assertCurrent(generation, operation)
468
+ for (const handler of [...this.messageHandlers]) {
314
469
  this.assertCurrent(generation, operation)
315
- for (const handler of [...this.messageHandlers]) {
470
+ handler(message)
471
+ }
472
+ }
473
+
474
+ /** Dispatch a complete SSE event as it arrives, without waiting for EOF. */
475
+ private async dispatchSseResponseMessages(
476
+ body: ReadableStream<Uint8Array>,
477
+ operation: ConnectorHttpOperation,
478
+ generation: number,
479
+ requestId: string | number | undefined,
480
+ ): Promise<void> {
481
+ const reader = body.getReader()
482
+ const decoder = new TextDecoder()
483
+ let buffer = ''
484
+ let previousChunkEndedWithCr = false
485
+ const dispatchEvent = (event: string): boolean => {
486
+ if (event.length > MAX_SSE_EVENT_CHARS) {
487
+ throw new Error('MCP response SSE event exceeds its size limit')
488
+ }
489
+ if (sseBatchExceedsMessageLimit(event, MAX_RESPONSE_SSE_MESSAGES_PER_EVENT)) {
490
+ throw new Error('MCP response SSE event exceeds its message limit')
491
+ }
492
+ const parsed = parseSseMessages(event)
493
+ if (parsed.lastEventId !== undefined) this.lastEventId = parsed.lastEventId
494
+ if (parsed.messages.length > MAX_RESPONSE_SSE_MESSAGES_PER_EVENT) {
495
+ throw new Error('MCP response SSE event exceeds its message limit')
496
+ }
497
+ for (const message of parsed.messages) {
498
+ this.dispatchMessage(message, operation, generation)
499
+ if (
500
+ requestId !== undefined &&
501
+ message.id === requestId &&
502
+ message.method === undefined &&
503
+ (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))
504
+ ) {
505
+ // The request is complete even if the peer keeps its SSE body open.
506
+ return true
507
+ }
508
+ }
509
+ return false
510
+ }
511
+ const appendChunk = (decoded: string): boolean => {
512
+ if (!decoded) return false
513
+ let chunk = decoded
514
+ // A CRLF split across reads is one line break; LF, CRLF and bare
515
+ // CR all delimit SSE lines.
516
+ if (previousChunkEndedWithCr && chunk.startsWith('\n')) chunk = chunk.slice(1)
517
+ previousChunkEndedWithCr = chunk.endsWith('\r')
518
+ buffer += chunk.replace(/\r\n|\r/g, '\n')
519
+ while (true) {
520
+ const boundary = findSseEventBoundary(buffer, 0)
521
+ if (!boundary) break
522
+ if (boundary.start > MAX_SSE_EVENT_CHARS) {
523
+ throw new Error('MCP response SSE event exceeds its size limit')
524
+ }
525
+ const event = buffer.slice(0, boundary.start)
526
+ buffer = buffer.slice(boundary.end)
527
+ if (dispatchEvent(event)) return true
528
+ }
529
+ if (buffer.length > MAX_SSE_EVENT_CHARS) {
530
+ throw new Error('MCP response SSE event exceeds its size limit')
531
+ }
532
+ return false
533
+ }
534
+ try {
535
+ while (true) {
536
+ const { done, value } = await operation.run(() => reader.read())
316
537
  this.assertCurrent(generation, operation)
317
- handler(message)
538
+ if (done) {
539
+ if (appendChunk(decoder.decode())) return
540
+ // Retain the old whole-body reader's acceptance of a final event
541
+ // without a trailing blank line when the peer actually closes.
542
+ if (buffer.trim()) dispatchEvent(buffer)
543
+ return
544
+ }
545
+ if (value.byteLength > MAX_SSE_CHUNK_BYTES) {
546
+ throw new Error('MCP response SSE chunk exceeds its size limit')
547
+ }
548
+ if (appendChunk(decoder.decode(value, { stream: true }))) return
318
549
  }
550
+ } finally {
551
+ // Cancelling is best effort: a hostile or custom body must not hold
552
+ // send() open after a terminal reply, timeout, or caller abort.
553
+ void reader.cancel().catch(() => undefined)
554
+ reader.releaseLock()
319
555
  }
320
556
  }
321
557
 
@@ -334,6 +570,67 @@ export class StreamableHttpTransport implements MCPTransport {
334
570
  }
335
571
  }
336
572
 
573
+ /** Count only top-level JSON-array separators, skipping nested values and strings. */
574
+ function sseBatchExceedsMessageLimit(event: string, limit: number): boolean {
575
+ const data = event
576
+ .split('\n')
577
+ .filter((line) => line.startsWith('data:'))
578
+ .map((line) => line.slice('data:'.length).trimStart())
579
+ .join('\n')
580
+ .trimStart()
581
+ if (!data.startsWith('[')) return false
582
+ let depth = 0
583
+ let inString = false
584
+ let escaped = false
585
+ let separators = 0
586
+ for (const character of data) {
587
+ if (inString) {
588
+ if (escaped) escaped = false
589
+ else if (character === '\\') escaped = true
590
+ else if (character === '"') inString = false
591
+ continue
592
+ }
593
+ if (character === '"') inString = true
594
+ else if (character === '[' || character === '{') depth++
595
+ else if (character === ']' || character === '}') depth--
596
+ else if (character === ',' && depth === 1 && ++separators >= limit) return true
597
+ }
598
+ return false
599
+ }
600
+
601
+ /** Find an SSE blank line after line endings have been normalized to LF. */
602
+ function findSseEventBoundary(
603
+ value: string,
604
+ from: number,
605
+ ): { start: number; end: number } | undefined {
606
+ const start = value.indexOf('\n\n', from)
607
+ return start < 0 ? undefined : { start, end: start + 2 }
608
+ }
609
+
610
+ /** Keep a rejected listen response useful for diagnosis without buffering an unbounded body. */
611
+ async function readSubscriptionErrorBody(
612
+ response: Response,
613
+ signal: AbortSignal,
614
+ timeoutMs: number,
615
+ ): Promise<string> {
616
+ const operation = new ConnectorHttpOperation(signal, timeoutMs, 'MCP subscription error body')
617
+ try {
618
+ const body = await readConnectorResponseBody(
619
+ response,
620
+ operation,
621
+ MAX_SUBSCRIPTION_ERROR_BODY_BYTES,
622
+ )
623
+ return typeof body === 'string' ? body : (JSON.stringify(body) ?? '')
624
+ } catch {
625
+ // The status is still the answer when a peer sends an unreadable,
626
+ // oversized, or stalled error body.
627
+ return ''
628
+ } finally {
629
+ operation.close()
630
+ void response.body?.cancel().catch(() => undefined)
631
+ }
632
+ }
633
+
337
634
  /**
338
635
  * The body of a failed response, or an empty string.
339
636
  *
@@ -1,4 +1,5 @@
1
- import { rm } from 'node:fs/promises'
1
+ import { realpath, rm, stat } from 'node:fs/promises'
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
2
3
  import { AGENT_MANAGER_DEFAULTS } from '../../constants/agent/index.js'
3
4
  import { EMPTY_TOKEN_USAGE } from '../../constants/limits.js'
4
5
  import { GENAI } from '../../constants/telemetry/index.js'
@@ -9,6 +10,7 @@ import {
9
10
  } from '../../session/handoff/capacity.js'
10
11
  import type { SessionLocator, SessionPaths } from '../../session/paths.js'
11
12
  import type { SessionSummaryMaterializer } from '../../session/summary/materialize.js'
13
+ import type { WorkspaceBackendDriver } from '../../session/workspace/driver.js'
12
14
  import type { WorkspaceBackendRegistry } from '../../session/workspace/registry.js'
13
15
  import {
14
16
  DiskSessionLog,
@@ -151,6 +153,7 @@ interface ChildSpawnRecord {
151
153
  /** Removes the child's log from the process-local lookup. */
152
154
  releaseLog?: () => void
153
155
  workspaceRef?: WorkspaceRef
156
+ workspaceRetention?: 'retain'
154
157
  /**
155
158
  * What this child was actually granted, after the ancestor union.
156
159
  *
@@ -180,6 +183,81 @@ function mergeEnv(
180
183
  return { ...base, ...override }
181
184
  }
182
185
 
186
+ /** A requested isolated child must never resolve to its caller's directory. */
187
+ async function requireIsolatedWorktreePath(
188
+ ref: WorkspaceRef | undefined,
189
+ callerCwd?: string,
190
+ subdirectory?: string,
191
+ ): Promise<string> {
192
+ const meta = ref?.meta
193
+ if (
194
+ typeof ref?.id !== 'string' ||
195
+ ref.id.length === 0 ||
196
+ meta?.backend !== 'git-worktree' ||
197
+ typeof meta.repoRoot !== 'string' ||
198
+ !isAbsolute(meta.repoRoot) ||
199
+ typeof meta.branch !== 'string' ||
200
+ meta.branch.length === 0 ||
201
+ typeof meta.worktreePath !== 'string' ||
202
+ !isAbsolute(meta.worktreePath) ||
203
+ resolve(meta.worktreePath) === resolve(callerCwd ?? process.cwd())
204
+ ) {
205
+ throw new Error('Isolated git-worktree driver returned an invalid workspace ref or path')
206
+ }
207
+ // A custom driver can return a different absolute spelling of the same
208
+ // directory (for example, a symlink to the caller). Both paths must exist
209
+ // before the child is admitted, and their physical roots must differ.
210
+ let physicalPaths: [string, string]
211
+ try {
212
+ physicalPaths = await Promise.all([
213
+ realpath(meta.worktreePath),
214
+ realpath(resolve(callerCwd ?? process.cwd())),
215
+ ])
216
+ if (!(await stat(physicalPaths[0])).isDirectory()) {
217
+ throw new Error('The worktree path is not a directory')
218
+ }
219
+ } catch (cause) {
220
+ throw new Error('Isolated git-worktree driver returned an invalid workspace ref or path', {
221
+ cause,
222
+ })
223
+ }
224
+ if (physicalPaths[0] === physicalPaths[1]) {
225
+ throw new Error('Isolated git-worktree driver returned an invalid workspace ref or path')
226
+ }
227
+ if (subdirectory === undefined) return meta.worktreePath
228
+ if (typeof subdirectory !== 'string' || subdirectory.length === 0 || isAbsolute(subdirectory)) {
229
+ throw new Error('Isolated workspace subdirectory must be a nonempty relative path')
230
+ }
231
+ const lexicalChild = resolve(meta.worktreePath, subdirectory)
232
+ const lexicalRelative = relative(resolve(meta.worktreePath), lexicalChild)
233
+ if (
234
+ isAbsolute(lexicalRelative) ||
235
+ lexicalRelative === '..' ||
236
+ lexicalRelative.startsWith(`..${sep}`)
237
+ ) {
238
+ throw new Error('Isolated workspace subdirectory escapes the worktree')
239
+ }
240
+ let physicalChild: string
241
+ try {
242
+ physicalChild = await realpath(lexicalChild)
243
+ if (!(await stat(physicalChild)).isDirectory()) {
244
+ throw new Error('The selected worktree path is not a directory')
245
+ }
246
+ } catch (cause) {
247
+ throw new Error('Isolated workspace subdirectory is missing or not a directory', { cause })
248
+ }
249
+ const physicalRelative = relative(physicalPaths[0], physicalChild)
250
+ if (
251
+ physicalRelative === '' ||
252
+ isAbsolute(physicalRelative) ||
253
+ physicalRelative === '..' ||
254
+ physicalRelative.startsWith(`..${sep}`)
255
+ ) {
256
+ throw new Error('Isolated workspace subdirectory resolves outside its worktree')
257
+ }
258
+ return physicalChild
259
+ }
260
+
183
261
  /**
184
262
  * One `reviewAllowedCalls` that answers `true` when any of `sources` does.
185
263
  *
@@ -283,7 +361,9 @@ export class AgentManager {
283
361
  if (task.context === context || context.parentAbortController.signal.aborted)
284
362
  this.cancel(task.taskId, 'parent')
285
363
  }
286
- task.childAbortController.signal.addEventListener('abort', onAbort, { once: true })
364
+ task.childAbortController.signal.addEventListener('abort', onAbort, {
365
+ once: true,
366
+ })
287
367
  const entry: PendingSpawn = {
288
368
  ready: false,
289
369
  task,
@@ -522,9 +602,25 @@ export class AgentManager {
522
602
  await childBudget.flush()
523
603
  throw error
524
604
  }
605
+ let childOptions: SendMessageOptions = options
525
606
  let childAbortController: AbortController | undefined
526
607
  let agentTask: AgentTask | undefined
527
608
  try {
609
+ // Explicit isolation is the child's execution root, not a side
610
+ // resource. Keep validation in this rollback envelope too.
611
+ if (options.workspace?.mode === 'isolated') {
612
+ childOptions = {
613
+ ...options,
614
+ input: {
615
+ ...options.input,
616
+ workingDirectory: await requireIsolatedWorktreePath(
617
+ spawnRecord.workspaceRef,
618
+ options.input.workingDirectory,
619
+ options.workspace.subdirectory,
620
+ ),
621
+ },
622
+ }
623
+ }
528
624
  childAbortController =
529
625
  queuedTask?.childAbortController ??
530
626
  createChildAbortController(context.parentAbortController)
@@ -599,6 +695,7 @@ export class AgentManager {
599
695
  state: 'pending',
600
696
  pendingMessages: queuedTask?.pendingMessages ?? [],
601
697
  createdAt: queuedTask?.createdAt ?? Date.now(),
698
+ workspace: options.workspace?.mode === 'isolated' ? spawnRecord.workspaceRef : undefined,
602
699
  sessionEventListener: listener,
603
700
  } satisfies AgentTask)
604
701
 
@@ -676,6 +773,7 @@ export class AgentManager {
676
773
  ...(context.factoryOptions ?? {}),
677
774
  tokenBudget: allocatedTokens,
678
775
  timeoutMs: options.budgetAllocation?.timeoutMs ?? this.config.childTimeoutMs,
776
+ workingDirectory: childOptions.input.workingDirectory,
679
777
  sessionId: spawnRecord.childSessionId,
680
778
  parentSessionId: spawnRecord.parentSessionId,
681
779
  parentTurnId: spawnRecord.parentTurnId,
@@ -872,11 +970,11 @@ export class AgentManager {
872
970
  spawnRecord,
873
971
  childConfig,
874
972
  context,
875
- options,
973
+ childOptions,
876
974
  taskId,
877
975
  )
878
976
  if (childLog) {
879
- await this.startChildSessionLog(childLog, spawnRecord, context, options, taskId, {
977
+ await this.startChildSessionLog(childLog, spawnRecord, context, childOptions, taskId, {
880
978
  id: agent.metadata.id,
881
979
  name: agent.metadata.name,
882
980
  })
@@ -903,7 +1001,7 @@ export class AgentManager {
903
1001
  childAbortController.signal.throwIfAborted()
904
1002
  this.executingTasks.add(taskId)
905
1003
  const runningTask = agentTask
906
- this.runChild(runningTask, options, childConfig, listener)
1004
+ this.runChild(runningTask, childOptions, childConfig, listener)
907
1005
  .catch(async (err) => {
908
1006
  // A thrown invocation supplied no final usage receipt. Keep its
909
1007
  // reservation, including when its task handle was canceled or evicted.
@@ -999,6 +1097,9 @@ export class AgentManager {
999
1097
  this.scheduleEviction(agentTask.taskId)
1000
1098
  } else this.instances.delete(agentTask.taskId)
1001
1099
  this.dropSpawnRecord(agentTask.taskId)
1100
+ // Admission never completed. The checkout is rollback-owned, not a
1101
+ // retained result that a terminal handle may advertise.
1102
+ agentTask.workspace = undefined
1002
1103
  this.resolveCompletionCallbacks(agentTask.taskId)
1003
1104
  await this.rollbackSpawnResources(spawnRecord)
1004
1105
  }
@@ -1015,7 +1116,7 @@ export class AgentManager {
1015
1116
  }),
1016
1117
  )
1017
1118
  }
1018
- await this.disposeChildWorkspace(spawnRecord)
1119
+ await this.disposeChildWorkspace({ ...spawnRecord, workspaceRetention: undefined })
1019
1120
  try {
1020
1121
  // The edge must be removed before its child: stores reject deletion
1021
1122
  // of a session that still has a subsession reference.
@@ -1135,7 +1236,10 @@ export class AgentManager {
1135
1236
  taskId: TaskId,
1136
1237
  agent: { readonly id: string; readonly name: string },
1137
1238
  ): Promise<void> {
1138
- const lease = await log.claim({ holder: `namzu:agent-manager:${taskId}`, ttlMs: 30_000 })
1239
+ const lease = await log.claim({
1240
+ holder: `namzu:agent-manager:${taskId}`,
1241
+ ttlMs: 30_000,
1242
+ })
1139
1243
  if (lease === null) {
1140
1244
  throw new Error(`Child session ${spawnRecord.childSessionId} is already leased`)
1141
1245
  }
@@ -1459,6 +1563,7 @@ export class AgentManager {
1459
1563
  // parent but counted against `maxDelegationWidth`.
1460
1564
  let subSession: Awaited<ReturnType<typeof store.createSubSession>> | undefined
1461
1565
  let workspaceRef: WorkspaceRef | undefined
1566
+ let workspaceDriver: WorkspaceBackendDriver | undefined
1462
1567
  try {
1463
1568
  // Flip to 'active' so the materializer's atomic write + status flip
1464
1569
  // lands on terminal — §5.3: pending→active→idle.
@@ -1483,10 +1588,30 @@ export class AgentManager {
1483
1588
  // provisioning: an unregistered backend leaves `workspaceRef:
1484
1589
  // undefined` on the spawn record, not a hard error — the registry
1485
1590
  // is the capability surface.
1486
- const backend = options.workspaceBackend ?? 'git-worktree'
1487
- if (this.deps.workspaceRegistry.has(backend)) {
1591
+ const request = options.workspace
1592
+ const backend =
1593
+ request?.mode === 'shared'
1594
+ ? undefined
1595
+ : request?.mode === 'isolated'
1596
+ ? request.backend
1597
+ : (options.workspaceBackend ??
1598
+ (this.config.workspaceDefault === 'shared' ? undefined : 'git-worktree'))
1599
+ if (request?.mode === 'isolated' && !this.deps.workspaceRegistry.has(request.backend)) {
1600
+ throw new Error(`Isolated workspace backend ${request.backend} is unavailable`)
1601
+ }
1602
+ if (backend && this.deps.workspaceRegistry.has(backend)) {
1488
1603
  const driver = this.deps.workspaceRegistry.get(backend)
1489
- workspaceRef = await driver.create({ label: subSession.id })
1604
+ workspaceDriver = driver
1605
+ workspaceRef = await driver.create({
1606
+ label: subSession.id,
1607
+ ...(request?.mode === 'isolated' && request.baseRef ? { baseRef: request.baseRef } : {}),
1608
+ })
1609
+ if (request?.mode === 'isolated')
1610
+ await requireIsolatedWorktreePath(
1611
+ workspaceRef,
1612
+ options.input.workingDirectory,
1613
+ request.subdirectory,
1614
+ )
1490
1615
 
1491
1616
  // Write the workspace onto the record that outlives this process.
1492
1617
  //
@@ -1503,16 +1628,23 @@ export class AgentManager {
1503
1628
  // exist before the record it is named after. Inside the try, so
1504
1629
  // the compensating rollback below covers it like every other
1505
1630
  // mutation here.
1506
- subSession = { ...subSession, workspaceId: workspaceRef.id }
1631
+ subSession = {
1632
+ ...subSession,
1633
+ workspaceId: workspaceRef.id,
1634
+ ...(request?.mode === 'isolated' && request.retention === 'retain'
1635
+ ? { workspaceRetention: 'retain' as const }
1636
+ : {}),
1637
+ }
1507
1638
  await store.updateSubSession(subSession, context.tenantId)
1508
1639
  }
1509
1640
  } catch (err) {
1510
- if (workspaceRef && subSession) {
1511
- await this.disposeChildWorkspace({
1512
- workspaceRef,
1513
- subSessionId: subSession.id,
1514
- })
1515
- }
1641
+ if (workspaceRef && workspaceDriver)
1642
+ await workspaceDriver.dispose(workspaceRef).catch((disposeErr) =>
1643
+ this.log.warn('Unstarted workspace dispose failed', {
1644
+ 'namzu.sub_session.id': subSession?.id,
1645
+ 'exception.message': toErrorMessage(disposeErr),
1646
+ }),
1647
+ )
1516
1648
  // Compensating rollback order is mandated by the store's
1517
1649
  // deny-by-default cascade policy (Convention #5): `deleteSession`
1518
1650
  // throws when any subsession still references it, so the subsession
@@ -1540,6 +1672,9 @@ export class AgentManager {
1540
1672
  ancestry: parentAncestry,
1541
1673
  childDepth,
1542
1674
  workspaceRef,
1675
+ ...(options.workspace?.mode === 'isolated' && options.workspace.retention === 'retain'
1676
+ ? { workspaceRetention: 'retain' as const }
1677
+ : {}),
1543
1678
  }
1544
1679
  }
1545
1680
 
@@ -1769,7 +1904,10 @@ export class AgentManager {
1769
1904
  status: 'failed',
1770
1905
  usage: agentTask.context.budget.ownUsage,
1771
1906
  budget: agentTask.context.budget.summary(),
1772
- cost: { ...ZERO_COST, unpricedTokens: agentTask.context.budget.ownTokens },
1907
+ cost: {
1908
+ ...ZERO_COST,
1909
+ unpricedTokens: agentTask.context.budget.ownTokens,
1910
+ },
1773
1911
  iterations: 0,
1774
1912
  durationMs: Date.now() - agentTask.createdAt,
1775
1913
  messages: [],
@@ -1835,8 +1973,9 @@ export class AgentManager {
1835
1973
  * removed is an operator's problem and silence is how it stays one.
1836
1974
  */
1837
1975
  private async disposeChildWorkspace(
1838
- spawnRecord: Pick<ChildSpawnRecord, 'workspaceRef' | 'subSessionId'>,
1976
+ spawnRecord: Pick<ChildSpawnRecord, 'workspaceRef' | 'workspaceRetention' | 'subSessionId'>,
1839
1977
  ): Promise<void> {
1978
+ if (spawnRecord.workspaceRetention === 'retain') return
1840
1979
  if (!spawnRecord.workspaceRef) return
1841
1980
  const backend = spawnRecord.workspaceRef.meta.backend
1842
1981
  if (!this.deps.workspaceRegistry.has(backend)) return
@@ -113,6 +113,7 @@ export class LocalTaskScheduler implements TaskScheduler {
113
113
  {
114
114
  agentId: options.agentId,
115
115
  beforeStart: options.beforeStart,
116
+ ...(options.workspace ? { workspace: options.workspace } : {}),
116
117
  ...(options.planId ? { planId: options.planId } : {}),
117
118
  ...(options.planStepId ? { planStepId: options.planStepId } : {}),
118
119
  // Display grouping travels with the spawn so the manager can put it
@@ -468,5 +469,6 @@ function toHandle(task: import('../types/agent/task.js').AgentTask): TaskHandle
468
469
  result: task.result,
469
470
  createdAt: task.createdAt,
470
471
  completedAt: task.completedAt,
472
+ ...(task.workspace ? { workspace: task.workspace } : {}),
471
473
  }
472
474
  }