@nifrajs/coding-agent 3.5.0 → 4.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 (68) hide show
  1. package/README.md +11 -4
  2. package/SECURITY.md +2 -1
  3. package/dist/approvals.d.ts +14 -2
  4. package/dist/approvals.d.ts.map +1 -1
  5. package/dist/approvals.js +51 -43
  6. package/dist/approvals.js.map +1 -1
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +11 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/handoffs.d.ts.map +1 -1
  12. package/dist/handoffs.js +12 -4
  13. package/dist/handoffs.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/native.d.ts +7 -0
  18. package/dist/native.d.ts.map +1 -1
  19. package/dist/native.js +28 -7
  20. package/dist/native.js.map +1 -1
  21. package/dist/orchestration/compile.d.ts +7 -0
  22. package/dist/orchestration/compile.d.ts.map +1 -1
  23. package/dist/orchestration/compile.js +16 -2
  24. package/dist/orchestration/compile.js.map +1 -1
  25. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  26. package/dist/orchestration/evidence-store.js +9 -4
  27. package/dist/orchestration/evidence-store.js.map +1 -1
  28. package/dist/orchestration/host.d.ts +10 -0
  29. package/dist/orchestration/host.d.ts.map +1 -1
  30. package/dist/orchestration/host.js +9 -0
  31. package/dist/orchestration/host.js.map +1 -1
  32. package/dist/rpc.d.ts +1 -1
  33. package/dist/rpc.d.ts.map +1 -1
  34. package/dist/rpc.js +10 -9
  35. package/dist/rpc.js.map +1 -1
  36. package/dist/session-migration.d.ts.map +1 -1
  37. package/dist/session-migration.js +3 -2
  38. package/dist/session-migration.js.map +1 -1
  39. package/dist/sessions.d.ts +6 -0
  40. package/dist/sessions.d.ts.map +1 -1
  41. package/dist/sessions.js +17 -2
  42. package/dist/sessions.js.map +1 -1
  43. package/dist/subagents.d.ts +30 -0
  44. package/dist/subagents.d.ts.map +1 -1
  45. package/dist/subagents.js +118 -15
  46. package/dist/subagents.js.map +1 -1
  47. package/dist/verification.d.ts +5 -1
  48. package/dist/verification.d.ts.map +1 -1
  49. package/dist/verification.js +6 -2
  50. package/dist/verification.js.map +1 -1
  51. package/dist/workflows.d.ts.map +1 -1
  52. package/dist/workflows.js +12 -2
  53. package/dist/workflows.js.map +1 -1
  54. package/package.json +9 -6
  55. package/src/approvals.ts +62 -45
  56. package/src/cli.ts +11 -2
  57. package/src/handoffs.ts +20 -8
  58. package/src/index.ts +2 -0
  59. package/src/native.ts +41 -8
  60. package/src/orchestration/compile.ts +28 -2
  61. package/src/orchestration/evidence-store.ts +9 -3
  62. package/src/orchestration/host.ts +17 -0
  63. package/src/rpc.ts +14 -13
  64. package/src/session-migration.ts +3 -2
  65. package/src/sessions.ts +18 -2
  66. package/src/subagents.ts +147 -17
  67. package/src/verification.ts +6 -2
  68. package/src/workflows.ts +10 -2
@@ -1,5 +1,6 @@
1
1
  import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"
2
2
  import { basename, join, resolve } from "node:path"
3
+ import { sessionFileStem } from "./sessions.ts"
3
4
 
4
5
  /** Version of the evidence-only legacy session file format. */
5
6
  export const SESSION_EVIDENCE_VERSION = 1 as const
@@ -160,7 +161,7 @@ export async function migrateLegacySession(
160
161
  const lines = records.map((record) => JSON.stringify(record))
161
162
  const content = lines.length === 0 ? "" : `${lines.join("\n")}\n`
162
163
  const digest = await sha256Hex(content)
163
- const targetFile = join(targetRoot, `${sessionId}.jsonl`)
164
+ const targetFile = join(targetRoot, `${sessionFileStem(sessionId)}.jsonl`)
164
165
  await commitTarget(targetRoot, targetFile, content, options.signal)
165
166
  // Re-read the committed target and validate every line before the caller treats this as active.
166
167
  const committed = await readEvidenceTarget(targetFile, sessionId)
@@ -221,7 +222,7 @@ async function readLegacyEntries(
221
222
  sessionId: string,
222
223
  maxSourceBytes: number,
223
224
  ): Promise<readonly LegacyEntry[]> {
224
- const path = join(sourceRoot, `${sessionId}.jsonl`)
225
+ const path = join(sourceRoot, `${sessionFileStem(sessionId)}.jsonl`)
225
226
  let text: string
226
227
  try {
227
228
  const bytes = await readFile(path)
package/src/sessions.ts CHANGED
@@ -132,7 +132,7 @@ export class FileSessionStore implements SessionStore {
132
132
  async checkpoint(sessionId: string, payload: unknown): Promise<void> {
133
133
  validateToken(sessionId, "sessionId")
134
134
  await this.withSessionLock(sessionId, async () => {
135
- const target = join(this.root, `${sessionId}.checkpoint.json`)
135
+ const target = join(this.root, `${sessionFileStem(sessionId)}.checkpoint.json`)
136
136
  const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`
137
137
  await mkdir(dirname(target), { recursive: true })
138
138
  const content = JSON.stringify({
@@ -178,7 +178,7 @@ export class FileSessionStore implements SessionStore {
178
178
  }
179
179
 
180
180
  private pathFor(sessionId: string): string {
181
- return join(this.root, `${sessionId}.jsonl`)
181
+ return join(this.root, `${sessionFileStem(sessionId)}.jsonl`)
182
182
  }
183
183
 
184
184
  private async nextSequence(sessionId: string): Promise<number> {
@@ -418,6 +418,22 @@ async function rejectSessionSymlink(path: string): Promise<void> {
418
418
  }
419
419
  }
420
420
 
421
+ // Device names Windows reserves even with an extension after them: `nul.jsonl` is the NUL device.
422
+ const WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i
423
+
424
+ /**
425
+ * The file name stem for a session id. On Windows a `:` in a file name addresses an NTFS alternate
426
+ * data stream, so it is written as `%3A` there, and an id that starts with a reserved device name has
427
+ * its first character percent-encoded. `%` is outside the id grammar, so no two ids collide.
428
+ */
429
+ export function sessionFileStem(sessionId: string, platform = process.platform): string {
430
+ if (platform !== "win32") return sessionId
431
+ const stem = sessionId.replaceAll(":", "%3A")
432
+ return WINDOWS_DEVICE.test(stem)
433
+ ? `%${stem.charCodeAt(0).toString(16).toUpperCase()}${stem.slice(1)}`
434
+ : stem
435
+ }
436
+
421
437
  function validateToken(value: string, name: string): void {
422
438
  if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(value))
423
439
  throw new TypeError(`session store: ${name} must be a bounded token`)
package/src/subagents.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { relative, resolve, sep } from "node:path"
1
+ import { realpath } from "node:fs/promises"
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
2
3
 
3
4
  export interface SubagentSpec {
4
5
  readonly id: string
@@ -28,6 +29,10 @@ export interface SubagentExecutor {
28
29
 
29
30
  export interface SubagentWorkspaceLease {
30
31
  readonly cwd: string
32
+ /**
33
+ * Runs once the executor has settled. A run that times out or is cancelled returns at once, but
34
+ * its workspace stays until the executor finishes, so a child never loses its cwd mid-run.
35
+ */
31
36
  readonly cleanup?: () => void | PromiseLike<void>
32
37
  }
33
38
 
@@ -41,6 +46,22 @@ export interface SubagentWorkspacePolicy {
41
46
  ) => SubagentWorkspaceLease | PromiseLike<SubagentWorkspaceLease>
42
47
  }
43
48
 
49
+ /** A run that returned while its executor kept running, having ignored its abort signal. */
50
+ export interface SubagentAbandonment {
51
+ readonly spec: SubagentSpec
52
+ readonly reason: "timeout" | "cancelled"
53
+ /** The workspace the executor was given; undefined when it had none. */
54
+ readonly cwd: string | undefined
55
+ /** Settles once the executor does; its workspace lease is cleaned up after that. */
56
+ readonly settled: Promise<void>
57
+ }
58
+
59
+ /** Abandoned executors still running. Runners sharing one ledger are counted, and bounded by
60
+ * `maxAbandoned`, together. */
61
+ export interface SubagentAbandonmentLedger {
62
+ abandoned: number
63
+ }
64
+
44
65
  export interface SubagentRunnerOptions {
45
66
  readonly maxChildren?: number
46
67
  readonly maxDepth?: number
@@ -48,6 +69,25 @@ export interface SubagentRunnerOptions {
48
69
  readonly signal?: AbortSignal
49
70
  readonly allowedCapabilities?: readonly string[]
50
71
  readonly workspace?: SubagentWorkspacePolicy
72
+ /**
73
+ * Called when a run returns while its executor keeps running: it timed out or was cancelled and
74
+ * ignored its abort signal. Use it to stop the executor another way, such as ending its process.
75
+ */
76
+ readonly onAbandoned?: (abandonment: SubagentAbandonment) => void
77
+ /** Refuse new children while this many abandoned executors are still running. Default: no bound. */
78
+ readonly maxAbandoned?: number
79
+ /** Where abandoned executors are counted. Default: a ledger of this runner's own. */
80
+ readonly abandonment?: SubagentAbandonmentLedger
81
+ }
82
+
83
+ /** `work`'s outcome, or `stopped()` as soon as `signal` aborts, even when `work` ignores it. */
84
+ function untilAborted<T>(work: Promise<T>, signal: AbortSignal, stopped: () => Error): Promise<T> {
85
+ return new Promise<T>((resolve, reject) => {
86
+ const stop = (): void => reject(stopped())
87
+ if (signal.aborted) return stop()
88
+ signal.addEventListener("abort", stop, { once: true })
89
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop))
90
+ })
51
91
  }
52
92
 
53
93
  /** Explicitly bounded child execution. Recursive fan-out is impossible without a caller budget. */
@@ -58,6 +98,7 @@ export class BoundedSubagentRunner {
58
98
  > &
59
99
  SubagentRunnerOptions
60
100
  private children = 0
101
+ private readonly ledger: SubagentAbandonmentLedger
61
102
 
62
103
  constructor(executor: SubagentExecutor, options: SubagentRunnerOptions = {}) {
63
104
  this.executor = executor
@@ -67,15 +108,37 @@ export class BoundedSubagentRunner {
67
108
  maxDepth: options.maxDepth ?? 2,
68
109
  depth: options.depth ?? 0,
69
110
  }
70
- if (this.options.maxChildren < 1 || this.options.maxDepth < 0 || this.options.depth < 0)
111
+ this.ledger = options.abandonment ?? { abandoned: 0 }
112
+ if (
113
+ this.options.maxChildren < 1 ||
114
+ this.options.maxDepth < 0 ||
115
+ this.options.depth < 0 ||
116
+ (options.maxAbandoned !== undefined &&
117
+ (!Number.isSafeInteger(options.maxAbandoned) || options.maxAbandoned < 0))
118
+ )
71
119
  throw new RangeError(
72
120
  "subagents: limits must be non-negative and maxChildren must be positive",
73
121
  )
74
122
  }
75
123
 
124
+ /** Executors this runner's ledger holds as abandoned and still running. */
125
+ get abandoned(): number {
126
+ return this.ledger.abandoned
127
+ }
128
+
76
129
  async run(spec: SubagentSpec): Promise<SubagentResult> {
77
130
  if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(spec.id))
78
131
  return { id: spec.id, role: spec.role, ok: false, error: "invalid subagent id" }
132
+ if (
133
+ this.options.maxAbandoned !== undefined &&
134
+ this.ledger.abandoned >= this.options.maxAbandoned
135
+ )
136
+ return {
137
+ id: spec.id,
138
+ role: spec.role,
139
+ ok: false,
140
+ error: "subagent abandoned-executor limit reached",
141
+ }
79
142
  if (++this.children > this.options.maxChildren)
80
143
  return { id: spec.id, role: spec.role, ok: false, error: "subagent child limit exceeded" }
81
144
  if (this.options.depth > this.options.maxDepth)
@@ -110,13 +173,23 @@ export class BoundedSubagentRunner {
110
173
  }
111
174
  const controller = new AbortController()
112
175
  const onAbort = (): void => controller.abort(this.options.signal?.reason)
113
- this.options.signal?.addEventListener("abort", onAbort, { once: true })
176
+ if (this.options.signal?.aborted === true) onAbort()
177
+ else this.options.signal?.addEventListener("abort", onAbort, { once: true })
178
+ let timedOut = false
114
179
  const timeout =
115
180
  spec.timeoutMs === undefined
116
181
  ? undefined
117
- : setTimeout(() => controller.abort("timeout"), spec.timeoutMs)
182
+ : setTimeout(() => {
183
+ timedOut = true
184
+ controller.abort("timeout")
185
+ }, spec.timeoutMs)
186
+ const stopped = (): Error => new Error(timedOut ? "subagent timed out" : "subagent cancelled")
118
187
  let workspace: SubagentWorkspaceLease | undefined
188
+ let cwd: string | undefined
189
+ let executorDone: Promise<void> | undefined
190
+ let executorSettled = false
119
191
  try {
192
+ if (controller.signal.aborted) throw stopped()
120
193
  const requestedCwd =
121
194
  spec.cwd === undefined
122
195
  ? this.options.workspace?.root
@@ -124,7 +197,8 @@ export class BoundedSubagentRunner {
124
197
  ? resolve(spec.cwd)
125
198
  : resolve(this.options.workspace.root, spec.cwd)
126
199
  if (requestedCwd !== undefined && this.options.workspace !== undefined) {
127
- if (!within(this.options.workspace.root, requestedCwd))
200
+ const requested = await physicalPath(requestedCwd)
201
+ if (!(await within(this.options.workspace.root, requested)))
128
202
  return {
129
203
  id: spec.id,
130
204
  role: spec.role,
@@ -132,7 +206,7 @@ export class BoundedSubagentRunner {
132
206
  error: "subagent workspace escapes policy root",
133
207
  }
134
208
  const allowedRoots = this.options.workspace.allowedRoots ?? [this.options.workspace.root]
135
- if (!allowedRoots.some((root) => within(root, requestedCwd)))
209
+ if (!(await withinAny(allowedRoots, requested)))
136
210
  return {
137
211
  id: spec.id,
138
212
  role: spec.role,
@@ -145,11 +219,15 @@ export class BoundedSubagentRunner {
145
219
  } else if (requestedCwd !== undefined) {
146
220
  workspace = { cwd: requestedCwd }
147
221
  }
222
+ cwd = workspace?.cwd
148
223
  if (workspace !== undefined && this.options.workspace !== undefined) {
224
+ // The executor gets the path that was checked, with its links already resolved, so a link
225
+ // swapped after the check cannot move it out of the policy root.
226
+ cwd = await physicalPath(workspace.cwd)
149
227
  const allowedRoots = this.options.workspace.allowedRoots ?? [this.options.workspace.root]
150
228
  if (
151
- !within(this.options.workspace.root, workspace.cwd) ||
152
- !allowedRoots.some((root) => within(root, workspace!.cwd))
229
+ !(await within(this.options.workspace.root, cwd)) ||
230
+ !(await withinAny(allowedRoots, cwd))
153
231
  )
154
232
  return {
155
233
  id: spec.id,
@@ -158,11 +236,20 @@ export class BoundedSubagentRunner {
158
236
  error: "isolated worktree escapes workspace policy",
159
237
  }
160
238
  }
161
- const output = await this.executor.run({
162
- spec,
163
- signal: controller.signal,
164
- ...(workspace === undefined ? {} : { cwd: workspace.cwd }),
165
- })
239
+ if (controller.signal.aborted) throw stopped()
240
+ const work = Promise.resolve().then(() =>
241
+ this.executor.run({
242
+ spec,
243
+ signal: controller.signal,
244
+ ...(cwd === undefined ? {} : { cwd }),
245
+ }),
246
+ )
247
+ // Registered before untilAborted's reaction, so a settled executor is seen as settled below.
248
+ const settle = (): void => {
249
+ executorSettled = true
250
+ }
251
+ executorDone = work.then(settle, settle)
252
+ const output = await untilAborted(work, controller.signal, stopped)
166
253
  return { id: spec.id, role: spec.role, ok: true, output }
167
254
  } catch (error) {
168
255
  return {
@@ -172,9 +259,29 @@ export class BoundedSubagentRunner {
172
259
  error: error instanceof Error ? error.message : String(error),
173
260
  }
174
261
  } finally {
175
- await workspace?.cleanup?.()
176
262
  if (timeout !== undefined) clearTimeout(timeout)
177
263
  this.options.signal?.removeEventListener("abort", onAbort)
264
+ const cleanup = workspace?.cleanup
265
+ // An executor that honours the abort settles within a few ticks; give it one turn before
266
+ // calling it abandoned.
267
+ if (executorDone !== undefined && !executorSettled)
268
+ await Promise.race([executorDone, new Promise((resolve) => setTimeout(resolve, 0))])
269
+ if (executorDone !== undefined && !executorSettled) {
270
+ // The executor is counted until it settles, and may still be writing to its workspace, so
271
+ // the release waits for it too.
272
+ const ledger = this.ledger
273
+ ledger.abandoned++
274
+ const settled = executorDone.then(() => {
275
+ ledger.abandoned--
276
+ })
277
+ if (cleanup !== undefined) settled.then(() => cleanup()).catch(() => {})
278
+ this.options.onAbandoned?.({
279
+ spec,
280
+ reason: timedOut ? "timeout" : "cancelled",
281
+ cwd,
282
+ settled,
283
+ })
284
+ } else if (cleanup !== undefined) await cleanup()
178
285
  }
179
286
  }
180
287
 
@@ -201,10 +308,33 @@ export class BoundedSubagentRunner {
201
308
  }
202
309
  }
203
310
 
204
- function within(root: string, candidate: string): boolean {
205
- const relativePath = relative(resolve(root), resolve(candidate))
311
+ /** Whether `physical`, a path whose links are already resolved, lies inside the physical root, so a
312
+ * symlink inside the root cannot lead out of it. */
313
+ async function within(root: string, physical: string): Promise<boolean> {
314
+ const relativePath = relative(await physicalPath(root), physical)
206
315
  return (
207
316
  relativePath === "" ||
208
- (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !relativePath.startsWith("/"))
317
+ (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath))
209
318
  )
210
319
  }
320
+
321
+ async function withinAny(roots: readonly string[], physical: string): Promise<boolean> {
322
+ for (const root of roots) if (await within(root, physical)) return true
323
+ return false
324
+ }
325
+
326
+ /** The path with its existing part's symlinks resolved; a part not created yet is kept as written. */
327
+ async function physicalPath(path: string): Promise<string> {
328
+ const missing: string[] = []
329
+ let existing = resolve(path)
330
+ for (;;) {
331
+ try {
332
+ return join(await realpath(existing), ...missing)
333
+ } catch {
334
+ const parent = dirname(existing)
335
+ if (parent === existing) return resolve(path)
336
+ missing.unshift(basename(existing))
337
+ existing = parent
338
+ }
339
+ }
340
+ }
@@ -161,12 +161,16 @@ export async function runNifraReview(options: ReviewOptions): Promise<ReviewExec
161
161
  }
162
162
  }
163
163
 
164
- /** Run an existing Nifra gate without importing the large framework CLI into the agent runtime. */
164
+ /**
165
+ * Run an existing Nifra gate without importing the large framework CLI into the agent runtime.
166
+ * `check` and `assure` are `nifra` commands; `test` is the project's own suite, run with `bun test`
167
+ * by the Bun this agent runs on.
168
+ */
165
169
  export async function runNifraVerification(
166
170
  name: "check" | "assure" | "test",
167
171
  options: VerificationOptions,
168
172
  ): Promise<VerificationResult> {
169
- const command = options.command ?? "nifra"
173
+ const command = options.command ?? (name === "test" ? process.execPath : "nifra")
170
174
  const commandArgs = options.commandArgs ?? []
171
175
  const args = name === "test" ? ["test"] : [name, "--json"]
172
176
  const proc = Bun.spawn([command, ...commandArgs, ...args], {
package/src/workflows.ts CHANGED
@@ -235,16 +235,24 @@ export class WorkflowRunner {
235
235
  throw new RangeError("workflow: maxConcurrency must be positive")
236
236
  const outputs: unknown[] = new Array(steps.length)
237
237
  let next = 0
238
+ // The first failure starts no further step, and the group settles only once the steps already
239
+ // running have, so nothing runs on after the workflow reports its failure.
240
+ let failure: { readonly error: unknown } | undefined
238
241
  const worker = async (): Promise<void> => {
239
- for (;;) {
242
+ while (failure === undefined) {
240
243
  const index = next++
241
244
  if (index >= steps.length) return
242
- outputs[index] = await this.execute(steps[index]!, depth + 1)
245
+ try {
246
+ outputs[index] = await this.execute(steps[index]!, depth + 1)
247
+ } catch (error) {
248
+ failure ??= { error }
249
+ }
243
250
  }
244
251
  }
245
252
  await Promise.all(
246
253
  Array.from({ length: Math.min(maxConcurrency, steps.length) }, () => worker()),
247
254
  )
255
+ if (failure !== undefined) throw failure.error
248
256
  return outputs
249
257
  }
250
258