@nifrajs/coding-agent 4.0.2 → 5.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/dist/approvals.d.ts +30 -2
  4. package/dist/approvals.d.ts.map +1 -1
  5. package/dist/approvals.js +43 -12
  6. package/dist/approvals.js.map +1 -1
  7. package/dist/cli.d.ts +13 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +120 -100
  10. package/dist/cli.js.map +1 -1
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +9 -21
  13. package/dist/context.js.map +1 -1
  14. package/dist/diff.d.ts.map +1 -1
  15. package/dist/diff.js +8 -21
  16. package/dist/diff.js.map +1 -1
  17. package/dist/extensions.js +4 -2
  18. package/dist/extensions.js.map +1 -1
  19. package/dist/handoffs.d.ts +5 -0
  20. package/dist/handoffs.d.ts.map +1 -1
  21. package/dist/handoffs.js +27 -0
  22. package/dist/handoffs.js.map +1 -1
  23. package/dist/host.d.ts +15 -1
  24. package/dist/host.d.ts.map +1 -1
  25. package/dist/host.js +152 -37
  26. package/dist/host.js.map +1 -1
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/isolated.js +3 -2
  32. package/dist/isolated.js.map +1 -1
  33. package/dist/native.d.ts +29 -2
  34. package/dist/native.d.ts.map +1 -1
  35. package/dist/native.js +86 -26
  36. package/dist/native.js.map +1 -1
  37. package/dist/orchestration/dispatch.d.ts +5 -1
  38. package/dist/orchestration/dispatch.d.ts.map +1 -1
  39. package/dist/orchestration/dispatch.js +1 -1
  40. package/dist/orchestration/dispatch.js.map +1 -1
  41. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  42. package/dist/orchestration/evidence-store.js +5 -6
  43. package/dist/orchestration/evidence-store.js.map +1 -1
  44. package/dist/orchestration/host.d.ts +14 -0
  45. package/dist/orchestration/host.d.ts.map +1 -1
  46. package/dist/orchestration/host.js +26 -0
  47. package/dist/orchestration/host.js.map +1 -1
  48. package/dist/orchestration/index.d.ts +1 -1
  49. package/dist/orchestration/index.d.ts.map +1 -1
  50. package/dist/orchestration/index.js.map +1 -1
  51. package/dist/orchestration/recovery.d.ts +5 -1
  52. package/dist/orchestration/recovery.d.ts.map +1 -1
  53. package/dist/orchestration/recovery.js +5 -3
  54. package/dist/orchestration/recovery.js.map +1 -1
  55. package/dist/process.d.ts +22 -1
  56. package/dist/process.d.ts.map +1 -1
  57. package/dist/process.js +75 -1
  58. package/dist/process.js.map +1 -1
  59. package/dist/rpc.d.ts +29 -0
  60. package/dist/rpc.d.ts.map +1 -1
  61. package/dist/rpc.js +205 -46
  62. package/dist/rpc.js.map +1 -1
  63. package/dist/session-migration.d.ts.map +1 -1
  64. package/dist/session-migration.js +30 -6
  65. package/dist/session-migration.js.map +1 -1
  66. package/dist/sessions.d.ts +13 -2
  67. package/dist/sessions.d.ts.map +1 -1
  68. package/dist/sessions.js +104 -24
  69. package/dist/sessions.js.map +1 -1
  70. package/dist/subagents.d.ts.map +1 -1
  71. package/dist/subagents.js +13 -2
  72. package/dist/subagents.js.map +1 -1
  73. package/dist/turn-replay.d.ts +40 -0
  74. package/dist/turn-replay.d.ts.map +1 -0
  75. package/dist/turn-replay.js +136 -0
  76. package/dist/turn-replay.js.map +1 -0
  77. package/dist/ui.js +84 -30
  78. package/dist/ui.js.map +1 -1
  79. package/dist/verification.d.ts +7 -0
  80. package/dist/verification.d.ts.map +1 -1
  81. package/dist/verification.js +66 -63
  82. package/dist/verification.js.map +1 -1
  83. package/dist/workflows.d.ts +5 -0
  84. package/dist/workflows.d.ts.map +1 -1
  85. package/dist/workflows.js +2 -1
  86. package/dist/workflows.js.map +1 -1
  87. package/package.json +6 -6
  88. package/src/approvals.ts +70 -16
  89. package/src/cli.ts +125 -93
  90. package/src/context.ts +12 -20
  91. package/src/diff.ts +11 -23
  92. package/src/extensions.ts +6 -2
  93. package/src/handoffs.ts +27 -0
  94. package/src/host.ts +165 -34
  95. package/src/index.ts +3 -0
  96. package/src/isolated.ts +3 -2
  97. package/src/native.ts +137 -34
  98. package/src/orchestration/dispatch.ts +6 -2
  99. package/src/orchestration/evidence-store.ts +5 -6
  100. package/src/orchestration/host.ts +44 -0
  101. package/src/orchestration/index.ts +1 -0
  102. package/src/orchestration/recovery.ts +14 -8
  103. package/src/process.ts +89 -0
  104. package/src/rpc.ts +268 -49
  105. package/src/session-migration.ts +29 -5
  106. package/src/sessions.ts +115 -29
  107. package/src/subagents.ts +11 -2
  108. package/src/turn-replay.ts +151 -0
  109. package/src/ui.ts +90 -32
  110. package/src/verification.ts +74 -60
  111. package/src/workflows.ts +7 -1
package/src/rpc.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AgentBackend,
3
3
  type AgentSessionSnapshot,
4
+ type CreateSessionInput,
4
5
  type ReloadResult,
5
6
  resumeFromCursor,
6
7
  } from "@nifrajs/agent-protocol"
@@ -10,6 +11,7 @@ import type { ExtensionHost } from "./extensions.ts"
10
11
  import { CodingAgentHost, type CodingAgentHostOptions } from "./host.ts"
11
12
  import { readBoundedText } from "./process.ts"
12
13
  import type { ContextWindowOptions, SessionStore } from "./sessions.ts"
14
+ import { ReplayableTurn, type TurnReplayLimits } from "./turn-replay.ts"
13
15
  import type { UiExtensionHost, UiExtensionManifest } from "./ui.ts"
14
16
  import { isSafeReviewDiff, runNifraReview, runNifraVerification } from "./verification.ts"
15
17
 
@@ -34,6 +36,27 @@ export interface CodingAgentRpcServerOptions {
34
36
  readonly verifyAfterTurn?: readonly ("check" | "assure" | "test")[]
35
37
  readonly maxRepairAttempts?: number
36
38
  readonly verification?: CodingAgentHostOptions["verification"]
39
+ readonly extensionEvents?: CodingAgentHostOptions["extensionEvents"]
40
+ /**
41
+ * The host's approval broker: how long an approval or a tool's question waits for its answer
42
+ * (`timeoutMs`, default 5 minutes, or `"never"`), how many may wait (`maxPending`, default 32),
43
+ * and its `onRequired`/`onResolved` hooks.
44
+ */
45
+ readonly approvals?: CodingAgentHostOptions["approvals"]
46
+ /**
47
+ * Keep a `turn.send` running when its client disconnects, and let a client that comes back read
48
+ * what it missed with `turn.attach` (an `afterSeq` cursor, or `Last-Event-ID`). The newest
49
+ * `replayEvents` frames, up to `replayBytes`, are held (defaults 1024 and 8 MiB); a turn no client
50
+ * has followed for `detachedTimeoutMs` (default 2 minutes) is cancelled. A client that stops reading
51
+ * is dropped once it falls `replayBytes` behind what is held, and can attach again from its cursor.
52
+ */
53
+ readonly resumableTurns?:
54
+ | boolean
55
+ | {
56
+ readonly replayEvents?: number
57
+ readonly replayBytes?: number
58
+ readonly detachedTimeoutMs?: number
59
+ }
37
60
  }
38
61
 
39
62
  export interface CodingAgentRpcServerHandle {
@@ -60,6 +83,13 @@ const MIN_REMOTE_TOKEN_BYTES = 32
60
83
  const AUTH_FAILURE_WINDOW_MS = 60_000
61
84
  const AUTH_FAILURE_THRESHOLD = 3
62
85
  const AUTH_MAX_BACKOFF_MS = 2_000
86
+ const MAX_REPLAY_EVENTS = 65_536
87
+ const MAX_REPLAY_BYTES = 256 * 1024 * 1024
88
+ const MAX_DETACHED_TIMEOUT_MS = 24 * 60 * 60 * 1000
89
+
90
+ interface ResumableTurnConfig extends TurnReplayLimits {
91
+ readonly detachedTimeoutMs: number
92
+ }
63
93
 
64
94
  /**
65
95
  * Minimal loopback RPC surface for the CLI, Workbench, CI clients, and a future mobile companion.
@@ -76,6 +106,11 @@ export class CodingAgentRpcServer {
76
106
  private authFailures = 0
77
107
  private authLastFailureAt = 0
78
108
  private authBackoffUntil = 0
109
+ private creating: Promise<AgentSessionSnapshot> | undefined
110
+ private readonly resumable: ResumableTurnConfig | undefined
111
+ private turn: ReplayableTurn | undefined
112
+ private turnController: AbortController | undefined
113
+ private detachedTimer: ReturnType<typeof setTimeout> | undefined
79
114
 
80
115
  constructor(options: CodingAgentRpcServerOptions) {
81
116
  this.options = Object.freeze({ ...options })
@@ -92,7 +127,12 @@ export class CodingAgentRpcServer {
92
127
  : { maxRepairAttempts: options.maxRepairAttempts }),
93
128
  exposeErrorStacks: options.exposeErrorStacks === true,
94
129
  ...(options.verification === undefined ? {} : { verification: options.verification }),
130
+ ...(options.extensionEvents === undefined
131
+ ? {}
132
+ : { extensionEvents: options.extensionEvents }),
133
+ ...(options.approvals === undefined ? {} : { approvals: options.approvals }),
95
134
  })
135
+ this.resumable = resumableTurnConfig(options.resumableTurns)
96
136
  this.token = options.authToken ?? randomAuthToken()
97
137
  if (!/^[A-Za-z0-9._~-]{16,256}$/.test(this.token))
98
138
  throw new TypeError("agent rpc: authToken must be a bounded token")
@@ -147,6 +187,8 @@ export class CodingAgentRpcServer {
147
187
  async stop(): Promise<void> {
148
188
  const listener = this.listener
149
189
  this.listener = undefined
190
+ clearTimeout(this.detachedTimer)
191
+ this.turnController?.abort()
150
192
  try {
151
193
  await this.host.stop("rpc server stopped")
152
194
  } finally {
@@ -247,18 +289,16 @@ export class CodingAgentRpcServer {
247
289
  422,
248
290
  cors,
249
291
  )
250
- const snapshot =
251
- this.host.snapshot ??
252
- (await this.host.start({
253
- cwd: this.options.cwd,
254
- backend: this.options.backend.info.name,
255
- ...(params.sessionId === undefined ? {} : { sessionId: params.sessionId }),
256
- }))
257
- return json(snapshot, 200, cors)
292
+ const snapshot = await this.ensureSession({
293
+ cwd: this.options.cwd,
294
+ backend: this.options.backend.info.name,
295
+ ...(params.sessionId === undefined ? {} : { sessionId: params.sessionId }),
296
+ })
297
+ return json(this.advertised(snapshot), 200, cors)
258
298
  }
259
299
  case "session.snapshot": {
260
300
  const snapshot = this.requireSnapshot()
261
- return json(snapshot, 200, cors)
301
+ return json(this.advertised(snapshot), 200, cors)
262
302
  }
263
303
  case "session.resume": {
264
304
  const params = record(request.params)
@@ -268,14 +308,12 @@ export class CodingAgentRpcServer {
268
308
  422,
269
309
  cors,
270
310
  )
271
- const snapshot =
272
- this.host.snapshot ??
273
- (await this.host.start({
274
- cwd: this.options.cwd,
275
- backend: this.options.backend.info.name,
276
- sessionId: params.sessionId,
277
- }))
278
- return json(snapshot, 200, cors)
311
+ const snapshot = await this.ensureSession({
312
+ cwd: this.options.cwd,
313
+ backend: this.options.backend.info.name,
314
+ sessionId: params.sessionId,
315
+ })
316
+ return json(this.advertised(snapshot), 200, cors)
279
317
  }
280
318
  case "session.events": {
281
319
  const snapshot = this.requireSnapshot()
@@ -299,7 +337,11 @@ export class CodingAgentRpcServer {
299
337
  422,
300
338
  cors,
301
339
  )
302
- return json({ snapshot, resume: resumeFromCursor(window, cursor) }, 200, cors)
340
+ return json(
341
+ { snapshot: this.advertised(snapshot), resume: resumeFromCursor(window, cursor) },
342
+ 200,
343
+ cors,
344
+ )
303
345
  }
304
346
  case "session.checkpoint": {
305
347
  this.requireSnapshot()
@@ -335,42 +377,38 @@ export class CodingAgentRpcServer {
335
377
  422,
336
378
  cors,
337
379
  )
380
+ if (this.resumable !== undefined)
381
+ return this.sendResumableTurn(params.message, this.resumable, cors)
338
382
  const stream = this.host.prompt(params.message, httpRequest.signal)
339
383
  const sessionId = this.host.snapshot?.id
340
384
  const encoder = new TextEncoder()
341
385
  const includeErrorStacks = this.options.exposeErrorStacks === true
342
386
  const maxResponseBytes = this.maxResponseBytes
387
+ // Pulled, not pushed: the turn advances only as the client reads, so one that stops reading
388
+ // holds the backend still instead of the server queueing the rest of the turn for it.
389
+ const events = stream[Symbol.asyncIterator]()
343
390
  let finished = false
344
- const turnError = (error: unknown) => ({
345
- code: "turn_failed",
346
- ...publicErrorDetails(error, "turn could not be completed", includeErrorStacks),
347
- })
348
391
  const body = new ReadableStream<Uint8Array>({
349
- async start(controller) {
392
+ async pull(controller) {
393
+ if (finished) return
350
394
  try {
351
- for await (const event of stream) {
352
- if (finished) return
353
- const serialized = serializeRpcValue(event, includeErrorStacks)
354
- if (serializedByteLength(`data: ${serialized}\n\n`) > maxResponseBytes)
355
- throw new Error("turn event exceeded the configured response limit")
356
- controller.enqueue(encoder.encode(`data: ${serialized}\n\n`))
357
- }
358
- if (!finished) {
395
+ const next = await events.next()
396
+ if (finished) return
397
+ if (next.done === true) {
359
398
  finished = true
360
399
  controller.close()
400
+ return
361
401
  }
402
+ const frame = eventFrame(next.value, includeErrorStacks)
403
+ if (serializedByteLength(frame) > maxResponseBytes)
404
+ throw new Error("turn event exceeded the configured response limit")
405
+ controller.enqueue(encoder.encode(frame))
362
406
  } catch (error) {
363
407
  if (finished) return
364
408
  finished = true
365
409
  try {
366
- const detail = JSON.stringify(turnError(error))
367
- const frame = `event: error\ndata: ${detail}\n\n`
368
410
  controller.enqueue(
369
- encoder.encode(
370
- serializedByteLength(frame) <= maxResponseBytes
371
- ? frame
372
- : 'event: error\ndata: {"code":"turn_failed","message":"turn could not be completed"}\n\n',
373
- ),
411
+ encoder.encode(turnErrorFrame(error, includeErrorStacks, maxResponseBytes)),
374
412
  )
375
413
  controller.close()
376
414
  } catch {
@@ -382,19 +420,55 @@ export class CodingAgentRpcServer {
382
420
  cancel: async () => {
383
421
  if (finished) return
384
422
  finished = true
423
+ void events.return?.()
385
424
  if (sessionId !== undefined)
386
425
  await this.host.backend.cancel(sessionId, "client disconnected").catch(() => {})
387
426
  },
388
427
  })
389
- return new Response(body, {
390
- status: 200,
391
- headers: new Headers({
392
- ...Object.fromEntries(cors),
393
- "content-type": "text/event-stream; charset=utf-8",
394
- "cache-control": "no-cache, no-transform",
395
- connection: "keep-alive",
396
- }),
397
- })
428
+ return eventStreamResponse(body, cors)
429
+ }
430
+ case "turn.attach": {
431
+ if (this.resumable === undefined)
432
+ return json(
433
+ {
434
+ error: {
435
+ code: "feature_unsupported",
436
+ message: "this server does not keep turns for clients that come back",
437
+ },
438
+ },
439
+ 501,
440
+ cors,
441
+ )
442
+ const params = record(request.params)
443
+ const header = httpRequest.headers.get("last-event-id")
444
+ const raw = params.afterSeq ?? (header === null || header === "" ? undefined : header)
445
+ const cursor = raw === undefined ? undefined : Number(raw)
446
+ if (cursor !== undefined && (!Number.isSafeInteger(cursor) || cursor < -1))
447
+ return json(
448
+ { error: { code: "invalid_cursor", message: "afterSeq must be a safe integer >= -1" } },
449
+ 422,
450
+ cors,
451
+ )
452
+ const turn = this.turn
453
+ if (turn === undefined)
454
+ return json(
455
+ { error: { code: "no_turn", message: "no turn has run on this server" } },
456
+ 404,
457
+ cors,
458
+ )
459
+ const stream = turn.attach(cursor)
460
+ if (stream === undefined)
461
+ return json(
462
+ {
463
+ error: {
464
+ code: "resync_required",
465
+ message: "the events after that seq are no longer held; read session.events",
466
+ },
467
+ },
468
+ 409,
469
+ cors,
470
+ )
471
+ return eventStreamResponse(stream, cors)
398
472
  }
399
473
  case "session.reload": {
400
474
  const result: ReloadResult = await this.host.reload()
@@ -406,7 +480,12 @@ export class CodingAgentRpcServer {
406
480
  }
407
481
  case "approval.list": {
408
482
  this.requireSnapshot()
409
- return json({ pending: this.host.pendingApprovals }, 200, cors)
483
+ // `approvalId` is the name `approval.resolve` takes; `id` stays for existing readers.
484
+ const pending = this.host.pendingApprovals.map((request) => ({
485
+ ...request,
486
+ approvalId: request.id,
487
+ }))
488
+ return json({ pending }, 200, cors)
410
489
  }
411
490
  case "approval.request": {
412
491
  const params = record(request.params)
@@ -543,7 +622,13 @@ export class CodingAgentRpcServer {
543
622
  : {}),
544
623
  exposeErrorStacks: this.options.exposeErrorStacks === true,
545
624
  })
546
- return json(result, result.ok ? 200 : 422, cors, this.options.exposeErrorStacks === true)
625
+ return json(
626
+ // A Map serialises to `{}`; the wire carries the values as a plain object.
627
+ { ...result, values: Object.fromEntries(result.values) },
628
+ result.ok ? 200 : 422,
629
+ cors,
630
+ this.options.exposeErrorStacks === true,
631
+ )
547
632
  }
548
633
  case "verification.run": {
549
634
  const params = record(request.params)
@@ -627,6 +712,76 @@ export class CodingAgentRpcServer {
627
712
  }
628
713
  }
629
714
 
715
+ private sendResumableTurn(message: string, config: ResumableTurnConfig, cors: Headers): Response {
716
+ if (this.turn !== undefined && !this.turn.done)
717
+ return this.response(
718
+ {
719
+ error: {
720
+ code: "turn_running",
721
+ message: "a turn is already running; follow it with turn.attach",
722
+ },
723
+ },
724
+ 409,
725
+ cors,
726
+ )
727
+ clearTimeout(this.detachedTimer)
728
+ const controller = new AbortController()
729
+ const turn = new ReplayableTurn(this.turn?.latestSeq ?? -1, config, (count) => {
730
+ clearTimeout(this.detachedTimer)
731
+ if (count > 0 || this.turn !== turn) return
732
+ this.detachedTimer = setTimeout(() => {
733
+ if (this.turn !== turn || turn.done || turn.following > 0) return
734
+ controller.abort()
735
+ const sessionId = this.host.snapshot?.id
736
+ if (sessionId !== undefined)
737
+ void this.host.backend.cancel(sessionId, "no client followed the turn").catch(() => {})
738
+ }, config.detachedTimeoutMs)
739
+ })
740
+ this.turn = turn
741
+ this.turnController = controller
742
+ const stream = turn.follow()
743
+ const includeErrorStacks = this.options.exposeErrorStacks === true
744
+ const maxResponseBytes = this.maxResponseBytes
745
+ const encoder = new TextEncoder()
746
+ void (async () => {
747
+ try {
748
+ for await (const event of this.host.prompt(message, controller.signal)) {
749
+ const frame = eventFrame(event, includeErrorStacks)
750
+ if (serializedByteLength(frame) > maxResponseBytes)
751
+ throw new Error("turn event exceeded the configured response limit")
752
+ turn.push(event.seq, encoder.encode(frame))
753
+ }
754
+ turn.end()
755
+ } catch (error) {
756
+ turn.end(encoder.encode(turnErrorFrame(error, includeErrorStacks, maxResponseBytes)))
757
+ } finally {
758
+ if (this.turn === turn) clearTimeout(this.detachedTimer)
759
+ }
760
+ })()
761
+ return eventStreamResponse(stream, cors)
762
+ }
763
+
764
+ /** The active session, or one started from `input`; concurrent requests share a single start. */
765
+ private async ensureSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
766
+ const active = this.host.snapshot
767
+ if (active !== undefined) return active
768
+ this.creating ??= this.host.start(input).finally(() => {
769
+ this.creating = undefined
770
+ })
771
+ return await this.creating
772
+ }
773
+
774
+ /** The backend's capabilities plus the Agent App features this server answers itself. */
775
+ private advertised(snapshot: AgentSessionSnapshot): AgentSessionSnapshot {
776
+ const { backend, sessionStore, extensions } = this.options
777
+ const served = ["approvals", "resume"]
778
+ if (backend.checkpoint !== undefined || sessionStore !== undefined) served.push("checkpoint")
779
+ if (backend.fork !== undefined || sessionStore !== undefined) served.push("fork")
780
+ if (extensions !== undefined) served.push("workflows")
781
+ const capabilities = [...new Set([...snapshot.capabilities, ...served])]
782
+ return Object.freeze({ ...snapshot, capabilities: Object.freeze(capabilities) })
783
+ }
784
+
630
785
  private requireSnapshot(): AgentSessionSnapshot {
631
786
  if (this.host.snapshot === undefined) throw new Error("agent RPC session has not been created")
632
787
  return this.host.snapshot
@@ -811,6 +966,70 @@ function json(
811
966
  return new Response(body, { status: responseStatus, headers })
812
967
  }
813
968
 
969
+ function resumableTurnConfig(
970
+ option: CodingAgentRpcServerOptions["resumableTurns"],
971
+ ): ResumableTurnConfig | undefined {
972
+ if (option === undefined || option === false) return undefined
973
+ const settings = option === true ? {} : option
974
+ const config: ResumableTurnConfig = {
975
+ maxEvents: settings.replayEvents ?? 1024,
976
+ maxBytes: settings.replayBytes ?? 8 * 1024 * 1024,
977
+ detachedTimeoutMs: settings.detachedTimeoutMs ?? 120_000,
978
+ }
979
+ if (
980
+ !Number.isSafeInteger(config.maxEvents) ||
981
+ config.maxEvents < 1 ||
982
+ config.maxEvents > MAX_REPLAY_EVENTS
983
+ )
984
+ throw new RangeError(`agent rpc: replayEvents must be between 1 and ${MAX_REPLAY_EVENTS}`)
985
+ if (
986
+ !Number.isSafeInteger(config.maxBytes) ||
987
+ config.maxBytes < 1024 ||
988
+ config.maxBytes > MAX_REPLAY_BYTES
989
+ )
990
+ throw new RangeError(`agent rpc: replayBytes must be between 1024 and ${MAX_REPLAY_BYTES}`)
991
+ if (
992
+ !Number.isSafeInteger(config.detachedTimeoutMs) ||
993
+ config.detachedTimeoutMs < 1 ||
994
+ config.detachedTimeoutMs > MAX_DETACHED_TIMEOUT_MS
995
+ )
996
+ throw new RangeError(
997
+ `agent rpc: detachedTimeoutMs must be between 1 and ${MAX_DETACHED_TIMEOUT_MS}`,
998
+ )
999
+ return Object.freeze(config)
1000
+ }
1001
+
1002
+ function eventFrame(event: { readonly seq: number }, includeErrorStacks: boolean): string {
1003
+ return `id: ${event.seq}\ndata: ${serializeRpcValue(event, includeErrorStacks)}\n\n`
1004
+ }
1005
+
1006
+ function turnErrorFrame(
1007
+ error: unknown,
1008
+ includeErrorStacks: boolean,
1009
+ maxResponseBytes: number,
1010
+ ): string {
1011
+ const detail = JSON.stringify({
1012
+ code: "turn_failed",
1013
+ ...publicErrorDetails(error, "turn could not be completed", includeErrorStacks),
1014
+ })
1015
+ const frame = `event: error\ndata: ${detail}\n\n`
1016
+ return serializedByteLength(frame) <= maxResponseBytes
1017
+ ? frame
1018
+ : 'event: error\ndata: {"code":"turn_failed","message":"turn could not be completed"}\n\n'
1019
+ }
1020
+
1021
+ function eventStreamResponse(body: ReadableStream<Uint8Array>, cors: Headers): Response {
1022
+ return new Response(body, {
1023
+ status: 200,
1024
+ headers: new Headers({
1025
+ ...Object.fromEntries(cors),
1026
+ "content-type": "text/event-stream; charset=utf-8",
1027
+ "cache-control": "no-cache, no-transform",
1028
+ connection: "keep-alive",
1029
+ }),
1030
+ })
1031
+ }
1032
+
814
1033
  function serializeRpcValue(value: unknown, includeErrorStacks: boolean): string {
815
1034
  const serialized = JSON.stringify(
816
1035
  value,
@@ -1,4 +1,5 @@
1
- import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"
1
+ import { createReadStream } from "node:fs"
2
+ import { access, mkdir, rename, unlink, writeFile } from "node:fs/promises"
2
3
  import { basename, join, resolve } from "node:path"
3
4
  import { sessionFileStem } from "./sessions.ts"
4
5
 
@@ -164,7 +165,7 @@ export async function migrateLegacySession(
164
165
  const targetFile = join(targetRoot, `${sessionFileStem(sessionId)}.jsonl`)
165
166
  await commitTarget(targetRoot, targetFile, content, options.signal)
166
167
  // Re-read the committed target and validate every line before the caller treats this as active.
167
- const committed = await readEvidenceTarget(targetFile, sessionId)
168
+ const committed = await readEvidenceTarget(targetFile, sessionId, Buffer.byteLength(content))
168
169
  if (committed.records.length !== records.length || (await sha256Hex(committed.raw)) !== digest)
169
170
  throw new SessionMigrationError("target_validation", "committed evidence target changed")
170
171
  for (let index = 0; index < committed.records.length; index++) {
@@ -225,8 +226,8 @@ async function readLegacyEntries(
225
226
  const path = join(sourceRoot, `${sessionFileStem(sessionId)}.jsonl`)
226
227
  let text: string
227
228
  try {
228
- const bytes = await readFile(path)
229
- if (bytes.byteLength > maxSourceBytes)
229
+ const bytes = await readCapped(path, maxSourceBytes)
230
+ if (bytes === undefined)
230
231
  throw new SessionMigrationError("source_too_large", "legacy session exceeds maxSourceBytes")
231
232
  text = bytes.toString("utf8")
232
233
  } catch (error) {
@@ -306,11 +307,34 @@ async function commitTarget(
306
307
  }
307
308
  }
308
309
 
310
+ /** The file's bytes, or `undefined` as soon as it passes `maxBytes`, without reading the rest. */
311
+ async function readCapped(path: string, maxBytes: number): Promise<Buffer | undefined> {
312
+ const chunks: Buffer[] = []
313
+ let total = 0
314
+ // Sequential reads, not a start/end range: a positioned read fails on a pipe.
315
+ const stream = createReadStream(path, { highWaterMark: Math.min(maxBytes + 1, 64 * 1024) })
316
+ try {
317
+ for await (const chunk of stream) {
318
+ const bytes: Buffer = chunk
319
+ total += bytes.byteLength
320
+ if (total > maxBytes) return undefined
321
+ chunks.push(bytes)
322
+ }
323
+ } finally {
324
+ stream.destroy()
325
+ }
326
+ return Buffer.concat(chunks, total)
327
+ }
328
+
309
329
  async function readEvidenceTarget(
310
330
  path: string,
311
331
  sessionId: string,
332
+ expectedBytes: number,
312
333
  ): Promise<{ readonly records: readonly SessionEvidenceRecord[]; readonly raw: string }> {
313
- const raw = await readFile(path, "utf8")
334
+ const bytes = await readCapped(path, expectedBytes)
335
+ if (bytes === undefined)
336
+ throw new SessionMigrationError("target_validation", "committed evidence target changed")
337
+ const raw = bytes.toString("utf8")
314
338
  const records: SessionEvidenceRecord[] = []
315
339
  let previousSeq = -1
316
340
  for (const line of raw.split("\n")) {