@sigrun/sigrun 0.1.3 → 0.1.5

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 (79) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +24 -2
  3. package/dist/blocks/builders.js +125 -0
  4. package/dist/blocks/images.js +57 -0
  5. package/dist/blocks/index.js +20 -0
  6. package/dist/blocks/limits.js +26 -0
  7. package/dist/blocks/placements.js +99 -0
  8. package/dist/blocks/schema.js +559 -0
  9. package/dist/blocks/validate.js +602 -0
  10. package/dist/config.js +31 -0
  11. package/dist/extension.d.ts +476 -9
  12. package/dist/harness/boot.js +8 -1
  13. package/dist/harness/event-bus.js +33 -0
  14. package/dist/harness/identity/users.js +3 -2
  15. package/dist/harness/loader.js +369 -13
  16. package/dist/harness/packages.js +71 -2
  17. package/dist/harness/seam-host.js +69 -2
  18. package/dist/harness/settings.js +5 -0
  19. package/dist/jobs/fire.js +11 -0
  20. package/dist/jobs/spec.js +19 -0
  21. package/dist/serve.js +33 -41
  22. package/dist/server/auth/pipeline.js +16 -1
  23. package/dist/server/conversation-ops.js +482 -10
  24. package/dist/server/gateway.js +122 -0
  25. package/dist/server/helpers.js +26 -0
  26. package/dist/server/host-bridge.js +141 -4
  27. package/dist/server/index.js +27 -0
  28. package/dist/server/notifications.js +6 -2
  29. package/dist/server/routes/conversations.js +37 -93
  30. package/dist/server/routes/events.js +39 -0
  31. package/dist/server/routes/ext-assets.js +84 -0
  32. package/dist/server/routes/extensions.js +31 -1
  33. package/dist/server/routes/files.js +38 -83
  34. package/dist/server/routes/meta.js +37 -2
  35. package/dist/server/routes/providers.js +5 -21
  36. package/dist/server/routes/submissions.js +36 -111
  37. package/dist/server/routes/surfaces.js +387 -0
  38. package/dist/server/routes/users.js +6 -1
  39. package/dist/server/schema-fixture.js +7 -0
  40. package/dist/server/session-meta.js +12 -6
  41. package/dist/server/shutdown.js +85 -0
  42. package/dist/server/surfaces/access-cache.js +43 -0
  43. package/dist/server/surfaces/dedup.js +67 -0
  44. package/dist/server/surfaces/instance.js +42 -0
  45. package/dist/server/surfaces/mime.js +73 -0
  46. package/dist/server/surfaces/rate-limit.js +63 -0
  47. package/dist/server/surfaces/service.js +493 -0
  48. package/dist/server/surfaces/watchers.js +94 -0
  49. package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
  50. package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
  51. package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
  53. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
  54. package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
  55. package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
  56. package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
  57. package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
  58. package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
  59. package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
  60. package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
  61. package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
  62. package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
  63. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
  64. package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
  65. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
  66. package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
  67. package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
  68. package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
  69. package/node_modules/@sigrun/shared/dist/live.js +82 -0
  70. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
  71. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
  72. package/node_modules/@sigrun/shared/dist/views.d.ts +83 -3
  73. package/node_modules/@sigrun/shared/package.json +10 -2
  74. package/package.json +9 -3
  75. package/webui/dist/assets/index-B4-GYqEb.js +50 -0
  76. package/webui/dist/assets/index-BiSMljer.css +1 -0
  77. package/webui/dist/index.html +2 -2
  78. package/webui/dist/assets/index-2RfqJO57.css +0 -1
  79. package/webui/dist/assets/index-DkZgTngD.js +0 -50
@@ -82,12 +82,13 @@
82
82
  * factory-result key the loader reads is a key of `SigrunExtensionResult`.
83
83
  * The implementation and the contract cannot drift silently.
84
84
  */
85
+ import { ICON_NAMES } from "@sigrun/shared/blocks";
85
86
  import { validateJobSpec } from "../jobs/spec.js";
86
87
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
87
88
  import { createRequire } from "node:module";
88
89
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
89
90
  import { fileURLToPath } from "node:url";
90
- import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
91
+ import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageAssetsDir, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
91
92
  import { RESERVED_CORE_SECTION_KEYS } from "./reserved-sections.js";
92
93
  import { slugifyName } from "./stores.js";
93
94
  /** Every lifecycle field name, in validation order (mirrors the contract doc). */
@@ -96,6 +97,7 @@ const LIFECYCLE_FIELDS = [
96
97
  "conversationForked",
97
98
  "conversationArchived",
98
99
  "conversationIdle",
100
+ "interactionSettled",
99
101
  "harnessShutdown",
100
102
  "userDeleted",
101
103
  ];
@@ -145,6 +147,7 @@ const WAKE_EVENTS = new Set([
145
147
  "conversationForked",
146
148
  "conversationArchived",
147
149
  "conversationIdle",
150
+ "interactionSettled",
148
151
  ]);
149
152
  /**
150
153
  * The `every` retirement message (ruling 12, jobs Task 3): jobs are the
@@ -232,10 +235,113 @@ function validateJobs(value) {
232
235
  return specs;
233
236
  }
234
237
  /**
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
+ * The icon-name membership probe (spec §10.2): the closed set from
239
+ * `@sigrun/shared/blocks` — a `Set` so `unknown`-typed field values can
240
+ * be tested without a cast.
238
241
  */
242
+ const ICON_NAME_SET = new Set(ICON_NAMES);
243
+ /** The refreshMs floor (spec §7.1): channels refetch no faster than 5 s. */
244
+ const MIN_REFRESH_MS = 5000;
245
+ /**
246
+ * Validate a factory result's `surfaces` declaration (UI-blocks U3
247
+ * Task 1, spec §7.1/§15). Absent → undefined (no surfaces). Present
248
+ * but malformed → throw with ONE message per problem; the loader
249
+ * quarantines the extension with the thrown message (fail-loud: an
250
+ * extension that THINKS it declared a surface must not silently lose
251
+ * it — the providers/health class). Per def: non-empty string `id`
252
+ * (unique across BOTH lists — one namespace), string `title`, `icon`
253
+ * in the closed set, `placement` exactly `view` (in `views`) or
254
+ * `panel` (in `panels`), `sharedData` a boolean, `actions` an object
255
+ * whose every handler is a function (message names the action),
256
+ * `refreshMs ≥ 5000` when present, `data()` a function when
257
+ * `refreshMs` is set (a data refetch would otherwise re-run `render`,
258
+ * §7.1), `render` a function, and NO `settings` key (reserved — the
259
+ * §13.3 sections are a separate workstream). Returns the
260
+ * FLATTENED defs (views then panels, declaration order) attributed to
261
+ * the slug at collection.
262
+ */
263
+ function validateSurfaces(value) {
264
+ if (value === undefined)
265
+ return undefined;
266
+ if (typeof value !== "object" || Array.isArray(value)) {
267
+ throw new Error("extension factory returned a surfaces field that is not an object");
268
+ }
269
+ // The settings sections key is RESERVED (spec §7.1 names it; §13.3
270
+ // owns it): this workstream cannot serve it, and a JS package
271
+ // declaring one must not lose it silently — quarantine loud, naming
272
+ // the workstream (TS consumers are already stopped by the contract's
273
+ // `readonly never[]`).
274
+ if (value.settings !== undefined) {
275
+ throw new Error("extension factory returned a surfaces.settings declaration — settings sections are a separate workstream (they land in U4)");
276
+ }
277
+ const defs = [];
278
+ const seen = new Set();
279
+ for (const list of ["views", "panels"]) {
280
+ const entries = value[list];
281
+ if (entries === undefined)
282
+ continue;
283
+ if (!Array.isArray(entries)) {
284
+ throw new Error(`extension factory returned a surfaces.${list} field that is not an array`);
285
+ }
286
+ const expected = list === "views" ? "view" : "panel";
287
+ for (const entry of entries) {
288
+ const def = entry;
289
+ if (typeof def?.id !== "string" || def.id.length === 0) {
290
+ throw new Error("extension factory returned a surface whose id is missing or not a non-empty string");
291
+ }
292
+ if (seen.has(def.id)) {
293
+ throw new Error(`extension factory returned a duplicate surface id "${def.id}"`);
294
+ }
295
+ seen.add(def.id);
296
+ if (typeof def?.title !== "string" || def.title.length === 0) {
297
+ throw new Error(`extension factory returned a surface "${def.id}" whose title is missing or not a non-empty string`);
298
+ }
299
+ if (typeof def?.icon !== "string" || !ICON_NAME_SET.has(def.icon)) {
300
+ throw new Error(`extension factory returned a surface "${def.id}" with unknown icon "${String(def?.icon)}"`);
301
+ }
302
+ if (def?.placement !== expected) {
303
+ throw new Error(`extension factory returned a surface "${def.id}" whose placement must be "${expected}" in surfaces.${list}`);
304
+ }
305
+ if (def?.instanceKey !== undefined && typeof def.instanceKey !== "function") {
306
+ throw new Error(`extension factory returned a surface "${def.id}" whose instanceKey is not a function`);
307
+ }
308
+ if (def?.sharedData !== undefined &&
309
+ typeof def.sharedData !== "boolean") {
310
+ throw new Error(`extension factory returned a surface "${def.id}" whose sharedData is not a boolean`);
311
+ }
312
+ if (def?.actions !== undefined) {
313
+ if (typeof def.actions !== "object" ||
314
+ def.actions === null ||
315
+ Array.isArray(def.actions)) {
316
+ throw new Error(`extension factory returned a surface "${def.id}" whose actions is not an object`);
317
+ }
318
+ for (const [action, handler] of Object.entries(def.actions)) {
319
+ if (typeof handler !== "function") {
320
+ throw new Error(`extension factory returned a surface "${def.id}" whose action "${action}" handler is not a function`);
321
+ }
322
+ }
323
+ }
324
+ if (typeof def?.render !== "function") {
325
+ throw new Error(`extension factory returned a surface "${def.id}" whose render is not a function`);
326
+ }
327
+ if (def?.data !== undefined && typeof def.data !== "function") {
328
+ throw new Error(`extension factory returned a surface "${def.id}" whose data is not a function`);
329
+ }
330
+ if (def?.refreshMs !== undefined) {
331
+ if (typeof def.refreshMs !== "number" ||
332
+ !Number.isInteger(def.refreshMs) ||
333
+ def.refreshMs < MIN_REFRESH_MS) {
334
+ throw new Error(`extension factory returned a surface "${def.id}" declaring refreshMs ${String(def.refreshMs)} (minimum ${MIN_REFRESH_MS})`);
335
+ }
336
+ if (def?.data === undefined) {
337
+ throw new Error(`extension factory returned a surface "${def.id}" declaring refreshMs without data()`);
338
+ }
339
+ }
340
+ defs.push(entry);
341
+ }
342
+ }
343
+ return defs;
344
+ }
239
345
  const ROUTE_METHODS = new Set([
240
346
  "GET",
241
347
  "POST",
@@ -244,6 +350,13 @@ const ROUTE_METHODS = new Set([
244
350
  "DELETE",
245
351
  ]);
246
352
  const ROUTE_AUTHS = new Set(["user", "admin", "public"]);
353
+ /**
354
+ * The route-path prefixes reserved for core under `/v1/ext/<slug>/`
355
+ * (spec §7.2): `surfaces/` and `assets/` are the extension-surface
356
+ * and asset mounts — an extension route there would shadow them, so
357
+ * declaring one quarantines at load, naming the route.
358
+ */
359
+ const RESERVED_ROUTE_PREFIXES = ["surfaces/", "assets/"];
247
360
  /**
248
361
  * Validate a factory result's `routes` declaration (core hardening W5
249
362
  * Task 2, spec §9.2). Absent → undefined (no routes). Present but
@@ -251,9 +364,10 @@ const ROUTE_AUTHS = new Set(["user", "admin", "public"]);
251
364
  * thrown message (fail-loud: an extension that THINKS it mounted a
252
365
  * webhook must not silently lose it). Per entry: `method` in the five
253
366
  * 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.
367
+ * nothing may escape the `/v1/ext/<slug>/` mount, and never under a
368
+ * RESERVED core prefix — §7.2), `auth` one of the three values,
369
+ * `bodyLimit` a positive integer when present, and `handler` a
370
+ * function.
257
371
  */
258
372
  function validateRoutes(value) {
259
373
  if (value === undefined)
@@ -273,6 +387,13 @@ function validateRoutes(value) {
273
387
  route.path.includes("..")) {
274
388
  throw new Error(`extension factory returned a route whose path must be a non-empty relative string (no leading "/", no "..")`);
275
389
  }
390
+ // Reserved core prefixes (spec §7.2): surfaces/ and assets/ belong
391
+ // to core under this slug's mount — quarantine naming the route.
392
+ const path = route?.path;
393
+ if (typeof path === "string" &&
394
+ RESERVED_ROUTE_PREFIXES.some((prefix) => path.startsWith(prefix))) {
395
+ throw new Error(`extension factory returned a ${describe()} whose path starts with a reserved core prefix (surfaces/ and assets/ belong to core under /v1/ext/<slug>/)`);
396
+ }
276
397
  if (typeof route?.auth !== "string" || !ROUTE_AUTHS.has(route.auth)) {
277
398
  throw new Error(`extension factory returned a ${describe()} whose auth is not one of user, admin, public`);
278
399
  }
@@ -355,6 +476,156 @@ function validateNotifiers(value) {
355
476
  }
356
477
  return value;
357
478
  }
479
+ /**
480
+ * The channel-id grammar (gateway C1, spec §7): `^[a-z][a-z0-9-]*$` —
481
+ * the same shape as every other extension-declared name (commands,
482
+ * workers, bundles), and the key the gateway engine's chat bindings are
483
+ * addressed by (why a collision quarantines instead of last-wins).
484
+ */
485
+ const CHANNEL_ID = /^[a-z][a-z0-9-]*$/;
486
+ /** The `chatTypes` vocabulary of a `capabilities` declaration (spec §7). */
487
+ const CHANNEL_CHAT_TYPES = new Set([
488
+ "dm",
489
+ "group",
490
+ "thread",
491
+ ]);
492
+ /** The `capabilities.format` vocabulary (spec §7). */
493
+ const CHANNEL_FORMATS = new Set([
494
+ "plain",
495
+ "markdown",
496
+ "native",
497
+ ]);
498
+ /**
499
+ * Validate a factory result's `channels` declaration (gateway C1, spec
500
+ * §7). Absent → undefined (no contributions). Present but malformed →
501
+ * throw; the loader quarantines the extension with the thrown message
502
+ * (fail-loud: an adapter that THINKS it declared a channel must not
503
+ * silently lose it — the providers/health class). Per entry: `id` matching
504
+ * the grammar, `label` a non-empty string, `capabilities` an object with
505
+ * `chatTypes` a non-empty array over the vocabulary, `maxMessageLength` a
506
+ * positive integer, `format` one of the three values, the optional
507
+ * `edit.minIntervalMs`/`typing.intervalMs`/`buttons.max` positive
508
+ * integers, `configured`/`start`/`send` functions, the optional members
509
+ * functions when present, and `format` REQUIRED when
510
+ * `capabilities.format` is `"native"` (native markup has no default
511
+ * renderer). Duplicate ids within one result reject — they would collide
512
+ * on the engine's channel-id key exactly like a cross-extension pair.
513
+ */
514
+ function validateChannels(value) {
515
+ if (value === undefined)
516
+ return undefined;
517
+ if (!Array.isArray(value)) {
518
+ throw new Error("extension factory returned a channels field that is not an array");
519
+ }
520
+ const seen = new Set();
521
+ for (const entry of value) {
522
+ const channel = entry;
523
+ const describe = () => `channel "${typeof channel?.id === "string" ? channel.id : String(channel?.id)}"`;
524
+ if (typeof channel?.id !== "string" ||
525
+ !CHANNEL_ID.test(channel.id)) {
526
+ throw new Error(`extension factory returned a ${describe()} whose id does not match ^[a-z][a-z0-9-]*$`);
527
+ }
528
+ if (seen.has(channel.id)) {
529
+ throw new Error(`extension factory returned a duplicate channel id "${channel.id}" within one result`);
530
+ }
531
+ seen.add(channel.id);
532
+ if (typeof channel?.label !== "string" || channel.label.length === 0) {
533
+ throw new Error(`extension factory returned a ${describe()} whose label is missing or empty`);
534
+ }
535
+ const capabilities = channel?.capabilities;
536
+ if (typeof capabilities !== "object" || capabilities === null) {
537
+ throw new Error(`extension factory returned a ${describe()} whose capabilities is missing or not an object`);
538
+ }
539
+ if (!Array.isArray(capabilities.chatTypes) ||
540
+ capabilities.chatTypes.length === 0 ||
541
+ capabilities.chatTypes.some((type) => typeof type !== "string" || !CHANNEL_CHAT_TYPES.has(type))) {
542
+ throw new Error(`extension factory returned a ${describe()} whose capabilities.chatTypes is not a non-empty array over dm, group, thread`);
543
+ }
544
+ if (typeof capabilities.maxMessageLength !== "number" ||
545
+ !Number.isInteger(capabilities.maxMessageLength) ||
546
+ capabilities.maxMessageLength <= 0) {
547
+ throw new Error(`extension factory returned a ${describe()} whose capabilities.maxMessageLength is not a positive integer`);
548
+ }
549
+ if (typeof capabilities.format !== "string" ||
550
+ !CHANNEL_FORMATS.has(capabilities.format)) {
551
+ throw new Error(`extension factory returned a ${describe()} whose capabilities.format is not one of plain, markdown, native`);
552
+ }
553
+ // The optional numeric members: positive integers when present.
554
+ if (capabilities.edit !== undefined &&
555
+ (typeof capabilities.edit?.minIntervalMs !== "number" ||
556
+ !Number.isInteger(capabilities.edit.minIntervalMs) ||
557
+ capabilities.edit.minIntervalMs <= 0)) {
558
+ throw new Error(`extension factory returned a ${describe()} whose capabilities.edit.minIntervalMs is not a positive integer`);
559
+ }
560
+ if (capabilities.buttons !== undefined &&
561
+ (typeof capabilities.buttons?.max !== "number" ||
562
+ !Number.isInteger(capabilities.buttons.max) ||
563
+ capabilities.buttons.max <= 0)) {
564
+ throw new Error(`extension factory returned a ${describe()} whose capabilities.buttons.max is not a positive integer`);
565
+ }
566
+ if (capabilities.typing !== undefined &&
567
+ (typeof capabilities.typing?.intervalMs !== "number" ||
568
+ !Number.isInteger(capabilities.typing.intervalMs) ||
569
+ capabilities.typing.intervalMs <= 0)) {
570
+ throw new Error(`extension factory returned a ${describe()} whose capabilities.typing.intervalMs is not a positive integer`);
571
+ }
572
+ if (typeof channel?.configured !== "function") {
573
+ throw new Error(`extension factory returned a ${describe()} whose configured is not a function`);
574
+ }
575
+ if (typeof channel?.start !== "function") {
576
+ throw new Error(`extension factory returned a ${describe()} whose start is not a function`);
577
+ }
578
+ if (typeof channel?.send !== "function") {
579
+ throw new Error(`extension factory returned a ${describe()} whose send is not a function`);
580
+ }
581
+ for (const field of [
582
+ "stop",
583
+ "edit",
584
+ "delete",
585
+ "typing",
586
+ "setCommands",
587
+ "format",
588
+ "classifyError",
589
+ ]) {
590
+ if (channel[field] !== undefined && typeof channel[field] !== "function") {
591
+ throw new Error(`extension factory returned a ${describe()} whose ${field} is not a function`);
592
+ }
593
+ }
594
+ if (capabilities.format === "native" && channel?.format === undefined) {
595
+ throw new Error(`extension factory returned a ${describe()} without a format function while its capabilities.format is "native"`);
596
+ }
597
+ }
598
+ return value;
599
+ }
600
+ /**
601
+ * Validate a factory result's `gateway` declaration (gateway C2, spec
602
+ * §8.0). Absent → undefined (no engine). Present but malformed → throw;
603
+ * the loader quarantines the extension with the thrown message
604
+ * (fail-loud: an extension that THINKS it declared the engine must not
605
+ * silently lose it — the providers/health class). An object whose
606
+ * `start`/`stopIntake`/`stop`/`health` are all functions is the shape; a
607
+ * result carrying BOTH `gateway` and the `health` field is a contract
608
+ * violation (an engine reports health through `gateway.health()` — the
609
+ * `health` field is the provider-contributor surface and would
610
+ * double-report), thrown with the documented message.
611
+ */
612
+ function validateGateway(value, healthPresent) {
613
+ if (value === undefined)
614
+ return undefined;
615
+ if (typeof value !== "object" || value === null) {
616
+ throw new Error("extension factory returned a gateway field that is not an object");
617
+ }
618
+ const engine = value;
619
+ for (const field of ["start", "stopIntake", "stop", "health"]) {
620
+ if (typeof engine[field] !== "function") {
621
+ throw new Error(`extension factory returned a gateway whose ${field} is not a function`);
622
+ }
623
+ }
624
+ if (healthPresent) {
625
+ throw new Error("a gateway engine reports health through gateway.health(), not the health field");
626
+ }
627
+ return value;
628
+ }
358
629
  /**
359
630
  * Validate a factory result's `skills` declaration (core hardening W5
360
631
  * Task 4, spec §9.4). Absent → undefined (no contributions). Present but
@@ -443,15 +714,20 @@ function validateWebuiBundles(value) {
443
714
  return value;
444
715
  }
445
716
  /**
446
- * The one host module whose version must match the host EXACTLY (spec
717
+ * The host modules whose version must match the host EXACTLY (spec
447
718
  * §6.4 R6): an extension's `Extension`/tool objects flow into the HOST's
448
719
  * pi-durable registry under structural typing — a different pi-durable
449
720
  * 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).
721
+ * never fire), so any difference at all quarantines. `@sigrun/sigrun`
722
+ * joins that tier for the same reason the spec makes it a peer dependency
723
+ * (§14.1): `@sigrun/sigrun/blocks` must emit the SERVER's block-set
724
+ * version, and a different sigrun can only ever emit a different one —
725
+ * any difference at all quarantines. Every other host module only shares
726
+ * the major (a minor/patch difference warns).
452
727
  */
453
728
  const EXACT_HOST_MODULES = new Set([
454
729
  "@earendil-works/pi-durable",
730
+ "@sigrun/sigrun",
455
731
  ]);
456
732
  /** The major segment of a semver-ish version string (`"1.2.0"` → `"1"`). */
457
733
  function majorSegment(version) {
@@ -675,17 +951,25 @@ async function resolvedFromFactory(factory, hostContext) {
675
951
  const commands = validateCommands(candidate.commands);
676
952
  const skills = validateSkills(candidate.skills);
677
953
  const notifiers = validateNotifiers(candidate.notifiers);
954
+ const channels = validateChannels(candidate.channels);
955
+ // The engine (gateway C2): validated against the health-field conflict
956
+ // rule too — a result carrying both quarantines with the documented
957
+ // message (an engine reports health through gateway.health()).
958
+ const gateway = validateGateway(candidate.gateway, candidate.health !== undefined);
959
+ const surfaces = validateSurfaces(candidate.surfaces);
678
960
  const extension = isExtension(candidate.extension)
679
961
  ? candidate.extension
680
962
  : undefined;
681
963
  if (extension === undefined) {
682
964
  // 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
965
+ // present (`providers`, `webuiBundles`, `channels`); otherwise this
966
+ // factory result is not the contract form (fall through to the next
685
967
  // resolution candidate). The contributor's name is its
686
968
  // collision-record identity — without one there is nothing to
687
969
  // attribute the contributions to, which is a contract violation.
688
- if (providers === undefined && webuiBundles === undefined) {
970
+ if (providers === undefined &&
971
+ webuiBundles === undefined &&
972
+ channels === undefined) {
689
973
  return undefined;
690
974
  }
691
975
  if (typeof candidate.name !== "string" || candidate.name.length === 0) {
@@ -758,6 +1042,9 @@ async function resolvedFromFactory(factory, hostContext) {
758
1042
  ...(commands === undefined ? {} : { commands }),
759
1043
  ...(skills === undefined ? {} : { skills }),
760
1044
  ...(notifiers === undefined ? {} : { notifiers }),
1045
+ ...(channels === undefined ? {} : { channels }),
1046
+ ...(gateway === undefined ? {} : { gateway }),
1047
+ ...(surfaces === undefined ? {} : { surfaces }),
761
1048
  };
762
1049
  }
763
1050
  /**
@@ -913,6 +1200,19 @@ export async function loadExtensions(specifiers, registry, options = {}) {
913
1200
  // Notifier transports (W5 Task 6, spec §9.6), attributed the same way —
914
1201
  // the server's notifications surface delivers to every collected entry.
915
1202
  const notifierBindings = [];
1203
+ // Channel adapters (gateway C1, spec §7), attributed the same way — the
1204
+ // gateway engine's start context and the /v1/extensions inventory read
1205
+ // them. Collected only for LOADED extensions (a colliding adapter never
1206
+ // reaches here — the channelIds check below throws first).
1207
+ const channelBindings = [];
1208
+ // The gateway engine (gateway C2, spec §8.0): the ONE-engine rule — the
1209
+ // first loaded declarer wins; a later result declaring `gateway`
1210
+ // quarantines naming the first slug (an engine owns supervision over the
1211
+ // whole channel set, so a shadowing engine would inherit another
1212
+ // engine's bindings — the same never-last-wins reasoning as channel ids).
1213
+ let gatewayEngine;
1214
+ // Surfaces (U3 Task 1): the flattened validated defs, slug-attributed.
1215
+ const surfaceBindings = [];
916
1216
  // Skill roots (W5 Task 4, spec §9.4): every skill dir a loaded package
917
1217
  // contributed — skills-only resolutions, entry-bearing packages' manifest
918
1218
  // skills, and factory-result `skills` fields — in load order, attributed
@@ -922,6 +1222,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
922
1222
  // Slug → declared name, for collision rejection at load (this boot's
923
1223
  // loaded set only — quarantined extensions never claim a slug).
924
1224
  const slugs = new Map();
1225
+ // Channel id → slug (gateway C1): the same claimed-only-by-LOADED pattern
1226
+ // as `slugs`. Chat bindings are keyed by channel id, so a shadowing
1227
+ // adapter would inherit another adapter's chats — the collision
1228
+ // quarantines the later loader instead of last-wins (deliberately unlike
1229
+ // commands and bundles). Checked BEFORE `registry.install` so a colliding
1230
+ // extension leaves no registry entry, no slug claim, and no binding.
1231
+ const channelIds = new Map();
925
1232
  for (const { name: label, entry, skills, builtin, coreResolved, packageDir } of entries) {
926
1233
  const display = label ?? entry ?? "skills-only";
927
1234
  try {
@@ -1101,6 +1408,25 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1101
1408
  };
1102
1409
  options.hostContextFactory(slug, kinds, host);
1103
1410
  }
1411
+ if (resolved.channels !== undefined) {
1412
+ for (const channel of resolved.channels) {
1413
+ const owner = channelIds.get(channel.id);
1414
+ if (owner !== undefined) {
1415
+ throw new Error(`channel "${channel.id}" is already contributed by "${owner}" — ` +
1416
+ "channel ids must be unique across extensions (chat bindings are keyed by id)");
1417
+ }
1418
+ }
1419
+ }
1420
+ // The one-engine rule (gateway C2): checked BEFORE the registry
1421
+ // install, exactly like the channel-id collision — a second engine
1422
+ // leaves no registry entry, no slug claim, and no binding. First
1423
+ // loaded wins.
1424
+ if (resolved.gateway !== undefined) {
1425
+ if (gatewayEngine !== undefined) {
1426
+ throw new Error(`gateway engine already declared by "${gatewayEngine.extension}"`);
1427
+ }
1428
+ gatewayEngine = { extension: slug, engine: resolved.gateway };
1429
+ }
1104
1430
  if (extension !== undefined) {
1105
1431
  registry.install(extension);
1106
1432
  }
@@ -1108,6 +1434,12 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1108
1434
  // later stage's quarantine (never loaded) leaves the slug free
1109
1435
  // for the next boot's specifier list.
1110
1436
  slugs.set(slug, declared);
1437
+ // ...and its channel ids (the same claimed-only-by-loaded rule).
1438
+ if (resolved.channels !== undefined) {
1439
+ for (const channel of resolved.channels) {
1440
+ channelIds.set(channel.id, slug);
1441
+ }
1442
+ }
1111
1443
  // The manifest version for the §7.7 inventory: read off the
1112
1444
  // module's own directory (a bare entry with no manifest stays
1113
1445
  // versionless — the field is optional by design).
@@ -1118,6 +1450,9 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1118
1450
  status: "ok",
1119
1451
  ...(version === undefined ? {} : { version }),
1120
1452
  ...(builtin === true ? { builtin: true } : {}),
1453
+ ...(resolved.channels === undefined
1454
+ ? {}
1455
+ : { channels: resolved.channels.map((channel) => channel.id) }),
1121
1456
  });
1122
1457
  if (extension !== undefined && resolved.health !== undefined) {
1123
1458
  healthProducers.push({
@@ -1167,6 +1502,24 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1167
1502
  if (resolved.notifiers !== undefined) {
1168
1503
  notifierBindings.push({ extension: slug, notifiers: resolved.notifiers });
1169
1504
  }
1505
+ if (resolved.channels !== undefined) {
1506
+ channelBindings.push({ extension: slug, channels: resolved.channels });
1507
+ }
1508
+ if (resolved.surfaces !== undefined) {
1509
+ // The §11.1 assets dir: the package manifest's `sigrun.assets`
1510
+ // relative to the package root (the same anchor the version
1511
+ // read below uses); absent or undeclared → `undefined` and
1512
+ // every asset request 404s.
1513
+ const assetsRoot = packageDir ?? extensionModuleDir(loaded);
1514
+ const declaredAssets = assetsRoot === undefined
1515
+ ? undefined
1516
+ : readPackageAssetsDir(assetsRoot);
1517
+ surfaceBindings.push({
1518
+ extension: slug,
1519
+ defs: resolved.surfaces,
1520
+ ...(declaredAssets === undefined ? {} : { assetsDir: declaredAssets }),
1521
+ });
1522
+ }
1170
1523
  if (resolved.providers !== undefined) {
1171
1524
  providerContributors.push({
1172
1525
  name: slug,
@@ -1232,5 +1585,8 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1232
1585
  routeBindings,
1233
1586
  commandBindings,
1234
1587
  notifierBindings,
1588
+ channelBindings,
1589
+ gatewayEngine,
1590
+ surfaces: surfaceBindings,
1235
1591
  };
1236
1592
  }
@@ -69,12 +69,23 @@ export const NPM_TIMEOUT_MS = 120_000;
69
69
  * own. {@link ensureHostModuleLinks} links sigrun's own copies into the
70
70
  * packages dir's node_modules so the dynamic import of such a package
71
71
  * resolves them — the SAME module instances the host runs.
72
+ *
73
+ * `@sigrun/sigrun` itself is here too (spec §14.1): the builders behind
74
+ * `@sigrun/sigrun/blocks` must emit the SERVER's block-set version, so
75
+ * extensions import the running copy — never a bundled or
76
+ * registry-fetched one (the loader's exact-match rule quarantines a
77
+ * tree that resolves a different version). Sigrun is the RUNNING
78
+ * package, not a dependency: {@link resolveHostModuleDir} resolves it
79
+ * to its own root instead of walking node_modules.
72
80
  */
81
+ /** The package this code ships in — the host runtime module that is the host itself. */
82
+ const SELF_MODULE_NAME = "@sigrun/sigrun";
73
83
  export const HOST_RUNTIME_MODULES = [
74
84
  "@earendil-works/pi-ai",
75
85
  "@earendil-works/pi-durable",
76
86
  "@earendil-works/chord",
77
87
  "typebox",
88
+ SELF_MODULE_NAME,
78
89
  ];
79
90
  /**
80
91
  * Read the `version` field of the package at `moduleDir` (following
@@ -101,13 +112,47 @@ export function hostModuleVersion(name) {
101
112
  const dir = resolveHostModuleDir(name);
102
113
  return dir === undefined ? undefined : installedVersionAt(dir);
103
114
  }
115
+ /**
116
+ * The running package's own root: the nearest ancestor of THIS module
117
+ * whose manifest is named `@sigrun/sigrun`. The node_modules walk below
118
+ * can never find the running package — sigrun is not a dependency of
119
+ * itself, and a source checkout carries no
120
+ * `node_modules/@sigrun/sigrun` anywhere above `src/` (the published
121
+ * install resolves through its OWN root, which is this same dir).
122
+ * `undefined` when no ancestor carries the manifest.
123
+ */
124
+ function selfPackageRoot() {
125
+ let dir = dirname(fileURLToPath(import.meta.url));
126
+ for (let hops = 0; hops < 10; hops++) {
127
+ const manifest = join(dir, "package.json");
128
+ if (existsSync(manifest)) {
129
+ try {
130
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
131
+ if (parsed.name === SELF_MODULE_NAME)
132
+ return dir;
133
+ }
134
+ catch {
135
+ // Unreadable manifest — keep walking.
136
+ }
137
+ }
138
+ const parent = dirname(dir);
139
+ if (parent === dir)
140
+ return undefined;
141
+ dir = parent;
142
+ }
143
+ return undefined;
144
+ }
104
145
  /**
105
146
  * Resolve a host runtime module's package dir from sigrun's own tree: the
106
147
  * nearest `node_modules/<name>` walking up from THIS module's location
107
148
  * (the repo root in every deploy shape — sigrun runs from source). The
108
149
  * walk stops at the filesystem root; `undefined` when nothing resolves.
150
+ * The ONE exception is sigrun itself (see {@link selfPackageRoot}): the
151
+ * link target is the running package's own root.
109
152
  */
110
153
  function resolveHostModuleDir(name) {
154
+ if (name === SELF_MODULE_NAME)
155
+ return selfPackageRoot();
111
156
  let dir = dirname(fileURLToPath(import.meta.url));
112
157
  for (let hops = 0; hops < 10; hops++) {
113
158
  const candidate = join(dir, "node_modules", ...name.split("/"));
@@ -210,13 +255,25 @@ export class PackageSyncError extends Error {
210
255
  }
211
256
  /**
212
257
  * One `npm <args>` invocation against the packages dir (execFile — no
213
- * shell, so no quoting surface). Failures and timeouts reject with a
258
+ * shell, so no quoting surface). `--legacy-peer-deps` disables npm's
259
+ * peer-resolution for every managed install — pi's own posture for its
260
+ * extension installs: an extension declaring `@sigrun/sigrun` (or
261
+ * pi-durable/pi-ai) a `peerDependency: "*"` must resolve the HOST's
262
+ * linked copy, never an npm-solved registry copy whose version would
263
+ * quarantine the extension at load. Failures and timeouts reject with a
214
264
  * `PackageSyncError` carrying npm's stderr tail; `verbose` captures the
215
265
  * full output for the error message.
216
266
  */
217
267
  function runNpm(args, packagesDir, verbose) {
218
268
  return new Promise((resolve, reject) => {
219
- execFile("npm", [...args, "--prefix", packagesDir, "--no-audit", "--no-fund"], { timeout: NPM_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
269
+ execFile("npm", [
270
+ ...args,
271
+ "--prefix",
272
+ packagesDir,
273
+ "--no-audit",
274
+ "--no-fund",
275
+ "--legacy-peer-deps",
276
+ ], { timeout: NPM_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
220
277
  if (error === null) {
221
278
  if (verbose === true && stderr !== "") {
222
279
  console.warn(`packages: npm ${args[0] ?? ""}: ${stderr.trim()}`);
@@ -346,6 +403,18 @@ export function readPackageVersion(absPath) {
346
403
  const version = readManifest(absPath)?.version;
347
404
  return typeof version === "string" && version !== "" ? version : undefined;
348
405
  }
406
+ /**
407
+ * The declared `sigrun.assets` directory (spec §11.1), resolved
408
+ * absolute against the package root; `undefined` when the manifest
409
+ * declares none (or no manifest reads). A non-string declaration is
410
+ * ignored — assets are simply absent.
411
+ */
412
+ export function readPackageAssetsDir(absPath) {
413
+ const declared = readManifest(absPath)?.sigrun?.assets;
414
+ if (typeof declared !== "string" || declared === "")
415
+ return undefined;
416
+ return resolve(absPath, declared);
417
+ }
349
418
  /**
350
419
  * The identity a specifier installs under: registry specs key by their
351
420
  * parsed name; path specs key by the target package's OWN manifest name