@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.
- package/EXTENSIONS.md +25 -0
- package/PERFORMANCE.md +19 -0
- package/README.md +69 -0
- package/REGISTRY.md +15 -0
- package/SECURITY.md +32 -0
- package/dist/approvals.d.ts +70 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +174 -0
- package/dist/approvals.js.map +1 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +49 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +380 -0
- package/dist/cli.js.map +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +66 -0
- package/dist/context.js.map +1 -0
- package/dist/deployment-adapters.d.ts +40 -0
- package/dist/deployment-adapters.d.ts.map +1 -0
- package/dist/deployment-adapters.js +147 -0
- package/dist/deployment-adapters.js.map +1 -0
- package/dist/diff.d.ts +17 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +61 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/extensions.d.ts +104 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +284 -0
- package/dist/extensions.js.map +1 -0
- package/dist/handoffs.d.ts +97 -0
- package/dist/handoffs.d.ts.map +1 -0
- package/dist/handoffs.js +199 -0
- package/dist/handoffs.js.map +1 -0
- package/dist/healing.d.ts +56 -0
- package/dist/healing.d.ts.map +1 -0
- package/dist/healing.js +54 -0
- package/dist/healing.js.map +1 -0
- package/dist/host.d.ts +61 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +369 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/isolated-worker.d.ts +2 -0
- package/dist/isolated-worker.d.ts.map +1 -0
- package/dist/isolated-worker.js +155 -0
- package/dist/isolated-worker.js.map +1 -0
- package/dist/isolated.d.ts +52 -0
- package/dist/isolated.d.ts.map +1 -0
- package/dist/isolated.js +293 -0
- package/dist/isolated.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +339 -0
- package/dist/native.js.map +1 -0
- package/dist/orchestration/artifact-port.d.ts +30 -0
- package/dist/orchestration/artifact-port.d.ts.map +1 -0
- package/dist/orchestration/artifact-port.js +59 -0
- package/dist/orchestration/artifact-port.js.map +1 -0
- package/dist/orchestration/catalog.d.ts +71 -0
- package/dist/orchestration/catalog.d.ts.map +1 -0
- package/dist/orchestration/catalog.js +46 -0
- package/dist/orchestration/catalog.js.map +1 -0
- package/dist/orchestration/compile.d.ts +59 -0
- package/dist/orchestration/compile.d.ts.map +1 -0
- package/dist/orchestration/compile.js +268 -0
- package/dist/orchestration/compile.js.map +1 -0
- package/dist/orchestration/dispatch.d.ts +115 -0
- package/dist/orchestration/dispatch.d.ts.map +1 -0
- package/dist/orchestration/dispatch.js +220 -0
- package/dist/orchestration/dispatch.js.map +1 -0
- package/dist/orchestration/durable-jobs.d.ts +35 -0
- package/dist/orchestration/durable-jobs.d.ts.map +1 -0
- package/dist/orchestration/durable-jobs.js +273 -0
- package/dist/orchestration/durable-jobs.js.map +1 -0
- package/dist/orchestration/effect-key.d.ts +23 -0
- package/dist/orchestration/effect-key.d.ts.map +1 -0
- package/dist/orchestration/effect-key.js +24 -0
- package/dist/orchestration/effect-key.js.map +1 -0
- package/dist/orchestration/evidence-store.d.ts +75 -0
- package/dist/orchestration/evidence-store.d.ts.map +1 -0
- package/dist/orchestration/evidence-store.js +183 -0
- package/dist/orchestration/evidence-store.js.map +1 -0
- package/dist/orchestration/hash.d.ts +12 -0
- package/dist/orchestration/hash.d.ts.map +1 -0
- package/dist/orchestration/hash.js +43 -0
- package/dist/orchestration/hash.js.map +1 -0
- package/dist/orchestration/host.d.ts +121 -0
- package/dist/orchestration/host.d.ts.map +1 -0
- package/dist/orchestration/host.js +334 -0
- package/dist/orchestration/host.js.map +1 -0
- package/dist/orchestration/index.d.ts +20 -0
- package/dist/orchestration/index.d.ts.map +1 -0
- package/dist/orchestration/index.js +19 -0
- package/dist/orchestration/index.js.map +1 -0
- package/dist/orchestration/policy.d.ts +63 -0
- package/dist/orchestration/policy.d.ts.map +1 -0
- package/dist/orchestration/policy.js +80 -0
- package/dist/orchestration/policy.js.map +1 -0
- package/dist/orchestration/recovery.d.ts +87 -0
- package/dist/orchestration/recovery.d.ts.map +1 -0
- package/dist/orchestration/recovery.js +197 -0
- package/dist/orchestration/recovery.js.map +1 -0
- package/dist/orchestration/tracer.d.ts +31 -0
- package/dist/orchestration/tracer.d.ts.map +1 -0
- package/dist/orchestration/tracer.js +141 -0
- package/dist/orchestration/tracer.js.map +1 -0
- package/dist/plans.d.ts +35 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +63 -0
- package/dist/plans.js.map +1 -0
- package/dist/presets.d.ts +10 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +70 -0
- package/dist/presets.js.map +1 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +42 -0
- package/dist/process.js.map +1 -0
- package/dist/registry.d.ts +37 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +51 -0
- package/dist/registry.js.map +1 -0
- package/dist/replay.d.ts +24 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +154 -0
- package/dist/replay.js.map +1 -0
- package/dist/rpc.d.ts +51 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +412 -0
- package/dist/rpc.js.map +1 -0
- package/dist/session-migration.d.ts +59 -0
- package/dist/session-migration.d.ts.map +1 -0
- package/dist/session-migration.js +315 -0
- package/dist/session-migration.js.map +1 -0
- package/dist/sessions.d.ts +74 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +222 -0
- package/dist/sessions.js.map +1 -0
- package/dist/subagents.d.ts +51 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +141 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +13 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +37 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui.d.ts +42 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +93 -0
- package/dist/ui.js.map +1 -0
- package/dist/verification.d.ts +34 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +91 -0
- package/dist/verification.js.map +1 -0
- package/dist/workflows.d.ts +98 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +174 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +84 -0
- package/src/approvals.ts +249 -0
- package/src/capabilities.ts +74 -0
- package/src/cli.ts +405 -0
- package/src/context.ts +81 -0
- package/src/deployment-adapters.ts +181 -0
- package/src/diff.ts +80 -0
- package/src/errors.ts +46 -0
- package/src/extensions.ts +402 -0
- package/src/handoffs.ts +299 -0
- package/src/healing.ts +93 -0
- package/src/host.ts +465 -0
- package/src/index.ts +165 -0
- package/src/isolated-worker.ts +177 -0
- package/src/isolated.ts +344 -0
- package/src/native.ts +478 -0
- package/src/orchestration/artifact-port.ts +75 -0
- package/src/orchestration/catalog.ts +102 -0
- package/src/orchestration/compile.ts +373 -0
- package/src/orchestration/dispatch.ts +383 -0
- package/src/orchestration/durable-jobs.ts +374 -0
- package/src/orchestration/effect-key.ts +35 -0
- package/src/orchestration/evidence-store.ts +233 -0
- package/src/orchestration/hash.ts +43 -0
- package/src/orchestration/host.ts +445 -0
- package/src/orchestration/index.ts +131 -0
- package/src/orchestration/policy.ts +116 -0
- package/src/orchestration/recovery.ts +271 -0
- package/src/orchestration/tracer.ts +177 -0
- package/src/plans.ts +105 -0
- package/src/presets.ts +90 -0
- package/src/process.ts +47 -0
- package/src/registry.ts +80 -0
- package/src/replay.ts +190 -0
- package/src/rpc.ts +598 -0
- package/src/session-migration.ts +401 -0
- package/src/sessions.ts +303 -0
- package/src/subagents.ts +210 -0
- package/src/tools.ts +52 -0
- package/src/ui.ts +137 -0
- package/src/verification.ts +133 -0
- package/src/workflows.ts +279 -0
package/src/subagents.ts
ADDED
|
@@ -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
|
+
}
|