@nifrajs/coding-agent 4.0.0 → 5.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 (99) hide show
  1. package/README.md +14 -2
  2. package/dist/cli.d.ts +13 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +120 -100
  5. package/dist/cli.js.map +1 -1
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +9 -21
  8. package/dist/context.js.map +1 -1
  9. package/dist/diff.d.ts.map +1 -1
  10. package/dist/diff.js +8 -21
  11. package/dist/diff.js.map +1 -1
  12. package/dist/handoffs.d.ts +5 -0
  13. package/dist/handoffs.d.ts.map +1 -1
  14. package/dist/handoffs.js +27 -0
  15. package/dist/handoffs.js.map +1 -1
  16. package/dist/host.d.ts +15 -1
  17. package/dist/host.d.ts.map +1 -1
  18. package/dist/host.js +117 -13
  19. package/dist/host.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/native.d.ts +22 -0
  25. package/dist/native.d.ts.map +1 -1
  26. package/dist/native.js +75 -18
  27. package/dist/native.js.map +1 -1
  28. package/dist/orchestration/dispatch.d.ts +5 -1
  29. package/dist/orchestration/dispatch.d.ts.map +1 -1
  30. package/dist/orchestration/dispatch.js +1 -1
  31. package/dist/orchestration/dispatch.js.map +1 -1
  32. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  33. package/dist/orchestration/evidence-store.js +5 -6
  34. package/dist/orchestration/evidence-store.js.map +1 -1
  35. package/dist/orchestration/host.d.ts +14 -0
  36. package/dist/orchestration/host.d.ts.map +1 -1
  37. package/dist/orchestration/host.js +26 -0
  38. package/dist/orchestration/host.js.map +1 -1
  39. package/dist/orchestration/index.d.ts +1 -1
  40. package/dist/orchestration/index.d.ts.map +1 -1
  41. package/dist/orchestration/index.js.map +1 -1
  42. package/dist/orchestration/recovery.d.ts +5 -1
  43. package/dist/orchestration/recovery.d.ts.map +1 -1
  44. package/dist/orchestration/recovery.js +5 -3
  45. package/dist/orchestration/recovery.js.map +1 -1
  46. package/dist/process.d.ts +22 -1
  47. package/dist/process.d.ts.map +1 -1
  48. package/dist/process.js +63 -1
  49. package/dist/process.js.map +1 -1
  50. package/dist/rpc.d.ts +23 -0
  51. package/dist/rpc.d.ts.map +1 -1
  52. package/dist/rpc.js +204 -46
  53. package/dist/rpc.js.map +1 -1
  54. package/dist/session-migration.d.ts.map +1 -1
  55. package/dist/session-migration.js +30 -6
  56. package/dist/session-migration.js.map +1 -1
  57. package/dist/sessions.d.ts +13 -2
  58. package/dist/sessions.d.ts.map +1 -1
  59. package/dist/sessions.js +104 -24
  60. package/dist/sessions.js.map +1 -1
  61. package/dist/subagents.d.ts.map +1 -1
  62. package/dist/subagents.js +13 -2
  63. package/dist/subagents.js.map +1 -1
  64. package/dist/turn-replay.d.ts +40 -0
  65. package/dist/turn-replay.d.ts.map +1 -0
  66. package/dist/turn-replay.js +136 -0
  67. package/dist/turn-replay.js.map +1 -0
  68. package/dist/ui.js +84 -30
  69. package/dist/ui.js.map +1 -1
  70. package/dist/verification.d.ts +7 -0
  71. package/dist/verification.d.ts.map +1 -1
  72. package/dist/verification.js +66 -63
  73. package/dist/verification.js.map +1 -1
  74. package/dist/workflows.d.ts +5 -0
  75. package/dist/workflows.d.ts.map +1 -1
  76. package/dist/workflows.js +2 -1
  77. package/dist/workflows.js.map +1 -1
  78. package/package.json +6 -6
  79. package/src/cli.ts +125 -93
  80. package/src/context.ts +12 -20
  81. package/src/diff.ts +11 -23
  82. package/src/handoffs.ts +27 -0
  83. package/src/host.ts +134 -12
  84. package/src/index.ts +1 -0
  85. package/src/native.ts +118 -21
  86. package/src/orchestration/dispatch.ts +6 -2
  87. package/src/orchestration/evidence-store.ts +5 -6
  88. package/src/orchestration/host.ts +44 -0
  89. package/src/orchestration/index.ts +1 -0
  90. package/src/orchestration/recovery.ts +14 -8
  91. package/src/process.ts +77 -0
  92. package/src/rpc.ts +261 -49
  93. package/src/session-migration.ts +29 -5
  94. package/src/sessions.ts +115 -29
  95. package/src/subagents.ts +11 -2
  96. package/src/turn-replay.ts +151 -0
  97. package/src/ui.ts +90 -32
  98. package/src/verification.ts +74 -60
  99. 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,21 @@ 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
+ * Keep a `turn.send` running when its client disconnects, and let a client that comes back read
42
+ * what it missed with `turn.attach` (an `afterSeq` cursor, or `Last-Event-ID`). The newest
43
+ * `replayEvents` frames, up to `replayBytes`, are held (defaults 1024 and 8 MiB); a turn no client
44
+ * has followed for `detachedTimeoutMs` (default 2 minutes) is cancelled. A client that stops reading
45
+ * is dropped once it falls `replayBytes` behind what is held, and can attach again from its cursor.
46
+ */
47
+ readonly resumableTurns?:
48
+ | boolean
49
+ | {
50
+ readonly replayEvents?: number
51
+ readonly replayBytes?: number
52
+ readonly detachedTimeoutMs?: number
53
+ }
37
54
  }
38
55
 
39
56
  export interface CodingAgentRpcServerHandle {
@@ -60,6 +77,13 @@ const MIN_REMOTE_TOKEN_BYTES = 32
60
77
  const AUTH_FAILURE_WINDOW_MS = 60_000
61
78
  const AUTH_FAILURE_THRESHOLD = 3
62
79
  const AUTH_MAX_BACKOFF_MS = 2_000
80
+ const MAX_REPLAY_EVENTS = 65_536
81
+ const MAX_REPLAY_BYTES = 256 * 1024 * 1024
82
+ const MAX_DETACHED_TIMEOUT_MS = 24 * 60 * 60 * 1000
83
+
84
+ interface ResumableTurnConfig extends TurnReplayLimits {
85
+ readonly detachedTimeoutMs: number
86
+ }
63
87
 
64
88
  /**
65
89
  * Minimal loopback RPC surface for the CLI, Workbench, CI clients, and a future mobile companion.
@@ -76,6 +100,11 @@ export class CodingAgentRpcServer {
76
100
  private authFailures = 0
77
101
  private authLastFailureAt = 0
78
102
  private authBackoffUntil = 0
103
+ private creating: Promise<AgentSessionSnapshot> | undefined
104
+ private readonly resumable: ResumableTurnConfig | undefined
105
+ private turn: ReplayableTurn | undefined
106
+ private turnController: AbortController | undefined
107
+ private detachedTimer: ReturnType<typeof setTimeout> | undefined
79
108
 
80
109
  constructor(options: CodingAgentRpcServerOptions) {
81
110
  this.options = Object.freeze({ ...options })
@@ -92,7 +121,11 @@ export class CodingAgentRpcServer {
92
121
  : { maxRepairAttempts: options.maxRepairAttempts }),
93
122
  exposeErrorStacks: options.exposeErrorStacks === true,
94
123
  ...(options.verification === undefined ? {} : { verification: options.verification }),
124
+ ...(options.extensionEvents === undefined
125
+ ? {}
126
+ : { extensionEvents: options.extensionEvents }),
95
127
  })
128
+ this.resumable = resumableTurnConfig(options.resumableTurns)
96
129
  this.token = options.authToken ?? randomAuthToken()
97
130
  if (!/^[A-Za-z0-9._~-]{16,256}$/.test(this.token))
98
131
  throw new TypeError("agent rpc: authToken must be a bounded token")
@@ -147,6 +180,8 @@ export class CodingAgentRpcServer {
147
180
  async stop(): Promise<void> {
148
181
  const listener = this.listener
149
182
  this.listener = undefined
183
+ clearTimeout(this.detachedTimer)
184
+ this.turnController?.abort()
150
185
  try {
151
186
  await this.host.stop("rpc server stopped")
152
187
  } finally {
@@ -247,18 +282,16 @@ export class CodingAgentRpcServer {
247
282
  422,
248
283
  cors,
249
284
  )
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)
285
+ const snapshot = await this.ensureSession({
286
+ cwd: this.options.cwd,
287
+ backend: this.options.backend.info.name,
288
+ ...(params.sessionId === undefined ? {} : { sessionId: params.sessionId }),
289
+ })
290
+ return json(this.advertised(snapshot), 200, cors)
258
291
  }
259
292
  case "session.snapshot": {
260
293
  const snapshot = this.requireSnapshot()
261
- return json(snapshot, 200, cors)
294
+ return json(this.advertised(snapshot), 200, cors)
262
295
  }
263
296
  case "session.resume": {
264
297
  const params = record(request.params)
@@ -268,14 +301,12 @@ export class CodingAgentRpcServer {
268
301
  422,
269
302
  cors,
270
303
  )
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)
304
+ const snapshot = await this.ensureSession({
305
+ cwd: this.options.cwd,
306
+ backend: this.options.backend.info.name,
307
+ sessionId: params.sessionId,
308
+ })
309
+ return json(this.advertised(snapshot), 200, cors)
279
310
  }
280
311
  case "session.events": {
281
312
  const snapshot = this.requireSnapshot()
@@ -299,7 +330,11 @@ export class CodingAgentRpcServer {
299
330
  422,
300
331
  cors,
301
332
  )
302
- return json({ snapshot, resume: resumeFromCursor(window, cursor) }, 200, cors)
333
+ return json(
334
+ { snapshot: this.advertised(snapshot), resume: resumeFromCursor(window, cursor) },
335
+ 200,
336
+ cors,
337
+ )
303
338
  }
304
339
  case "session.checkpoint": {
305
340
  this.requireSnapshot()
@@ -335,42 +370,38 @@ export class CodingAgentRpcServer {
335
370
  422,
336
371
  cors,
337
372
  )
373
+ if (this.resumable !== undefined)
374
+ return this.sendResumableTurn(params.message, this.resumable, cors)
338
375
  const stream = this.host.prompt(params.message, httpRequest.signal)
339
376
  const sessionId = this.host.snapshot?.id
340
377
  const encoder = new TextEncoder()
341
378
  const includeErrorStacks = this.options.exposeErrorStacks === true
342
379
  const maxResponseBytes = this.maxResponseBytes
380
+ // Pulled, not pushed: the turn advances only as the client reads, so one that stops reading
381
+ // holds the backend still instead of the server queueing the rest of the turn for it.
382
+ const events = stream[Symbol.asyncIterator]()
343
383
  let finished = false
344
- const turnError = (error: unknown) => ({
345
- code: "turn_failed",
346
- ...publicErrorDetails(error, "turn could not be completed", includeErrorStacks),
347
- })
348
384
  const body = new ReadableStream<Uint8Array>({
349
- async start(controller) {
385
+ async pull(controller) {
386
+ if (finished) return
350
387
  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) {
388
+ const next = await events.next()
389
+ if (finished) return
390
+ if (next.done === true) {
359
391
  finished = true
360
392
  controller.close()
393
+ return
361
394
  }
395
+ const frame = eventFrame(next.value, includeErrorStacks)
396
+ if (serializedByteLength(frame) > maxResponseBytes)
397
+ throw new Error("turn event exceeded the configured response limit")
398
+ controller.enqueue(encoder.encode(frame))
362
399
  } catch (error) {
363
400
  if (finished) return
364
401
  finished = true
365
402
  try {
366
- const detail = JSON.stringify(turnError(error))
367
- const frame = `event: error\ndata: ${detail}\n\n`
368
403
  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
- ),
404
+ encoder.encode(turnErrorFrame(error, includeErrorStacks, maxResponseBytes)),
374
405
  )
375
406
  controller.close()
376
407
  } catch {
@@ -382,19 +413,55 @@ export class CodingAgentRpcServer {
382
413
  cancel: async () => {
383
414
  if (finished) return
384
415
  finished = true
416
+ void events.return?.()
385
417
  if (sessionId !== undefined)
386
418
  await this.host.backend.cancel(sessionId, "client disconnected").catch(() => {})
387
419
  },
388
420
  })
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
- })
421
+ return eventStreamResponse(body, cors)
422
+ }
423
+ case "turn.attach": {
424
+ if (this.resumable === undefined)
425
+ return json(
426
+ {
427
+ error: {
428
+ code: "feature_unsupported",
429
+ message: "this server does not keep turns for clients that come back",
430
+ },
431
+ },
432
+ 501,
433
+ cors,
434
+ )
435
+ const params = record(request.params)
436
+ const header = httpRequest.headers.get("last-event-id")
437
+ const raw = params.afterSeq ?? (header === null || header === "" ? undefined : header)
438
+ const cursor = raw === undefined ? undefined : Number(raw)
439
+ if (cursor !== undefined && (!Number.isSafeInteger(cursor) || cursor < -1))
440
+ return json(
441
+ { error: { code: "invalid_cursor", message: "afterSeq must be a safe integer >= -1" } },
442
+ 422,
443
+ cors,
444
+ )
445
+ const turn = this.turn
446
+ if (turn === undefined)
447
+ return json(
448
+ { error: { code: "no_turn", message: "no turn has run on this server" } },
449
+ 404,
450
+ cors,
451
+ )
452
+ const stream = turn.attach(cursor)
453
+ if (stream === undefined)
454
+ return json(
455
+ {
456
+ error: {
457
+ code: "resync_required",
458
+ message: "the events after that seq are no longer held; read session.events",
459
+ },
460
+ },
461
+ 409,
462
+ cors,
463
+ )
464
+ return eventStreamResponse(stream, cors)
398
465
  }
399
466
  case "session.reload": {
400
467
  const result: ReloadResult = await this.host.reload()
@@ -406,7 +473,12 @@ export class CodingAgentRpcServer {
406
473
  }
407
474
  case "approval.list": {
408
475
  this.requireSnapshot()
409
- return json({ pending: this.host.pendingApprovals }, 200, cors)
476
+ // `approvalId` is the name `approval.resolve` takes; `id` stays for existing readers.
477
+ const pending = this.host.pendingApprovals.map((request) => ({
478
+ ...request,
479
+ approvalId: request.id,
480
+ }))
481
+ return json({ pending }, 200, cors)
410
482
  }
411
483
  case "approval.request": {
412
484
  const params = record(request.params)
@@ -543,7 +615,13 @@ export class CodingAgentRpcServer {
543
615
  : {}),
544
616
  exposeErrorStacks: this.options.exposeErrorStacks === true,
545
617
  })
546
- return json(result, result.ok ? 200 : 422, cors, this.options.exposeErrorStacks === true)
618
+ return json(
619
+ // A Map serialises to `{}`; the wire carries the values as a plain object.
620
+ { ...result, values: Object.fromEntries(result.values) },
621
+ result.ok ? 200 : 422,
622
+ cors,
623
+ this.options.exposeErrorStacks === true,
624
+ )
547
625
  }
548
626
  case "verification.run": {
549
627
  const params = record(request.params)
@@ -627,6 +705,76 @@ export class CodingAgentRpcServer {
627
705
  }
628
706
  }
629
707
 
708
+ private sendResumableTurn(message: string, config: ResumableTurnConfig, cors: Headers): Response {
709
+ if (this.turn !== undefined && !this.turn.done)
710
+ return this.response(
711
+ {
712
+ error: {
713
+ code: "turn_running",
714
+ message: "a turn is already running; follow it with turn.attach",
715
+ },
716
+ },
717
+ 409,
718
+ cors,
719
+ )
720
+ clearTimeout(this.detachedTimer)
721
+ const controller = new AbortController()
722
+ const turn = new ReplayableTurn(this.turn?.latestSeq ?? -1, config, (count) => {
723
+ clearTimeout(this.detachedTimer)
724
+ if (count > 0 || this.turn !== turn) return
725
+ this.detachedTimer = setTimeout(() => {
726
+ if (this.turn !== turn || turn.done || turn.following > 0) return
727
+ controller.abort()
728
+ const sessionId = this.host.snapshot?.id
729
+ if (sessionId !== undefined)
730
+ void this.host.backend.cancel(sessionId, "no client followed the turn").catch(() => {})
731
+ }, config.detachedTimeoutMs)
732
+ })
733
+ this.turn = turn
734
+ this.turnController = controller
735
+ const stream = turn.follow()
736
+ const includeErrorStacks = this.options.exposeErrorStacks === true
737
+ const maxResponseBytes = this.maxResponseBytes
738
+ const encoder = new TextEncoder()
739
+ void (async () => {
740
+ try {
741
+ for await (const event of this.host.prompt(message, controller.signal)) {
742
+ const frame = eventFrame(event, includeErrorStacks)
743
+ if (serializedByteLength(frame) > maxResponseBytes)
744
+ throw new Error("turn event exceeded the configured response limit")
745
+ turn.push(event.seq, encoder.encode(frame))
746
+ }
747
+ turn.end()
748
+ } catch (error) {
749
+ turn.end(encoder.encode(turnErrorFrame(error, includeErrorStacks, maxResponseBytes)))
750
+ } finally {
751
+ if (this.turn === turn) clearTimeout(this.detachedTimer)
752
+ }
753
+ })()
754
+ return eventStreamResponse(stream, cors)
755
+ }
756
+
757
+ /** The active session, or one started from `input`; concurrent requests share a single start. */
758
+ private async ensureSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
759
+ const active = this.host.snapshot
760
+ if (active !== undefined) return active
761
+ this.creating ??= this.host.start(input).finally(() => {
762
+ this.creating = undefined
763
+ })
764
+ return await this.creating
765
+ }
766
+
767
+ /** The backend's capabilities plus the Agent App features this server answers itself. */
768
+ private advertised(snapshot: AgentSessionSnapshot): AgentSessionSnapshot {
769
+ const { backend, sessionStore, extensions } = this.options
770
+ const served = ["approvals", "resume"]
771
+ if (backend.checkpoint !== undefined || sessionStore !== undefined) served.push("checkpoint")
772
+ if (backend.fork !== undefined || sessionStore !== undefined) served.push("fork")
773
+ if (extensions !== undefined) served.push("workflows")
774
+ const capabilities = [...new Set([...snapshot.capabilities, ...served])]
775
+ return Object.freeze({ ...snapshot, capabilities: Object.freeze(capabilities) })
776
+ }
777
+
630
778
  private requireSnapshot(): AgentSessionSnapshot {
631
779
  if (this.host.snapshot === undefined) throw new Error("agent RPC session has not been created")
632
780
  return this.host.snapshot
@@ -811,6 +959,70 @@ function json(
811
959
  return new Response(body, { status: responseStatus, headers })
812
960
  }
813
961
 
962
+ function resumableTurnConfig(
963
+ option: CodingAgentRpcServerOptions["resumableTurns"],
964
+ ): ResumableTurnConfig | undefined {
965
+ if (option === undefined || option === false) return undefined
966
+ const settings = option === true ? {} : option
967
+ const config: ResumableTurnConfig = {
968
+ maxEvents: settings.replayEvents ?? 1024,
969
+ maxBytes: settings.replayBytes ?? 8 * 1024 * 1024,
970
+ detachedTimeoutMs: settings.detachedTimeoutMs ?? 120_000,
971
+ }
972
+ if (
973
+ !Number.isSafeInteger(config.maxEvents) ||
974
+ config.maxEvents < 1 ||
975
+ config.maxEvents > MAX_REPLAY_EVENTS
976
+ )
977
+ throw new RangeError(`agent rpc: replayEvents must be between 1 and ${MAX_REPLAY_EVENTS}`)
978
+ if (
979
+ !Number.isSafeInteger(config.maxBytes) ||
980
+ config.maxBytes < 1024 ||
981
+ config.maxBytes > MAX_REPLAY_BYTES
982
+ )
983
+ throw new RangeError(`agent rpc: replayBytes must be between 1024 and ${MAX_REPLAY_BYTES}`)
984
+ if (
985
+ !Number.isSafeInteger(config.detachedTimeoutMs) ||
986
+ config.detachedTimeoutMs < 1 ||
987
+ config.detachedTimeoutMs > MAX_DETACHED_TIMEOUT_MS
988
+ )
989
+ throw new RangeError(
990
+ `agent rpc: detachedTimeoutMs must be between 1 and ${MAX_DETACHED_TIMEOUT_MS}`,
991
+ )
992
+ return Object.freeze(config)
993
+ }
994
+
995
+ function eventFrame(event: { readonly seq: number }, includeErrorStacks: boolean): string {
996
+ return `id: ${event.seq}\ndata: ${serializeRpcValue(event, includeErrorStacks)}\n\n`
997
+ }
998
+
999
+ function turnErrorFrame(
1000
+ error: unknown,
1001
+ includeErrorStacks: boolean,
1002
+ maxResponseBytes: number,
1003
+ ): string {
1004
+ const detail = JSON.stringify({
1005
+ code: "turn_failed",
1006
+ ...publicErrorDetails(error, "turn could not be completed", includeErrorStacks),
1007
+ })
1008
+ const frame = `event: error\ndata: ${detail}\n\n`
1009
+ return serializedByteLength(frame) <= maxResponseBytes
1010
+ ? frame
1011
+ : 'event: error\ndata: {"code":"turn_failed","message":"turn could not be completed"}\n\n'
1012
+ }
1013
+
1014
+ function eventStreamResponse(body: ReadableStream<Uint8Array>, cors: Headers): Response {
1015
+ return new Response(body, {
1016
+ status: 200,
1017
+ headers: new Headers({
1018
+ ...Object.fromEntries(cors),
1019
+ "content-type": "text/event-stream; charset=utf-8",
1020
+ "cache-control": "no-cache, no-transform",
1021
+ connection: "keep-alive",
1022
+ }),
1023
+ })
1024
+ }
1025
+
814
1026
  function serializeRpcValue(value: unknown, includeErrorStacks: boolean): string {
815
1027
  const serialized = JSON.stringify(
816
1028
  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")) {