@nifrajs/coding-agent 5.0.0 → 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.
package/src/approvals.ts CHANGED
@@ -21,9 +21,44 @@ export interface ApprovalRequest {
21
21
  */
22
22
  readonly coordinate?: DecisionCoordinate
23
23
  readonly createdAt: number
24
+ /**
25
+ * Absolute epoch-ms after which the approval is denied as timed out. An approval opened with a
26
+ * `"never"` timeout reports {@link APPROVAL_NEVER_EXPIRES_AT}, so `now < expiresAt` keeps treating
27
+ * it as live.
28
+ */
24
29
  readonly expiresAt: number
25
30
  }
26
31
 
32
+ /**
33
+ * How long an approval may wait: whole milliseconds from 1 ms to 24 h, or `"never"` for no timer. A
34
+ * `"never"` approval still settles on an answer, a cancel, the end of its turn, and close.
35
+ */
36
+ export type ApprovalTimeout = number | "never"
37
+
38
+ /**
39
+ * The `expiresAt` of an approval that never times out: the largest epoch-ms a `Date` holds
40
+ * (+275760-09-13). It survives JSON, unlike `Infinity`, and `new Date(expiresAt)` stays valid.
41
+ */
42
+ export const APPROVAL_NEVER_EXPIRES_AT = 8_640_000_000_000_000
43
+
44
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60_000
45
+ const MAX_APPROVAL_TIMEOUT_MS = 24 * 60 * 60_000
46
+
47
+ /** True for `"never"` or a whole number of milliseconds from 1 ms to 24 h. */
48
+ export function isApprovalTimeout(value: unknown): value is ApprovalTimeout {
49
+ return (
50
+ value === "never" ||
51
+ (typeof value === "number" &&
52
+ Number.isSafeInteger(value) &&
53
+ value >= 1 &&
54
+ value <= MAX_APPROVAL_TIMEOUT_MS)
55
+ )
56
+ }
57
+
58
+ function approvalExpiresAt(createdAt: number, timeoutMs: ApprovalTimeout): number {
59
+ return timeoutMs === "never" ? APPROVAL_NEVER_EXPIRES_AT : createdAt + timeoutMs
60
+ }
61
+
27
62
  /** Stable, content-free reasons a coordinate-matched resolution is refused. */
28
63
  export type ApprovalMatchRejection =
29
64
  | "unknown_boundary"
@@ -45,7 +80,11 @@ export interface ApprovalDecision {
45
80
 
46
81
  export interface ApprovalManagerOptions {
47
82
  readonly maxPending?: number
48
- readonly timeoutMs?: number
83
+ /**
84
+ * How long an approval waits before it is denied as timed out (default 5 minutes, at most 24 h).
85
+ * `"never"` removes the timer; `maxPending` still bounds how many approvals can wait.
86
+ */
87
+ readonly timeoutMs?: ApprovalTimeout
49
88
  readonly onRequired?: (request: ApprovalRequest) => void | PromiseLike<void>
50
89
  readonly onResolved?: (decision: ApprovalDecision) => void | PromiseLike<void>
51
90
  }
@@ -55,7 +94,7 @@ interface PendingApproval {
55
94
  /** Settles with the decision; it exists before `onRequired` runs, so no decision can miss it. */
56
95
  readonly decided: Promise<boolean>
57
96
  readonly settle: (approved: boolean) => void
58
- readonly timer: ReturnType<typeof setTimeout>
97
+ readonly timer: ReturnType<typeof setTimeout> | undefined
59
98
  }
60
99
 
61
100
  /** The agent protocol's token alphabet, so every protocol-valid id is accepted here too. */
@@ -77,16 +116,17 @@ export class ApprovalManager {
77
116
  this.options = {
78
117
  ...options,
79
118
  maxPending: options.maxPending ?? 32,
80
- timeoutMs: options.timeoutMs ?? 5 * 60_000,
119
+ timeoutMs: options.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS,
81
120
  }
82
121
  if (!Number.isSafeInteger(this.options.maxPending) || this.options.maxPending < 1)
83
122
  throw new RangeError("approvals: maxPending must be positive")
84
- if (
85
- !Number.isSafeInteger(this.options.timeoutMs) ||
86
- this.options.timeoutMs < 1 ||
87
- this.options.timeoutMs > 24 * 60 * 60_000
88
- )
89
- throw new RangeError("approvals: timeoutMs must be between 1ms and 24h")
123
+ if (!isApprovalTimeout(this.options.timeoutMs))
124
+ throw new RangeError('approvals: timeoutMs must be between 1ms and 24h, or "never"')
125
+ }
126
+
127
+ /** The configured timeout, for {@link approvalRequestFromEvent}. */
128
+ get timeoutMs(): ApprovalTimeout {
129
+ return this.options.timeoutMs
90
130
  }
91
131
 
92
132
  get pending(): readonly ApprovalRequest[] {
@@ -219,13 +259,17 @@ export class ApprovalManager {
219
259
  ...(input.reason === undefined ? {} : { reason: input.reason.slice(0, 512) }),
220
260
  ...(input.coordinate === undefined ? {} : { coordinate: input.coordinate }),
221
261
  createdAt,
222
- expiresAt: createdAt + this.options.timeoutMs,
262
+ expiresAt: approvalExpiresAt(createdAt, this.options.timeoutMs),
223
263
  })
224
264
  const { promise: decided, resolve: settle } = Promise.withResolvers<boolean>()
225
- const timer = setTimeout(() => {
226
- this.settle(request.id, false, "approval timed out")
227
- }, this.options.timeoutMs)
228
- ;(timer as unknown as { unref?: () => void }).unref?.()
265
+ const timeoutMs = this.options.timeoutMs
266
+ let timer: ReturnType<typeof setTimeout> | undefined
267
+ if (timeoutMs !== "never") {
268
+ timer = setTimeout(() => {
269
+ this.settle(request.id, false, "approval timed out")
270
+ }, timeoutMs)
271
+ ;(timer as unknown as { unref?: () => void }).unref?.()
272
+ }
229
273
  const pending: PendingApproval = { request, decided, settle, timer }
230
274
  this.pendingApprovals.set(request.id, pending)
231
275
  await this.options.onRequired?.(request)
@@ -233,7 +277,17 @@ export class ApprovalManager {
233
277
  }
234
278
  }
235
279
 
236
- export function approvalRequestFromEvent(event: AgentApprovalRequiredEvent): ApprovalRequest {
280
+ /**
281
+ * Project a backend approval event into a request. Pass the broker's timeout
282
+ * (`ApprovalManager.timeoutMs`) so `expiresAt` matches when it will actually be denied; the default
283
+ * is the broker's default of 5 minutes.
284
+ */
285
+ export function approvalRequestFromEvent(
286
+ event: AgentApprovalRequiredEvent,
287
+ timeoutMs: ApprovalTimeout = DEFAULT_APPROVAL_TIMEOUT_MS,
288
+ ): ApprovalRequest {
289
+ if (!isApprovalTimeout(timeoutMs))
290
+ throw new RangeError('approvals: timeoutMs must be between 1ms and 24h, or "never"')
237
291
  return Object.freeze({
238
292
  id: event.approvalId,
239
293
  sessionId: event.sessionId,
@@ -242,7 +296,7 @@ export function approvalRequestFromEvent(event: AgentApprovalRequiredEvent): App
242
296
  capability: event.capability,
243
297
  ...(event.reason === undefined ? {} : { reason: event.reason }),
244
298
  createdAt: event.at,
245
- expiresAt: event.at + 5 * 60_000,
299
+ expiresAt: approvalExpiresAt(event.at, timeoutMs),
246
300
  })
247
301
  }
248
302
 
package/src/extensions.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, realpathSync } from "node:fs"
2
- import { join, relative, resolve } from "node:path"
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path"
3
3
  import {
4
4
  type WorkflowEvent,
5
5
  type WorkflowResult,
@@ -398,5 +398,9 @@ function isWithin(root: string, candidate: string): boolean {
398
398
  }
399
399
  }
400
400
  const relativePath = relative(canonical(root), canonical(candidate))
401
- return relativePath === "" || (!relativePath.startsWith("..") && !relativePath.includes("/.."))
401
+ // On Windows a path on another drive has no relative form, so `relative` returns it absolute.
402
+ return (
403
+ relativePath === "" ||
404
+ (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath))
405
+ )
402
406
  }
package/src/host.ts CHANGED
@@ -191,31 +191,40 @@ export class CodingAgentHost {
191
191
  const sessionId = this.session.id
192
192
  let nextMessage = message
193
193
  let repairAttempts = 0
194
- for (;;) {
195
- const stream = this.backend.send({
196
- sessionId,
197
- message: nextMessage,
198
- ...(signal === undefined ? {} : { signal }),
199
- })
200
- for await (const rawEvent of stream) {
201
- const event = this.acceptBackendEvent(rawEvent)
202
- await this.recordEvent(event)
203
- yield event
204
- if (event.type === "session.stopped") return
205
- if (event.type === "session.failed") {
206
- if (event.error.code === "BACKEND_PROTOCOL")
207
- await this.backend.cancel(sessionId, "backend protocol error").catch(() => {})
208
- return
194
+ // A reader that leaves early (an RPC client disconnecting) never records the backend's
195
+ // `approval.resolved`, so the turn's approvals are denied here instead of waiting on a timer.
196
+ const raised = new Set<string>()
197
+ try {
198
+ for (;;) {
199
+ const stream = this.backend.send({
200
+ sessionId,
201
+ message: nextMessage,
202
+ ...(signal === undefined ? {} : { signal }),
203
+ })
204
+ for await (const rawEvent of stream) {
205
+ const event = this.acceptBackendEvent(rawEvent)
206
+ await this.recordEvent(event)
207
+ if (event.type === "approval.required") raised.add(event.approvalId)
208
+ yield event
209
+ if (event.type === "session.stopped") return
210
+ if (event.type === "session.failed") {
211
+ if (event.error.code === "BACKEND_PROTOCOL")
212
+ await this.backend.cancel(sessionId, "backend protocol error").catch(() => {})
213
+ return
214
+ }
209
215
  }
210
- }
211
- if (signal?.aborted === true) return
216
+ if (signal?.aborted === true) return
212
217
 
213
- const repairTasks = await this.verifyTurn()
214
- for (const event of repairTasks.events) yield event
215
- if (repairTasks.tasks.length === 0 || repairAttempts >= this.maxRepairAttempts) return
218
+ const repairTasks = await this.verifyTurn()
219
+ for (const event of repairTasks.events) yield event
220
+ if (repairTasks.tasks.length === 0 || repairAttempts >= this.maxRepairAttempts) return
216
221
 
217
- repairAttempts++
218
- nextMessage = createRepairPrompt(repairTasks.tasks, repairAttempts, this.maxRepairAttempts)
222
+ repairAttempts++
223
+ nextMessage = createRepairPrompt(repairTasks.tasks, repairAttempts, this.maxRepairAttempts)
224
+ }
225
+ } finally {
226
+ for (const approvalId of raised)
227
+ this.approvals.resolve(approvalId, false, "approval turn ended")
219
228
  }
220
229
  }
221
230
 
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  export { PiBackend, type PiBackendOptions } from "@nifrajs/pi"
2
2
  export {
3
+ APPROVAL_NEVER_EXPIRES_AT,
3
4
  type ApprovalDecision,
4
5
  ApprovalManager,
5
6
  type ApprovalManagerOptions,
6
7
  type ApprovalMatchRejection,
7
8
  type ApprovalMatchResult,
8
9
  type ApprovalRequest,
10
+ type ApprovalTimeout,
9
11
  approvalRequestFromEvent,
10
12
  approvalResolvedEvent,
11
13
  } from "./approvals.ts"
package/src/isolated.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { realpathSync } from "node:fs"
2
- import { isAbsolute, relative, resolve } from "node:path"
2
+ import { isAbsolute, relative, resolve, sep } from "node:path"
3
3
  import { fileURLToPath } from "node:url"
4
4
  import { readBoundedText } from "./process.ts"
5
5
 
@@ -321,7 +321,8 @@ export class IsolatedExtensionWorker {
321
321
 
322
322
  function isWithin(root: string, candidate: string): boolean {
323
323
  const path = relative(resolve(root), resolve(candidate))
324
- return path === "" || (!path.startsWith("..") && !path.includes("/.."))
324
+ // On Windows a path on another drive has no relative form, so `relative` returns it absolute.
325
+ return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path))
325
326
  }
326
327
 
327
328
  function isValidPort(value: unknown): value is number {
package/src/native.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  type SendMessageInput,
22
22
  splitAgentText,
23
23
  } from "@nifrajs/agent-protocol"
24
+ import { type ApprovalTimeout, isApprovalTimeout } from "./approvals.ts"
24
25
 
25
26
  export interface NativeMessage {
26
27
  readonly role: "user" | "assistant" | "tool"
@@ -135,8 +136,12 @@ export interface NifraBackendOptions {
135
136
  readonly model: NativeModelPort
136
137
  readonly tools?: readonly NativeTool[]
137
138
  readonly approval?: NativeApprovalPort
138
- /** Maximum time a protocol-visible native approval may remain unresolved. */
139
- readonly approvalTimeoutMs?: number
139
+ /**
140
+ * Maximum time a protocol-visible native approval may remain unresolved (default 5 minutes, at
141
+ * most 24 h). `"never"` removes the timer: the approval waits for an answer, a cancel, the end of
142
+ * its turn, or session close.
143
+ */
144
+ readonly approvalTimeoutMs?: ApprovalTimeout
140
145
  readonly maxSteps?: number
141
146
  /** Longest message `send` accepts (default 64 KiB). */
142
147
  readonly maxMessageChars?: number
@@ -175,7 +180,7 @@ interface NativePendingApproval {
175
180
  readonly action: string
176
181
  readonly capability: string
177
182
  readonly resolve: (resolution: NativeApprovalResolution) => void
178
- readonly timer: ReturnType<typeof setTimeout>
183
+ readonly timer: ReturnType<typeof setTimeout> | undefined
179
184
  readonly onAbort: () => void
180
185
  readonly signal: AbortSignal
181
186
  }
@@ -238,11 +243,11 @@ export class NifraBackend implements AgentBackend {
238
243
  throw new RangeError("nifra backend: maxHistoryChars must be at least 256")
239
244
  if (
240
245
  this.options.approvalTimeoutMs !== undefined &&
241
- (!Number.isSafeInteger(this.options.approvalTimeoutMs) ||
242
- this.options.approvalTimeoutMs < 1 ||
243
- this.options.approvalTimeoutMs > 24 * 60 * 60_000)
246
+ !isApprovalTimeout(this.options.approvalTimeoutMs)
244
247
  )
245
- throw new RangeError("nifra backend: approvalTimeoutMs must be between 1ms and 24h")
248
+ throw new RangeError(
249
+ 'nifra backend: approvalTimeoutMs must be between 1ms and 24h, or "never"',
250
+ )
246
251
  }
247
252
 
248
253
  async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
@@ -627,13 +632,14 @@ export class NifraBackend implements AgentBackend {
627
632
  session.closed ? "APPROVAL_CLOSED" : "APPROVAL_CANCELLED",
628
633
  )
629
634
  }
630
- const timer = setTimeout(
631
- () => {
635
+ const timeoutMs = this.options.approvalTimeoutMs ?? 5 * 60_000
636
+ let timer: ReturnType<typeof setTimeout> | undefined
637
+ if (timeoutMs !== "never") {
638
+ timer = setTimeout(() => {
632
639
  this.settleApproval(session, approvalId, false, "approval timed out", "APPROVAL_TIMEOUT")
633
- },
634
- this.options.approvalTimeoutMs ?? 5 * 60_000,
635
- )
636
- ;(timer as unknown as { unref?: () => void }).unref?.()
640
+ }, timeoutMs)
641
+ ;(timer as unknown as { unref?: () => void }).unref?.()
642
+ }
637
643
  pending = {
638
644
  id: approvalId,
639
645
  sessionId: session.id,
package/src/process.ts CHANGED
@@ -70,13 +70,15 @@ export interface BoundedProcessResult {
70
70
  }
71
71
 
72
72
  const KILL_GRACE_MS = 2_000
73
+ // How long the pipes are still read once the child has exited, for output it wrote just before.
74
+ const DRAIN_GRACE_MS = 2_000
73
75
  const PROCESS_GROUPS = process.platform !== "win32"
74
76
 
75
77
  /**
76
78
  * Run a trusted command to completion within `timeoutMs`. On POSIX the child leads its own process
77
79
  * group, so a timeout reaches every process it started: SIGTERM, then SIGKILL after a grace period.
78
- * Once the child is gone, pipes still held open by something outside the group are no longer
79
- * waited on, so the call always settles.
80
+ * The call settles when the child exits: a process it left running that still holds the pipes open
81
+ * is read from for a short grace and then no longer waited on, and `timedOut` stays `false`.
80
82
  */
81
83
  export async function runBoundedProcess(
82
84
  argv: readonly string[],
@@ -110,15 +112,25 @@ export async function runBoundedProcess(
110
112
  void proc.exited.then(() => reading.abort())
111
113
  }, graceMs)
112
114
  }, options.timeoutMs)
115
+ let drain: ReturnType<typeof setTimeout> | undefined
116
+ // Awaited below, so the drain timer is armed before the `finally` that clears it can run.
117
+ const exited = proc.exited.then((status) => {
118
+ if (!timedOut) {
119
+ clearTimeout(timer)
120
+ drain = setTimeout(() => reading.abort(), DRAIN_GRACE_MS)
121
+ }
122
+ return status
123
+ })
113
124
  try {
114
125
  const [stdout, stderr, status] = await Promise.all([
115
126
  readBoundedText(proc.stdout, options.maxStdoutBytes, reading.signal),
116
127
  readBoundedText(proc.stderr, options.maxStderrBytes, reading.signal),
117
- proc.exited,
128
+ exited,
118
129
  ])
119
130
  return { stdout, stderr, status, timedOut }
120
131
  } finally {
121
132
  clearTimeout(timer)
122
133
  clearTimeout(escalation)
134
+ clearTimeout(drain)
123
135
  }
124
136
  }
package/src/rpc.ts CHANGED
@@ -37,6 +37,12 @@ export interface CodingAgentRpcServerOptions {
37
37
  readonly maxRepairAttempts?: number
38
38
  readonly verification?: CodingAgentHostOptions["verification"]
39
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"]
40
46
  /**
41
47
  * Keep a `turn.send` running when its client disconnects, and let a client that comes back read
42
48
  * what it missed with `turn.attach` (an `afterSeq` cursor, or `Last-Event-ID`). The newest
@@ -124,6 +130,7 @@ export class CodingAgentRpcServer {
124
130
  ...(options.extensionEvents === undefined
125
131
  ? {}
126
132
  : { extensionEvents: options.extensionEvents }),
133
+ ...(options.approvals === undefined ? {} : { approvals: options.approvals }),
127
134
  })
128
135
  this.resumable = resumableTurnConfig(options.resumableTurns)
129
136
  this.token = options.authToken ?? randomAuthToken()