@namzu/sdk 35.0.0 → 36.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 (82) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/provider/token-budget.d.ts.map +1 -1
  3. package/dist/provider/token-budget.js +56 -8
  4. package/dist/provider/token-budget.js.map +1 -1
  5. package/dist/public-runtime.d.ts +2 -2
  6. package/dist/public-runtime.d.ts.map +1 -1
  7. package/dist/public-runtime.js +1 -1
  8. package/dist/public-runtime.js.map +1 -1
  9. package/dist/runtime/query/executor.d.ts +1 -0
  10. package/dist/runtime/query/executor.d.ts.map +1 -1
  11. package/dist/runtime/query/executor.js +50 -10
  12. package/dist/runtime/query/executor.js.map +1 -1
  13. package/dist/runtime/query/index.d.ts +7 -0
  14. package/dist/runtime/query/index.d.ts.map +1 -1
  15. package/dist/runtime/query/index.js +1 -0
  16. package/dist/runtime/query/index.js.map +1 -1
  17. package/dist/runtime/query/iteration/index.d.ts +3 -3
  18. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  19. package/dist/runtime/query/iteration/index.js +31 -6
  20. package/dist/runtime/query/iteration/index.js.map +1 -1
  21. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  22. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  23. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  24. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  25. package/dist/runtime/query/iteration/stream-turn.js +10 -5
  26. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  27. package/dist/runtime/query/prompt.d.ts.map +1 -1
  28. package/dist/runtime/query/prompt.js +14 -4
  29. package/dist/runtime/query/prompt.js.map +1 -1
  30. package/dist/sandbox/file-walk-program.d.ts +3 -0
  31. package/dist/sandbox/file-walk-program.d.ts.map +1 -0
  32. package/dist/sandbox/file-walk-program.js +94 -0
  33. package/dist/sandbox/file-walk-program.js.map +1 -0
  34. package/dist/sandbox/file-walk.d.ts +12 -0
  35. package/dist/sandbox/file-walk.d.ts.map +1 -0
  36. package/dist/sandbox/file-walk.js +429 -0
  37. package/dist/sandbox/file-walk.js.map +1 -0
  38. package/dist/sandbox/index.d.ts +2 -0
  39. package/dist/sandbox/index.d.ts.map +1 -1
  40. package/dist/sandbox/index.js +1 -0
  41. package/dist/sandbox/index.js.map +1 -1
  42. package/dist/sandbox/provider/local.d.ts.map +1 -1
  43. package/dist/sandbox/provider/local.js +9 -0
  44. package/dist/sandbox/provider/local.js.map +1 -1
  45. package/dist/scheduler/completion-inbox.d.ts +3 -2
  46. package/dist/scheduler/completion-inbox.d.ts.map +1 -1
  47. package/dist/scheduler/completion-inbox.js +29 -15
  48. package/dist/scheduler/completion-inbox.js.map +1 -1
  49. package/dist/tools/builtins/bash.d.ts.map +1 -1
  50. package/dist/tools/builtins/bash.js +215 -59
  51. package/dist/tools/builtins/bash.js.map +1 -1
  52. package/dist/tools/builtins/glob.d.ts +2 -1
  53. package/dist/tools/builtins/glob.d.ts.map +1 -1
  54. package/dist/tools/builtins/glob.js +95 -103
  55. package/dist/tools/builtins/glob.js.map +1 -1
  56. package/dist/tools/builtins/grep.d.ts.map +1 -1
  57. package/dist/tools/builtins/grep.js +155 -111
  58. package/dist/tools/builtins/grep.js.map +1 -1
  59. package/dist/tools/builtins/ls.js +2 -2
  60. package/dist/tools/builtins/ls.js.map +1 -1
  61. package/dist/types/sandbox/index.d.ts +20 -0
  62. package/dist/types/sandbox/index.d.ts.map +1 -1
  63. package/dist/types/sandbox/index.js.map +1 -1
  64. package/package.json +6 -1
  65. package/src/provider/token-budget.ts +52 -8
  66. package/src/public-runtime.ts +2 -1
  67. package/src/runtime/query/executor.ts +54 -14
  68. package/src/runtime/query/index.ts +8 -0
  69. package/src/runtime/query/iteration/index.ts +29 -9
  70. package/src/runtime/query/iteration/phases/context.ts +2 -0
  71. package/src/runtime/query/iteration/stream-turn.ts +9 -5
  72. package/src/runtime/query/prompt.ts +21 -4
  73. package/src/sandbox/file-walk-program.ts +93 -0
  74. package/src/sandbox/file-walk.ts +463 -0
  75. package/src/sandbox/index.ts +2 -0
  76. package/src/sandbox/provider/local.ts +13 -0
  77. package/src/scheduler/completion-inbox.ts +29 -19
  78. package/src/tools/builtins/bash.ts +230 -58
  79. package/src/tools/builtins/glob.ts +104 -122
  80. package/src/tools/builtins/grep.ts +166 -132
  81. package/src/tools/builtins/ls.ts +2 -2
  82. package/src/types/sandbox/index.ts +21 -0
@@ -0,0 +1,463 @@
1
+ import { isAbsolute, posix } from 'node:path'
2
+ import braceExpansion from 'brace-expansion'
3
+ import { GLOBSTAR, Minimatch } from 'minimatch'
4
+ import type {
5
+ SandboxExecOptions,
6
+ SandboxExecResult,
7
+ SandboxFileEntry,
8
+ SandboxWalkFilesOptions,
9
+ } from '../types/sandbox/index.js'
10
+ import { subscribeToAbort } from '../utils/abort.js'
11
+ import { FILE_WALK_PROGRAM } from './file-walk-program.js'
12
+
13
+ interface WalkPlan {
14
+ root: string
15
+ prefix: string[]
16
+ expressions: { source: string; flags: string }[]
17
+ maxDepth: number | null
18
+ maxEntries: number
19
+ maxVisitedEntries: number
20
+ includeHidden: boolean
21
+ patterns: (string | { source: string; flags: string } | null)[][]
22
+ }
23
+
24
+ // brace-expansion 2.1.4 implements these bounds; its older DefinitelyTyped
25
+ // declaration only describes the first argument.
26
+ const expandBraces = braceExpansion as (
27
+ pattern: string,
28
+ options: { max: number; maxLength: number },
29
+ ) => string[]
30
+
31
+ function positiveInteger(value: number, name: string): number {
32
+ if (!Number.isSafeInteger(value) || value <= 0) {
33
+ throw new RangeError(`${name} must be a positive safe integer`)
34
+ }
35
+ return value
36
+ }
37
+
38
+ function planWalk(root: string, options: SandboxWalkFilesOptions): WalkPlan {
39
+ if (!isAbsolute(root) && !posix.isAbsolute(root)) {
40
+ throw new Error('File walk root must be an absolute path')
41
+ }
42
+ let pattern = options.pattern ?? '**/*'
43
+ while (pattern.startsWith('./')) pattern = pattern.slice(2)
44
+ if (
45
+ !pattern ||
46
+ pattern.startsWith('/') ||
47
+ /^[a-z]:/i.test(pattern) ||
48
+ pattern.split('/').includes('..')
49
+ ) {
50
+ throw new Error('File walk pattern must be relative to its root and cannot contain ..')
51
+ }
52
+ if (pattern.length > 4096) throw new RangeError('File walk pattern exceeds 4096 characters')
53
+ // Expansion cannot lengthen a branch beyond its source. Bound both source
54
+ // length and branch count, then disable the library's separate character cap:
55
+ // that cap can silently truncate before our extra branch proves incompleteness.
56
+ const branches = expandBraces(pattern, { max: 257, maxLength: Number.POSITIVE_INFINITY })
57
+ if (branches.length > 256) throw new RangeError('File walk pattern exceeds 256 brace expansions')
58
+ const matchers = branches.map((branch) => {
59
+ if (branch.startsWith('/') || /^[a-z]:/i.test(branch) || branch.split('/').includes('..')) {
60
+ throw new Error('Expanded file walk pattern escapes its root')
61
+ }
62
+ return new Minimatch(branch, {
63
+ nobrace: true,
64
+ nonegate: true,
65
+ nocomment: true,
66
+ dot: options.includeHidden ?? false,
67
+ platform: 'linux',
68
+ })
69
+ })
70
+ const sets = matchers.flatMap((matcher) => matcher.set)
71
+ if (sets.some((set) => set.some((segment) => segment === '..'))) {
72
+ throw new Error('Expanded file walk pattern escapes its root')
73
+ }
74
+ const patternDepth = Math.max(
75
+ 1,
76
+ ...sets.map((set) => (set.includes(GLOBSTAR) ? Number.POSITIVE_INFINITY : set.length)),
77
+ )
78
+ const maxDepth = Math.min(
79
+ patternDepth,
80
+ options.maxDepth === undefined
81
+ ? Number.POSITIVE_INFINITY
82
+ : positiveInteger(options.maxDepth, 'maxDepth'),
83
+ )
84
+ const prefix: string[] = []
85
+ for (let index = 0; sets.length > 0; index += 1) {
86
+ const segment = sets[0]?.[index]
87
+ if (
88
+ typeof segment !== 'string' ||
89
+ !segment ||
90
+ segment === '.' ||
91
+ sets.some((set) => index >= set.length - 1 || set[index] !== segment)
92
+ )
93
+ break
94
+ prefix.push(segment)
95
+ }
96
+ const expressions = matchers.flatMap((matcher) => {
97
+ const expression = matcher.makeRe()
98
+ return expression ? [{ source: expression.source, flags: expression.flags }] : []
99
+ })
100
+ return {
101
+ root,
102
+ prefix,
103
+ expressions,
104
+ maxDepth: Number.isFinite(maxDepth) ? maxDepth : null,
105
+ maxEntries: positiveInteger(options.maxEntries, 'maxEntries'),
106
+ maxVisitedEntries: positiveInteger(options.maxVisitedEntries ?? 20_000, 'maxVisitedEntries'),
107
+ includeHidden: options.includeHidden ?? false,
108
+ patterns: sets.map((set) =>
109
+ set.map((segment) =>
110
+ segment === GLOBSTAR
111
+ ? null
112
+ : typeof segment === 'string'
113
+ ? segment
114
+ : { source: segment.source, flags: segment.flags },
115
+ ),
116
+ ),
117
+ }
118
+ }
119
+
120
+ /** Whether at least one parsed pattern can match a file below this directory. */
121
+ function directoryMatcher(plan: WalkPlan): (relative: string) => boolean {
122
+ const patterns = plan.patterns.map((set) =>
123
+ set.map((part) =>
124
+ typeof part === 'object' && part !== null ? new RegExp(part.source, `${part.flags}s`) : part,
125
+ ),
126
+ )
127
+ return (relative) =>
128
+ patterns.some((pattern) => {
129
+ const closure = (states: Set<number>) => {
130
+ for (const index of states) if (pattern[index] === null) states.add(index + 1)
131
+ return states
132
+ }
133
+ let states = closure(new Set([0]))
134
+ for (const name of relative.split('/')) {
135
+ const next = new Set<number>()
136
+ for (const index of states) {
137
+ const segment = pattern[index]
138
+ if (segment === null) {
139
+ if (plan.includeHidden || !name.startsWith('.')) next.add(index)
140
+ } else if (typeof segment === 'string' ? segment === name : segment?.test(name))
141
+ next.add(index + 1)
142
+ }
143
+ states = closure(next)
144
+ if (states.size === 0) return false
145
+ }
146
+ return [...states].some((index) => index < pattern.length)
147
+ })
148
+ }
149
+
150
+ /** Local traversal uses the same compiled matching/pruning plan as the guest. */
151
+ async function* executeWalk(
152
+ plan: WalkPlan,
153
+ signal?: AbortSignal,
154
+ ): AsyncGenerator<SandboxFileEntry> {
155
+ const fs = await import('node:fs/promises')
156
+ const path = await import('node:path')
157
+ const { EventEmitter } = await import('node:events')
158
+ const open: { dir: import('node:fs').Dir; relative: string; depth: number }[] = []
159
+ let visited = 0
160
+ let emitted = 0
161
+ const matchers = plan.expressions.map(({ source, flags }) => new RegExp(source, `${flags}s`))
162
+ const canDescend = directoryMatcher(plan)
163
+ const maxDepth = plan.maxDepth ?? Number.POSITIVE_INFINITY
164
+ const missing = (error: unknown) =>
165
+ ['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')
166
+ const close = async (dir: import('node:fs').Dir) => {
167
+ try {
168
+ await dir.close()
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') throw error
171
+ }
172
+ }
173
+ const check = () => signal?.throwIfAborted()
174
+ async function wait<T>(pending: Promise<T>, late?: (value: T) => void): Promise<T> {
175
+ if (!signal) return await pending
176
+ let abandoned = signal.aborted
177
+ let dispose: (() => void) | undefined
178
+ const observed = pending.then((value) => {
179
+ if (abandoned) late?.(value)
180
+ return value
181
+ })
182
+ try {
183
+ return await Promise.race([
184
+ observed,
185
+ new Promise<never>((_, reject) => {
186
+ const abort = () => {
187
+ abandoned = true
188
+ reject(signal.reason)
189
+ }
190
+ if (signal.aborted) {
191
+ abort()
192
+ return
193
+ }
194
+ if (typeof EventEmitter.addAbortListener === 'function') {
195
+ const subscription = EventEmitter.addAbortListener(signal, abort)
196
+ dispose = () => subscription[Symbol.dispose]()
197
+ } else {
198
+ signal.addEventListener('abort', abort, { once: true })
199
+ dispose = () => signal.removeEventListener('abort', abort)
200
+ }
201
+ }),
202
+ ])
203
+ } finally {
204
+ dispose?.()
205
+ }
206
+ }
207
+ const noteVisit = () => {
208
+ visited += 1
209
+ if (visited > plan.maxVisitedEntries) {
210
+ throw Object.assign(
211
+ new Error(
212
+ `File search stopped after examining ${plan.maxVisitedEntries} entries; narrow its root or pattern.`,
213
+ ),
214
+ { code: 'ERR_FILE_WALK_LIMIT' },
215
+ )
216
+ }
217
+ }
218
+ try {
219
+ check()
220
+ if (plan.patterns.length === 0) return
221
+ let canonicalRoot: string
222
+ try {
223
+ canonicalRoot = await wait(fs.realpath(plan.root))
224
+ } catch (error) {
225
+ if (missing(error)) return
226
+ throw error
227
+ }
228
+ if (canonicalRoot !== path.resolve(plan.root))
229
+ throw new Error('File walk root follows a symbolic link; use its authorized real directory')
230
+ let start = plan.root
231
+ // Never follow a static-prefix symlink to speed up a pattern: traversal
232
+ // and its optimized starting point must have the same link policy.
233
+ for (const part of plan.prefix) {
234
+ check()
235
+ start = path.join(start, part)
236
+ let info: import('node:fs').Stats
237
+ try {
238
+ info = await wait(fs.lstat(start))
239
+ } catch (error) {
240
+ if (missing(error)) return
241
+ throw error
242
+ }
243
+ noteVisit()
244
+ if (!info.isDirectory()) return
245
+ }
246
+ if (plan.prefix.length >= maxDepth) return
247
+ const openDirectory = async (absolute: string, relative: string, depth: number) => {
248
+ check()
249
+ try {
250
+ const dir = await wait(fs.opendir(absolute), (late) => {
251
+ void close(late).catch(() => {})
252
+ })
253
+ open.push({ dir, relative, depth })
254
+ } catch (error) {
255
+ if (!missing(error)) throw error
256
+ }
257
+ }
258
+ await openDirectory(start, plan.prefix.join('/'), plan.prefix.length)
259
+ while (open.length > 0) {
260
+ check()
261
+ const frame = open[open.length - 1] as (typeof open)[number]
262
+ const entry = await wait(frame.dir.read())
263
+ check()
264
+ if (!entry) {
265
+ open.pop()
266
+ await close(frame.dir)
267
+ continue
268
+ }
269
+ noteVisit()
270
+ const relative = frame.relative ? `${frame.relative}/${entry.name}` : entry.name
271
+ const absolute = path.join(plan.root, relative)
272
+ const depth = frame.depth + 1
273
+ if (entry.isDirectory()) {
274
+ if (depth < maxDepth && canDescend(relative)) await openDirectory(absolute, relative, depth)
275
+ continue
276
+ }
277
+ if (!entry.isFile() || !matchers.some((matcher) => matcher.test(relative))) continue
278
+ let info: import('node:fs').Stats
279
+ try {
280
+ info = await wait(fs.lstat(absolute))
281
+ } catch (error) {
282
+ if (missing(error)) continue
283
+ throw error
284
+ }
285
+ check()
286
+ if (!info.isFile()) continue
287
+ emitted += 1
288
+ yield { path: absolute, size: info.size }
289
+ if (emitted >= plan.maxEntries) return
290
+ }
291
+ } finally {
292
+ for (const { dir } of open.reverse()) {
293
+ if (signal?.aborted) {
294
+ void close(dir).catch(() => {})
295
+ } else await close(dir)
296
+ }
297
+ }
298
+ }
299
+
300
+ /** Internal host path; the caller first resolves its authorized filesystem root. */
301
+ export async function* walkFilesLocally(
302
+ rootPath: string,
303
+ options: SandboxWalkFilesOptions,
304
+ ): AsyncGenerator<SandboxFileEntry> {
305
+ options.signal?.throwIfAborted()
306
+ yield* executeWalk(planWalk(rootPath, options), options.signal)
307
+ }
308
+
309
+ /** The existing sandbox execution seam; no separate worker protocol is needed. */
310
+ export type SandboxFileWalkExec = (
311
+ command: string,
312
+ argv?: string[],
313
+ options?: SandboxExecOptions,
314
+ ) => Promise<SandboxExecResult>
315
+
316
+ /**
317
+ * Lazy, bounded JSONL enumeration inside the execution boundary owned by exec.
318
+ * The adapter must implement SandboxExecOptions.signal: iterator cleanup waits
319
+ * for cancellation settlement and preserves failures to confirm remote shutdown.
320
+ */
321
+ export async function* walkFilesViaExec(
322
+ exec: SandboxFileWalkExec,
323
+ rootPath: string,
324
+ options: SandboxWalkFilesOptions,
325
+ ): AsyncGenerator<SandboxFileEntry> {
326
+ options.signal?.throwIfAborted()
327
+ const plan = planWalk(rootPath, options)
328
+ const controller = new AbortController()
329
+ const dispose = options.signal
330
+ ? subscribeToAbort(options.signal, () => controller.abort(options.signal?.reason))
331
+ : undefined
332
+ const queue: SandboxFileEntry[] = []
333
+ let wake: (() => void) | undefined
334
+ let buffered = ''
335
+ let observedOutput = false
336
+ let settled = false
337
+ let terminal = false
338
+ let received = 0
339
+ let failure: unknown
340
+ const reject = (error: unknown) => {
341
+ failure ??= error instanceof Error ? error : new Error(String(error))
342
+ controller.abort(error)
343
+ wake?.()
344
+ }
345
+ const consume = (line: string) => {
346
+ if (line.length > 65_536) throw new Error('File walk record exceeded its output bound')
347
+ const value: unknown = JSON.parse(line)
348
+ if (!value || typeof value !== 'object' || terminal) throw new Error('Invalid file walk record')
349
+ const record = value as Record<string, unknown>
350
+ if (record.type === 'done') {
351
+ terminal = true
352
+ return
353
+ }
354
+ if (record.type === 'error' && typeof record.message === 'string') {
355
+ terminal = true
356
+ throw Object.assign(new Error(record.message), {
357
+ code: typeof record.code === 'string' ? record.code : undefined,
358
+ })
359
+ }
360
+ if (
361
+ record.type !== 'entry' ||
362
+ typeof record.path !== 'string' ||
363
+ typeof record.size !== 'number' ||
364
+ !Number.isSafeInteger(record.size) ||
365
+ record.size < 0
366
+ ) {
367
+ throw new Error('Invalid file walk entry')
368
+ }
369
+ const relative = posix.relative(rootPath, record.path)
370
+ if (
371
+ !posix.isAbsolute(record.path) ||
372
+ relative === '..' ||
373
+ relative.startsWith('../') ||
374
+ posix.isAbsolute(relative)
375
+ ) {
376
+ throw new Error('File walk entry escaped its requested root')
377
+ }
378
+ if (++received > plan.maxEntries) throw new Error('File walk exceeded its entry bound')
379
+ queue.push({ path: record.path, size: record.size })
380
+ }
381
+ const ingest = (data: string) => {
382
+ try {
383
+ let start = 0
384
+ for (;;) {
385
+ const newline = data.indexOf('\n', start)
386
+ const end = newline < 0 ? data.length : newline
387
+ if (buffered.length + end - start > 65_536)
388
+ throw new Error('File walk record exceeded its output bound')
389
+ buffered += data.slice(start, end)
390
+ if (newline < 0) break
391
+ consume(buffered)
392
+ buffered = ''
393
+ start = newline + 1
394
+ }
395
+ } catch (error) {
396
+ reject(error)
397
+ }
398
+ wake?.()
399
+ }
400
+ const pending = Promise.resolve()
401
+ .then(() => {
402
+ controller.signal.throwIfAborted()
403
+ return exec('node', ['-e', FILE_WALK_PROGRAM, JSON.stringify(plan)], {
404
+ signal: controller.signal,
405
+ onOutput: ({ stream, data }) => {
406
+ if (stream === 'stdout') {
407
+ observedOutput = true
408
+ ingest(data)
409
+ }
410
+ },
411
+ })
412
+ })
413
+ .then(
414
+ (result) => {
415
+ if (controller.signal.aborted) return
416
+ if (!observedOutput && result.stdout) ingest(result.stdout)
417
+ if (buffered) reject(new Error('File walk ended with an incomplete record'))
418
+ if (result.stdoutTruncated || result.stderrTruncated)
419
+ reject(new Error('File walk transport truncated its output'))
420
+ if (result.timedOut || result.exitCode !== 0)
421
+ reject(
422
+ new Error(
423
+ result.timedOut
424
+ ? 'File walk timed out'
425
+ : `File walk failed with exit code ${result.exitCode}: ${result.stderr}`,
426
+ ),
427
+ )
428
+ if (!terminal && !controller.signal.aborted)
429
+ reject(new Error('File walk ended without its completion record'))
430
+ },
431
+ (error) => {
432
+ reject(error)
433
+ },
434
+ )
435
+ .finally(() => {
436
+ settled = true
437
+ wake?.()
438
+ })
439
+ try {
440
+ for (;;) {
441
+ options.signal?.throwIfAborted()
442
+ const entry = queue.shift()
443
+ if (entry) {
444
+ yield entry
445
+ continue
446
+ }
447
+ if (failure) throw failure
448
+ if (settled) break
449
+ await new Promise<void>((resolve) => {
450
+ wake = resolve
451
+ })
452
+ wake = undefined
453
+ }
454
+ } finally {
455
+ dispose?.()
456
+ if (!settled) controller.abort(new Error('File walk consumer stopped'))
457
+ await pending
458
+ // A rejected cancellation can mean remote work is still alive. Preserve
459
+ // that error so the owning backend can retire the uncertain handle.
460
+ // biome-ignore lint/correctness/noUnsafeFinally: a failed remote cleanup must also reject iterator.return().
461
+ if (failure) throw failure
462
+ }
463
+ }
@@ -2,3 +2,5 @@ export { LocalSandboxProvider } from './provider/local.js'
2
2
  export type { LocalSandboxProviderOptions } from './provider/local.js'
3
3
  export { SandboxProviderFactory } from './factory.js'
4
4
  export { assertIsolation, describeIsolation, isolationOf, missingIsolation } from './isolation.js'
5
+ export { walkFilesViaExec } from './file-walk.js'
6
+ export type { SandboxFileWalkExec } from './file-walk.js'
@@ -40,6 +40,7 @@ import type {
40
40
  SandboxProvider,
41
41
  SandboxSpawnOptions,
42
42
  SandboxStatus,
43
+ SandboxWalkFilesOptions,
43
44
  } from '../../types/sandbox/index.js'
44
45
  import { subscribeToAbort } from '../../utils/abort.js'
45
46
  import { generateSandboxId } from '../../utils/id.js'
@@ -49,6 +50,7 @@ import {
49
50
  applyEnvironmentOverrides,
50
51
  pickEnvironmentEntries,
51
52
  } from '../../utils/process-environment.js'
53
+ import { walkFilesLocally } from '../file-walk.js'
52
54
  import { assertIsolation, describeIsolation } from '../isolation.js'
53
55
  import type { PtyLoader } from '../terminal.js'
54
56
 
@@ -779,6 +781,17 @@ class LocalSandbox implements Sandbox {
779
781
  return entries
780
782
  }
781
783
 
784
+ async *walkFiles(
785
+ rootPath: string,
786
+ options: SandboxWalkFilesOptions,
787
+ ): AsyncGenerator<SandboxFileEntry> {
788
+ if (this._status === 'destroyed') throw new Error(`Sandbox ${this.id} is destroyed`)
789
+ options.signal?.throwIfAborted()
790
+ const resolved = await resolveWithinAnyReal(this.roots, rootPath)
791
+ options.signal?.throwIfAborted()
792
+ yield* walkFilesLocally(resolved, options)
793
+ }
794
+
782
795
  async destroy(_options?: SandboxDestroyOptions): Promise<void> {
783
796
  if (this._status === 'destroyed') {
784
797
  return
@@ -231,38 +231,33 @@ export class CompletionInbox {
231
231
  }
232
232
 
233
233
  /**
234
- * Wait for the next completion, or for the deadline, whichever comes first.
234
+ * Wait for the next completion, deadline or abort, whichever comes first.
235
+ * Aborting releases only this waiter; work and undelivered results remain owned.
235
236
  *
236
237
  * Bounded on purpose. A worker that never finishes must not hold a run
237
238
  * open forever, and the caller decides how long "long enough" is — the
238
239
  * run's own budget is the only thing that knows.
239
240
  */
240
- waitForArrival(timeoutMs: number): Promise<void> {
241
+ waitForArrival(timeoutMs: number, signal?: AbortSignal): Promise<void> {
242
+ if (signal?.aborted) return Promise.resolve()
241
243
  if (this.unheard.size > 0) return Promise.resolve()
242
244
  if (this.outstanding.size === 0) return Promise.resolve()
243
245
 
244
246
  return new Promise((resolve) => {
247
+ const finish = (): void => {
248
+ clearTimeout(timer)
249
+ this.arrivals.delete(finish)
250
+ signal?.removeEventListener('abort', finish)
251
+ resolve()
252
+ }
245
253
  const timer = setTimeout(finish, timeoutMs)
246
254
  // `unref` where the runtime has it, so a pending wait never keeps
247
255
  // a process alive past the work it was waiting for.
248
256
  ;(timer as { unref?: () => void }).unref?.()
249
257
 
250
- function finish(): void {
251
- clearTimeout(timer)
252
- wake.done = true
253
- resolve()
254
- }
255
-
256
- const wake = Object.assign(
257
- () => {
258
- if (!wake.done) {
259
- this.arrivals.delete(wake)
260
- finish()
261
- }
262
- },
263
- { done: false },
264
- )
265
- this.arrivals.add(wake)
258
+ this.arrivals.add(finish)
259
+ signal?.addEventListener('abort', finish, { once: true })
260
+ if (signal?.aborted) finish()
266
261
  })
267
262
  }
268
263
 
@@ -423,7 +418,21 @@ function neutralizeNotificationDelimiter(content: string): string {
423
418
  export function formatCompletionNotification(handles: readonly TaskHandle[]): string {
424
419
  const blocks = handles.map((handle) => {
425
420
  const durationMs = handle.completedAt ? handle.completedAt - handle.createdAt : undefined
426
- const output = handle.result?.result ?? handle.result?.lastError ?? ''
421
+ const run = handle.result
422
+ let output = run?.result || run?.lastError || ''
423
+ // A hard guard can stop immediately after a tool round, before the result
424
+ // assembler has a final answer. Preserve visible partial prose, never reasoning.
425
+ if (!output && run?.stopReason && run.stopReason !== 'end_turn') {
426
+ const partial = [...(run.messages ?? [])]
427
+ .reverse()
428
+ .find(
429
+ (message) =>
430
+ message.role === 'assistant' &&
431
+ typeof message.content === 'string' &&
432
+ message.content.length > 0,
433
+ )
434
+ if (partial) output = `Partial output before ${run.stopReason}:\n${partial.content}`
435
+ }
427
436
  const overLimit = output.length > NOTIFICATION_OUTPUT_LIMIT
428
437
  const shown = overLimit ? output.slice(0, NOTIFICATION_OUTPUT_LIMIT) : output
429
438
 
@@ -455,6 +464,7 @@ export function formatCompletionNotification(handles: readonly TaskHandle[]): st
455
464
  `task_id: ${handle.taskId}`,
456
465
  `agent: ${handle.agentId}`,
457
466
  `state: ${handle.state}`,
467
+ ...(handle.result?.stopReason ? [`stop_reason: ${handle.result.stopReason}`] : []),
458
468
  ...(durationMs !== undefined ? [`duration_ms: ${durationMs}`] : []),
459
469
  '',
460
470
  body,