@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,181 @@
1
+ /**
2
+ * Truthful public deployment reference profiles.
3
+ *
4
+ * These adapters are deterministic lifecycle fixtures. They do not start a process, connect to a
5
+ * network, distribute secrets, or isolate hostile code. A real deployment implementation belongs
6
+ * behind the public `AgentDeploymentAdapter` contract and must provide its own security proof.
7
+ */
8
+
9
+ import {
10
+ type AgentDeploymentAdapter,
11
+ type DeploymentCancelRequest,
12
+ type DeploymentCancelResult,
13
+ type DeploymentCapabilityReport,
14
+ type DeploymentDisposeRequest,
15
+ type DeploymentDisposeResult,
16
+ DeploymentError,
17
+ type DeploymentInspection,
18
+ type DeploymentInspectRequest,
19
+ type DeploymentPrepareRequest,
20
+ type DeploymentPrepareResult,
21
+ type DeploymentStartRequest,
22
+ type DeploymentStartResult,
23
+ parseDeploymentCapabilityReport,
24
+ } from "@nifrajs/agent"
25
+
26
+ type ReferenceState = "prepared" | "running" | "cancelled" | "disposed"
27
+
28
+ const LOCAL_PROCESS_CAPABILITIES: DeploymentCapabilityReport = Object.freeze({
29
+ schemaVersion: 1,
30
+ adapterId: "local-process",
31
+ capabilities: Object.freeze({
32
+ runtime: "local",
33
+ network: "none",
34
+ filesystem: "workspace",
35
+ process: "child",
36
+ secrets: "none",
37
+ workspace: Object.freeze({ mode: "scoped", maxBytes: 10_000_000_000 }),
38
+ cancellation: "cooperative",
39
+ hostileCodeIsolation: "none",
40
+ }),
41
+ limitations: Object.freeze(["not-a-sandbox", "trusted-local"]),
42
+ })
43
+
44
+ const CI_CAPABILITIES: DeploymentCapabilityReport = Object.freeze({
45
+ schemaVersion: 1,
46
+ adapterId: "ci",
47
+ capabilities: Object.freeze({
48
+ runtime: "ci",
49
+ network: "none",
50
+ filesystem: "workspace",
51
+ process: "child",
52
+ secrets: "none",
53
+ workspace: Object.freeze({ mode: "scoped", maxBytes: 10_000_000_000 }),
54
+ cancellation: "cooperative",
55
+ hostileCodeIsolation: "none",
56
+ }),
57
+ limitations: Object.freeze(["not-a-sandbox", "ephemeral"]),
58
+ })
59
+
60
+ const REPLAY_CAPABILITIES: DeploymentCapabilityReport = Object.freeze({
61
+ schemaVersion: 1,
62
+ adapterId: "replay",
63
+ capabilities: Object.freeze({
64
+ runtime: "replay",
65
+ network: "none",
66
+ filesystem: "none",
67
+ process: "none",
68
+ secrets: "none",
69
+ workspace: Object.freeze({ mode: "none", maxBytes: 0 }),
70
+ cancellation: "cooperative",
71
+ hostileCodeIsolation: "none",
72
+ }),
73
+ limitations: Object.freeze(["not-a-sandbox", "no-side-effects"]),
74
+ })
75
+
76
+ abstract class DeterministicDeploymentAdapter implements AgentDeploymentAdapter {
77
+ readonly id: string
78
+ private readonly report: DeploymentCapabilityReport
79
+ private readonly states = new Map<string, ReferenceState>()
80
+
81
+ protected constructor(report: DeploymentCapabilityReport) {
82
+ this.report = parseDeploymentCapabilityReport(report)
83
+ this.id = this.report.adapterId
84
+ }
85
+
86
+ capabilityReport(): DeploymentCapabilityReport {
87
+ return this.report
88
+ }
89
+
90
+ prepare(request: DeploymentPrepareRequest): DeploymentPrepareResult {
91
+ this.assertActive(request.signal)
92
+ const deploymentId = request.plan.deploymentId
93
+ if (this.states.has(deploymentId)) throw new DeploymentError("invalid_transition")
94
+ this.states.set(deploymentId, "prepared")
95
+ return Object.freeze({
96
+ deploymentId,
97
+ state: "prepared",
98
+ preparedRef: `${deploymentId}:prepared`,
99
+ })
100
+ }
101
+
102
+ start(request: DeploymentStartRequest): DeploymentStartResult {
103
+ this.assertActive(request.signal)
104
+ if (
105
+ this.states.get(request.deploymentId) !== "prepared" ||
106
+ request.preparedRef !== `${request.deploymentId}:prepared`
107
+ )
108
+ throw new DeploymentError("invalid_transition")
109
+ this.states.set(request.deploymentId, "running")
110
+ return Object.freeze({
111
+ deploymentId: request.deploymentId,
112
+ state: "running",
113
+ handleRef: `${request.deploymentId}:handle`,
114
+ })
115
+ }
116
+
117
+ inspect(request: DeploymentInspectRequest): DeploymentInspection {
118
+ this.assertActive(request.signal)
119
+ const state = this.states.get(request.deploymentId)
120
+ if (state === undefined) throw new DeploymentError("invalid_transition")
121
+ return Object.freeze({
122
+ deploymentId: request.deploymentId,
123
+ state,
124
+ ...(state === "running" ? { handleRef: `${request.deploymentId}:handle` } : {}),
125
+ })
126
+ }
127
+
128
+ cancel(request: DeploymentCancelRequest): DeploymentCancelResult {
129
+ this.assertActive(request.signal)
130
+ const state = this.states.get(request.deploymentId)
131
+ if (state !== "prepared" && state !== "running") throw new DeploymentError("invalid_transition")
132
+ this.states.set(request.deploymentId, "cancelled")
133
+ return Object.freeze({ deploymentId: request.deploymentId, state: "cancelled" })
134
+ }
135
+
136
+ dispose(request: DeploymentDisposeRequest): DeploymentDisposeResult {
137
+ this.assertActive(request.signal)
138
+ this.states.set(request.deploymentId, "disposed")
139
+ return Object.freeze({ deploymentId: request.deploymentId, state: "disposed" })
140
+ }
141
+
142
+ private assertActive(signal: AbortSignal): void {
143
+ if (signal.aborted) throw new DeploymentError("cancelled")
144
+ }
145
+ }
146
+
147
+ export class LocalProcessDeploymentAdapter extends DeterministicDeploymentAdapter {
148
+ constructor() {
149
+ super(LOCAL_PROCESS_CAPABILITIES)
150
+ }
151
+ }
152
+
153
+ export class CiDeploymentAdapter extends DeterministicDeploymentAdapter {
154
+ constructor() {
155
+ super(CI_CAPABILITIES)
156
+ }
157
+ }
158
+
159
+ export class ReplayDeploymentAdapter extends DeterministicDeploymentAdapter {
160
+ constructor() {
161
+ super(REPLAY_CAPABILITIES)
162
+ }
163
+ }
164
+
165
+ export function createLocalProcessDeploymentAdapter(): LocalProcessDeploymentAdapter {
166
+ return new LocalProcessDeploymentAdapter()
167
+ }
168
+
169
+ export function createCiDeploymentAdapter(): CiDeploymentAdapter {
170
+ return new CiDeploymentAdapter()
171
+ }
172
+
173
+ export function createReplayDeploymentAdapter(): ReplayDeploymentAdapter {
174
+ return new ReplayDeploymentAdapter()
175
+ }
176
+
177
+ export const DEPLOYMENT_REFERENCE_PROFILES = Object.freeze({
178
+ localProcess: LOCAL_PROCESS_CAPABILITIES,
179
+ ci: CI_CAPABILITIES,
180
+ replay: REPLAY_CAPABILITIES,
181
+ })
package/src/diff.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { publicErrorDetails } from "./errors.ts"
2
+ import { readBoundedText } from "./process.ts"
3
+
4
+ export interface ProjectDiffOptions {
5
+ readonly cwd: string
6
+ readonly timeoutMs?: number
7
+ readonly maxOutputBytes?: number
8
+ readonly exposeErrorStacks?: boolean
9
+ }
10
+
11
+ export interface ProjectDiffResult {
12
+ readonly ok: boolean
13
+ readonly status: number | null
14
+ readonly output?: string
15
+ readonly truncated?: boolean
16
+ readonly error?: string
17
+ readonly stack?: string
18
+ }
19
+
20
+ /** Read a bounded, non-interactive git diff for review surfaces. No user-supplied git arguments are accepted. */
21
+ export async function readProjectDiff(options: ProjectDiffOptions): Promise<ProjectDiffResult> {
22
+ const timeoutMs = options.timeoutMs ?? 30_000
23
+ const maxOutputBytes = options.maxOutputBytes ?? 512 * 1024
24
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10 * 60_000)
25
+ throw new RangeError("project diff: timeoutMs is invalid")
26
+ if (
27
+ !Number.isSafeInteger(maxOutputBytes) ||
28
+ maxOutputBytes < 1024 ||
29
+ maxOutputBytes > 16 * 1024 * 1024
30
+ )
31
+ throw new RangeError("project diff: maxOutputBytes is invalid")
32
+ const proc = Bun.spawn(
33
+ ["git", "--no-pager", "diff", "HEAD", "--no-ext-diff", "--unified=3", "--"],
34
+ {
35
+ cwd: options.cwd,
36
+ stdin: "ignore",
37
+ stdout: "pipe",
38
+ stderr: "pipe",
39
+ env: safeEnv(),
40
+ },
41
+ )
42
+ let timedOut = false
43
+ const timer = setTimeout(() => {
44
+ timedOut = true
45
+ proc.kill()
46
+ }, timeoutMs)
47
+ try {
48
+ const [stdout, stderr, status] = await Promise.all([
49
+ readBoundedText(proc.stdout, maxOutputBytes),
50
+ readBoundedText(proc.stderr, Math.min(maxOutputBytes, 64 * 1024)),
51
+ proc.exited,
52
+ ])
53
+ return {
54
+ ok: !timedOut && status === 0,
55
+ status,
56
+ output: stdout.text.length > 0 ? stdout.text : stderr.text,
57
+ ...(stdout.truncated ? { truncated: true } : {}),
58
+ ...(timedOut ? { error: `git diff timed out after ${timeoutMs}ms` } : {}),
59
+ }
60
+ } catch (error) {
61
+ const details = publicErrorDetails(error, "git diff failed", options.exposeErrorStacks === true)
62
+ return {
63
+ ok: false,
64
+ status: null,
65
+ error: details.message,
66
+ ...(details.stack === undefined ? {} : { stack: details.stack }),
67
+ }
68
+ } finally {
69
+ clearTimeout(timer)
70
+ }
71
+ }
72
+
73
+ function safeEnv(): Record<string, string> {
74
+ const result: Record<string, string> = {}
75
+ for (const name of ["PATH", "HOME", "LANG", "LC_ALL", "TERM", "GIT_PAGER"]) {
76
+ const value = name === "GIT_PAGER" ? "cat" : process.env[name]
77
+ if (value !== undefined) result[name] = value
78
+ }
79
+ return result
80
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,46 @@
1
+ const MAX_PUBLIC_ERROR_LENGTH = 4096
2
+ const MAX_PUBLIC_STACK_LENGTH = 16 * 1024
3
+
4
+ export interface PublicErrorDetails {
5
+ readonly message: string
6
+ readonly stack?: string
7
+ }
8
+
9
+ /** Return the useful diagnostic without serializing an Error object or its stack. */
10
+ export function publicErrorMessage(error: unknown, fallback: string): string {
11
+ const message =
12
+ error instanceof Error
13
+ ? error.message
14
+ : typeof error === "string"
15
+ ? error
16
+ : error !== null && typeof error === "object" && "message" in error
17
+ ? (error as { readonly message?: unknown }).message
18
+ : undefined
19
+ if (typeof message !== "string" || message.length === 0) return fallback
20
+ return message.length > MAX_PUBLIC_ERROR_LENGTH
21
+ ? `${message.slice(0, MAX_PUBLIC_ERROR_LENGTH)}…`
22
+ : message
23
+ }
24
+
25
+ /** Add a bounded stack only for an explicitly trusted local diagnostics channel. */
26
+ export function publicErrorDetails(
27
+ error: unknown,
28
+ fallback: string,
29
+ includeStack = false,
30
+ ): PublicErrorDetails {
31
+ const message = publicErrorMessage(error, fallback)
32
+ if (!includeStack || !(error instanceof Error) || typeof error.stack !== "string")
33
+ return { message }
34
+ const stack = error.stack
35
+ return {
36
+ message,
37
+ ...(stack.length === 0
38
+ ? {}
39
+ : {
40
+ stack:
41
+ stack.length > MAX_PUBLIC_STACK_LENGTH
42
+ ? `${stack.slice(0, MAX_PUBLIC_STACK_LENGTH)}…`
43
+ : stack,
44
+ }),
45
+ }
46
+ }
@@ -0,0 +1,402 @@
1
+ import { existsSync, realpathSync } from "node:fs"
2
+ import { join, relative, resolve } from "node:path"
3
+ import {
4
+ type WorkflowEvent,
5
+ type WorkflowResult,
6
+ WorkflowRunner,
7
+ type WorkflowStep,
8
+ } from "./workflows.ts"
9
+
10
+ export interface ExtensionContext {
11
+ readonly cwd: string
12
+ readonly registerCommand: (name: string, handler: ExtensionCommand) => void
13
+ readonly registerTool: (tool: ExtensionTool) => void
14
+ readonly registerWorkflow: (name: string, workflow: ExtensionWorkflow) => void
15
+ readonly registerSubagent: (role: ExtensionSubagent) => void
16
+ readonly registerProvider: (provider: ExtensionProvider) => void
17
+ readonly on: (event: string, handler: ExtensionEventHandler) => void
18
+ }
19
+
20
+ export type ExtensionCommand = (
21
+ args: string,
22
+ context: ExtensionContext,
23
+ ) => unknown | PromiseLike<unknown>
24
+
25
+ export interface ExtensionTool {
26
+ readonly name: string
27
+ readonly description: string
28
+ readonly capabilities?: readonly string[]
29
+ readonly execute: (input: unknown, context: ExtensionContext) => unknown | PromiseLike<unknown>
30
+ }
31
+
32
+ export interface ExtensionSubagent {
33
+ readonly name: string
34
+ readonly description: string
35
+ readonly prompt: string
36
+ readonly capabilities?: readonly string[]
37
+ readonly maxDepth?: number
38
+ }
39
+
40
+ export interface ExtensionProvider {
41
+ readonly name: string
42
+ readonly description: string
43
+ readonly capabilities?: readonly string[]
44
+ }
45
+
46
+ export type ExtensionEventHandler = (
47
+ payload: unknown,
48
+ context: ExtensionContext,
49
+ ) => unknown | PromiseLike<unknown>
50
+
51
+ /** A workflow factory keeps orchestration lazy and lets every extension fully customize its steps. */
52
+ export type ExtensionWorkflow = (
53
+ context: ExtensionContext,
54
+ ) => WorkflowStep | PromiseLike<WorkflowStep>
55
+
56
+ export interface WorkflowRunOptions {
57
+ readonly signal?: AbortSignal
58
+ readonly maxSteps?: number
59
+ readonly maxDepth?: number
60
+ readonly onEvent?: (event: WorkflowEvent) => void | PromiseLike<void>
61
+ readonly exposeErrorStacks?: boolean
62
+ }
63
+
64
+ export interface CodingAgentExtension {
65
+ readonly id: string
66
+ readonly path: string
67
+ readonly capabilities: readonly string[]
68
+ readonly dispose?: () => void | PromiseLike<void>
69
+ }
70
+
71
+ export interface ExtensionReloadResult {
72
+ readonly revision: string
73
+ readonly loaded: readonly string[]
74
+ readonly disabled: readonly string[]
75
+ readonly rolledBack: boolean
76
+ readonly error?: string
77
+ }
78
+
79
+ export interface ExtensionHostOptions {
80
+ readonly cwd: string
81
+ readonly roots: readonly string[]
82
+ readonly trustedCapabilities?: readonly string[]
83
+ /** Optional fast syntax/type/build gate that runs before a module is staged. */
84
+ readonly validate?: (path: string) => void | PromiseLike<void>
85
+ readonly onError?: (error: { readonly path: string; readonly error: unknown }) => void
86
+ }
87
+
88
+ /** Discover only project-local extension files; no home-directory or dependency scan is implicit. */
89
+ export async function discoverExtensions(cwd: string): Promise<readonly string[]> {
90
+ const directory = resolve(cwd, ".nifra/extensions")
91
+ if (!existsSync(directory)) return Object.freeze([])
92
+ const roots: string[] = []
93
+ for await (const path of new Bun.Glob("**/*").scan({ cwd: directory, dot: false })) {
94
+ if (/\.(?:ts|tsx|js|jsx)$/.test(path)) roots.push(join(".nifra/extensions", path))
95
+ }
96
+ return Object.freeze(roots.sort())
97
+ }
98
+
99
+ /** Parse an extension without activating it. This is a fast syntax gate before staging. */
100
+ export async function validateExtensionModule(path: string): Promise<void> {
101
+ const source = await Bun.file(path).text()
102
+ const loader = /\.tsx?$/.test(path) ? "tsx" : "js"
103
+ try {
104
+ new Bun.Transpiler({ loader }).transformSync(source)
105
+ } catch (error) {
106
+ throw new Error(
107
+ `extension validation failed: ${error instanceof Error ? error.message : String(error)}`,
108
+ )
109
+ }
110
+ }
111
+
112
+ type ExtensionFactory = (
113
+ context: ExtensionContext,
114
+ ) =>
115
+ | void
116
+ | { readonly dispose?: () => void | PromiseLike<void> }
117
+ | PromiseLike<void | { readonly dispose?: () => void | PromiseLike<void> }>
118
+
119
+ /**
120
+ * Transactional TypeScript extension loader for the Nifra-native backend.
121
+ *
122
+ * This loader only activates explicitly supplied files. It never scans a project implicitly and it
123
+ * leaves the previous extension graph intact when a staged reload fails.
124
+ */
125
+ export class ExtensionHost {
126
+ private readonly options: ExtensionHostOptions
127
+ private active: readonly CodingAgentExtension[] = []
128
+ private commands = new Map<string, ExtensionCommand>()
129
+ private tools = new Map<string, ExtensionTool>()
130
+ private workflows = new Map<string, ExtensionWorkflow>()
131
+ private subagents = new Map<string, ExtensionSubagent>()
132
+ private providers = new Map<string, ExtensionProvider>()
133
+ private handlers = new Map<string, ExtensionEventHandler[]>()
134
+ private revision = "0"
135
+
136
+ constructor(options: ExtensionHostOptions) {
137
+ this.options = Object.freeze({
138
+ ...options,
139
+ roots: Object.freeze([...options.roots]),
140
+ trustedCapabilities: Object.freeze([...(options.trustedCapabilities ?? [])]),
141
+ })
142
+ }
143
+
144
+ get extensions(): readonly CodingAgentExtension[] {
145
+ return this.active
146
+ }
147
+
148
+ get currentRevision(): string {
149
+ return this.revision
150
+ }
151
+
152
+ get availableCommands(): readonly string[] {
153
+ return Object.freeze([...this.commands.keys()].sort())
154
+ }
155
+
156
+ get availableTools(): readonly ExtensionTool[] {
157
+ return Object.freeze([...this.tools.values()])
158
+ }
159
+
160
+ get availableWorkflows(): readonly string[] {
161
+ return Object.freeze([...this.workflows.keys()].sort())
162
+ }
163
+
164
+ get availableSubagents(): readonly ExtensionSubagent[] {
165
+ return Object.freeze([...this.subagents.values()])
166
+ }
167
+
168
+ get availableProviders(): readonly ExtensionProvider[] {
169
+ return Object.freeze([...this.providers.values()])
170
+ }
171
+
172
+ async reload(): Promise<ExtensionReloadResult> {
173
+ let pathError: string | undefined
174
+ const paths = this.options.roots
175
+ .map((root) => resolve(this.options.cwd, root))
176
+ .filter((path) => {
177
+ if (!isWithin(this.options.cwd, path)) {
178
+ pathError = `extension path escapes project root: ${path}`
179
+ return false
180
+ }
181
+ if (!existsSync(path)) return false
182
+ try {
183
+ if (!isWithin(this.options.cwd, realpathSync(path))) {
184
+ pathError = `extension path escapes project root: ${path}`
185
+ return false
186
+ }
187
+ return true
188
+ } catch {
189
+ pathError = `extension path could not be resolved: ${path}`
190
+ return false
191
+ }
192
+ })
193
+ const next: CodingAgentExtension[] = []
194
+ const nextCommands = new Map<string, ExtensionCommand>()
195
+ const nextTools = new Map<string, ExtensionTool>()
196
+ const nextWorkflows = new Map<string, ExtensionWorkflow>()
197
+ const nextSubagents = new Map<string, ExtensionSubagent>()
198
+ const nextProviders = new Map<string, ExtensionProvider>()
199
+ const nextHandlers = new Map<string, ExtensionEventHandler[]>()
200
+ try {
201
+ if (pathError !== undefined) throw new Error(pathError)
202
+ for (const path of paths) {
203
+ await this.options.validate?.(path)
204
+ const loaded = await this.loadOne(
205
+ path,
206
+ nextCommands,
207
+ nextTools,
208
+ nextWorkflows,
209
+ nextSubagents,
210
+ nextProviders,
211
+ nextHandlers,
212
+ )
213
+ next.push(loaded)
214
+ }
215
+ const previous = this.active
216
+ this.active = Object.freeze(next)
217
+ this.commands = nextCommands
218
+ this.tools = nextTools
219
+ this.workflows = nextWorkflows
220
+ this.subagents = nextSubagents
221
+ this.providers = nextProviders
222
+ this.handlers = nextHandlers
223
+ this.revision = `${Date.now().toString(36)}-${next.length}`
224
+ for (const extension of previous) {
225
+ try {
226
+ await extension.dispose?.()
227
+ } catch (error) {
228
+ this.options.onError?.({ path: extension.path, error })
229
+ }
230
+ }
231
+ return {
232
+ revision: this.revision,
233
+ loaded: Object.freeze(next.map((extension) => extension.id)),
234
+ disabled: [],
235
+ rolledBack: false,
236
+ }
237
+ } catch (error) {
238
+ for (const extension of next.reverse()) {
239
+ try {
240
+ await extension.dispose?.()
241
+ } catch {
242
+ /* staged cleanup is best effort */
243
+ }
244
+ }
245
+ const message = error instanceof Error ? error.message : String(error)
246
+ this.options.onError?.({ path: paths[next.length] ?? this.options.cwd, error })
247
+ return {
248
+ revision: this.revision,
249
+ loaded: Object.freeze(this.active.map((extension) => extension.id)),
250
+ disabled: Object.freeze(paths.slice(next.length)),
251
+ rolledBack: true,
252
+ error: message,
253
+ }
254
+ }
255
+ }
256
+
257
+ async close(): Promise<void> {
258
+ const active = this.active
259
+ this.active = []
260
+ this.commands = new Map()
261
+ this.tools = new Map()
262
+ this.workflows = new Map()
263
+ this.subagents = new Map()
264
+ this.providers = new Map()
265
+ this.handlers = new Map()
266
+ for (const extension of active) await extension.dispose?.()
267
+ }
268
+
269
+ command(name: string): ExtensionCommand | undefined {
270
+ return this.commands.get(name)
271
+ }
272
+
273
+ tool(name: string): ExtensionTool | undefined {
274
+ return this.tools.get(name)
275
+ }
276
+
277
+ workflow(name: string): ExtensionWorkflow | undefined {
278
+ return this.workflows.get(name)
279
+ }
280
+
281
+ subagent(name: string): ExtensionSubagent | undefined {
282
+ return this.subagents.get(name)
283
+ }
284
+
285
+ provider(name: string): ExtensionProvider | undefined {
286
+ return this.providers.get(name)
287
+ }
288
+
289
+ async runWorkflow(name: string, options: WorkflowRunOptions = {}): Promise<WorkflowResult> {
290
+ const workflow = this.workflows.get(name)
291
+ if (workflow === undefined) throw new Error(`unknown extension workflow: ${name}`)
292
+ const step = await workflow(this.context())
293
+ return new WorkflowRunner(options).run(step)
294
+ }
295
+
296
+ async emit(event: string, payload: unknown): Promise<void> {
297
+ for (const handler of this.handlers.get(event) ?? []) await handler(payload, this.context())
298
+ }
299
+
300
+ private async loadOne(
301
+ path: string,
302
+ commands: Map<string, ExtensionCommand>,
303
+ tools: Map<string, ExtensionTool>,
304
+ workflows: Map<string, ExtensionWorkflow>,
305
+ subagents: Map<string, ExtensionSubagent>,
306
+ providers: Map<string, ExtensionProvider>,
307
+ handlers: Map<string, ExtensionEventHandler[]>,
308
+ ): Promise<CodingAgentExtension> {
309
+ const module = (await import(`${path}?revision=${Date.now()}-${Math.random()}`)) as {
310
+ default?: ExtensionFactory
311
+ id?: string
312
+ capabilities?: readonly string[]
313
+ }
314
+ if (typeof module.default !== "function")
315
+ throw new Error(`extension ${path} has no default factory`)
316
+ const capabilities = Object.freeze([...(module.capabilities ?? [])])
317
+ const trusted = new Set(this.options.trustedCapabilities ?? [])
318
+ const denied = capabilities.find((capability) => !trusted.has(capability))
319
+ if (denied !== undefined)
320
+ throw new Error(`extension ${path} requires untrusted capability: ${denied}`)
321
+ const context = this.context(commands, tools, workflows, subagents, providers, handlers)
322
+ const result = await module.default(context)
323
+ return Object.freeze({
324
+ id: module.id ?? path,
325
+ path,
326
+ capabilities,
327
+ ...(result &&
328
+ typeof result === "object" &&
329
+ "dispose" in result &&
330
+ typeof result.dispose === "function"
331
+ ? { dispose: result.dispose }
332
+ : {}),
333
+ })
334
+ }
335
+
336
+ private context(
337
+ commands = this.commands,
338
+ tools = this.tools,
339
+ workflows = this.workflows,
340
+ subagents = this.subagents,
341
+ providers = this.providers,
342
+ handlers = this.handlers,
343
+ ): ExtensionContext {
344
+ return {
345
+ cwd: this.options.cwd,
346
+ registerCommand: (name, handler) => {
347
+ if (!/^[a-z][a-z0-9._-]{0,63}$/.test(name))
348
+ throw new Error(`invalid extension command: ${name}`)
349
+ if (commands.has(name)) throw new Error(`duplicate extension command: ${name}`)
350
+ commands.set(name, handler)
351
+ },
352
+ registerTool: (tool) => {
353
+ if (!/^[a-z][a-z0-9._-]{0,63}$/.test(tool.name))
354
+ throw new Error(`invalid extension tool: ${tool.name}`)
355
+ if (tools.has(tool.name)) throw new Error(`duplicate extension tool: ${tool.name}`)
356
+ const denied = (tool.capabilities ?? []).find(
357
+ (capability) => !(this.options.trustedCapabilities ?? []).includes(capability),
358
+ )
359
+ if (denied !== undefined)
360
+ throw new Error(`extension tool ${tool.name} requires untrusted capability: ${denied}`)
361
+ tools.set(tool.name, tool)
362
+ },
363
+ registerWorkflow: (name, workflow) => {
364
+ if (!/^[a-z][a-z0-9._-]{0,63}$/.test(name))
365
+ throw new Error(`invalid extension workflow: ${name}`)
366
+ if (workflows.has(name)) throw new Error(`duplicate extension workflow: ${name}`)
367
+ workflows.set(name, workflow)
368
+ },
369
+ registerSubagent: (role) => {
370
+ if (!/^[a-z][a-z0-9._-]{0,63}$/.test(role.name))
371
+ throw new Error(`invalid extension subagent: ${role.name}`)
372
+ if (subagents.has(role.name)) throw new Error(`duplicate extension subagent: ${role.name}`)
373
+ subagents.set(role.name, Object.freeze({ ...role }))
374
+ },
375
+ registerProvider: (provider) => {
376
+ if (!/^[a-z][a-z0-9._-]{0,63}$/.test(provider.name))
377
+ throw new Error(`invalid extension provider: ${provider.name}`)
378
+ if (providers.has(provider.name))
379
+ throw new Error(`duplicate extension provider: ${provider.name}`)
380
+ providers.set(provider.name, Object.freeze({ ...provider }))
381
+ },
382
+ on: (event, handler) => {
383
+ if (!/^[a-z][a-z0-9._-]{0,63}$/.test(event))
384
+ throw new Error(`invalid extension event: ${event}`)
385
+ const current = handlers.get(event) ?? []
386
+ handlers.set(event, [...current, handler])
387
+ },
388
+ }
389
+ }
390
+ }
391
+
392
+ function isWithin(root: string, candidate: string): boolean {
393
+ const canonical = (path: string): string => {
394
+ try {
395
+ return realpathSync(path)
396
+ } catch {
397
+ return resolve(path)
398
+ }
399
+ }
400
+ const relativePath = relative(canonical(root), canonical(candidate))
401
+ return relativePath === "" || (!relativePath.startsWith("..") && !relativePath.includes("/.."))
402
+ }