@nifrajs/coding-agent 3.2.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 (211) hide show
  1. package/EXTENSIONS.md +25 -0
  2. package/PERFORMANCE.md +19 -0
  3. package/README.md +69 -0
  4. package/REGISTRY.md +15 -0
  5. package/SECURITY.md +32 -0
  6. package/dist/approvals.d.ts +70 -0
  7. package/dist/approvals.d.ts.map +1 -0
  8. package/dist/approvals.js +174 -0
  9. package/dist/approvals.js.map +1 -0
  10. package/dist/capabilities.d.ts +20 -0
  11. package/dist/capabilities.d.ts.map +1 -0
  12. package/dist/capabilities.js +49 -0
  13. package/dist/capabilities.js.map +1 -0
  14. package/dist/cli.d.ts +24 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +380 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/context.d.ts +17 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/context.js +66 -0
  21. package/dist/context.js.map +1 -0
  22. package/dist/deployment-adapters.d.ts +40 -0
  23. package/dist/deployment-adapters.d.ts.map +1 -0
  24. package/dist/deployment-adapters.js +147 -0
  25. package/dist/deployment-adapters.js.map +1 -0
  26. package/dist/diff.d.ts +17 -0
  27. package/dist/diff.d.ts.map +1 -0
  28. package/dist/diff.js +61 -0
  29. package/dist/diff.js.map +1 -0
  30. package/dist/errors.d.ts +9 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +35 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/extensions.d.ts +104 -0
  35. package/dist/extensions.d.ts.map +1 -0
  36. package/dist/extensions.js +284 -0
  37. package/dist/extensions.js.map +1 -0
  38. package/dist/handoffs.d.ts +97 -0
  39. package/dist/handoffs.d.ts.map +1 -0
  40. package/dist/handoffs.js +199 -0
  41. package/dist/handoffs.js.map +1 -0
  42. package/dist/healing.d.ts +56 -0
  43. package/dist/healing.d.ts.map +1 -0
  44. package/dist/healing.js +54 -0
  45. package/dist/healing.js.map +1 -0
  46. package/dist/host.d.ts +61 -0
  47. package/dist/host.d.ts.map +1 -0
  48. package/dist/host.js +369 -0
  49. package/dist/host.js.map +1 -0
  50. package/dist/index.d.ts +26 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +26 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/isolated-worker.d.ts +2 -0
  55. package/dist/isolated-worker.d.ts.map +1 -0
  56. package/dist/isolated-worker.js +155 -0
  57. package/dist/isolated-worker.js.map +1 -0
  58. package/dist/isolated.d.ts +52 -0
  59. package/dist/isolated.d.ts.map +1 -0
  60. package/dist/isolated.js +293 -0
  61. package/dist/isolated.js.map +1 -0
  62. package/dist/native.d.ts +92 -0
  63. package/dist/native.d.ts.map +1 -0
  64. package/dist/native.js +339 -0
  65. package/dist/native.js.map +1 -0
  66. package/dist/orchestration/artifact-port.d.ts +30 -0
  67. package/dist/orchestration/artifact-port.d.ts.map +1 -0
  68. package/dist/orchestration/artifact-port.js +59 -0
  69. package/dist/orchestration/artifact-port.js.map +1 -0
  70. package/dist/orchestration/catalog.d.ts +71 -0
  71. package/dist/orchestration/catalog.d.ts.map +1 -0
  72. package/dist/orchestration/catalog.js +46 -0
  73. package/dist/orchestration/catalog.js.map +1 -0
  74. package/dist/orchestration/compile.d.ts +59 -0
  75. package/dist/orchestration/compile.d.ts.map +1 -0
  76. package/dist/orchestration/compile.js +268 -0
  77. package/dist/orchestration/compile.js.map +1 -0
  78. package/dist/orchestration/dispatch.d.ts +115 -0
  79. package/dist/orchestration/dispatch.d.ts.map +1 -0
  80. package/dist/orchestration/dispatch.js +220 -0
  81. package/dist/orchestration/dispatch.js.map +1 -0
  82. package/dist/orchestration/durable-jobs.d.ts +35 -0
  83. package/dist/orchestration/durable-jobs.d.ts.map +1 -0
  84. package/dist/orchestration/durable-jobs.js +273 -0
  85. package/dist/orchestration/durable-jobs.js.map +1 -0
  86. package/dist/orchestration/effect-key.d.ts +23 -0
  87. package/dist/orchestration/effect-key.d.ts.map +1 -0
  88. package/dist/orchestration/effect-key.js +24 -0
  89. package/dist/orchestration/effect-key.js.map +1 -0
  90. package/dist/orchestration/evidence-store.d.ts +75 -0
  91. package/dist/orchestration/evidence-store.d.ts.map +1 -0
  92. package/dist/orchestration/evidence-store.js +183 -0
  93. package/dist/orchestration/evidence-store.js.map +1 -0
  94. package/dist/orchestration/hash.d.ts +12 -0
  95. package/dist/orchestration/hash.d.ts.map +1 -0
  96. package/dist/orchestration/hash.js +43 -0
  97. package/dist/orchestration/hash.js.map +1 -0
  98. package/dist/orchestration/host.d.ts +121 -0
  99. package/dist/orchestration/host.d.ts.map +1 -0
  100. package/dist/orchestration/host.js +334 -0
  101. package/dist/orchestration/host.js.map +1 -0
  102. package/dist/orchestration/index.d.ts +20 -0
  103. package/dist/orchestration/index.d.ts.map +1 -0
  104. package/dist/orchestration/index.js +19 -0
  105. package/dist/orchestration/index.js.map +1 -0
  106. package/dist/orchestration/policy.d.ts +63 -0
  107. package/dist/orchestration/policy.d.ts.map +1 -0
  108. package/dist/orchestration/policy.js +80 -0
  109. package/dist/orchestration/policy.js.map +1 -0
  110. package/dist/orchestration/recovery.d.ts +87 -0
  111. package/dist/orchestration/recovery.d.ts.map +1 -0
  112. package/dist/orchestration/recovery.js +197 -0
  113. package/dist/orchestration/recovery.js.map +1 -0
  114. package/dist/orchestration/tracer.d.ts +31 -0
  115. package/dist/orchestration/tracer.d.ts.map +1 -0
  116. package/dist/orchestration/tracer.js +141 -0
  117. package/dist/orchestration/tracer.js.map +1 -0
  118. package/dist/plans.d.ts +35 -0
  119. package/dist/plans.d.ts.map +1 -0
  120. package/dist/plans.js +63 -0
  121. package/dist/plans.js.map +1 -0
  122. package/dist/presets.d.ts +10 -0
  123. package/dist/presets.d.ts.map +1 -0
  124. package/dist/presets.js +70 -0
  125. package/dist/presets.js.map +1 -0
  126. package/dist/process.d.ts +6 -0
  127. package/dist/process.d.ts.map +1 -0
  128. package/dist/process.js +42 -0
  129. package/dist/process.js.map +1 -0
  130. package/dist/registry.d.ts +37 -0
  131. package/dist/registry.d.ts.map +1 -0
  132. package/dist/registry.js +51 -0
  133. package/dist/registry.js.map +1 -0
  134. package/dist/replay.d.ts +24 -0
  135. package/dist/replay.d.ts.map +1 -0
  136. package/dist/replay.js +154 -0
  137. package/dist/replay.js.map +1 -0
  138. package/dist/rpc.d.ts +51 -0
  139. package/dist/rpc.d.ts.map +1 -0
  140. package/dist/rpc.js +412 -0
  141. package/dist/rpc.js.map +1 -0
  142. package/dist/session-migration.d.ts +59 -0
  143. package/dist/session-migration.d.ts.map +1 -0
  144. package/dist/session-migration.js +315 -0
  145. package/dist/session-migration.js.map +1 -0
  146. package/dist/sessions.d.ts +74 -0
  147. package/dist/sessions.d.ts.map +1 -0
  148. package/dist/sessions.js +222 -0
  149. package/dist/sessions.js.map +1 -0
  150. package/dist/subagents.d.ts +51 -0
  151. package/dist/subagents.d.ts.map +1 -0
  152. package/dist/subagents.js +141 -0
  153. package/dist/subagents.js.map +1 -0
  154. package/dist/tools.d.ts +13 -0
  155. package/dist/tools.d.ts.map +1 -0
  156. package/dist/tools.js +37 -0
  157. package/dist/tools.js.map +1 -0
  158. package/dist/ui.d.ts +42 -0
  159. package/dist/ui.d.ts.map +1 -0
  160. package/dist/ui.js +93 -0
  161. package/dist/ui.js.map +1 -0
  162. package/dist/verification.d.ts +34 -0
  163. package/dist/verification.d.ts.map +1 -0
  164. package/dist/verification.js +91 -0
  165. package/dist/verification.js.map +1 -0
  166. package/dist/workflows.d.ts +98 -0
  167. package/dist/workflows.d.ts.map +1 -0
  168. package/dist/workflows.js +174 -0
  169. package/dist/workflows.js.map +1 -0
  170. package/package.json +84 -0
  171. package/src/approvals.ts +249 -0
  172. package/src/capabilities.ts +74 -0
  173. package/src/cli.ts +405 -0
  174. package/src/context.ts +81 -0
  175. package/src/deployment-adapters.ts +181 -0
  176. package/src/diff.ts +80 -0
  177. package/src/errors.ts +46 -0
  178. package/src/extensions.ts +402 -0
  179. package/src/handoffs.ts +299 -0
  180. package/src/healing.ts +93 -0
  181. package/src/host.ts +465 -0
  182. package/src/index.ts +165 -0
  183. package/src/isolated-worker.ts +177 -0
  184. package/src/isolated.ts +344 -0
  185. package/src/native.ts +478 -0
  186. package/src/orchestration/artifact-port.ts +75 -0
  187. package/src/orchestration/catalog.ts +102 -0
  188. package/src/orchestration/compile.ts +373 -0
  189. package/src/orchestration/dispatch.ts +383 -0
  190. package/src/orchestration/durable-jobs.ts +374 -0
  191. package/src/orchestration/effect-key.ts +35 -0
  192. package/src/orchestration/evidence-store.ts +233 -0
  193. package/src/orchestration/hash.ts +43 -0
  194. package/src/orchestration/host.ts +445 -0
  195. package/src/orchestration/index.ts +131 -0
  196. package/src/orchestration/policy.ts +116 -0
  197. package/src/orchestration/recovery.ts +271 -0
  198. package/src/orchestration/tracer.ts +177 -0
  199. package/src/plans.ts +105 -0
  200. package/src/presets.ts +90 -0
  201. package/src/process.ts +47 -0
  202. package/src/registry.ts +80 -0
  203. package/src/replay.ts +190 -0
  204. package/src/rpc.ts +598 -0
  205. package/src/session-migration.ts +401 -0
  206. package/src/sessions.ts +303 -0
  207. package/src/subagents.ts +210 -0
  208. package/src/tools.ts +52 -0
  209. package/src/ui.ts +137 -0
  210. package/src/verification.ts +133 -0
  211. package/src/workflows.ts +279 -0
@@ -0,0 +1,210 @@
1
+ import { relative, resolve, sep } from "node:path"
2
+
3
+ export interface SubagentSpec {
4
+ readonly id: string
5
+ readonly role: string
6
+ readonly prompt: string
7
+ readonly capabilities?: readonly string[]
8
+ readonly maxDepth?: number
9
+ readonly timeoutMs?: number
10
+ readonly cwd?: string
11
+ }
12
+
13
+ export interface SubagentResult {
14
+ readonly id: string
15
+ readonly role: string
16
+ readonly ok: boolean
17
+ readonly output?: unknown
18
+ readonly error?: string
19
+ }
20
+
21
+ export interface SubagentExecutor {
22
+ run(input: {
23
+ readonly spec: SubagentSpec
24
+ readonly signal: AbortSignal
25
+ readonly cwd?: string
26
+ }): unknown | PromiseLike<unknown>
27
+ }
28
+
29
+ export interface SubagentWorkspaceLease {
30
+ readonly cwd: string
31
+ readonly cleanup?: () => void | PromiseLike<void>
32
+ }
33
+
34
+ /** Workspace policy seam for callers that need project boundaries or isolated worktrees. */
35
+ export interface SubagentWorkspacePolicy {
36
+ readonly root: string
37
+ readonly allowedRoots?: readonly string[]
38
+ readonly isolatedWorktree?: (
39
+ spec: SubagentSpec,
40
+ signal: AbortSignal,
41
+ ) => SubagentWorkspaceLease | PromiseLike<SubagentWorkspaceLease>
42
+ }
43
+
44
+ export interface SubagentRunnerOptions {
45
+ readonly maxChildren?: number
46
+ readonly maxDepth?: number
47
+ readonly depth?: number
48
+ readonly signal?: AbortSignal
49
+ readonly allowedCapabilities?: readonly string[]
50
+ readonly workspace?: SubagentWorkspacePolicy
51
+ }
52
+
53
+ /** Explicitly bounded child execution. Recursive fan-out is impossible without a caller budget. */
54
+ export class BoundedSubagentRunner {
55
+ private readonly executor: SubagentExecutor
56
+ private readonly options: Required<
57
+ Pick<SubagentRunnerOptions, "maxChildren" | "maxDepth" | "depth">
58
+ > &
59
+ SubagentRunnerOptions
60
+ private children = 0
61
+
62
+ constructor(executor: SubagentExecutor, options: SubagentRunnerOptions = {}) {
63
+ this.executor = executor
64
+ this.options = {
65
+ ...options,
66
+ maxChildren: options.maxChildren ?? 4,
67
+ maxDepth: options.maxDepth ?? 2,
68
+ depth: options.depth ?? 0,
69
+ }
70
+ if (this.options.maxChildren < 1 || this.options.maxDepth < 0 || this.options.depth < 0)
71
+ throw new RangeError(
72
+ "subagents: limits must be non-negative and maxChildren must be positive",
73
+ )
74
+ }
75
+
76
+ async run(spec: SubagentSpec): Promise<SubagentResult> {
77
+ if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(spec.id))
78
+ return { id: spec.id, role: spec.role, ok: false, error: "invalid subagent id" }
79
+ if (++this.children > this.options.maxChildren)
80
+ return { id: spec.id, role: spec.role, ok: false, error: "subagent child limit exceeded" }
81
+ if (this.options.depth > this.options.maxDepth)
82
+ return { id: spec.id, role: spec.role, ok: false, error: "subagent depth limit exceeded" }
83
+ if (spec.prompt.length === 0 || spec.prompt.length > 16_384)
84
+ return {
85
+ id: spec.id,
86
+ role: spec.role,
87
+ ok: false,
88
+ error: "subagent prompt is empty or too long",
89
+ }
90
+ if (
91
+ spec.timeoutMs !== undefined &&
92
+ (!Number.isSafeInteger(spec.timeoutMs) ||
93
+ spec.timeoutMs < 1 ||
94
+ spec.timeoutMs > 24 * 60 * 60_000)
95
+ )
96
+ return { id: spec.id, role: spec.role, ok: false, error: "subagent timeout is invalid" }
97
+ const allowed =
98
+ this.options.allowedCapabilities === undefined
99
+ ? undefined
100
+ : new Set(this.options.allowedCapabilities)
101
+ const denied = spec.capabilities?.find(
102
+ (capability) => allowed !== undefined && !allowed.has(capability),
103
+ )
104
+ if (denied !== undefined)
105
+ return {
106
+ id: spec.id,
107
+ role: spec.role,
108
+ ok: false,
109
+ error: `subagent capability denied: ${denied}`,
110
+ }
111
+ const controller = new AbortController()
112
+ const onAbort = (): void => controller.abort(this.options.signal?.reason)
113
+ this.options.signal?.addEventListener("abort", onAbort, { once: true })
114
+ const timeout =
115
+ spec.timeoutMs === undefined
116
+ ? undefined
117
+ : setTimeout(() => controller.abort("timeout"), spec.timeoutMs)
118
+ let workspace: SubagentWorkspaceLease | undefined
119
+ try {
120
+ const requestedCwd =
121
+ spec.cwd === undefined
122
+ ? this.options.workspace?.root
123
+ : this.options.workspace === undefined
124
+ ? resolve(spec.cwd)
125
+ : resolve(this.options.workspace.root, spec.cwd)
126
+ if (requestedCwd !== undefined && this.options.workspace !== undefined) {
127
+ if (!within(this.options.workspace.root, requestedCwd))
128
+ return {
129
+ id: spec.id,
130
+ role: spec.role,
131
+ ok: false,
132
+ error: "subagent workspace escapes policy root",
133
+ }
134
+ const allowedRoots = this.options.workspace.allowedRoots ?? [this.options.workspace.root]
135
+ if (!allowedRoots.some((root) => within(root, requestedCwd)))
136
+ return {
137
+ id: spec.id,
138
+ role: spec.role,
139
+ ok: false,
140
+ error: "subagent workspace is not in an allowed root",
141
+ }
142
+ }
143
+ if (this.options.workspace?.isolatedWorktree !== undefined) {
144
+ workspace = await this.options.workspace.isolatedWorktree(spec, controller.signal)
145
+ } else if (requestedCwd !== undefined) {
146
+ workspace = { cwd: requestedCwd }
147
+ }
148
+ if (workspace !== undefined && this.options.workspace !== undefined) {
149
+ const allowedRoots = this.options.workspace.allowedRoots ?? [this.options.workspace.root]
150
+ if (
151
+ !within(this.options.workspace.root, workspace.cwd) ||
152
+ !allowedRoots.some((root) => within(root, workspace!.cwd))
153
+ )
154
+ return {
155
+ id: spec.id,
156
+ role: spec.role,
157
+ ok: false,
158
+ error: "isolated worktree escapes workspace policy",
159
+ }
160
+ }
161
+ const output = await this.executor.run({
162
+ spec,
163
+ signal: controller.signal,
164
+ ...(workspace === undefined ? {} : { cwd: workspace.cwd }),
165
+ })
166
+ return { id: spec.id, role: spec.role, ok: true, output }
167
+ } catch (error) {
168
+ return {
169
+ id: spec.id,
170
+ role: spec.role,
171
+ ok: false,
172
+ error: error instanceof Error ? error.message : String(error),
173
+ }
174
+ } finally {
175
+ await workspace?.cleanup?.()
176
+ if (timeout !== undefined) clearTimeout(timeout)
177
+ this.options.signal?.removeEventListener("abort", onAbort)
178
+ }
179
+ }
180
+
181
+ async runMany(
182
+ specs: readonly SubagentSpec[],
183
+ maxConcurrency = this.options.maxChildren,
184
+ ): Promise<readonly SubagentResult[]> {
185
+ if (specs.length > this.options.maxChildren) throw new Error("subagent child limit exceeded")
186
+ if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1)
187
+ throw new RangeError("subagents: maxConcurrency must be positive")
188
+ const results: SubagentResult[] = new Array(specs.length)
189
+ let next = 0
190
+ const worker = async (): Promise<void> => {
191
+ for (;;) {
192
+ const index = next++
193
+ if (index >= specs.length) return
194
+ results[index] = await this.run(specs[index]!)
195
+ }
196
+ }
197
+ await Promise.all(
198
+ Array.from({ length: Math.min(maxConcurrency, specs.length) }, () => worker()),
199
+ )
200
+ return results
201
+ }
202
+ }
203
+
204
+ function within(root: string, candidate: string): boolean {
205
+ const relativePath = relative(resolve(root), resolve(candidate))
206
+ return (
207
+ relativePath === "" ||
208
+ (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !relativePath.startsWith("/"))
209
+ )
210
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { type NifraContextOptions, type NifraContextResult, runNifraContext } from "./context.ts"
2
+ import {
3
+ runNifraVerification,
4
+ type VerificationOptions,
5
+ type VerificationResult,
6
+ } from "./verification.ts"
7
+
8
+ export interface NifraAgentTool {
9
+ readonly name: "nifra.context" | "nifra.check" | "nifra.assure" | "nifra.test"
10
+ readonly description: string
11
+ readonly capability: "process"
12
+ execute(): Promise<NifraContextResult | VerificationResult>
13
+ }
14
+
15
+ /** Short, provider-neutral instructions injected into Pi only when the optional agent is used. */
16
+ export const NIFRA_AGENT_INSTRUCTIONS = `
17
+ You are operating inside a Nifra project. Before making framework changes, use \`nifra context\` when available to inspect the current typed project surfaces. After changes, run \`nifra check --json\` and, when assurance is configured, \`nifra assure --json\`. Treat failed checks as structured repair work. Keep changes scoped, preserve existing user edits, and never expose secrets in responses or session notes.
18
+ `.trim()
19
+
20
+ /** Optional first-party tool descriptors. The Pi adapter can register these through an extension. */
21
+ export function createNifraTools(
22
+ options: NifraContextOptions & Pick<VerificationOptions, "command">,
23
+ ): readonly NifraAgentTool[] {
24
+ const verification = (name: "check" | "assure" | "test"): Promise<VerificationResult> =>
25
+ runNifraVerification(name, options)
26
+ return Object.freeze([
27
+ {
28
+ name: "nifra.context",
29
+ description: "Read the current Nifra project context and machine-readable agent surfaces.",
30
+ capability: "process",
31
+ execute: () => runNifraContext(options),
32
+ },
33
+ {
34
+ name: "nifra.check",
35
+ description: "Run the Nifra typed contract check and return structured diagnostics.",
36
+ capability: "process",
37
+ execute: () => verification("check"),
38
+ },
39
+ {
40
+ name: "nifra.assure",
41
+ description: "Run Nifra capability assurance and return structured diagnostics.",
42
+ capability: "process",
43
+ execute: () => verification("assure"),
44
+ },
45
+ {
46
+ name: "nifra.test",
47
+ description: "Run the configured Nifra test command with bounded output.",
48
+ capability: "process",
49
+ execute: () => verification("test"),
50
+ },
51
+ ])
52
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { publicErrorMessage } from "./errors.ts"
2
+
3
+ export type UiExtensionSlot = "sidebar" | "main" | "timeline" | "diff" | "workflow" | "status"
4
+
5
+ export interface UiThemeDescriptor {
6
+ readonly name: string
7
+ readonly accent?: string
8
+ readonly density?: "compact" | "comfortable"
9
+ }
10
+
11
+ export interface UiStatusWidget {
12
+ readonly text: string
13
+ readonly tone?: "neutral" | "positive" | "warning" | "danger"
14
+ }
15
+
16
+ export interface UiExtensionManifest {
17
+ readonly id: string
18
+ readonly revision: string
19
+ readonly slot: UiExtensionSlot
20
+ readonly label: string
21
+ readonly capabilities?: readonly string[]
22
+ readonly theme?: UiThemeDescriptor
23
+ readonly status?: UiStatusWidget
24
+ }
25
+
26
+ export interface UiExtensionHostOptions {
27
+ readonly trustedCapabilities?: readonly string[]
28
+ readonly maxExtensions?: number
29
+ }
30
+
31
+ export interface UiReloadResult {
32
+ readonly revision: string
33
+ readonly active: readonly UiExtensionManifest[]
34
+ readonly rolledBack: boolean
35
+ readonly error?: string
36
+ }
37
+
38
+ /** Data-only UI extension registry. The stable Workbench shell owns rendering and approval UX. */
39
+ export class UiExtensionHost {
40
+ private readonly options: Required<Pick<UiExtensionHostOptions, "maxExtensions">> &
41
+ UiExtensionHostOptions
42
+ private active: readonly UiExtensionManifest[] = []
43
+ private revision = "0"
44
+
45
+ constructor(options: UiExtensionHostOptions = {}) {
46
+ this.options = { ...options, maxExtensions: options.maxExtensions ?? 32 }
47
+ if (!Number.isSafeInteger(this.options.maxExtensions) || this.options.maxExtensions < 1)
48
+ throw new RangeError("ui extensions: maxExtensions must be positive")
49
+ }
50
+
51
+ get extensions(): readonly UiExtensionManifest[] {
52
+ return this.active
53
+ }
54
+ get currentRevision(): string {
55
+ return this.revision
56
+ }
57
+
58
+ /** Validate a candidate graph without changing the active shell-owned graph. */
59
+ preview(manifests: readonly UiExtensionManifest[]): UiReloadResult {
60
+ try {
61
+ const active = validateManifests(manifests, this.options)
62
+ return {
63
+ revision: `preview:${this.revision}`,
64
+ active,
65
+ rolledBack: false,
66
+ }
67
+ } catch (error) {
68
+ return {
69
+ revision: this.revision,
70
+ active: this.active,
71
+ rolledBack: true,
72
+ error: publicErrorMessage(error, "UI extension preview failed"),
73
+ }
74
+ }
75
+ }
76
+
77
+ reload(manifests: readonly UiExtensionManifest[]): UiReloadResult {
78
+ try {
79
+ const active = validateManifests(manifests, this.options)
80
+ this.active = active
81
+ this.revision = `${Date.now().toString(36)}-${this.active.length}`
82
+ return { revision: this.revision, active: this.active, rolledBack: false }
83
+ } catch (error) {
84
+ return {
85
+ revision: this.revision,
86
+ active: this.active,
87
+ rolledBack: true,
88
+ error: publicErrorMessage(error, "UI extension reload failed"),
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ function validateManifests(
95
+ manifests: readonly UiExtensionManifest[],
96
+ options: UiExtensionHostOptions & { readonly maxExtensions: number },
97
+ ): readonly UiExtensionManifest[] {
98
+ if (manifests.length > options.maxExtensions) throw new Error("ui extension limit exceeded")
99
+ const trusted = new Set(options.trustedCapabilities ?? [])
100
+ const ids = new Set<string>()
101
+ return Object.freeze(
102
+ manifests.map((manifest) => {
103
+ if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(manifest.id))
104
+ throw new Error(`invalid UI extension id: ${manifest.id}`)
105
+ if (!/^[a-zA-Z0-9._:-]{1,64}$/.test(manifest.revision))
106
+ throw new Error(`invalid UI extension revision: ${manifest.id}`)
107
+ if (!manifest.label || manifest.label.length > 120)
108
+ throw new Error(`invalid UI extension label: ${manifest.id}`)
109
+ if (manifest.theme !== undefined) {
110
+ if (!/^[a-z][a-z0-9._-]{0,31}$/.test(manifest.theme.name))
111
+ throw new Error(`invalid UI theme: ${manifest.id}`)
112
+ if (manifest.theme.accent !== undefined && !/^#[0-9a-fA-F]{6}$/.test(manifest.theme.accent))
113
+ throw new Error(`invalid UI theme accent: ${manifest.id}`)
114
+ }
115
+ if (manifest.status !== undefined) {
116
+ if (!manifest.status.text || manifest.status.text.length > 160)
117
+ throw new Error(`invalid UI status widget: ${manifest.id}`)
118
+ if (
119
+ manifest.status.tone !== undefined &&
120
+ !["neutral", "positive", "warning", "danger"].includes(manifest.status.tone)
121
+ )
122
+ throw new Error(`invalid UI status tone: ${manifest.id}`)
123
+ }
124
+ if (ids.has(manifest.id)) throw new Error(`duplicate UI extension: ${manifest.id}`)
125
+ ids.add(manifest.id)
126
+ const denied = (manifest.capabilities ?? []).find((capability) => !trusted.has(capability))
127
+ if (denied !== undefined)
128
+ throw new Error(`UI extension ${manifest.id} requires untrusted capability: ${denied}`)
129
+ return Object.freeze({
130
+ ...manifest,
131
+ capabilities: Object.freeze([...(manifest.capabilities ?? [])]),
132
+ ...(manifest.theme === undefined ? {} : { theme: Object.freeze({ ...manifest.theme }) }),
133
+ ...(manifest.status === undefined ? {} : { status: Object.freeze({ ...manifest.status }) }),
134
+ })
135
+ }),
136
+ )
137
+ }
@@ -0,0 +1,133 @@
1
+ import { publicErrorDetails } from "./errors.ts"
2
+ import { readBoundedText } from "./process.ts"
3
+
4
+ export interface VerificationResult {
5
+ readonly name: "check" | "assure" | "test"
6
+ readonly ok: boolean
7
+ readonly status: number | null
8
+ readonly report?: unknown
9
+ readonly output?: string
10
+ readonly error?: string
11
+ readonly stack?: string
12
+ }
13
+
14
+ export interface VerificationRepairTask {
15
+ readonly id: string
16
+ readonly verification: VerificationResult["name"]
17
+ readonly cwd: string
18
+ readonly reason: string
19
+ readonly capabilities: readonly string[]
20
+ readonly output?: string
21
+ readonly report?: unknown
22
+ readonly stack?: string
23
+ }
24
+
25
+ /** Turn a failed gate into a bounded, auditable repair task for the agent loop. */
26
+ export function createVerificationRepairTask(
27
+ result: VerificationResult,
28
+ cwd: string,
29
+ ): VerificationRepairTask | undefined {
30
+ if (result.ok) return undefined
31
+ return Object.freeze({
32
+ id: `verification-${result.name}-${Date.now().toString(36)}`,
33
+ verification: result.name,
34
+ cwd,
35
+ reason:
36
+ result.error ??
37
+ `${result.name} failed${result.status === null ? "" : ` with exit ${result.status}`}`,
38
+ capabilities: Object.freeze(["process", "read", "write"]),
39
+ ...(result.output === undefined ? {} : { output: result.output.slice(0, 32_768) }),
40
+ ...(result.report === undefined ? {} : { report: result.report }),
41
+ ...(result.stack === undefined ? {} : { stack: result.stack }),
42
+ })
43
+ }
44
+
45
+ export interface VerificationOptions {
46
+ readonly cwd: string
47
+ readonly command?: string
48
+ /** Arguments placed before the gate name; useful for portable interpreter-backed test commands. */
49
+ readonly commandArgs?: readonly string[]
50
+ readonly timeoutMs?: number
51
+ readonly maxOutputBytes?: number
52
+ readonly env?: Readonly<Record<string, string | undefined>>
53
+ readonly exposeErrorStacks?: boolean
54
+ }
55
+
56
+ /** Run an existing Nifra gate without importing the large framework CLI into the agent runtime. */
57
+ export async function runNifraVerification(
58
+ name: "check" | "assure" | "test",
59
+ options: VerificationOptions,
60
+ ): Promise<VerificationResult> {
61
+ const command = options.command ?? "nifra"
62
+ const commandArgs = options.commandArgs ?? []
63
+ const args = name === "test" ? ["test"] : [name, "--json"]
64
+ const proc = Bun.spawn([command, ...commandArgs, ...args], {
65
+ cwd: options.cwd,
66
+ stdin: "ignore",
67
+ stdout: "pipe",
68
+ stderr: "pipe",
69
+ env: safeEnv(options.env),
70
+ })
71
+ const timeoutMs = options.timeoutMs ?? 120_000
72
+ let timedOut = false
73
+ const timer = setTimeout(() => {
74
+ timedOut = true
75
+ proc.kill()
76
+ }, timeoutMs)
77
+ try {
78
+ const [stdout, stderr, status] = await Promise.all([
79
+ readBoundedText(proc.stdout, options.maxOutputBytes ?? 1_048_576),
80
+ readBoundedText(proc.stderr, options.maxOutputBytes ?? 1_048_576),
81
+ proc.exited,
82
+ ])
83
+ const output = stdout.text || stderr.text
84
+ let report: unknown
85
+ try {
86
+ report = JSON.parse(stdout.text)
87
+ } catch {
88
+ report = undefined
89
+ }
90
+ return {
91
+ name,
92
+ ok: !timedOut && status === 0,
93
+ status,
94
+ ...(report === undefined ? {} : { report }),
95
+ ...(output.length === 0 ? {} : { output }),
96
+ ...(timedOut ? { error: `nifra ${name} timed out after ${timeoutMs}ms` } : {}),
97
+ }
98
+ } catch (error) {
99
+ const details = publicErrorDetails(
100
+ error,
101
+ "verification failed",
102
+ options.exposeErrorStacks === true,
103
+ )
104
+ return {
105
+ name,
106
+ ok: false,
107
+ status: null,
108
+ error: details.message,
109
+ ...(details.stack === undefined ? {} : { stack: details.stack }),
110
+ }
111
+ } finally {
112
+ clearTimeout(timer)
113
+ }
114
+ }
115
+
116
+ function safeEnv(
117
+ overrides: Readonly<Record<string, string | undefined>> | undefined,
118
+ ): Record<string, string> {
119
+ const result: Record<string, string> = {}
120
+ for (const name of new Set([
121
+ "PATH",
122
+ "HOME",
123
+ "LANG",
124
+ "LC_ALL",
125
+ "TERM",
126
+ "CI",
127
+ ...Object.keys(overrides ?? {}),
128
+ ])) {
129
+ const value = overrides?.[name] ?? process.env[name]
130
+ if (value !== undefined) result[name] = value
131
+ }
132
+ return result
133
+ }