@sigrun/sigrun 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,1236 @@
1
+ /**
2
+ * The extension loader (spec §3 boot order, §4.1 deploy-time list).
3
+ *
4
+ * Extensions are EXTERNAL packages (separate repos — the
5
+ * extension-separation ruling). `SIGRUN_EXTENSIONS` carries a comma-separated
6
+ * specifiers (npm package names or resolvable paths); the loader
7
+ * dynamic-imports each, resolves the sigrun extension-package contract, and
8
+ * installs the pi-durable `Extension` into the registry *before*
9
+ * `Harness.open` reads it.
10
+ *
11
+ * A failing extension is QUARANTINED: logged, reported as a degraded surface
12
+ * via the outcome list (which `GET /v1/meta`, Task 9, surfaces as
13
+ * `extensions[].status`), and boot continues — the loader never throws.
14
+ *
15
+ * Extension IDENTITY is the slug (spec §6): each resolved extension's
16
+ * declared name is sanitized ONCE at load (`slugifyName`,
17
+ * src/harness/stores.ts) and the slug replaces the name for everything
18
+ * downstream — the registry key, the seam host's identity, interaction/
19
+ * ambient records, worker names, and health correlation. A declared name
20
+ * that sanitizes to an empty slug, or a slug already claimed by another
21
+ * loaded extension this boot, fails the LOAD (quarantine naming the
22
+ * declared name(s)) — never the first store use mid-run.
23
+ *
24
+ * The sigrun extension-package contract: a package's default export is a
25
+ * factory returning an object with an `extension: Extension` field, plus an
26
+ * optional §3 `health` producer (spec §3 — the memory-backend form; Task
27
+ * 9e retains it for the §5 meta `health.memory` aggregation). A factory
28
+ * result may also carry a `providers` array (`ProviderContribution[]`,
29
+ * provider plugins Task 1): boot feeds every loaded contributor into
30
+ * `collectProviders` after the `options.extensions` seam — and a result
31
+ * carrying `providers` with NO `extension` is a valid PROVIDER-ONLY
32
+ * contributor (the model-plugin shape: no pi-durable surface, identified
33
+ * by its `name` field). A result may likewise carry a `webuiBundles`
34
+ * array (`{name, dir}[]`, build-and-webui Task 6): the loader validates it
35
+ * fail-loud, anchors each `dir` at the extension module's URL, and
36
+ * collects the entries attributed to the slug for boot's
37
+ * contributed-bundle registry — the same contributor-only form applies (a
38
+ * `webuiBundles` result with no `extension` needs a `name`). Factories may
39
+ * return a Promise (the plugins read their scoped settings store inside the
40
+ * factory); the loader awaits the result. Packages that predate the settled
41
+ * contract export a named `extension` directly, so the loader resolves,
42
+ * in order:
43
+ *
44
+ * 1. the default export, if it is a factory returning `{ extension,
45
+ * health? }` or is itself an `Extension` (named `name`) — the supported
46
+ * form,
47
+ * 2. a named `extension` export (DEPRECATED bridge — one warning per
48
+ * load). The pre-contract `createMemory` named-factory bridge was
49
+ * deleted in release-0.1 (its last consumer, the memory package, now
50
+ * exports the factory as default).
51
+ *
52
+ * A factory result still carrying a `submit` pass-through (the retired
53
+ * pre-seam memory wiring) is IGNORED with one warning — never
54
+ * quarantined, so pre-seam packages keep loading.
55
+ *
56
+ * A factory MAY declare an arity-1 signature: the loader then passes the
57
+ * host context (src/harness/seam-host.ts — the interaction/ambient seam
58
+ * handles plus the scoped extension-stores handle `stores` (spec §5:
59
+ * get/set on the extension's OWN secrets section and settings file, and
60
+ * its own raw data/cache/state paths; src/harness/stores.ts). The host
61
+ * is passed only to factories with `factory.length > 0` — a length-0
62
+ * factory may carry a defaulted first parameter that would consume the
63
+ * host as a real dependency — and the factory-result
64
+ * `interactionKinds`/`ambientKinds` string arrays declare which seam
65
+ * kinds/keys the extension writes — validated fail-loud here and
66
+ * consumed by the seam host's write gate and the /v1/meta inventory.
67
+ * Only factory results can carry a health producer or kind declarations
68
+ * (the direct-`Extension` forms have no factory result to read one from).
69
+ * A `health` field that is present but not the expected shape, or kinds
70
+ * that are not string arrays, is a contract violation — quarantined,
71
+ * never silently ignored.
72
+ * A `providers` entry whose `provider` has no string `id` is the same class
73
+ * of violation (a plugin that THINKS it contributed a provider must not
74
+ * silently lose it).
75
+ *
76
+ * The contract is DOCUMENTED as one types-only type surface (W5 Task 1,
77
+ * spec §9.1): `src/extension.ts`, shipped as the `@sigrun/sigrun/extension`
78
+ * subpath (types-only — a runtime import of it fails loud by design).
79
+ * This loader's validated candidate shape is DERIVED from that contract
80
+ * (`CandidateFactoryResult` — reading a key the contract does not declare
81
+ * is a compile error here), and `LoaderReadKeys` compiles only while every
82
+ * factory-result key the loader reads is a key of `SigrunExtensionResult`.
83
+ * The implementation and the contract cannot drift silently.
84
+ */
85
+ import { validateJobSpec } from "../jobs/spec.js";
86
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
87
+ import { createRequire } from "node:module";
88
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
89
+ import { fileURLToPath } from "node:url";
90
+ import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
91
+ import { RESERVED_CORE_SECTION_KEYS } from "./reserved-sections.js";
92
+ import { slugifyName } from "./stores.js";
93
+ /** Every lifecycle field name, in validation order (mirrors the contract doc). */
94
+ const LIFECYCLE_FIELDS = [
95
+ "conversationCreated",
96
+ "conversationForked",
97
+ "conversationArchived",
98
+ "conversationIdle",
99
+ "harnessShutdown",
100
+ "userDeleted",
101
+ ];
102
+ /**
103
+ * The reserved-key membership probe (spec §6.5 R11): a `Set` because the
104
+ * `as const` tuple's `includes` narrows its parameter to the literal
105
+ * union, which a section's `string` key can never satisfy.
106
+ */
107
+ const RESERVED_SECTION_KEY_SET = new Set(RESERVED_CORE_SECTION_KEYS);
108
+ /** A structural check for "looks like a pi-durable Extension". */
109
+ function isExtension(value) {
110
+ return (typeof value === "object" &&
111
+ value !== null &&
112
+ typeof value.name === "string");
113
+ }
114
+ /** Whether `dir` is an EXISTING directory (never throws). */
115
+ function existingDirectory(dir) {
116
+ try {
117
+ return statSync(dir).isDirectory();
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
123
+ /**
124
+ * A kinds declaration must be an array of non-empty strings (or absent).
125
+ * The declaration drives the seam host's write gate and the /v1/meta
126
+ * inventory — a malformed one is a contract violation, not a shrug.
127
+ */
128
+ function validateKinds(value) {
129
+ if (value === undefined)
130
+ return null;
131
+ if (!Array.isArray(value))
132
+ return "kinds declaration must be an array of strings";
133
+ if (value.some((k) => typeof k !== "string" || k.length === 0)) {
134
+ return "kinds declaration must be an array of non-empty strings";
135
+ }
136
+ return null;
137
+ }
138
+ /**
139
+ * The lifecycle event names a worker's `wakeOn` may declare (memory-seam
140
+ * Task 5): `harnessShutdown` is excluded — workers stop BEFORE the shutdown
141
+ * handlers run (spec §3 shutdown ordering), so waking on it is meaningless.
142
+ */
143
+ const WAKE_EVENTS = new Set([
144
+ "conversationCreated",
145
+ "conversationForked",
146
+ "conversationArchived",
147
+ "conversationIdle",
148
+ ]);
149
+ /**
150
+ * The `every` retirement message (ruling 12, jobs Task 3): jobs are the
151
+ * only time-based scheduler — an extension that needs a timer declares a
152
+ * job with a `task` action (spec §8.1a).
153
+ */
154
+ const EVERY_RETIRED = "workers.every was retired — declare a job with a task action";
155
+ /**
156
+ * Validate a factory result's `workers` declaration (memory-seam Task 5,
157
+ * the curator contract, post-`every`-retirement). Absent → undefined (no
158
+ * workers). Present but malformed → throw; the loader quarantines the
159
+ * extension with the thrown message (fail-loud: an extension that THINKS
160
+ * it declared a curator must not silently lose it). Validation order per
161
+ * worker: name, duplicate, run, every-retired, wakeOn,
162
+ * wakeOn-required-non-empty.
163
+ */
164
+ function validateWorkers(value) {
165
+ if (value === undefined)
166
+ return undefined;
167
+ if (!Array.isArray(value)) {
168
+ throw new Error("extension factory returned a workers field that is not an array");
169
+ }
170
+ const seen = new Set();
171
+ for (const entry of value) {
172
+ const worker = entry;
173
+ const name = worker?.name;
174
+ if (typeof name !== "string" || name.length === 0) {
175
+ throw new Error("extension factory returned a worker without a name");
176
+ }
177
+ if (seen.has(name)) {
178
+ throw new Error(`extension factory returned a worker with a duplicate worker name "${name}"`);
179
+ }
180
+ seen.add(name);
181
+ if (typeof worker?.run !== "function") {
182
+ throw new Error(`extension factory returned a worker whose worker "${name}" has a run field that is not a function`);
183
+ }
184
+ // Ruling 12: `every` is RETIRED — a worker carrying it quarantines
185
+ // with the retirement message (jobs are the only timer).
186
+ if (worker?.every !== undefined) {
187
+ throw new Error(EVERY_RETIRED);
188
+ }
189
+ if (worker?.wakeOn === undefined ||
190
+ !Array.isArray(worker.wakeOn) ||
191
+ worker.wakeOn.length === 0 ||
192
+ worker.wakeOn.some((event) => typeof event !== "string" || !WAKE_EVENTS.has(event))) {
193
+ if (worker?.wakeOn !== undefined && Array.isArray(worker.wakeOn) && worker.wakeOn.length === 0) {
194
+ throw new Error(`extension factory returned a worker whose worker "${name}" has an empty wakeOn (declare at least one wake event)`);
195
+ }
196
+ if (worker?.wakeOn === undefined) {
197
+ throw new Error("extension factory returned a worker that declares a worker with neither every nor wakeOn");
198
+ }
199
+ throw new Error(`extension factory returned a worker whose worker "${name}" has an invalid wakeOn entry`);
200
+ }
201
+ }
202
+ return value;
203
+ }
204
+ /**
205
+ * Validate a factory result's `jobs` declaration (jobs §8.3 — the
206
+ * `extension` source). Absent → undefined (no jobs). Present but
207
+ * malformed → throw; the loader quarantines the extension with the
208
+ * thrown message (fail-loud: an extension that THINKS it declared a job
209
+ * must not silently lose it). Each entry is a full `validateJobSpec` run
210
+ * (the §8.2 grammar), and duplicate names within one extension are
211
+ * rejected (they would collide on the `ext:<slug>:<name>` id).
212
+ */
213
+ function validateJobs(value) {
214
+ if (value === undefined)
215
+ return undefined;
216
+ if (!Array.isArray(value)) {
217
+ throw new Error("extension factory returned a jobs field that is not an array");
218
+ }
219
+ const seen = new Set();
220
+ const specs = [];
221
+ for (const entry of value) {
222
+ const result = validateJobSpec(entry);
223
+ if (!result.ok) {
224
+ throw new Error(`extension factory returned a job whose ${result.field} ${result.message}`);
225
+ }
226
+ if (seen.has(result.spec.name)) {
227
+ throw new Error(`extension factory returned a duplicate job name "${result.spec.name}"`);
228
+ }
229
+ seen.add(result.spec.name);
230
+ specs.push(result.spec);
231
+ }
232
+ return specs;
233
+ }
234
+ /**
235
+ * The `method`/`auth` vocabularies of a `routes` declaration (spec
236
+ * §9.2) — Sets because `includes` on a `const` tuple narrows to the
237
+ * literal union, which an `unknown` field value can never satisfy.
238
+ */
239
+ const ROUTE_METHODS = new Set([
240
+ "GET",
241
+ "POST",
242
+ "PUT",
243
+ "PATCH",
244
+ "DELETE",
245
+ ]);
246
+ const ROUTE_AUTHS = new Set(["user", "admin", "public"]);
247
+ /**
248
+ * Validate a factory result's `routes` declaration (core hardening W5
249
+ * Task 2, spec §9.2). Absent → undefined (no routes). Present but
250
+ * malformed → throw; the loader quarantines the extension with the
251
+ * thrown message (fail-loud: an extension that THINKS it mounted a
252
+ * webhook must not silently lose it). Per entry: `method` in the five
253
+ * verbs, `path` a non-empty RELATIVE string (no leading `/`, no `..` —
254
+ * nothing may escape the `/v1/ext/<slug>/` mount), `auth` one of the
255
+ * three values, `bodyLimit` a positive integer when present, and
256
+ * `handler` a function.
257
+ */
258
+ function validateRoutes(value) {
259
+ if (value === undefined)
260
+ return undefined;
261
+ if (!Array.isArray(value)) {
262
+ throw new Error("extension factory returned a routes field that is not an array");
263
+ }
264
+ for (const entry of value) {
265
+ const route = entry;
266
+ const describe = () => `route "${typeof route?.path === "string" ? route.path : String(route?.path)}"`;
267
+ if (typeof route?.method !== "string" || !ROUTE_METHODS.has(route.method)) {
268
+ throw new Error(`extension factory returned a ${describe()} whose method is not one of GET, POST, PUT, PATCH, DELETE`);
269
+ }
270
+ if (typeof route?.path !== "string" ||
271
+ route.path === "" ||
272
+ route.path.startsWith("/") ||
273
+ route.path.includes("..")) {
274
+ throw new Error(`extension factory returned a route whose path must be a non-empty relative string (no leading "/", no "..")`);
275
+ }
276
+ if (typeof route?.auth !== "string" || !ROUTE_AUTHS.has(route.auth)) {
277
+ throw new Error(`extension factory returned a ${describe()} whose auth is not one of user, admin, public`);
278
+ }
279
+ if (route?.bodyLimit !== undefined &&
280
+ (typeof route.bodyLimit !== "number" ||
281
+ !Number.isInteger(route.bodyLimit) ||
282
+ route.bodyLimit <= 0)) {
283
+ throw new Error(`extension factory returned a ${describe()} whose bodyLimit is not a positive integer`);
284
+ }
285
+ if (typeof route?.handler !== "function") {
286
+ throw new Error(`extension factory returned a ${describe()} whose handler is not a function`);
287
+ }
288
+ }
289
+ return value;
290
+ }
291
+ /**
292
+ * The extension command NAME grammar (spec §9.3): `^[a-z][a-z0-9-]*$` —
293
+ * the same shape as every other extension-declared name (workers,
294
+ * bundles), so the composer's `/` menu renders one spelling of a name.
295
+ */
296
+ const COMMAND_NAME = /^[a-z][a-z0-9-]*$/;
297
+ /**
298
+ * Validate a factory result's `commands` declaration (core hardening W5
299
+ * Task 3, spec §9.3). Absent → undefined (no commands). Present but
300
+ * malformed → throw; the loader quarantines the extension with the
301
+ * thrown message (fail-loud: an extension that THINKS it declared a
302
+ * command must not silently lose it — the providers/health class). Per
303
+ * entry: `name` matching the grammar, `description` a string, optional
304
+ * `args` a string, and `run` a function. Core-name collisions are NOT a
305
+ * validation failure — the catalog builder drops those with a warning
306
+ * (they are a deployment choice, not a broken declaration).
307
+ */
308
+ function validateCommands(value) {
309
+ if (value === undefined)
310
+ return undefined;
311
+ if (!Array.isArray(value)) {
312
+ throw new Error("extension factory returned a commands field that is not an array");
313
+ }
314
+ for (const entry of value) {
315
+ const command = entry;
316
+ const describe = () => `command "${typeof command?.name === "string" ? command.name : String(command?.name)}"`;
317
+ if (typeof command?.name !== "string" || !COMMAND_NAME.test(command.name)) {
318
+ throw new Error(`extension factory returned a ${describe()} whose name does not match ^[a-z][a-z0-9-]*$`);
319
+ }
320
+ if (typeof command?.description !== "string") {
321
+ throw new Error(`extension factory returned a ${describe()} whose description is missing or not a string`);
322
+ }
323
+ if (command?.args !== undefined && typeof command.args !== "string") {
324
+ throw new Error(`extension factory returned a ${describe()} whose args is not a string`);
325
+ }
326
+ if (typeof command?.run !== "function") {
327
+ throw new Error(`extension factory returned a ${describe()} whose run is not a function`);
328
+ }
329
+ }
330
+ return value;
331
+ }
332
+ /**
333
+ * Validate a factory result's `notifiers` declaration (core hardening W5
334
+ * Task 6, spec §9.6). Absent → undefined (no contributions). Present but
335
+ * malformed → throw; the loader quarantines the extension with the thrown
336
+ * message (fail-loud: an extension that THINKS it declared a transport
337
+ * must not silently lose it — the providers/health class). Per entry: a
338
+ * non-empty `name` and a `notify` function.
339
+ */
340
+ function validateNotifiers(value) {
341
+ if (value === undefined)
342
+ return undefined;
343
+ if (!Array.isArray(value)) {
344
+ throw new Error("extension factory returned a notifiers field that is not an array");
345
+ }
346
+ for (const entry of value) {
347
+ const notifier = entry;
348
+ const describe = () => `notifier "${typeof notifier?.name === "string" ? notifier.name : String(notifier?.name)}"`;
349
+ if (typeof notifier?.name !== "string" || notifier.name.length === 0) {
350
+ throw new Error(`extension factory returned a ${describe()} whose name is missing or empty`);
351
+ }
352
+ if (typeof notifier?.notify !== "function") {
353
+ throw new Error(`extension factory returned a ${describe()} whose notify is not a function`);
354
+ }
355
+ }
356
+ return value;
357
+ }
358
+ /**
359
+ * Validate a factory result's `skills` declaration (core hardening W5
360
+ * Task 4, spec §9.4). Absent → undefined (no contributions). Present but
361
+ * malformed → throw; the loader quarantines the extension with the thrown
362
+ * message (fail-loud: an extension that THINKS it contributed skills must
363
+ * not silently lose them — the providers/health class). Per entry: an
364
+ * absolute path naming an EXISTING directory.
365
+ */
366
+ function validateSkills(value) {
367
+ if (value === undefined)
368
+ return undefined;
369
+ if (!Array.isArray(value)) {
370
+ throw new Error("extension factory returned a skills field that is not an array");
371
+ }
372
+ for (const entry of value) {
373
+ if (typeof entry !== "string" || entry.length === 0) {
374
+ throw new Error("extension factory returned a skills entry that is not a non-empty string");
375
+ }
376
+ if (!isAbsolute(entry)) {
377
+ throw new Error(`extension factory returned a skills entry that is not an absolute directory: ${entry}`);
378
+ }
379
+ if (!existingDirectory(entry)) {
380
+ throw new Error(`extension factory returned a skills entry that is not an existing directory: ${entry}`);
381
+ }
382
+ }
383
+ return value;
384
+ }
385
+ /**
386
+ * Validate a factory result's `providers` declaration (provider plugins
387
+ * Task 1). Absent → undefined (no contributions). Present but malformed →
388
+ * throw; the loader quarantines the extension with the thrown message
389
+ * (fail-loud: a plugin that THINKS it contributed a provider must not
390
+ * silently lose it). Each entry must be a `ProviderContribution` — an
391
+ * object whose `provider` carries a string `id`, the key the collector
392
+ * merges by and the §5 ownership records read.
393
+ */
394
+ function validateProviders(value) {
395
+ if (value === undefined)
396
+ return undefined;
397
+ if (!Array.isArray(value)) {
398
+ throw new Error("extension factory returned a providers field that is not an array");
399
+ }
400
+ for (const entry of value) {
401
+ const provider = entry?.provider;
402
+ if (typeof provider !== "object" ||
403
+ provider === null ||
404
+ typeof provider.id !== "string") {
405
+ throw new Error("extension factory returned a providers entry whose provider.id is missing or not a string");
406
+ }
407
+ }
408
+ return value;
409
+ }
410
+ /**
411
+ * The contributed-bundle NAME grammar (spec §4.1: `^[a-z][a-z0-9_-]*$` —
412
+ * a selector value matching it is a contributed-bundle NAME). Kept in
413
+ * sync with `BUNDLE_NAME` in src/server/static.ts by the spec grammar,
414
+ * not a shared import: this module must not pull the server's runtime
415
+ * graph (the loader has no fs/server coupling by contract).
416
+ */
417
+ const WEBUI_BUNDLE_NAME = /^[a-z][a-z0-9_-]*$/;
418
+ /**
419
+ * Validate a factory result's `webuiBundles` declaration (build-and-webui
420
+ * Task 6). Absent → undefined (no contributions). Present but malformed →
421
+ * throw; the loader quarantines the extension with the thrown message
422
+ * (fail-loud: an extension that THINKS it contributed a bundle must not
423
+ * silently lose it — the providers/health/submit class). Each entry must
424
+ * be a `{name, dir}` object whose `name` matches the contributed-bundle
425
+ * grammar and whose `dir` is a non-empty string; extra fields are inert.
426
+ */
427
+ function validateWebuiBundles(value) {
428
+ if (value === undefined)
429
+ return undefined;
430
+ if (!Array.isArray(value)) {
431
+ throw new Error("extension factory returned a webuiBundles field that is not an array");
432
+ }
433
+ for (const entry of value) {
434
+ const bundle = entry;
435
+ if (typeof bundle?.name !== "string" ||
436
+ !WEBUI_BUNDLE_NAME.test(bundle.name)) {
437
+ throw new Error("extension factory returned a webuiBundles entry whose name is missing or does not match ^[a-z][a-z0-9_-]*$");
438
+ }
439
+ if (typeof bundle?.dir !== "string" || bundle.dir.length === 0) {
440
+ throw new Error(`extension factory returned a webuiBundles entry "${bundle?.name}" whose dir is missing or not a non-empty string`);
441
+ }
442
+ }
443
+ return value;
444
+ }
445
+ /**
446
+ * The one host module whose version must match the host EXACTLY (spec
447
+ * §6.4 R6): an extension's `Extension`/tool objects flow into the HOST's
448
+ * pi-durable registry under structural typing — a different pi-durable
449
+ * is the classic silent-breakage trap (identity never matches, hooks
450
+ * never fire), so any difference at all quarantines. Every other host
451
+ * module only shares the major (a minor/patch difference warns).
452
+ */
453
+ const EXACT_HOST_MODULES = new Set([
454
+ "@earendil-works/pi-durable",
455
+ ]);
456
+ /** The major segment of a semver-ish version string (`"1.2.0"` → `"1"`). */
457
+ function majorSegment(version) {
458
+ return version.split(".")[0] ?? version;
459
+ }
460
+ /**
461
+ * The nearest `package.json` at or above `dir` (bounded walk) — the
462
+ * manifest owning a resolved module ENTRY file. `undefined` at the walk
463
+ * bound or filesystem root.
464
+ */
465
+ function nearestManifest(dir) {
466
+ let current = dir;
467
+ for (let hops = 0; hops < 6; hops++) {
468
+ const candidate = join(current, "package.json");
469
+ if (existsSync(candidate))
470
+ return candidate;
471
+ const parent = dirname(current);
472
+ if (parent === current)
473
+ return undefined;
474
+ current = parent;
475
+ }
476
+ return undefined;
477
+ }
478
+ /**
479
+ * The nearest `node_modules/<name>/package.json` walking up from `dir` —
480
+ * the package-directory form of Node's own lookup (which copy of `<name>`
481
+ * a module at `dir` resolves). Bounded like every walk here; `undefined`
482
+ * when no ancestor tree carries the package.
483
+ */
484
+ function nearestModuleManifest(dir, name) {
485
+ let current = dir;
486
+ for (let hops = 0; hops < 10; hops++) {
487
+ const candidate = join(current, "node_modules", ...name.split("/"), "package.json");
488
+ if (existsSync(candidate))
489
+ return candidate;
490
+ const parent = dirname(current);
491
+ if (parent === current)
492
+ return undefined;
493
+ current = parent;
494
+ }
495
+ return undefined;
496
+ }
497
+ /**
498
+ * The version of one host runtime module as an extension's tree resolves
499
+ * it, anchored at the extension ENTRY's directory — the exact resolution
500
+ * the extension's own bare imports use (a nested `node_modules` copy
501
+ * counts, and so does the host's copy when the walk finds nothing
502
+ * nearer). `undefined` when the extension's tree resolves no copy at
503
+ * all: the extension does not depend on the module, and the guard
504
+ * skips it.
505
+ *
506
+ * Two resolution forms, in order: `<name>/package.json` (explicit, and
507
+ * the only form for manifest-less trees), falling back to the module
508
+ * ENTRY + nearest manifest above it — some host modules' exports maps
509
+ * (pi-ai, typebox) do not expose the `./package.json` subpath, which
510
+ * `require.resolve("<name>/package.json")"` reports as
511
+ * ERR_PACKAGE_PATH_NOT_EXPORTED even though the package itself resolves
512
+ * fine; without the fallback those modules would be invisible to the
513
+ * guard, the exact R6 blind spot it exists to close.
514
+ */
515
+ function moduleVersionFrom(moduleDir, name) {
516
+ // Node's ESM loader realpaths the referrer before resolving ITS bare
517
+ // imports (preserveSymlinks is off by default) — anchor the walk at the
518
+ // realpath'd dir so the tree examined is the tree the extension's own
519
+ // imports resolve from, not the symlink path that named it (a `file:`
520
+ // dir install symlinks node_modules/<pkg> → the checkout).
521
+ let anchor = moduleDir;
522
+ try {
523
+ anchor = realpathSync(moduleDir);
524
+ }
525
+ catch {
526
+ // Not realpath-able — the literal dir is the best anchor available.
527
+ }
528
+ const req = createRequire(join(anchor, "package.json"));
529
+ let manifestPath;
530
+ try {
531
+ manifestPath = req.resolve(`${name}/package.json`);
532
+ }
533
+ catch {
534
+ // No package.json subpath from here — either the exports map gates
535
+ // it or the tree carries no copy: the fallbacks below decide.
536
+ }
537
+ if (manifestPath === undefined) {
538
+ try {
539
+ manifestPath = nearestManifest(dirname(req.resolve(name)));
540
+ }
541
+ catch {
542
+ // The entry itself is not require-resolvable — pi-ai's exports
543
+ // map carries ONLY `import`/`types` conditions, so createRequire
544
+ // cannot resolve the package AT ALL even though extensions
545
+ // import it fine. Fall back to the node_modules DIRECTORY walk
546
+ // (the package-dir form of Node's lookup); without it pi-ai skew
547
+ // would be invisible to the guard — the exact R6 blind spot.
548
+ manifestPath = nearestModuleManifest(anchor, name);
549
+ }
550
+ }
551
+ if (manifestPath === undefined)
552
+ return undefined;
553
+ try {
554
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
555
+ return typeof parsed.version === "string" ? parsed.version : undefined;
556
+ }
557
+ catch {
558
+ return undefined;
559
+ }
560
+ }
561
+ /**
562
+ * The host-module version guard (spec §6.4 R6, core-hardening Task 5):
563
+ * every host runtime module the extension's tree RESOLVES must agree
564
+ * with the copy the host runs — `@earendil-works/pi-durable` EXACTLY
565
+ * (else quarantine), the others in the major (a minor/patch difference
566
+ * warns, a major difference quarantines). Runs at LOAD, immediately
567
+ * after the import and before anything is keyed, collected, or
568
+ * installed: a skewing extension quarantines with this message and
569
+ * leaves no trace in the registry, the seam hosts, or any binding.
570
+ *
571
+ * @param moduleDir the extension ENTRY's directory (the tree its bare
572
+ * imports resolve from — `extensionModuleDir` above).
573
+ * @throws the quarantine message on a disallowed difference.
574
+ */
575
+ function checkHostModules(moduleDir) {
576
+ for (const name of HOST_RUNTIME_MODULES) {
577
+ const extVersion = moduleVersionFrom(moduleDir, name);
578
+ if (extVersion === undefined)
579
+ continue; // not a dependency of this extension
580
+ const hostVersion = hostModuleVersion(name);
581
+ if (hostVersion === undefined)
582
+ continue; // no host copy to compare against
583
+ if (extVersion === hostVersion)
584
+ continue;
585
+ if (EXACT_HOST_MODULES.has(name) ||
586
+ majorSegment(extVersion) !== majorSegment(hostVersion)) {
587
+ throw new Error(`host module skew: ${name} resolves to ${extVersion} from ${moduleDir}, host runs ${hostVersion}`);
588
+ }
589
+ console.warn(`host module minor skew: ${name} resolves to ${extVersion} from ${moduleDir}, ` +
590
+ `host runs ${hostVersion} (same major, allowed)`);
591
+ }
592
+ }
593
+ /**
594
+ * The extension module's DIRECTORY — the anchor a contributed bundle's
595
+ * relative `dir` resolves against (build-and-webui Task 6: an
596
+ * npm-installed extension's bundle lives in its own package, so the dir
597
+ * anchors where the MODULE loaded from, never the process cwd). Path-shaped
598
+ * specifiers (file:// URLs, POSIX absolutes — the packages-dir and
599
+ * pre-resolved flows) derive directly; bare specifiers resolve through
600
+ * the loader's own require anchor (the same mechanism
601
+ * `resolveExtensionEntry`'s fallback uses). `undefined` when nothing
602
+ * path-shaped derives — a contributed `dir` then passes through unchanged
603
+ * (absolute dirs always do; `resolve` keeps them verbatim).
604
+ */
605
+ function extensionModuleDir(loaded) {
606
+ let path;
607
+ if (loaded.startsWith("file://")) {
608
+ try {
609
+ path = fileURLToPath(loaded);
610
+ }
611
+ catch {
612
+ return undefined;
613
+ }
614
+ }
615
+ else if (loaded.startsWith("/")) {
616
+ path = loaded;
617
+ }
618
+ else {
619
+ try {
620
+ const resolved = createRequire(import.meta.url).resolve(loaded);
621
+ path = resolved.startsWith("file://")
622
+ ? fileURLToPath(resolved)
623
+ : resolved;
624
+ }
625
+ catch {
626
+ return undefined;
627
+ }
628
+ }
629
+ return path === undefined ? undefined : dirname(path);
630
+ }
631
+ /**
632
+ * Call an unknown-typed factory and validate the `{ extension, health? }`
633
+ * result. A `health` field that is present but not the expected shape is a
634
+ * contract violation — thrown, so the loader quarantines the extension
635
+ * (fail-loud: a package that THINKS it reports health must not read as
636
+ * healthy). A `submit` field is IGNORED with a flag (§6.7: the
637
+ * pass-through is retired; the loader warns, never quarantines — old
638
+ * packages keep loading).
639
+ *
640
+ * The factory result may be a Promise (provider plugins read their scoped
641
+ * settings store inside the factory) — awaited before validation. A result
642
+ * carrying `providers` with NO `extension` is a valid provider-only
643
+ * contributor (named by its `name` field); a result with neither is not
644
+ * this contract form at all (returns undefined, falling through to the
645
+ * next resolution candidate).
646
+ */
647
+ async function resolvedFromFactory(factory, hostContext) {
648
+ if (typeof factory !== "function")
649
+ return undefined;
650
+ // Arity gate: the host context goes ONLY to factories that declare the
651
+ // arity-1 signature (factory.length > 0). A length-0 factory with a
652
+ // defaulted first parameter (createMemory(backend = new Backend()))
653
+ // consumes its first argument as a real dependency — passing the host
654
+ // unconditionally landed it in that slot and broke the extension at
655
+ // runtime (memory: every submit 500ed on `bindRecallSource is not a
656
+ // function`). Extra arguments are inert only for factories that read
657
+ // none; `length` is how the contract declares it.
658
+ const result = factory.length > 0
659
+ ? factory(hostContext)
660
+ : factory();
661
+ // Provider plugins may resolve asynchronously; `await` passes a
662
+ // synchronous result through unchanged.
663
+ const awaited = await result;
664
+ if (typeof awaited !== "object" || awaited === null)
665
+ return undefined;
666
+ // The candidate is keyed by the PUBLIC contract (CandidateFactoryResult):
667
+ // reading a field the contract does not declare is a compile error here,
668
+ // and the `LoaderReadKeys` assertion (above ResolvedExtension) pins the
669
+ // full read list to the contract's keys.
670
+ const candidate = awaited;
671
+ const providers = validateProviders(candidate.providers);
672
+ const webuiBundles = validateWebuiBundles(candidate.webuiBundles);
673
+ const jobs = validateJobs(candidate.jobs);
674
+ const routes = validateRoutes(candidate.routes);
675
+ const commands = validateCommands(candidate.commands);
676
+ const skills = validateSkills(candidate.skills);
677
+ const notifiers = validateNotifiers(candidate.notifiers);
678
+ const extension = isExtension(candidate.extension)
679
+ ? candidate.extension
680
+ : undefined;
681
+ if (extension === undefined) {
682
+ // Contributor-only result: valid when ANY contribution field is
683
+ // present (`providers`, `webuiBundles`); otherwise this factory
684
+ // result is not the contract form (fall through to the next
685
+ // resolution candidate). The contributor's name is its
686
+ // collision-record identity — without one there is nothing to
687
+ // attribute the contributions to, which is a contract violation.
688
+ if (providers === undefined && webuiBundles === undefined) {
689
+ return undefined;
690
+ }
691
+ if (typeof candidate.name !== "string" || candidate.name.length === 0) {
692
+ throw new Error("extension factory returned a contributor-only result without a name");
693
+ }
694
+ }
695
+ if (candidate.health !== undefined &&
696
+ typeof candidate.health !== "function") {
697
+ throw new Error("extension factory returned a health field that is not a function");
698
+ }
699
+ if (candidate.runSettled !== undefined &&
700
+ typeof candidate.runSettled !== "function") {
701
+ throw new Error("extension factory returned a runSettled field that is not a function");
702
+ }
703
+ // The five lifecycle fields (memory-seam Task 4) validate identically:
704
+ // present-but-not-a-function is a contract violation — the extension
705
+ // THINKS it listens to lifecycle events and must not silently miss them.
706
+ const lifecycle = {};
707
+ let anyLifecycle = false;
708
+ for (const field of LIFECYCLE_FIELDS) {
709
+ const value = candidate[field];
710
+ if (value === undefined)
711
+ continue;
712
+ if (typeof value !== "function") {
713
+ throw new Error(`extension factory returned a ${field} field that is not a function`);
714
+ }
715
+ lifecycle[field] = value;
716
+ anyLifecycle = true;
717
+ }
718
+ const kindsProblem = validateKinds(candidate.interactionKinds) ??
719
+ validateKinds(candidate.ambientKinds);
720
+ if (kindsProblem !== null)
721
+ throw new Error(kindsProblem);
722
+ // `workers` is a contract key — the candidate read needs no cast.
723
+ const workers = validateWorkers(candidate.workers);
724
+ return {
725
+ // Identity: the extension when one was carried, else the
726
+ // provider-only contributor's validated `name`.
727
+ ...(extension !== undefined
728
+ ? { extension }
729
+ : { name: candidate.name }),
730
+ ...(candidate.health === undefined
731
+ ? {}
732
+ : { health: candidate.health }),
733
+ ...(candidate.submit === undefined
734
+ ? {}
735
+ : { retiredSubmit: true }),
736
+ ...(candidate.runSettled === undefined
737
+ ? {}
738
+ : { runSettled: candidate.runSettled }),
739
+ ...(anyLifecycle
740
+ ? {
741
+ // SAFETY: every value under `lifecycle` passed `typeof === "function"`
742
+ // against its LIFECYCLE_FIELDS key above, so the object matches
743
+ // ExtensionLifecycleHandlers key-for-key and value-for-value.
744
+ lifecycle: lifecycle,
745
+ }
746
+ : {}),
747
+ ...(candidate.interactionKinds === undefined
748
+ ? {}
749
+ : { interactionKinds: candidate.interactionKinds }),
750
+ ...(candidate.ambientKinds === undefined
751
+ ? {}
752
+ : { ambientKinds: candidate.ambientKinds }),
753
+ ...(workers === undefined ? {} : { workers }),
754
+ ...(providers === undefined ? {} : { providers }),
755
+ ...(webuiBundles === undefined ? {} : { webuiBundles }),
756
+ ...(jobs === undefined ? {} : { jobs }),
757
+ ...(routes === undefined ? {} : { routes }),
758
+ ...(commands === undefined ? {} : { commands }),
759
+ ...(skills === undefined ? {} : { skills }),
760
+ ...(notifiers === undefined ? {} : { notifiers }),
761
+ };
762
+ }
763
+ /**
764
+ * Resolve one imported module to the extension the loader installs, per the
765
+ * contract order documented on this module, retaining a factory-result §3
766
+ * health producer when one was carried. Async: the factory result may be a
767
+ * Promise (settings-reading provider plugins).
768
+ */
769
+ export async function resolveExtension(module, hostContext = undefined) {
770
+ // 1. Default export: factory returning `{ extension, health? }`, or the
771
+ // Extension itself — the supported forms.
772
+ const defaultExport = module.default;
773
+ if (isExtension(defaultExport))
774
+ return { extension: defaultExport };
775
+ const fromDefault = await resolvedFromFactory(defaultExport, hostContext);
776
+ if (fromDefault !== undefined)
777
+ return fromDefault;
778
+ // 2. Named `extension` export (DEPRECATED bridge for the pre-contract
779
+ // packages — one warning per load at the caller). The pre-contract
780
+ // `createMemory` named-factory bridge was deleted in release-0.1; its
781
+ // last consumer exports the factory as default now.
782
+ if (isExtension(module.extension)) {
783
+ return { extension: module.extension, resolvedVia: "extension" };
784
+ }
785
+ return undefined;
786
+ }
787
+ /**
788
+ * The PROVISIONAL host identity for the pre-import factory call
789
+ * (provider plugins Task 9, review P1): the slug derived from the LOAD
790
+ * SPECIFIER — the package's own manifest name when the entry is
791
+ * path-shaped (packages-dir installs, git checkouts, dev checkouts:
792
+ * the nearest `package.json` at or above the entry, never read across
793
+ * a `node_modules` boundary — the packages root's own manifest is not
794
+ * an extension's identity), else the specifier's name part
795
+ * (`slugifyName` folds scoped names and `@version`s; an `npm:` install
796
+ * prefix is stripped). `""` when nothing survives (the pre-fix
797
+ * placeholder shape — an absent settings file reads as `{}` either
798
+ * way, but `""` never invents a wrong-but-plausible scope).
799
+ *
800
+ * Why a provisional at all: a factory that reads scoped settings
801
+ * DURING its body (sigrun-pareto's `baseUrl` override) runs BEFORE the
802
+ * declared name is known; with the `""` placeholder its read hit
803
+ * `settings/.json` and the override was silently inert. Pre-keying on
804
+ * the specifier-derived slug makes the factory-time read resolve the
805
+ * real package's file whenever the extension's declared name matches
806
+ * its package name — the documented requirement for provider plugins
807
+ * that read settings at factory time. A mismatching declared name
808
+ * still re-keys to the declared slug after resolution; only its
809
+ * factory-time settings reads saw the provisional scope.
810
+ */
811
+ function provisionalSlugFor(loaded) {
812
+ let absolute;
813
+ if (loaded.startsWith("file://")) {
814
+ try {
815
+ absolute = fileURLToPath(loaded);
816
+ }
817
+ catch {
818
+ // Not a usable file URL — fall through to the specifier form.
819
+ }
820
+ }
821
+ else if (loaded.startsWith("/") ||
822
+ loaded.startsWith("./") ||
823
+ loaded.startsWith("../")) {
824
+ absolute = resolve(loaded);
825
+ }
826
+ if (absolute !== undefined) {
827
+ // Bounded walk from the entry itself (a directory entry carries
828
+ // its manifest at the top) up its ancestors.
829
+ let dir = absolute;
830
+ for (let hops = 0; hops < 6; hops++) {
831
+ if (basename(dir) === "node_modules")
832
+ return "";
833
+ const name = readPackageName(dir);
834
+ if (name !== undefined) {
835
+ const slug = slugifyName(name);
836
+ if (slug !== "")
837
+ return slug;
838
+ }
839
+ const parent = dirname(dir);
840
+ if (parent === dir)
841
+ return "";
842
+ dir = parent;
843
+ }
844
+ return "";
845
+ }
846
+ return slugifyName(loaded.replace(/^npm:/, ""));
847
+ }
848
+ /**
849
+ * Scan the installed registry snapshot for tool names registered by more
850
+ * than one extension, and `console.warn` each collision (Task 7, spec
851
+ * slice 4): pi-durable resolves tools by NAME (`resolveAgent` composes a
852
+ * name→tool map in install order, so the LAST registration wins and the
853
+ * earlier one is shadowed silently). The scan changes nothing — last-wins
854
+ * is pi-durable's behavior — it only makes the shadowing visible at boot,
855
+ * which is the surface a deployer needs to notice a co-installed
856
+ * replacement extension (the slice-4 replacement contract is "do not
857
+ * co-install", so any collision here is a configuration mistake).
858
+ */
859
+ function warnToolNameCollisions(registry) {
860
+ const byName = new Map();
861
+ for (const { extension, tool } of registry.snapshot().tools()) {
862
+ const owners = byName.get(tool.name) ?? [];
863
+ owners.push({ extension: extension.name });
864
+ byName.set(tool.name, owners);
865
+ }
866
+ for (const [toolName, owners] of byName) {
867
+ if (owners.length < 2)
868
+ continue;
869
+ const names = owners.map((owner) => owner.extension).join(", ");
870
+ console.warn(`sigrun: duplicate tool name "${toolName}" registered by extensions ` +
871
+ `[${names}]; the last-listed registration wins (pi-durable resolves ` +
872
+ `tool names last-selected-wins) — do not co-install extensions ` +
873
+ `that register the same tool name`);
874
+ }
875
+ }
876
+ /**
877
+ * Load and install every configured extension, quarantining failures.
878
+ *
879
+ * @param specifiers package specifiers from `SIGRUN_EXTENSIONS` (npm names,
880
+ * absolute/relative paths, or `file://` URLs) — or the test seam's explicit
881
+ * list.
882
+ * @param registry the application-owned registry; install publishes at once,
883
+ * so extensions land before `Harness.open` reads the registry.
884
+ * @returns the outcome list (one entry per specifier, in order — never
885
+ * empty-entry gaps; boot stores this for `GET /v1/meta`, Task 9) plus the
886
+ * §3 health producers and submit pass-throughs retained from factory
887
+ * results, paired with their extensions for identity correlation (Task 9e,
888
+ * Task 26a).
889
+ */
890
+ export async function loadExtensions(specifiers, registry, options = {}) {
891
+ // Normalize: the caller may hand pre-resolved entries (the boot flow,
892
+ // syncPackages consumer) or raw specifiers (the test seam, env-only
893
+ // deploys). Each becomes {entry, label, skills} — label drives the warning
894
+ // name exactly as specifiers did; an ABSENT entry with skills is the
895
+ // skills-only resolution (§9.4).
896
+ const entries = specifiers.map((spec) => typeof spec === "string"
897
+ ? { name: undefined, entry: spec, skills: undefined, builtin: undefined, coreResolved: undefined, packageDir: undefined }
898
+ : { name: spec.name, entry: spec.entry, skills: spec.skills, builtin: spec.builtin, coreResolved: spec.coreResolved, packageDir: spec.packageDir });
899
+ const outcomes = [];
900
+ const providerContributors = [];
901
+ const webuiBundles = [];
902
+ const healthProducers = [];
903
+ const runSettledBindings = [];
904
+ const lifecycleBindings = [];
905
+ const workerBindings = [];
906
+ // Jobs Task 3: declared jobs, attributed to the extension slug (the
907
+ // `ext:<slug>:<name>` id's namespace).
908
+ const jobBindings = [];
909
+ // HTTP routes (W5 Task 2), attributed to the slug like the job bindings.
910
+ const routeBindings = [];
911
+ // Slash commands (W5 Task 3), attributed the same way.
912
+ const commandBindings = [];
913
+ // Notifier transports (W5 Task 6, spec §9.6), attributed the same way —
914
+ // the server's notifications surface delivers to every collected entry.
915
+ const notifierBindings = [];
916
+ // Skill roots (W5 Task 4, spec §9.4): every skill dir a loaded package
917
+ // contributed — skills-only resolutions, entry-bearing packages' manifest
918
+ // skills, and factory-result `skills` fields — in load order, attributed
919
+ // to the extension slug. Boot threads this to the skills section's
920
+ // package-roots option.
921
+ const skillRoots = [];
922
+ // Slug → declared name, for collision rejection at load (this boot's
923
+ // loaded set only — quarantined extensions never claim a slug).
924
+ const slugs = new Map();
925
+ for (const { name: label, entry, skills, builtin, coreResolved, packageDir } of entries) {
926
+ const display = label ?? entry ?? "skills-only";
927
+ try {
928
+ // Skills-only resolution (§9.4): the package declared skill dirs and
929
+ // has NO loadable entry — nothing is imported. The outcome records
930
+ // `ok` under the slug of the package name; the dirs (validated as
931
+ // existing directories — the sync filtered, a violation here is a
932
+ // contract break) append to skillRoots. Inside the per-entry try so
933
+ // a bad declaration QUARANTINES instead of aborting the boot.
934
+ if (entry === undefined) {
935
+ if (skills === undefined || skills.length === 0) {
936
+ throw new Error("a pre-resolved input carried neither an entry nor skill directories");
937
+ }
938
+ const skillsOnlyName = label ?? "skills-only";
939
+ const slug = slugifyName(skillsOnlyName);
940
+ if (slug === "") {
941
+ throw new Error(`skills-only package name "${skillsOnlyName}" sanitizes to an empty slug (nothing survives [a-z0-9_-] folding); refusing to load`);
942
+ }
943
+ for (const dir of skills) {
944
+ if (!existingDirectory(dir)) {
945
+ throw new Error(`skills: contributed directory ${dir} does not exist (or is not a directory)`);
946
+ }
947
+ }
948
+ // All-or-nothing (the quarantined-contributions invariant): the
949
+ // dirs append only once every one validated.
950
+ for (const dir of skills) {
951
+ skillRoots.push({ extension: slug, dir });
952
+ }
953
+ outcomes.push({ name: slug, status: "ok" });
954
+ continue;
955
+ }
956
+ // Pre-resolved entries (the boot flow) import verbatim;
957
+ // raw specifiers (the test seam) resolve through the
958
+ // packages dir first, then bare import resolution.
959
+ const loaded = coreResolved === true
960
+ ? entry
961
+ : label !== undefined
962
+ ? entry
963
+ : (options.packagesDir === undefined
964
+ ? entry
965
+ : (resolveExtensionEntry(entry, options.packagesDir) ?? entry));
966
+ // Resolution order (the packages-dir ruling): the deployment's
967
+ // packages dir FIRST (what `packages[]` installed wins over
968
+ // anything the image bundles), then bare import resolution
969
+ // (paths, file:// URLs, dev checkouts, app-tree packages). The
970
+ // packages dir is an unrelated node_modules tree — Node's
971
+ // upward walk never finds it from sigrun's own imports, so it
972
+ // must be consulted explicitly.
973
+ // A resolved entry ending in `.ts` is rejected BEFORE the import
974
+ // (build-and-webui Task 3, BREAKING): the built server ships no
975
+ // loader — a `.ts` entry is a from-source artifact whose behavior
976
+ // depends on the host node's type-stripping flags (newer node
977
+ // loads it, older node crashes with ERR_UNKNOWN_FILE_EXTENSION).
978
+ // Sources must ship a BUILT entry; the quarantine names it.
979
+ if (/\.ts$/.test(loaded)) {
980
+ throw new Error(`the resolved entry is a TypeScript source (${loaded}) — build the package and ship its built entry (dist/index.js); the built server loads JavaScript`);
981
+ }
982
+ const module = (await import(loaded));
983
+ // Host-module version guard (spec §6.4 R6): the module tree the
984
+ // extension's bare imports resolve from must agree with the copy
985
+ // the host runs — checked immediately after the import, BEFORE any
986
+ // host keying or contract resolution, so a skewing extension leaves
987
+ // no trace (no seam host, no bindings, no registry entry). A throw
988
+ // here rides the existing per-extension try (quarantine, boot
989
+ // continues) exactly like the reserved-section check below.
990
+ const moduleDir = extensionModuleDir(loaded);
991
+ if (moduleDir !== undefined)
992
+ checkHostModules(moduleDir);
993
+ // Two-phase host: the factory receives the host at CALL time, but
994
+ // the extension name and kinds it declares are only known from its
995
+ // RESULT. The loader therefore invokes the host factory TWICE with
996
+ // the SAME instance: once PRE-import with the PROVISIONAL identity
997
+ // (the slug derived from the load specifier — `provisionalSlugFor`;
998
+ // "" when nothing derives) so a factory that reads scoped settings
999
+ // during its body resolves the real package's store (provider
1000
+ // plugins Task 9 P1 — the pre-fix "" placeholder made sigrun-pareto's
1001
+ // `baseUrl` override silently inert), and once post-resolution with
1002
+ // the declared name + kinds PLUS the captured instance — boot's
1003
+ // factory re-keys that SAME host object IN PLACE (SeamHost.rekey),
1004
+ // so the instance the factory captured becomes the final host and
1005
+ // its write gate sees the declared kinds from then on. The
1006
+ // provisional normally EQUALS the resolved slug (extension name ==
1007
+ // package name); when it differs the re-key still corrects the
1008
+ // scope — only factory-time settings reads of such an extension
1009
+ // saw the provisional scope (documented: read scoped settings at
1010
+ // factory time only when the plugin name matches its package name).
1011
+ let host;
1012
+ if (options.hostContextFactory !== undefined) {
1013
+ host = options.hostContextFactory(provisionalSlugFor(loaded), {});
1014
+ }
1015
+ const resolved = await resolveExtension(module, host);
1016
+ if (resolved === undefined) {
1017
+ throw new Error(`does not satisfy the sigrun extension-package contract ` +
1018
+ `(expected a default-export factory returning { extension })`);
1019
+ }
1020
+ // Task 3 — the slug IS the extension identity from here down: ONE
1021
+ // sanitization point, computed from the DECLARED name at load and
1022
+ // applied before the seam host is re-keyed and before the registry
1023
+ // install — every downstream surface (registry key, seam-host
1024
+ // identity, interaction/ambient records, worker names, health
1025
+ // correlation) sees the slug, never the declared name. Rejections
1026
+ // ride the LOAD (Review Focus 2): an empty slug or a collision
1027
+ // quarantines HERE naming the declared name(s), never the first
1028
+ // store use mid-run.
1029
+ const declared = resolved.extension !== undefined
1030
+ ? resolved.extension.name
1031
+ : resolved.name;
1032
+ if (declared === undefined) {
1033
+ // Unreachable by construction (provider-only results validate
1034
+ // `name`; every other form carries an `extension`) — but the
1035
+ // identity rule fails loud rather than slugifying `undefined`.
1036
+ throw new Error("factory result carried neither an extension nor a contributor name");
1037
+ }
1038
+ const slug = slugifyName(declared);
1039
+ if (slug === "") {
1040
+ throw new Error(`extension name "${declared}" sanitizes to an empty slug ` +
1041
+ "(nothing survives [a-z0-9_-] folding); refusing to load");
1042
+ }
1043
+ const collides = slugs.get(slug);
1044
+ if (collides !== undefined) {
1045
+ throw new Error(`slug collision: extension "${declared}" sanitizes to ` +
1046
+ `"${slug}", already claimed by "${collides}" — extension ` +
1047
+ "identities must be unique after sanitization");
1048
+ }
1049
+ // Rename at the ONE point: a shallow copy with the slug as `name`
1050
+ // (the Extension contract is readonly; the declared object is
1051
+ // never mutated). Every binding below pairs with THIS object so
1052
+ // boot's identity correlation (health/submit/memory-owner
1053
+ // matching) stays exact.
1054
+ const extension = resolved.extension === undefined
1055
+ ? undefined
1056
+ : { ...resolved.extension, name: slug };
1057
+ // Reserved core section keys (spec §6.5 R11): a section under one
1058
+ // of them is a LOAD-TIME quarantine, checked HERE — after the slug
1059
+ // is final and BEFORE anything is collected. The registry would
1060
+ // otherwise accept the duplicate (sections are last-writer-wins
1061
+ // across extensions), silently replacing a core surface; the
1062
+ // existing catch quarantines with this message instead, leaving
1063
+ // nothing installed (no registry entry), no post-resolution host
1064
+ // re-key (boot's seamHosts never files the entry), and no
1065
+ // provider/health/submit/runSettled/lifecycle/worker/webui
1066
+ // binding collected. Boot's post-install walk over
1067
+ // RESERVED_CORE_SECTION_OWNERS stays as a core-only assertion.
1068
+ const reservedSectionKey = extension?.sections?.find((section) => RESERVED_SECTION_KEY_SET.has(section.key))?.key;
1069
+ if (reservedSectionKey !== undefined) {
1070
+ throw new Error(`extension "${slug}" declares reserved core section "${reservedSectionKey}"`);
1071
+ }
1072
+ // Skill contributions (W5 Task 4), validated HERE — before the
1073
+ // install/outcome, so a bad declaration quarantines with nothing
1074
+ // installed and exactly one outcome (the same point as every
1075
+ // other fail-loud contract check). The factory-result dirs were
1076
+ // already validated at read time (validateSkills, all-or-nothing);
1077
+ // the MANIFEST dirs (the sync's resolution, existing by
1078
+ // construction) are re-checked because a hand-built pre-resolved
1079
+ // input skips the sync.
1080
+ const contributed = [...(resolved.skills ?? []), ...(skills ?? [])];
1081
+ for (const dir of contributed) {
1082
+ if (!existingDirectory(dir)) {
1083
+ throw new Error(`skills: contributed directory ${dir} does not exist (or is not a directory)`);
1084
+ }
1085
+ }
1086
+ // The post-resolution call re-keys the captured host instance in
1087
+ // place with the slug + declared kinds (boot's
1088
+ // hostContextFactory; see src/harness/boot.ts) — the instance is
1089
+ // handed back explicitly as `priorHost`, so even a provisional ≠
1090
+ // resolved mismatch re-keys the SAME instance. No second host is
1091
+ // ever created — a rebuilt instance would orphan every inbound
1092
+ // handler the factory registered on the host it captured.
1093
+ if (options.hostContextFactory !== undefined) {
1094
+ const kinds = {
1095
+ ...(resolved.interactionKinds === undefined
1096
+ ? {}
1097
+ : { interaction: resolved.interactionKinds }),
1098
+ ...(resolved.ambientKinds === undefined
1099
+ ? {}
1100
+ : { ambient: resolved.ambientKinds }),
1101
+ };
1102
+ options.hostContextFactory(slug, kinds, host);
1103
+ }
1104
+ if (extension !== undefined) {
1105
+ registry.install(extension);
1106
+ }
1107
+ // Only a LOADED extension or contributor claims its slug — a
1108
+ // later stage's quarantine (never loaded) leaves the slug free
1109
+ // for the next boot's specifier list.
1110
+ slugs.set(slug, declared);
1111
+ // The manifest version for the §7.7 inventory: read off the
1112
+ // module's own directory (a bare entry with no manifest stays
1113
+ // versionless — the field is optional by design).
1114
+ const versionDir = packageDir ?? extensionModuleDir(loaded);
1115
+ const version = versionDir === undefined ? undefined : readPackageVersion(versionDir);
1116
+ outcomes.push({
1117
+ name: slug,
1118
+ status: "ok",
1119
+ ...(version === undefined ? {} : { version }),
1120
+ ...(builtin === true ? { builtin: true } : {}),
1121
+ });
1122
+ if (extension !== undefined && resolved.health !== undefined) {
1123
+ healthProducers.push({
1124
+ extension,
1125
+ health: resolved.health,
1126
+ });
1127
+ }
1128
+ // §6.7 retirements, warned ONCE at load (never quarantined): a
1129
+ // result resolved through the named-export bridge, and a result still
1130
+ // carrying the retired submit pass-through. Both keep loading —
1131
+ // the pre-contract `createMemory` named-factory bridge was deleted
1132
+ // in release-0.1 (its last consumer exports default now), and the
1133
+ // submit is simply ignored (the memory seam owns capture now).
1134
+ if (resolved.resolvedVia !== undefined) {
1135
+ console.warn(`extension ${display}: named-export factory "${resolved.resolvedVia}" is deprecated — export it as default`);
1136
+ }
1137
+ if (resolved.retiredSubmit === true) {
1138
+ console.warn(`extension "${slug}": the submit pass-through was retired; ignoring`);
1139
+ }
1140
+ if (extension !== undefined && resolved.runSettled !== undefined) {
1141
+ runSettledBindings.push({
1142
+ extension,
1143
+ runSettled: resolved.runSettled,
1144
+ });
1145
+ }
1146
+ if (extension !== undefined && resolved.lifecycle !== undefined) {
1147
+ lifecycleBindings.push({
1148
+ extension,
1149
+ handlers: resolved.lifecycle,
1150
+ });
1151
+ }
1152
+ if (extension !== undefined && resolved.workers !== undefined) {
1153
+ workerBindings.push({
1154
+ extension,
1155
+ workers: resolved.workers,
1156
+ });
1157
+ }
1158
+ if (resolved.jobs !== undefined) {
1159
+ jobBindings.push({ extension: slug, jobs: resolved.jobs });
1160
+ }
1161
+ if (resolved.routes !== undefined) {
1162
+ routeBindings.push({ extension: slug, routes: resolved.routes });
1163
+ }
1164
+ if (resolved.commands !== undefined) {
1165
+ commandBindings.push({ extension: slug, commands: resolved.commands });
1166
+ }
1167
+ if (resolved.notifiers !== undefined) {
1168
+ notifierBindings.push({ extension: slug, notifiers: resolved.notifiers });
1169
+ }
1170
+ if (resolved.providers !== undefined) {
1171
+ providerContributors.push({
1172
+ name: slug,
1173
+ providers: resolved.providers,
1174
+ ...(resolved.health === undefined
1175
+ ? {}
1176
+ : { health: resolved.health }),
1177
+ });
1178
+ }
1179
+ // Webui-bundle contributions (build-and-webui Task 6): each entry
1180
+ // is attributed to the extension slug and its `dir` anchored at
1181
+ // the MODULE's directory — resolved HERE, the one point the module
1182
+ // URL is known (an npm-installed extension's bundle lives in its
1183
+ // own package, never the server cwd). Absolute dirs pass through
1184
+ // `resolve` verbatim; the server owns the index.html check at
1185
+ // serve time (the loader has no fs coupling by contract).
1186
+ if (resolved.webuiBundles !== undefined) {
1187
+ const moduleDir = extensionModuleDir(loaded);
1188
+ for (const entry of resolved.webuiBundles) {
1189
+ webuiBundles.push({
1190
+ name: entry.name,
1191
+ dir: moduleDir === undefined || isAbsolute(entry.dir)
1192
+ ? entry.dir
1193
+ : resolve(moduleDir, entry.dir),
1194
+ extension: slug,
1195
+ });
1196
+ }
1197
+ }
1198
+ // Skill contributions (W5 Task 4): the validated combined list
1199
+ // (factory-result + manifest dirs — validated above, before the
1200
+ // install), attributed to the extension slug, in load order.
1201
+ for (const dir of contributed) {
1202
+ skillRoots.push({ extension: slug, dir });
1203
+ }
1204
+ }
1205
+ catch (cause) {
1206
+ const message = cause instanceof Error ? cause.message : String(cause);
1207
+ console.warn(`extension quarantined: ${display} failed to load: ${message}`);
1208
+ outcomes.push({
1209
+ name: display,
1210
+ status: "quarantined",
1211
+ error: message,
1212
+ ...(builtin === true ? { builtin: true } : {}),
1213
+ });
1214
+ // A quarantined extension contributes no health producer or submit
1215
+ // binding: the §3 surfaces are reported through
1216
+ // `extensions[].status`, never double-reported.
1217
+ }
1218
+ }
1219
+ // After all installs: surface tool-name collisions across extensions
1220
+ // (last-wins is pi-durable's resolution — unchanged, only warned).
1221
+ warnToolNameCollisions(registry);
1222
+ return {
1223
+ outcomes,
1224
+ providerContributors,
1225
+ webuiBundles,
1226
+ skillRoots,
1227
+ healthProducers,
1228
+ runSettledBindings,
1229
+ lifecycleBindings,
1230
+ workerBindings,
1231
+ jobBindings,
1232
+ routeBindings,
1233
+ commandBindings,
1234
+ notifierBindings,
1235
+ };
1236
+ }