@nifrajs/coding-agent 3.2.0 → 3.5.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 (49) hide show
  1. package/dist/context.js +29 -1
  2. package/dist/context.js.map +1 -1
  3. package/dist/diff.js +38 -2
  4. package/dist/diff.js.map +1 -1
  5. package/dist/host.d.ts.map +1 -1
  6. package/dist/host.js +217 -24
  7. package/dist/host.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/isolated.js +37 -2
  13. package/dist/isolated.js.map +1 -1
  14. package/dist/native.d.ts +17 -0
  15. package/dist/native.d.ts.map +1 -1
  16. package/dist/native.js +265 -41
  17. package/dist/native.js.map +1 -1
  18. package/dist/replay.d.ts +4 -0
  19. package/dist/replay.d.ts.map +1 -1
  20. package/dist/replay.js +189 -24
  21. package/dist/replay.js.map +1 -1
  22. package/dist/rpc.d.ts +10 -0
  23. package/dist/rpc.d.ts.map +1 -1
  24. package/dist/rpc.js +208 -29
  25. package/dist/rpc.js.map +1 -1
  26. package/dist/sessions.d.ts +14 -2
  27. package/dist/sessions.d.ts.map +1 -1
  28. package/dist/sessions.js +253 -59
  29. package/dist/sessions.js.map +1 -1
  30. package/dist/verification.d.ts +23 -0
  31. package/dist/verification.d.ts.map +1 -1
  32. package/dist/verification.js +113 -1
  33. package/dist/verification.js.map +1 -1
  34. package/dist/workflows.d.ts +1 -0
  35. package/dist/workflows.d.ts.map +1 -1
  36. package/dist/workflows.js +43 -21
  37. package/dist/workflows.js.map +1 -1
  38. package/package.json +6 -5
  39. package/src/context.ts +32 -1
  40. package/src/diff.ts +40 -2
  41. package/src/host.ts +219 -24
  42. package/src/index.ts +5 -0
  43. package/src/isolated.ts +39 -2
  44. package/src/native.ts +408 -42
  45. package/src/replay.ts +206 -25
  46. package/src/rpc.ts +261 -33
  47. package/src/sessions.ts +271 -56
  48. package/src/verification.ts +140 -1
  49. package/src/workflows.ts +37 -25
package/src/replay.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  agentError,
8
8
  type CreateSessionInput,
9
9
  createAgentEventStream,
10
+ isAgentEvent,
10
11
  type ReloadResult,
11
12
  type SendMessageInput,
12
13
  } from "@nifrajs/agent-protocol"
@@ -20,6 +21,9 @@ interface ReplaySession {
20
21
  readonly id: string
21
22
  snapshot: AgentSessionSnapshot
22
23
  active: AgentEventStream | undefined
24
+ controller: AbortController
25
+ turnId: string | undefined
26
+ turnAbortCleanup: (() => void) | undefined
23
27
  closed: boolean
24
28
  seq: number
25
29
  }
@@ -30,18 +34,59 @@ export class ReplayBackend implements AgentBackend {
30
34
  name: "replay",
31
35
  capabilities: Object.freeze(["sessions", "streaming", "replay"]),
32
36
  })
33
- private readonly events: readonly AgentEvent[]
37
+ /** Serialized immutable source records; parsing per replay prevents caller/consumer mutation leaks. */
38
+ private readonly events: readonly string[]
34
39
  private readonly delayMs: number
35
40
  private readonly sessions = new Map<string, ReplaySession>()
36
41
 
37
42
  constructor(options: ReplayBackendOptions) {
38
- this.events = Object.freeze(options.events.map((event) => Object.freeze({ ...event })))
43
+ if (
44
+ !Array.isArray(options.events) ||
45
+ options.events.length > MAX_REPLAY_EVENTS ||
46
+ !options.events.every((event) => isAgentEvent(event))
47
+ )
48
+ throw new TypeError("replay backend: events must be valid agent events")
49
+ const encoded: string[] = []
50
+ let totalBytes = 0
51
+ for (const event of options.events) {
52
+ let line: string
53
+ try {
54
+ // Clone before serializing so a hostile `toJSON`/nested reference cannot mutate the caller's
55
+ // record or make the replay depend on later mutations. The serialized form is the immutable
56
+ // source used for every turn below.
57
+ const clone = structuredClone(event) as AgentEvent
58
+ if (!isAgentEvent(clone)) throw new TypeError("replay event clone is invalid")
59
+ line = JSON.stringify(clone)
60
+ } catch {
61
+ throw new TypeError("replay backend: events must be JSON-serializable")
62
+ }
63
+ const bytes = new TextEncoder().encode(line).byteLength + 1
64
+ if (totalBytes > MAX_REPLAY_BYTES - bytes)
65
+ throw new RangeError("replay backend: events exceed the total size limit")
66
+ totalBytes += bytes
67
+ encoded.push(line)
68
+ }
69
+ this.events = Object.freeze(encoded)
39
70
  this.delayMs = options.delayMs ?? 0
40
71
  if (!Number.isSafeInteger(this.delayMs) || this.delayMs < 0 || this.delayMs > 60_000)
41
72
  throw new RangeError("replay backend: delayMs must be between 0 and 60000")
42
73
  }
43
74
 
44
75
  async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
76
+ if (
77
+ typeof input.cwd !== "string" ||
78
+ input.cwd.length === 0 ||
79
+ input.cwd.length > 4_096 ||
80
+ input.cwd.includes("\0")
81
+ )
82
+ throw new TypeError("replay backend: cwd must be a bounded non-empty path")
83
+ if (
84
+ input.capabilities !== undefined &&
85
+ (!Array.isArray(input.capabilities) ||
86
+ input.capabilities.length > 256 ||
87
+ !input.capabilities.every((value) => boundedReplayToken(value)))
88
+ )
89
+ throw new TypeError("replay backend: capabilities must be bounded tokens")
45
90
  const id = input.sessionId ?? crypto.randomUUID()
46
91
  if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(id))
47
92
  throw new TypeError("replay backend: invalid sessionId")
@@ -58,7 +103,16 @@ export class ReplayBackend implements AgentBackend {
58
103
  lastSeq: 0,
59
104
  capabilities: Object.freeze([...(input.capabilities ?? this.info.capabilities)]),
60
105
  }
61
- this.sessions.set(id, { id, snapshot, active: undefined, closed: false, seq: 0 })
106
+ this.sessions.set(id, {
107
+ id,
108
+ snapshot,
109
+ active: undefined,
110
+ controller: new AbortController(),
111
+ turnId: undefined,
112
+ turnAbortCleanup: undefined,
113
+ closed: false,
114
+ seq: 0,
115
+ })
62
116
  return snapshot
63
117
  }
64
118
 
@@ -70,20 +124,32 @@ export class ReplayBackend implements AgentBackend {
70
124
  return failedStream(agentError("SESSION_BUSY", "replay session is busy"))
71
125
  const stream = createAgentEventStream()
72
126
  session.active = stream
73
- void this.replay(session, stream)
127
+ session.controller = new AbortController()
128
+ session.turnId = crypto.randomUUID()
129
+ const turnId = session.turnId
130
+ const signal = session.controller.signal
131
+ if (input.signal !== undefined) {
132
+ const externalSignal = input.signal
133
+ const onAbort = (): void => {
134
+ if (session.active !== stream || session.turnId !== turnId) return
135
+ void this.cancelTurn(session, stream, turnId, "cancelled")
136
+ }
137
+ if (externalSignal.aborted) onAbort()
138
+ else {
139
+ externalSignal.addEventListener("abort", onAbort, { once: true })
140
+ session.turnAbortCleanup = () => externalSignal.removeEventListener("abort", onAbort)
141
+ if (externalSignal.aborted) onAbort()
142
+ }
143
+ }
144
+ if (session.active === stream && session.turnId === turnId)
145
+ void this.replay(session, stream, turnId, signal)
74
146
  return stream
75
147
  }
76
148
 
77
149
  async cancel(sessionId: string, reason = "cancelled"): Promise<void> {
78
150
  const session = this.requireSession(sessionId)
79
- session.active?.complete()
80
- session.active = undefined
81
- session.snapshot = Object.freeze({
82
- ...session.snapshot,
83
- status: "stopped",
84
- updatedAt: Date.now(),
85
- })
86
- if (session.seq === 0) this.push(session, { type: "session.stopped", reason })
151
+ if (session.active === undefined) return
152
+ await this.cancelTurn(session, session.active, session.turnId, reason)
87
153
  }
88
154
 
89
155
  async snapshot(sessionId: string): Promise<AgentSessionSnapshot> {
@@ -104,22 +170,35 @@ export class ReplayBackend implements AgentBackend {
104
170
  async close(sessionId: string): Promise<void> {
105
171
  const session = this.requireSession(sessionId)
106
172
  session.closed = true
173
+ session.turnAbortCleanup?.()
174
+ session.turnAbortCleanup = undefined
175
+ session.controller.abort("closed")
107
176
  session.active?.complete()
108
177
  session.active = undefined
178
+ session.turnId = undefined
109
179
  this.sessions.delete(sessionId)
110
180
  }
111
181
 
112
- private async replay(session: ReplaySession, stream: AgentEventStream): Promise<void> {
182
+ private async replay(
183
+ session: ReplaySession,
184
+ stream: AgentEventStream,
185
+ turnId: string,
186
+ signal: AbortSignal,
187
+ ): Promise<void> {
188
+ if (!this.isCurrentTurn(session, stream, turnId, signal)) return
113
189
  session.snapshot = Object.freeze({
114
190
  ...session.snapshot,
115
191
  status: "running",
116
192
  updatedAt: Date.now(),
117
193
  })
118
194
  try {
119
- for (const source of this.events) {
120
- if (this.delayMs > 0) await new Promise((resolve) => setTimeout(resolve, this.delayMs))
195
+ for (const encoded of this.events) {
196
+ if (this.delayMs > 0) await delay(this.delayMs, signal)
197
+ if (!this.isCurrentTurn(session, stream, turnId, signal)) return
198
+ const source = JSON.parse(encoded) as AgentEvent
121
199
  this.push(session, remap(source, session.id, session.seq))
122
200
  }
201
+ if (!this.isCurrentTurn(session, stream, turnId, signal)) return
123
202
  session.snapshot = Object.freeze({
124
203
  ...session.snapshot,
125
204
  status: "idle",
@@ -127,15 +206,81 @@ export class ReplayBackend implements AgentBackend {
127
206
  })
128
207
  this.push(session, { type: "session.completed", snapshot: session.snapshot })
129
208
  } catch (error) {
209
+ if (!this.isCurrentTurn(session, stream, turnId, signal)) return
130
210
  this.push(session, {
131
211
  type: "session.failed",
132
212
  error: agentError("REPLAY_FAILED", error instanceof Error ? error.message : String(error)),
133
213
  recoverable: true,
134
214
  })
135
215
  } finally {
216
+ if (this.isCurrentTurn(session, stream, turnId, signal)) {
217
+ stream.complete()
218
+ this.clearTurn(session, stream, turnId)
219
+ }
220
+ }
221
+ }
222
+
223
+ private async cancelTurn(
224
+ session: ReplaySession,
225
+ expectedStream: AgentEventStream | undefined,
226
+ expectedTurnId: string | undefined,
227
+ reason: string,
228
+ ): Promise<void> {
229
+ if (
230
+ expectedStream !== undefined &&
231
+ (session.active !== expectedStream || session.turnId !== expectedTurnId)
232
+ )
233
+ return
234
+ const stream = session.active
235
+ const turnId = session.turnId
236
+ const stopReason = boundedReplayReason(reason)
237
+ session.turnAbortCleanup?.()
238
+ session.turnAbortCleanup = undefined
239
+ session.controller.abort(stopReason)
240
+ if (stream !== undefined && turnId !== undefined) {
241
+ session.snapshot = Object.freeze({
242
+ ...session.snapshot,
243
+ status: "stopped",
244
+ updatedAt: Date.now(),
245
+ })
246
+ this.push(session, { type: "session.stopped", reason: stopReason })
136
247
  stream.complete()
137
- session.active = undefined
248
+ } else {
249
+ session.snapshot = Object.freeze({
250
+ ...session.snapshot,
251
+ status: "stopped",
252
+ updatedAt: Date.now(),
253
+ })
138
254
  }
255
+ this.clearTurn(session, stream, turnId)
256
+ }
257
+
258
+ private isCurrentTurn(
259
+ session: ReplaySession,
260
+ stream: AgentEventStream,
261
+ turnId: string,
262
+ signal: AbortSignal,
263
+ ): boolean {
264
+ return (
265
+ !session.closed &&
266
+ !signal.aborted &&
267
+ session.active === stream &&
268
+ session.turnId === turnId &&
269
+ session.controller.signal === signal
270
+ )
271
+ }
272
+
273
+ private clearTurn(
274
+ session: ReplaySession,
275
+ stream: AgentEventStream | undefined,
276
+ turnId: string | undefined,
277
+ ): void {
278
+ if (stream !== undefined && session.active !== stream) return
279
+ if (turnId !== undefined && session.turnId !== turnId) return
280
+ session.turnAbortCleanup?.()
281
+ session.turnAbortCleanup = undefined
282
+ if (stream === undefined || session.active === stream) session.active = undefined
283
+ if (turnId === undefined || session.turnId === turnId) session.turnId = undefined
139
284
  }
140
285
 
141
286
  private push(
@@ -146,6 +291,7 @@ export class ReplayBackend implements AgentBackend {
146
291
  "version" in event && "sessionId" in event
147
292
  ? event
148
293
  : { version: 1 as const, sessionId: session.id, seq: session.seq, at: Date.now(), ...event }
294
+ if (!isAgentEvent(next)) throw new Error("replay backend produced an invalid event")
149
295
  session.seq = next.seq + 1
150
296
  session.snapshot = Object.freeze({ ...session.snapshot, lastSeq: next.seq, updatedAt: next.at })
151
297
  session.active?.push(next as AgentEvent)
@@ -159,10 +305,13 @@ export class ReplayBackend implements AgentBackend {
159
305
  }
160
306
 
161
307
  export async function readReplayEvents(path: string): Promise<readonly AgentEvent[]> {
162
- const text = await Bun.file(path).text()
308
+ const file = Bun.file(path)
309
+ if (file.size > MAX_REPLAY_BYTES) throw new RangeError("replay: event file is too large")
310
+ const text = await file.text()
163
311
  const events: AgentEvent[] = []
164
312
  for (const line of text.split("\n")) {
165
313
  if (line.trim().length === 0) continue
314
+ if (events.length >= MAX_REPLAY_EVENTS) throw new RangeError("replay: too many events")
166
315
  const value: unknown = JSON.parse(line)
167
316
  if (!isAgentEvent(value)) throw new Error(`replay: invalid event in ${path}`)
168
317
  events.push(value)
@@ -171,16 +320,48 @@ export async function readReplayEvents(path: string): Promise<readonly AgentEven
171
320
  }
172
321
 
173
322
  function remap(event: AgentEvent, sessionId: string, seq: number): AgentEvent {
174
- return Object.freeze({ ...event, sessionId, seq, at: Date.now() })
323
+ const base = { ...event, sessionId, seq, at: Date.now() }
324
+ if (
325
+ event.type === "session.started" ||
326
+ event.type === "session.updated" ||
327
+ event.type === "session.completed"
328
+ )
329
+ return Object.freeze({
330
+ ...base,
331
+ snapshot: Object.freeze({ ...event.snapshot, id: sessionId }),
332
+ })
333
+ return Object.freeze(base)
175
334
  }
176
335
 
177
- function isAgentEvent(value: unknown): value is AgentEvent {
178
- return (
179
- value !== null &&
180
- typeof value === "object" &&
181
- (value as { version?: unknown }).version === 1 &&
182
- typeof (value as { type?: unknown }).type === "string"
183
- )
336
+ const MAX_REPLAY_BYTES = 64 * 1024 * 1024
337
+ const MAX_REPLAY_EVENTS = 100_000
338
+
339
+ function boundedReplayReason(reason: string): string {
340
+ if (reason.length <= 512) return reason
341
+ return `${reason.slice(0, 511)}…`
342
+ }
343
+
344
+ function boundedReplayToken(value: unknown): value is string {
345
+ return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
346
+ }
347
+
348
+ async function delay(ms: number, signal: AbortSignal): Promise<void> {
349
+ await new Promise<void>((resolve, reject) => {
350
+ let settled = false
351
+ const timer = setTimeout(() => settle(), ms)
352
+ const onAbort = (): void => settle(new Error("replay cancelled"))
353
+ const cleanup = (): void => signal.removeEventListener("abort", onAbort)
354
+ const settle = (error?: Error): void => {
355
+ if (settled) return
356
+ settled = true
357
+ clearTimeout(timer)
358
+ cleanup()
359
+ if (error === undefined) resolve()
360
+ else reject(error)
361
+ }
362
+ if (signal.aborted) onAbort()
363
+ else signal.addEventListener("abort", onAbort, { once: true })
364
+ })
184
365
  }
185
366
 
186
367
  function failedStream(error: unknown): AgentEventStream {