@sigrun/sigrun 0.1.4 → 0.1.6

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 (82) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +24 -2
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/builders.js +125 -0
  5. package/dist/blocks/images.js +57 -0
  6. package/dist/blocks/index.js +20 -0
  7. package/dist/blocks/limits.js +26 -0
  8. package/dist/blocks/placements.js +99 -0
  9. package/dist/blocks/schema.js +559 -0
  10. package/dist/blocks/validate.js +602 -0
  11. package/dist/config.js +31 -0
  12. package/dist/extension.d.ts +476 -9
  13. package/dist/harness/agent-env.js +62 -0
  14. package/dist/harness/boot.js +8 -1
  15. package/dist/harness/event-bus.js +33 -0
  16. package/dist/harness/identity/users.js +3 -2
  17. package/dist/harness/loader.js +369 -13
  18. package/dist/harness/packages.js +71 -2
  19. package/dist/harness/seam-host.js +69 -2
  20. package/dist/harness/settings.js +5 -0
  21. package/dist/harness/workspace.js +6 -4
  22. package/dist/jobs/fire.js +11 -0
  23. package/dist/jobs/spec.js +19 -0
  24. package/dist/serve.js +29 -39
  25. package/dist/server/auth/pipeline.js +16 -1
  26. package/dist/server/conversation-ops.js +482 -10
  27. package/dist/server/gateway.js +122 -0
  28. package/dist/server/helpers.js +26 -0
  29. package/dist/server/host-bridge.js +141 -4
  30. package/dist/server/index.js +27 -0
  31. package/dist/server/notifications.js +6 -2
  32. package/dist/server/routes/conversations.js +37 -93
  33. package/dist/server/routes/events.js +39 -0
  34. package/dist/server/routes/ext-assets.js +84 -0
  35. package/dist/server/routes/extensions.js +31 -1
  36. package/dist/server/routes/files.js +38 -83
  37. package/dist/server/routes/meta.js +35 -1
  38. package/dist/server/routes/providers.js +5 -21
  39. package/dist/server/routes/submissions.js +36 -111
  40. package/dist/server/routes/surfaces.js +387 -0
  41. package/dist/server/routes/users.js +6 -1
  42. package/dist/server/session-meta.js +12 -6
  43. package/dist/server/shutdown.js +85 -0
  44. package/dist/server/surfaces/access-cache.js +43 -0
  45. package/dist/server/surfaces/dedup.js +67 -0
  46. package/dist/server/surfaces/instance.js +42 -0
  47. package/dist/server/surfaces/mime.js +73 -0
  48. package/dist/server/surfaces/rate-limit.js +63 -0
  49. package/dist/server/surfaces/service.js +493 -0
  50. package/dist/server/surfaces/watchers.js +94 -0
  51. package/node_modules/@sigrun/shared/CHANGELOG.md +9 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
  53. package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
  54. package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
  55. package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
  56. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
  57. package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
  58. package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
  59. package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
  60. package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
  61. package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
  62. package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
  63. package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
  64. package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
  65. package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
  66. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
  67. package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
  68. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
  69. package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
  70. package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
  71. package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
  72. package/node_modules/@sigrun/shared/dist/live.js +82 -0
  73. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
  74. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
  75. package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
  76. package/node_modules/@sigrun/shared/package.json +10 -2
  77. package/package.json +11 -5
  78. package/webui/dist/assets/index-CiYInlLx.css +1 -0
  79. package/webui/dist/assets/index-JDfOQV9z.js +52 -0
  80. package/webui/dist/index.html +2 -2
  81. package/webui/dist/assets/index-BiSMljer.css +0 -1
  82. package/webui/dist/assets/index-hVLvsizV.js +0 -50
@@ -648,6 +648,32 @@ export class EventBus {
648
648
  callId: record.callId,
649
649
  kind: record.kind,
650
650
  });
651
+ // Gateway C6: a TERMINAL status transition additionally fans
652
+ // out to the extension lifecycle handlers
653
+ // (`interactionSettled`) from this same commit-observation
654
+ // site. Exactly one document change lands per transition —
655
+ // whatever wrote it (HTTP-route answer, host answer, host
656
+ // settleInteraction) — so exactly one event fires per
657
+ // transition; seeding (pending) fires nothing. It rides the
658
+ // same lifecycle queue + deadline-bounded dispatch as
659
+ // conversationCreated etc., NOT the host event stream. The
660
+ // host-event split above and this gate read the SAME
661
+ // imported set, so a future terminal status added to
662
+ // TERMINAL fires both events together by construction.
663
+ if (INTERACTION_TERMINAL.has(record.status)) {
664
+ lifecycle.push({
665
+ type: "interactionSettled",
666
+ conversationId: change.conversationId,
667
+ callId: record.callId,
668
+ kind: record.kind,
669
+ // SAFETY: `record.status` is a narrowed `string`, but
670
+ // the payload wants the terminal union — sound because
671
+ // the union and the TERMINAL set share one definition
672
+ // (src/harness/interactions.ts): membership in the set
673
+ // IS membership in the union.
674
+ status: record.status,
675
+ });
676
+ }
651
677
  }
652
678
  }
653
679
  else if (change.type === "document" &&
@@ -1010,6 +1036,13 @@ export class EventBus {
1010
1036
  return { conversationId: event.conversationId, archived: event.archived };
1011
1037
  case "conversationIdle":
1012
1038
  return { conversationId: event.conversationId, idleMs: event.idleMs };
1039
+ case "interactionSettled":
1040
+ return {
1041
+ conversationId: event.conversationId,
1042
+ callId: event.callId,
1043
+ kind: event.kind,
1044
+ status: event.status,
1045
+ };
1013
1046
  }
1014
1047
  }
1015
1048
  /** The conversation a lifecycle event's api scopes to (the fork child on forks). */
@@ -62,8 +62,9 @@ export class UserDirectory {
62
62
  }
63
63
  /**
64
64
  * Patch one user's mutable fields (`name`/`email`/`admin` — the
65
- * username itself is immutable). Absent user throws; the file is
66
- * rewritten atomically.
65
+ * username itself is immutable; `groupAdmin` is the pipeline's own
66
+ * write-through field, never the PATCH route's surface). Absent user
67
+ * throws; the file is rewritten atomically.
67
68
  */
68
69
  update(user, patch) {
69
70
  return this.mutations.run(async () => {
@@ -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
  }