@namzu/sdk 53.1.0 → 53.2.1

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 (100) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/compaction/index.d.ts +1 -1
  3. package/dist/compaction/index.d.ts.map +1 -1
  4. package/dist/compaction/index.js +1 -1
  5. package/dist/compaction/index.js.map +1 -1
  6. package/dist/compaction/manual.d.ts +5 -0
  7. package/dist/compaction/manual.d.ts.map +1 -1
  8. package/dist/compaction/manual.js +4 -4
  9. package/dist/compaction/manual.js.map +1 -1
  10. package/dist/compaction/salience/bm25.d.ts +1 -1
  11. package/dist/compaction/salience/bm25.d.ts.map +1 -1
  12. package/dist/compaction/salience/bm25.js +1 -2
  13. package/dist/compaction/salience/bm25.js.map +1 -1
  14. package/dist/compaction/salience/score.d.ts.map +1 -1
  15. package/dist/compaction/salience/score.js +1 -1
  16. package/dist/compaction/salience/score.js.map +1 -1
  17. package/dist/compaction/salience/tokenize.d.ts.map +1 -1
  18. package/dist/compaction/salience/tokenize.js +16 -2
  19. package/dist/compaction/salience/tokenize.js.map +1 -1
  20. package/dist/compaction/summary.d.ts +12 -2
  21. package/dist/compaction/summary.d.ts.map +1 -1
  22. package/dist/compaction/summary.js +13 -3
  23. package/dist/compaction/summary.js.map +1 -1
  24. package/dist/compaction/tool-result-editing.d.ts.map +1 -1
  25. package/dist/compaction/tool-result-editing.js +9 -6
  26. package/dist/compaction/tool-result-editing.js.map +1 -1
  27. package/dist/manager/agent/lifecycle.d.ts +15 -0
  28. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  29. package/dist/manager/agent/lifecycle.js +169 -1
  30. package/dist/manager/agent/lifecycle.js.map +1 -1
  31. package/dist/public-runtime.d.ts +2 -1
  32. package/dist/public-runtime.d.ts.map +1 -1
  33. package/dist/public-runtime.js +2 -1
  34. package/dist/public-runtime.js.map +1 -1
  35. package/dist/runtime/query/executor.d.ts +2 -0
  36. package/dist/runtime/query/executor.d.ts.map +1 -1
  37. package/dist/runtime/query/executor.js +21 -1
  38. package/dist/runtime/query/executor.js.map +1 -1
  39. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  40. package/dist/runtime/query/iteration/phases/compaction.js +6 -2
  41. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  42. package/dist/runtime/query/tool-output-budget.d.ts +30 -2
  43. package/dist/runtime/query/tool-output-budget.d.ts.map +1 -1
  44. package/dist/runtime/query/tool-output-budget.js +43 -10
  45. package/dist/runtime/query/tool-output-budget.js.map +1 -1
  46. package/dist/scheduler/local.d.ts +2 -1
  47. package/dist/scheduler/local.d.ts.map +1 -1
  48. package/dist/scheduler/local.js +8 -0
  49. package/dist/scheduler/local.js.map +1 -1
  50. package/dist/store/evidence/disk.d.ts +3 -2
  51. package/dist/store/evidence/disk.d.ts.map +1 -1
  52. package/dist/store/evidence/disk.js +23 -3
  53. package/dist/store/evidence/disk.js.map +1 -1
  54. package/dist/store/evidence/source-text.d.ts +20 -0
  55. package/dist/store/evidence/source-text.d.ts.map +1 -1
  56. package/dist/store/evidence/source-text.js +29 -2
  57. package/dist/store/evidence/source-text.js.map +1 -1
  58. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  59. package/dist/tools/builtins/read-file.js +7 -2
  60. package/dist/tools/builtins/read-file.js.map +1 -1
  61. package/dist/tools/retained-output.d.ts +12 -0
  62. package/dist/tools/retained-output.d.ts.map +1 -0
  63. package/dist/tools/retained-output.js +39 -0
  64. package/dist/tools/retained-output.js.map +1 -0
  65. package/dist/types/agent/manager.d.ts +48 -0
  66. package/dist/types/agent/manager.d.ts.map +1 -1
  67. package/dist/types/agent/scheduler.d.ts +20 -0
  68. package/dist/types/agent/scheduler.d.ts.map +1 -1
  69. package/dist/types/agent/task.d.ts +2 -0
  70. package/dist/types/agent/task.d.ts.map +1 -1
  71. package/dist/types/session/records.d.ts +25 -0
  72. package/dist/types/session/records.d.ts.map +1 -1
  73. package/dist/types/session/records.js +16 -0
  74. package/dist/types/session/records.js.map +1 -1
  75. package/dist/types/tool/index.d.ts +8 -0
  76. package/dist/types/tool/index.d.ts.map +1 -1
  77. package/dist/types/tool/index.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/compaction/index.ts +6 -1
  80. package/src/compaction/manual.ts +15 -3
  81. package/src/compaction/salience/bm25.ts +4 -2
  82. package/src/compaction/salience/score.ts +3 -1
  83. package/src/compaction/salience/tokenize.ts +16 -2
  84. package/src/compaction/summary.ts +17 -3
  85. package/src/compaction/tool-result-editing.ts +12 -9
  86. package/src/manager/agent/lifecycle.ts +181 -1
  87. package/src/public-runtime.ts +2 -1
  88. package/src/runtime/query/executor.ts +25 -1
  89. package/src/runtime/query/iteration/phases/compaction.ts +13 -2
  90. package/src/runtime/query/tool-output-budget.ts +65 -11
  91. package/src/scheduler/local.ts +14 -1
  92. package/src/store/evidence/disk.ts +28 -3
  93. package/src/store/evidence/source-text.ts +38 -2
  94. package/src/tools/builtins/read-file.ts +7 -2
  95. package/src/tools/retained-output.ts +38 -0
  96. package/src/types/agent/manager.ts +54 -0
  97. package/src/types/agent/scheduler.ts +21 -0
  98. package/src/types/agent/task.ts +2 -0
  99. package/src/types/session/records.ts +16 -0
  100. package/src/types/tool/index.ts +8 -0
@@ -42,6 +42,58 @@ export const MESSAGE_BUDGET_PREVIEW_CHARS = 2_000
42
42
  */
43
43
  export const SPILL_MARKER = 'The full output was written to:'
44
44
 
45
+ /**
46
+ * The recovery routes a host has mounted for a retained output. A pointer
47
+ * names only a tool that exists, so the same sentence is true wherever it is
48
+ * read: in the result, in the cleared placeholder that keeps it, and in a
49
+ * summary that quotes it.
50
+ */
51
+ export interface RetainedOutputRoutes {
52
+ /** The `read` tool is mounted and may open this session's own retained files. */
53
+ readonly read?: boolean
54
+ /** `search_conversation` and `read_conversation` are both mounted. */
55
+ readonly conversation?: boolean
56
+ }
57
+
58
+ /** Opening of the sentence that follows the spill line; how a pointer is recognised. */
59
+ export const RETAINED_OUTPUT_NOTE = 'This path identifies retained output, not the original input.'
60
+
61
+ const REPLAY_WARNING =
62
+ 'A fresh observation of the original input cannot recover its earlier contents. If recovery is unavailable, report the missing detail; do not replay the original action.'
63
+
64
+ /**
65
+ * The one sentence that says how to get at a retained output. Without routes
66
+ * (an embedder that told the budget nothing) it keeps the neutral wording.
67
+ */
68
+ export function describeRetainedOutputRecovery(routes?: RetainedOutputRoutes): string {
69
+ const read = routes?.read === true
70
+ const conversation = routes?.conversation === true
71
+ const how = [
72
+ read ? 'To read more of it, open this file with the read tool (offset and limit).' : '',
73
+ conversation
74
+ ? read
75
+ ? 'For anything else from earlier in this conversation, call search_conversation with a phrase from it, then read_conversation with the seq and part it returns.'
76
+ : 'To read more of it, call search_conversation with a phrase from it, then read_conversation with the seq and part it returns. The path is not a workspace file, so read and grep cannot open it.'
77
+ : '',
78
+ ].filter(Boolean)
79
+ if (how.length === 0)
80
+ return `${RETAINED_OUTPUT_NOTE} Use the host-authorized retained-output recovery tools when available; the path does not grant filesystem access. ${REPLAY_WARNING}`
81
+ return [RETAINED_OUTPUT_NOTE, ...how, REPLAY_WARNING].join(' ')
82
+ }
83
+
84
+ /**
85
+ * The spill line and the recovery sentence under it, when `text` carries them.
86
+ * Whatever shortens a result keeps this pair, so the route survives.
87
+ */
88
+ export function retainedOutputPointer(text: string): string | undefined {
89
+ // A tool may end its lines with \r\n or a bare \r; the pointer is one line either way.
90
+ const lines = text.split(/\r\n|\r|\n/)
91
+ const at = lines.findIndex((line) => line.startsWith(SPILL_MARKER))
92
+ if (at < 0) return undefined
93
+ const next = lines[at + 1]
94
+ return next?.startsWith(RETAINED_OUTPUT_NOTE) ? `${lines[at]}\n${next}` : (lines[at] as string)
95
+ }
96
+
45
97
  /**
46
98
  * Share of the budget spent on the head. The rest goes to the tail.
47
99
  *
@@ -91,7 +143,7 @@ function boundedPreview(
91
143
  '',
92
144
  ].join('\n')
93
145
  const compactMiddle = '\n[... omitted ...]\n'
94
- const spillLine = recovery.split('\n').find((line) => line.startsWith(SPILL_MARKER))
146
+ const spillLine = recovery.split(/\r\n|\r|\n/).find((line) => line.startsWith(SPILL_MARKER))
95
147
  const compactRecovery = spillLine ? `${compactMiddle}${spillLine}\n` : compactMiddle
96
148
  const middle =
97
149
  detailedMiddle.length < maxChars
@@ -182,6 +234,8 @@ export interface ApplyToolOutputBudgetOptions {
182
234
  * middle-elided instead — degraded, but never unbounded.
183
235
  */
184
236
  readonly spillDir?: string | undefined
237
+ /** Which recovery routes the host has mounted; see {@link describeRetainedOutputRecovery}. */
238
+ readonly recoveryRoutes?: RetainedOutputRoutes
185
239
  readonly onError?: (message: string) => void
186
240
  }
187
241
 
@@ -189,8 +243,9 @@ export interface ApplyToolOutputBudgetOptions {
189
243
  * Bound a tool result to the model-visible budget.
190
244
  *
191
245
  * Retention keeps the original while bounding its model-visible preview.
192
- * The host owns the recovery route and its permissions; a spill path is not
193
- * proof that workspace read/grep tools can access it. Middle-elision is the
246
+ * The recovery sentence names only the routes the host says are mounted
247
+ * ({@link RetainedOutputRoutes}); a spill path alone is not proof that a
248
+ * workspace tool can open it. Middle-elision is the
194
249
  * fallback for a turn with no directory to write to.
195
250
  *
196
251
  * The preview keeps head AND tail because the two ends carry different
@@ -224,10 +279,9 @@ export function applyToolOutputBudget(opts: ApplyToolOutputBudgetOptions): ToolO
224
279
 
225
280
  const spillPath = retained?.path
226
281
  const recovery = spillPath
227
- ? [
228
- `${SPILL_MARKER} ${spillPath}`,
229
- 'This path identifies retained output, not the original input. Use the host-authorized retained-output recovery tools when available; the path does not grant filesystem access. A fresh observation of the original input cannot recover its earlier contents. If recovery is unavailable, report the missing detail; do not replay the original action.',
230
- ].join('\n')
282
+ ? [`${SPILL_MARKER} ${spillPath}`, describeRetainedOutputRecovery(opts.recoveryRoutes)].join(
283
+ '\n',
284
+ )
231
285
  : 'The full output was not retained. Use a saved artifact or a read-only observation; do not repeat a state-changing action to recover its output.'
232
286
  const requestedPreview = opts.retainedPreviewChars
233
287
  // Separate the spill threshold from the cost of carrying its preview on every
@@ -358,11 +412,10 @@ export interface MessageBudgetReplacement {
358
412
  export interface FitResultsToMessageBudgetOptions {
359
413
  readonly maxChars: number
360
414
  readonly spillDir?: string | undefined
415
+ readonly recoveryRoutes?: RetainedOutputRoutes
361
416
  readonly onError?: (message: string) => void
362
417
  }
363
418
 
364
- const SPILL_LINE = new RegExp(`^${SPILL_MARKER} .+$`, 'm')
365
-
366
419
  /**
367
420
  * Hold one assistant message's combined tool results under a cap.
368
421
  *
@@ -409,7 +462,7 @@ function previewForMessageBudget(
409
462
  item: MessageBudgetItem,
410
463
  opts: FitResultsToMessageBudgetOptions,
411
464
  ): MessageBudgetReplacement {
412
- const spilledAt = SPILL_LINE.exec(item.output)?.[0]
465
+ const spilledAt = retainedOutputPointer(item.output)
413
466
  if (spilledAt !== undefined) {
414
467
  const output = [
415
468
  safeHead(item.output, MESSAGE_BUDGET_PREVIEW_CHARS / 2),
@@ -420,7 +473,7 @@ function previewForMessageBudget(
420
473
  return {
421
474
  output,
422
475
  originalLength: item.output.length,
423
- spillPath: spilledAt.slice(SPILL_MARKER.length + 1),
476
+ spillPath: (spilledAt.split('\n')[0] as string).slice(SPILL_MARKER.length + 1),
424
477
  }
425
478
  }
426
479
  const budgeted = applyToolOutputBudget({
@@ -430,6 +483,7 @@ function previewForMessageBudget(
430
483
  output: item.output,
431
484
  maxChars: MESSAGE_BUDGET_PREVIEW_CHARS,
432
485
  spillDir: opts.spillDir,
486
+ ...(opts.recoveryRoutes ? { recoveryRoutes: opts.recoveryRoutes } : {}),
433
487
  ...(opts.onError ? { onError: opts.onError } : {}),
434
488
  })
435
489
  return {
@@ -1,7 +1,11 @@
1
1
  import { GENAI } from '../constants/telemetry/index.js'
2
2
  import { taskFailed } from '../tools/coordinator/outcome.js'
3
3
  import type { AgentInput } from '../types/agent/base.js'
4
- import type { AgentManagerContract } from '../types/agent/manager.js'
4
+ import type {
5
+ AgentManagerContract,
6
+ RestoreChildOptions,
7
+ RestoreChildResult,
8
+ } from '../types/agent/manager.js'
5
9
  import type {
6
10
  CreateTaskOptions,
7
11
  SiblingFailurePolicy,
@@ -113,6 +117,9 @@ export class LocalTaskScheduler implements TaskScheduler {
113
117
  {
114
118
  agentId: options.agentId,
115
119
  ...(options.resumeSessionId ? { resumeSessionId: options.resumeSessionId } : {}),
120
+ ...(options.continuationProfile
121
+ ? { continuationProfile: options.continuationProfile }
122
+ : {}),
116
123
  beforeStart: options.beforeStart,
117
124
  ...(options.workspace ? { workspace: options.workspace } : {}),
118
125
  ...(options.planId ? { planId: options.planId } : {}),
@@ -363,6 +370,12 @@ export class LocalTaskScheduler implements TaskScheduler {
363
370
  await this.agentManager.continueTask(taskId, message)
364
371
  }
365
372
 
373
+ async restoreChild(options: RestoreChildOptions): Promise<RestoreChildResult> {
374
+ if (!this.agentManager.restoreChildConversation)
375
+ return { status: 'refused', reason: 'no-layout' }
376
+ return this.agentManager.restoreChildConversation(options, this.taskContext)
377
+ }
378
+
366
379
  cancelTask(taskId: TaskId, cause?: CancelCause): void {
367
380
  this.agentManager.cancel(taskId, cause)
368
381
  }
@@ -110,8 +110,9 @@ export interface EvidenceAnchor {
110
110
  * session log, or one turn of it. Closed by default (no active turn in scope);
111
111
  * snapshot mode also reads a session whose turn is running, without assuming
112
112
  * its writer is dead. The host owns authorization and the log's directory.
113
- * Changes to the log invalidate addresses; record and chunk digests detect
114
- * changed bytes. This is not a hostile filesystem sandbox.
113
+ * An address names a record by offset, length and digest and survives appends
114
+ * to the log; a replaced or rewritten log is refused and record and chunk
115
+ * digests detect changed bytes. This is not a hostile filesystem sandbox.
115
116
  */
116
117
  export function createSessionTextEvidenceSource(
117
118
  options: SessionEvidenceSourceOptions,
@@ -261,9 +262,18 @@ function createSource(
261
262
  if (!state.closed && consistency === 'closed') {
262
263
  throw new Error('Evidence scope has an active turn; it is not closed.')
263
264
  }
265
+ // The anchored prefix is verified here and again after the action. It is not
266
+ // part of the seal: the anchor moves with every record the writer appends, so
267
+ // binding it would void an address the moment the search call that returned
268
+ // it is itself recorded.
264
269
  const anchorKey = anchor ? await verifyAnchor(handle, anchor) : undefined
270
+ // A session log only grows. An address names one record by offset, length and
271
+ // digest, and a read verifies all three against the bytes, so what must
272
+ // bind a seal is the file's identity, never its size or times: those change
273
+ // with every append and would void every address and cursor handed out a
274
+ // moment earlier, including one a search just returned.
265
275
  const sourceKey = digest(
266
- `session-v1:${consistency}:${mode}:${scopeKey}:${anchorKey ?? stamp(before)}`,
276
+ `session-v1:${consistency}:${mode}:${scopeKey}:${fileIdentity(before)}`,
267
277
  )
268
278
  const seal = evidenceSeal(state.first, sourceKey)
269
279
  const value = await action(handle, size, seal, sourceKey, budget, !state.closed)
@@ -552,6 +562,21 @@ function createSource(
552
562
  })
553
563
  }
554
564
 
565
+ /**
566
+ * What names the log file across appends: device and inode, plus the creation
567
+ * time where the filesystem reports no inode (zero), so a replaced file still
568
+ * changes the identity there.
569
+ */
570
+ function fileIdentity(stats: {
571
+ dev: number | bigint
572
+ ino: number | bigint
573
+ birthtimeMs: number
574
+ }): string {
575
+ return Number(stats.ino) === 0
576
+ ? `${stats.dev}:0:${stats.birthtimeMs}`
577
+ : `${stats.dev}:${stats.ino}`
578
+ }
579
+
555
580
  /** The anchor record must still hold the bytes the writer vouched for. */
556
581
  async function verifyAnchor(handle: FileHandle, anchor: EvidenceAnchor): Promise<string> {
557
582
  const bytes = await readBytes(handle, anchor.offset, anchor.length, {
@@ -235,6 +235,28 @@ async function sourceText(
235
235
  }
236
236
  }
237
237
 
238
+ /**
239
+ * @experimental A read named a byte offset the retained text cannot start at.
240
+ * `inside_character` means the offset falls inside a multi-byte character (the
241
+ * character starts up to three bytes earlier); `past_end` means it lies beyond
242
+ * the text. Every other read failure (integrity, ownership, a changed source)
243
+ * is a plain `Error`, so a caller can recover from a bad position without
244
+ * treating a refusal as one.
245
+ */
246
+ export class SessionEvidenceOffsetError extends Error {
247
+ constructor(
248
+ readonly code: 'inside_character' | 'past_end',
249
+ message: string,
250
+ /** Bytes of the retained text. */
251
+ readonly totalBytes: number,
252
+ /** Bytes this failed read was charged. */
253
+ readonly scannedBytes: number,
254
+ ) {
255
+ super(message)
256
+ this.name = 'SessionEvidenceOffsetError'
257
+ }
258
+ }
259
+
238
260
  export async function readTextPage(
239
261
  source: TextSource,
240
262
  scope: SessionEvidenceScope,
@@ -242,13 +264,27 @@ export async function readTextPage(
242
264
  budget: EvidenceBudget,
243
265
  ): Promise<SessionTextEvidenceReadResult> {
244
266
  const entry = source.entry
245
- if (offset > source.bytes) throw new Error('Offset exceeds retained text.')
267
+ if (offset > source.bytes)
268
+ throw new SessionEvidenceOffsetError(
269
+ 'past_end',
270
+ 'Offset exceeds retained text.',
271
+ source.bytes,
272
+ budget.bytes,
273
+ )
246
274
  const chunk = entry.spill
247
275
  ? Math.min(Math.floor(offset / EVIDENCE_CHUNK_BYTES), source.chunks - 1)
248
276
  : 0
249
277
  const window = await source.window(chunk)
250
- if (offset < window.offset) throw new Error('Offset splits a UTF-8 character.')
278
+ const splits = () =>
279
+ new SessionEvidenceOffsetError(
280
+ 'inside_character',
281
+ 'Offset splits a UTF-8 character.',
282
+ source.bytes,
283
+ budget.bytes,
284
+ )
285
+ if (offset < window.offset) throw splits()
251
286
  const bytes = window.bytes.subarray(offset - window.offset)
287
+ if (bytes.length && ((bytes[0] ?? 0) & 0xc0) === 0x80) throw splits()
252
288
  let text = decode(utf8Page(bytes, 24_000))
253
289
  if (text.length > 6000) {
254
290
  let end = 6000
@@ -5,6 +5,7 @@ import type { ToolContext } from '../../types/tool/index.js'
5
5
  import type { ToolFacts } from '../../types/tool/presentation.js'
6
6
  import { defineTool } from '../defineTool.js'
7
7
  import { resolveWithinAnyReal, toolRoots } from '../paths.js'
8
+ import { resolveRetainedOutput } from '../retained-output.js'
8
9
  import { looksBinary } from '../text-window.js'
9
10
  import { fingerprintContent } from './content-fingerprint.js'
10
11
  import { type RenderedRead, renderCappedRead } from './read-render.js'
@@ -71,8 +72,12 @@ export const ReadFileTool = defineTool({
71
72
  },
72
73
 
73
74
  async execute(input, context) {
75
+ // This session's own retained tool output lives on the host, outside the
76
+ // roots and outside any sandbox. It is read here, as a plain window of text,
77
+ // because the result that named it told the model to.
78
+ const retained = await resolveRetainedOutput(context.retainedOutputDirectory, input.path)
74
79
  // Sandbox-aware: route through sandbox.readFile() when available
75
- if (context.sandbox) {
80
+ if (context.sandbox && retained === undefined) {
76
81
  const buffer = await context.sandbox.readFile(input.path)
77
82
  const binaryGuidance = describeStructuredBinaryRead(input.path, buffer)
78
83
  if (binaryGuidance) {
@@ -112,7 +117,7 @@ export const ReadFileTool = defineTool({
112
117
  }
113
118
  }
114
119
 
115
- const filePath = await resolveWithinAnyReal(toolRoots(context), input.path)
120
+ const filePath = retained ?? (await resolveWithinAnyReal(toolRoots(context), input.path))
116
121
  // Refused on the size alone, before the file is in memory, when no window
117
122
  // was named: the cap exists so a huge file is never loaded to be refused.
118
123
  if (input.readRange === undefined && input.limit === undefined) {
@@ -0,0 +1,38 @@
1
+ import { lstat, realpath } from 'node:fs/promises'
2
+ import { dirname, extname, isAbsolute, relative, resolve } from 'node:path'
3
+
4
+ function inside(root: string, path: string): boolean {
5
+ const rel = relative(root, path)
6
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
7
+ }
8
+
9
+ /**
10
+ * The real path of a retained tool-output file the model may read, or
11
+ * `undefined` when `candidate` is not one.
12
+ *
13
+ * Only this session's own retained directory counts. The file must be an
14
+ * absolute path to a regular `.txt` file that is not a symbolic link, has no
15
+ * other hard link, and whose real location is inside the directory's real
16
+ * location (so a link anywhere along the path, or `..`, takes it out). Nothing
17
+ * is created, and nothing but a read is offered.
18
+ */
19
+ export async function resolveRetainedOutput(
20
+ directory: string | undefined,
21
+ candidate: string | undefined,
22
+ ): Promise<string | undefined> {
23
+ if (!directory || !candidate || !isAbsolute(candidate) || candidate.includes('\0'))
24
+ return undefined
25
+ try {
26
+ const base = await realpath(directory)
27
+ const lexical = resolve(candidate)
28
+ if (extname(lexical).toLowerCase() !== '.txt') return undefined
29
+ if (!inside(resolve(directory), lexical) && !inside(base, lexical)) return undefined
30
+ const stat = await lstat(lexical)
31
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) return undefined
32
+ const real = await realpath(lexical)
33
+ if (!inside(base, real) || dirname(real) === real) return undefined
34
+ return real
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
@@ -5,6 +5,48 @@ import type { SessionEventListener } from '../session/events.js'
5
5
  import type { AgentLifecycleListener } from './lifecycle-event.js'
6
6
  import type { AgentTask, AgentTaskContext, AgentTaskState, SendMessageOptions } from './task.js'
7
7
 
8
+ /** Why a saved child could not be made continuable again. */
9
+ export type RestoreChildRefusal =
10
+ /** No project layout is known, so there is no saved log to read. */
11
+ | 'no-layout'
12
+ /** Nothing readable is saved under that id for this parent. */
13
+ | 'not-saved'
14
+ /** The saved record belongs to another parent session. */
15
+ | 'foreign'
16
+ /** The saved record carries no continuation profile, was isolated, or names an agent the host cannot rebuild. */
17
+ | 'not-resumable'
18
+ /** The child never recorded an end: its process died mid-turn. */
19
+ | 'unsettled'
20
+ /** The child was cancelled. */
21
+ | 'stopped'
22
+ /** This manager already holds a continuation for it that is still running. */
23
+ | 'running'
24
+ /** Too many continuations are retained to take another. */
25
+ | 'full'
26
+
27
+ export interface RestoreChildOptions {
28
+ /** The saved child's session id. */
29
+ readonly childSessionId: SessionId
30
+ /** The directory the continued child works in; the same one it ran in. */
31
+ readonly workingDirectory: string
32
+ /**
33
+ * Which registered agent the child continues as, given the profile the host
34
+ * recorded at launch. Return `undefined` when the host cannot rebuild it.
35
+ */
36
+ readonly agentFor: (profile: Readonly<Record<string, string>>) => string | undefined
37
+ }
38
+
39
+ export type RestoreChildResult =
40
+ | {
41
+ readonly status: 'restored'
42
+ readonly childSessionId: SessionId
43
+ /** The agent chosen by {@link RestoreChildOptions.agentFor}; pass it to `createTask` with `resumeSessionId`. */
44
+ readonly agentId: string
45
+ readonly profile: Readonly<Record<string, string>>
46
+ readonly description: string
47
+ }
48
+ | { readonly status: 'refused'; readonly reason: RestoreChildRefusal }
49
+
8
50
  /**
9
51
  * Agent task lifecycle contract — task creation, cancellation, messaging, and completion tracking.
10
52
  * Concrete implementation: `AgentManager` in `manager/agent/lifecycle.ts`.
@@ -48,6 +90,18 @@ export interface AgentManagerContract {
48
90
  on(listener: AgentLifecycleListener): void
49
91
  off(listener: AgentLifecycleListener): void
50
92
 
93
+ /**
94
+ * Rebuild the right to continue a child that finished in an earlier process
95
+ * from its saved log and meta document, under the invoking parent's scope.
96
+ * Optional: only a manager that can read saved children offers it. Grants
97
+ * nothing by itself; continuing still goes through `sendMessage` with
98
+ * `resumeSessionId`, with the invoking turn's authority and limits.
99
+ */
100
+ restoreChildConversation?(
101
+ options: RestoreChildOptions,
102
+ context: AgentTaskContext,
103
+ ): Promise<RestoreChildResult>
104
+
51
105
  cleanup(): void
52
106
  dispose(): void
53
107
  }
@@ -5,6 +5,7 @@ import type { CancelCause } from '../session/cancel-cause.js'
5
5
  import type { ChildSessionLifecycleEvent, SessionEventListener } from '../session/events.js'
6
6
  import type { WorkspaceRef } from '../workspace/ref.js'
7
7
  import type { AgentRuntimeContext, BaseAgentConfig, BaseAgentResult } from './base.js'
8
+ import type { RestoreChildOptions, RestoreChildResult } from './manager.js'
8
9
  import type { AgentTaskState, ChildWorkspaceRequest } from './task.js'
9
10
 
10
11
  export interface TaskHandle {
@@ -41,6 +42,15 @@ export interface CreateTaskOptions {
41
42
  * and unchanged agent definition. A saved log alone grants no authority.
42
43
  */
43
44
  readonly resumeSessionId?: SessionId
45
+ /**
46
+ * What a later process needs to rebuild this child, written to the child's
47
+ * saved meta document: at most 16 string entries, keys up to 64 and values up
48
+ * to 256 characters. The host defines the keys (which agent to rebuild,
49
+ * which model) and reads them back from `restoreChild`. Never put a secret
50
+ * here; it lands on disk beside the log. Absent, the child cannot be
51
+ * continued from disk after its process ends.
52
+ */
53
+ readonly continuationProfile?: Readonly<Record<string, string>>
44
54
  /** Per-task filesystem choice, forwarded to the local manager at admission. */
45
55
  readonly workspace?: ChildWorkspaceRequest
46
56
  /**
@@ -156,6 +166,17 @@ export interface TaskScheduler {
156
166
 
157
167
  continueTask(taskId: TaskId, message: string): Promise<void>
158
168
 
169
+ /**
170
+ * Make a child saved by an earlier process continuable again, from its saved
171
+ * log and meta document, under this scheduler's parent scope. OPTIONAL: only
172
+ * a scheduler that can read saved children offers it. It runs nothing; the
173
+ * caller then continues the child with `createTask({ resumeSessionId })`, so
174
+ * the invoking turn's authority and limits apply. See
175
+ * {@link CreateTaskOptions.continuationProfile} for what must have been
176
+ * recorded at launch.
177
+ */
178
+ restoreChild?(options: RestoreChildOptions): Promise<RestoreChildResult>
179
+
159
180
  /**
160
181
  * Stop one task. A cause is relative to the child: a blocking delegation
161
182
  * abandoned by the turn that launched it is cancelled by its `parent`.
@@ -238,6 +238,8 @@ export interface AgentTask {
238
238
  export interface SendMessageOptions {
239
239
  /** See {@link import('./scheduler.js').CreateTaskOptions.resumeSessionId}. */
240
240
  readonly resumeSessionId?: SessionId
241
+ /** See {@link import('./scheduler.js').CreateTaskOptions.continuationProfile}. */
242
+ readonly continuationProfile?: Readonly<Record<string, string>>
241
243
  /** Explicit child filesystem policy; omitted preserves legacy backend behavior. */
242
244
  readonly workspace?: ChildWorkspaceRequest
243
245
  /**
@@ -1039,6 +1039,22 @@ export const ChildSessionMetaSchema = z
1039
1039
  status: turnExecutionStatus,
1040
1040
  createdAt: isoUtc,
1041
1041
  endedAt: isoUtc.optional(),
1042
+ /**
1043
+ * What a later process needs to continue this child from its saved log:
1044
+ * the filesystem policy it ran under and a small host-defined profile
1045
+ * (which agent to rebuild, which model). Absent on children saved before
1046
+ * this field existed and on children whose host recorded no profile; those
1047
+ * cannot be continued from disk.
1048
+ */
1049
+ continuation: z
1050
+ .object({
1051
+ workspace: z.enum(['shared', 'isolated']),
1052
+ profile: z
1053
+ .record(z.string().min(1).max(64), z.string().max(256))
1054
+ .refine((value) => Object.keys(value).length <= 16, 'At most 16 profile entries'),
1055
+ })
1056
+ .strict()
1057
+ .optional(),
1042
1058
  })
1043
1059
  .strict()
1044
1060
  export type ChildSessionMeta = z.infer<typeof ChildSessionMetaSchema>
@@ -458,6 +458,14 @@ export interface ToolContext {
458
458
  * directory; an absolute path inside any of these is accepted.
459
459
  */
460
460
  additionalDirectories?: readonly string[]
461
+ /**
462
+ * Absolute path of the directory holding THIS session's retained tool
463
+ * output (the files an oversized result was saved to). The `read` tool may
464
+ * open a regular file in it, and only that, without the path being a
465
+ * reviewed or added directory: the model was already shown the path in the
466
+ * result it came from. Set by the executor whenever the turn retains output.
467
+ */
468
+ retainedOutputDirectory?: string
461
469
  /**
462
470
  * Absolute paths outside the working directory and the added directories
463
471
  * that THIS call was approved to reach.