@sigrun/sigrun 0.8.0 → 0.10.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 (49) hide show
  1. package/CHANGELOG.md +55 -87
  2. package/dist/config.js +47 -0
  3. package/dist/curation/host.js +125 -21
  4. package/dist/curation/runs.js +15 -4
  5. package/dist/extension.d.ts +268 -3
  6. package/dist/harness/boot.js +113 -3
  7. package/dist/harness/complete.js +127 -0
  8. package/dist/harness/decision-tool.js +93 -0
  9. package/dist/harness/decisions.js +477 -0
  10. package/dist/harness/event-bus.js +12 -0
  11. package/dist/harness/interactions.js +81 -0
  12. package/dist/harness/interactive-tools.js +97 -0
  13. package/dist/harness/loader.js +243 -1
  14. package/dist/harness/packages.js +7 -0
  15. package/dist/harness/published-hooks.js +208 -0
  16. package/dist/harness/seam-host.js +136 -1
  17. package/dist/harness/settings.js +41 -0
  18. package/dist/history/git.js +6 -2
  19. package/dist/jobs/fire.js +10 -0
  20. package/dist/jobs/index.js +3 -0
  21. package/dist/jobs/observe.js +27 -2
  22. package/dist/jobs/spec.js +15 -1
  23. package/dist/server/conversation-ops.js +31 -0
  24. package/dist/server/core-views/jobs-form.js +335 -0
  25. package/dist/server/core-views/jobs.js +300 -15
  26. package/dist/server/core-views/skills-tree.js +3 -12
  27. package/dist/server/core-views/skills.js +11 -23
  28. package/dist/server/host-bridge.js +53 -1
  29. package/dist/server/index.js +2 -1
  30. package/dist/server/routes/extensions.js +5 -0
  31. package/dist/server/routes/interactions.js +48 -1
  32. package/dist/server/routes/meta.js +17 -0
  33. package/dist/server/routes/providers.js +65 -8
  34. package/dist/server/routes/submissions.js +3 -0
  35. package/dist/settings/registry.js +18 -0
  36. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  37. package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
  38. package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
  39. package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
  40. package/node_modules/@sigrun/shared/dist/jobs.d.ts +4 -0
  41. package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
  42. package/node_modules/@sigrun/shared/dist/views.d.ts +58 -0
  43. package/node_modules/@sigrun/shared/package.json +6 -2
  44. package/package.json +8 -8
  45. package/webui/dist/assets/index-By63QKOA.js +55 -0
  46. package/webui/dist/assets/index-D5KowS7k.css +1 -0
  47. package/webui/dist/index.html +2 -2
  48. package/webui/dist/assets/index-B3uMNF59.css +0 -1
  49. package/webui/dist/assets/index-BXsCyod4.js +0 -55
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Interactive tools (ask-user Task 3, spec §6): the helpers that hide an
3
+ * extension's `interactiveTools` — tool names that need a human present —
4
+ * from every NON-INTERACTIVE run core creates.
5
+ *
6
+ * Core owns the definition of a non-interactive conversation (spec §6):
7
+ * scheduled job run conversations and the curator's internal runs. In
8
+ * those conversations core leaves interactive tools out of the offered
9
+ * tools at creation time by removing them from the ENABLED-tools filter
10
+ * (pi-durable `AgentDoc.tools`): a removal there makes the tool neither
11
+ * offered to the model NOR reachable from nested `executeTool()` calls
12
+ * (the nested resolution reads the same enabled set), which no
13
+ * `modelTools` narrowing could give.
14
+ *
15
+ * Fork reviews deliberately do NOT pass through here — they copy the
16
+ * parent's stored selection for byte-identical prompt prefixes (cache
17
+ * parity), and the curation gate blocks an interactive call instead.
18
+ *
19
+ * Jobs that fire into an EXISTING conversation are not covered by this
20
+ * creation-time filter either (the conversation predates the fire);
21
+ * the tool stays listed and must guard its own execute with
22
+ * `host.jobs.isJobRun` (spec §6's option A).
23
+ */
24
+ import { AgentDoc, LiveDoc } from "@earendil-works/pi-durable";
25
+ import { SessionMetaDoc } from "../server/session-meta.js";
26
+ /**
27
+ * Remove every name in `names` from the conversation's stored
28
+ * `AgentDoc.tools` enabled filter, preserving the stored shape:
29
+ * - unset → `{remove: [...names].sort()}`;
30
+ * - an object → `remove ∪ names` (sorted);
31
+ * - an array → the array without the names.
32
+ *
33
+ * A no-op when `names` is empty (the no-interactive-extension deploy
34
+ * writes nothing). Runs inside an existing commit (the callers are the
35
+ * creating commits and the server create op's post-create stamp commit);
36
+ * document access only — no table reads.
37
+ */
38
+ export async function excludeInteractiveTools(tx, conversationId, names) {
39
+ if (names.size === 0)
40
+ return;
41
+ const agent = await tx.doc(AgentDoc, conversationId);
42
+ const stored = agent.tools;
43
+ if (stored === undefined) {
44
+ agent.tools = { remove: [...names].sort() };
45
+ return;
46
+ }
47
+ if (Array.isArray(stored)) {
48
+ agent.tools = stored.filter((name) => !names.has(name));
49
+ return;
50
+ }
51
+ agent.tools = {
52
+ remove: [...new Set([...stored.remove, ...names])].sort(),
53
+ };
54
+ }
55
+ /**
56
+ * Whether the conversation's CURRENT run is job-driven (ask-user Task 3,
57
+ * spec §6): true when the session meta carries a `jobId` (a
58
+ * `conversation: "new"` run — the stamp persists past the run's settle,
59
+ * which is fine: every run of that conversation is a job run), or any
60
+ * input of the conversation's current run (`LiveDoc` `run.inputs`,
61
+ * present exactly while busy) resolves to a submission whose requestId
62
+ * carries the job prefix (`job:<fireKey>`, stamped by `jobs/fire.ts` on
63
+ * BOTH fire shapes — that branch covers a job firing into an EXISTING
64
+ * conversation, which carries no `jobId`). Interactive tools call this in
65
+ * execute and refuse instead of waiting on a human.
66
+ */
67
+ export async function isJobRunConversation(harness, conversationId, context) {
68
+ const meta = await harness.snapshot(SessionMetaDoc, conversationId, context);
69
+ if (meta?.jobId !== undefined)
70
+ return true;
71
+ const live = await harness.snapshot(LiveDoc, conversationId, context);
72
+ for (const id of live?.run?.inputs ?? []) {
73
+ const submission = await harness.submission(id, context);
74
+ if (submission === undefined)
75
+ continue;
76
+ const record = await submission.status(context);
77
+ if (record.requestId?.startsWith("job:"))
78
+ return true;
79
+ }
80
+ return false;
81
+ }
82
+ /**
83
+ * Filter `list` to the names outside `names` (a curator run's `allow`
84
+ * list losing its interactive members). Never fails — stripping must not
85
+ * let installing an interactive-tool extension break unrelated curation
86
+ * targets — but warns ONCE per call naming the removed tools and `where`
87
+ * (an all-interactive list then still hits the existing
88
+ * non-empty-`allow` check, loudly). Returns a fresh array in every case
89
+ * (the tracker requires alias-free roots).
90
+ */
91
+ export function withoutInteractive(list, names, warn, where) {
92
+ const removed = list.filter((name) => names.has(name));
93
+ if (removed.length === 0)
94
+ return [...list];
95
+ warn(`interactive tools [${removed.join(", ")}] removed from the ${where} allow list — they need a human present`);
96
+ return list.filter((name) => !names.has(name));
97
+ }
@@ -92,7 +92,9 @@ import { fileURLToPath } from "node:url";
92
92
  import { satisfies, validRange } from "semver";
93
93
  import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageAssetsDir, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
94
94
  import { RESERVED_CORE_SECTION_KEYS, RESERVED_EXTENSION_SLUGS, } from "./reserved-sections.js";
95
+ import { Compile } from "typebox/compile";
95
96
  import { slugifyName } from "./stores.js";
97
+ import { PUBLISHED_HOOK_NAME, PUBLISHED_HOOK_NAME_MAX_CHARS, } from "./published-hooks.js";
96
98
  import { wrapExtensionCurationTools } from "../curation/wrap.js";
97
99
  /** Every lifecycle field name, in validation order (mirrors the contract doc). */
98
100
  const LIFECYCLE_FIELDS = [
@@ -142,6 +144,29 @@ function validateKinds(value) {
142
144
  }
143
145
  return null;
144
146
  }
147
+ /**
148
+ * Validate the `interactiveTools` declaration (ask-user Task 3, spec §6):
149
+ * an array of non-empty strings, every one naming a tool the extension's
150
+ * OWN `extension.tools` registers (hiding a tool the extension never
151
+ * registered is a declaration bug — it quarantines, loudly).
152
+ */
153
+ function validateInteractiveTools(value, extension) {
154
+ if (value === undefined)
155
+ return undefined;
156
+ if (!Array.isArray(value)) {
157
+ throw new Error("interactiveTools must be an array of strings");
158
+ }
159
+ if (value.some((name) => typeof name !== "string" || name.length === 0)) {
160
+ throw new Error("interactiveTools must be an array of non-empty strings");
161
+ }
162
+ const registered = new Set(extension?.tools?.map((tool) => tool.name) ?? []);
163
+ for (const name of value) {
164
+ if (!registered.has(name)) {
165
+ throw new Error(`interactiveTools names "${name}", which this extension does not register`);
166
+ }
167
+ }
168
+ return value;
169
+ }
145
170
  /**
146
171
  * The lifecycle event names a worker's `wakeOn` may declare (memory-seam
147
172
  * Task 5): `harnessShutdown` is excluded — workers stop BEFORE the shutdown
@@ -262,6 +287,122 @@ function validateNoticeActions(value) {
262
287
  }
263
288
  return actions;
264
289
  }
290
+ /**
291
+ * Validate a factory result's `publishedHooks` declaration (knowledge C7).
292
+ * Absent → undefined (no hooks). Present but malformed → throw; the loader
293
+ * quarantines the extension with the thrown message (fail-loud: an
294
+ * extension that THINKS it published a hook must not silently lose it).
295
+ * Per entry: a string `name` matching {@link PUBLISHED_HOOK_NAME} (and the
296
+ * length bound), no intra-extension duplicates, `input` an object, `run` a
297
+ * function — plus a `Compile` of both schemas, which is what makes the
298
+ * registry build's compile-throw unreachable in production.
299
+ */
300
+ function validatePublishedHooks(value) {
301
+ if (value === undefined)
302
+ return undefined;
303
+ if (!Array.isArray(value)) {
304
+ throw new Error("extension factory returned a publishedHooks field that is not an array");
305
+ }
306
+ const seen = new Set();
307
+ for (const entry of value) {
308
+ const hook = entry;
309
+ const name = hook?.name;
310
+ if (typeof name !== "string" ||
311
+ name.length > PUBLISHED_HOOK_NAME_MAX_CHARS ||
312
+ !PUBLISHED_HOOK_NAME.test(name)) {
313
+ throw new Error(`extension factory returned a published hook with an invalid name ${JSON.stringify(name)} (must match ${PUBLISHED_HOOK_NAME.source}, ≤${PUBLISHED_HOOK_NAME_MAX_CHARS} chars)`);
314
+ }
315
+ if (seen.has(name)) {
316
+ throw new Error(`extension factory returned a duplicate published hook name "${name}"`);
317
+ }
318
+ seen.add(name);
319
+ if (typeof hook?.input !== "object" ||
320
+ hook.input === null ||
321
+ Array.isArray(hook.input)) {
322
+ throw new Error(`extension factory returned a published hook "${name}" whose input is not a schema object`);
323
+ }
324
+ if (hook.output !== undefined && (typeof hook.output !== "object" || hook.output === null)) {
325
+ throw new Error(`extension factory returned a published hook "${name}" whose output is not a schema object`);
326
+ }
327
+ if (typeof hook?.run !== "function") {
328
+ throw new Error(`extension factory returned a published hook "${name}" whose run is not a function`);
329
+ }
330
+ // Compile ONCE here: a schema that fails to compile quarantines the
331
+ // extension at load, so the registry build never sees one.
332
+ try {
333
+ Compile(hook.input);
334
+ if (hook.output !== undefined)
335
+ Compile(hook.output);
336
+ }
337
+ catch (err) {
338
+ throw new Error(`extension factory returned a published hook "${name}" whose schema fails to compile: ${err instanceof Error ? err.message : String(err)}`);
339
+ }
340
+ }
341
+ return value;
342
+ }
343
+ /** The decision-provider id grammar (warden Task 6): also the auth.json key. */
344
+ const DECISION_PROVIDER_ID = /^[a-z][a-z0-9-]*$/;
345
+ /** The decision-provider id length bound. */
346
+ const DECISION_PROVIDER_ID_MAX_CHARS = 64;
347
+ /** The decision-model id grammar (the `"<provider>/<model>"` spec's second half). */
348
+ const DECISION_MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
349
+ /** The `credentialEnv` grammar: a process-env NAME. */
350
+ const DECISION_CREDENTIAL_ENV = /^[A-Z][A-Z0-9_]*$/;
351
+ /**
352
+ * Validate a factory result's `decisionProviders` declaration (warden
353
+ * Task 6). Absent → undefined (no contributions). Present but malformed
354
+ * → throw; the loader quarantines the extension with the thrown message
355
+ * (fail-loud: an extension that THINKS it contributed a decision
356
+ * provider must not silently lose it — the providers/health class).
357
+ * Per entry: a string `id` matching {@link DECISION_PROVIDER_ID} (≤64
358
+ * chars), at least one model whose ids match {@link DECISION_MODEL_ID}
359
+ * with no intra-provider duplicates, `evaluate` a function, `readiness`
360
+ * absent or a function, and `credentialEnv` absent or a NAME.
361
+ */
362
+ function validateDecisionProviders(value) {
363
+ if (value === undefined)
364
+ return undefined;
365
+ if (!Array.isArray(value)) {
366
+ throw new Error("extension factory returned a decisionProviders field that is not an array");
367
+ }
368
+ for (const entry of value) {
369
+ const provider = entry;
370
+ if (typeof provider?.id !== "string" ||
371
+ provider.id.length > DECISION_PROVIDER_ID_MAX_CHARS ||
372
+ !DECISION_PROVIDER_ID.test(provider.id)) {
373
+ throw new Error(`extension factory returned a decision provider with an invalid id ${JSON.stringify(provider?.id)} (must match ${DECISION_PROVIDER_ID.source}, ≤${DECISION_PROVIDER_ID_MAX_CHARS} chars)`);
374
+ }
375
+ if (!Array.isArray(provider.models) ||
376
+ provider.models.length === 0) {
377
+ throw new Error(`extension factory returned a decision provider "${provider.id}" with no models`);
378
+ }
379
+ const seen = new Set();
380
+ for (const model of provider.models) {
381
+ const modelId = model?.id;
382
+ if (typeof modelId !== "string" ||
383
+ !DECISION_MODEL_ID.test(modelId)) {
384
+ throw new Error(`extension factory returned a decision provider "${provider.id}" with an invalid model id ${JSON.stringify(modelId)} (must match ${DECISION_MODEL_ID.source})`);
385
+ }
386
+ if (seen.has(modelId)) {
387
+ throw new Error(`extension factory returned a decision provider "${provider.id}" with a duplicate model id "${modelId}"`);
388
+ }
389
+ seen.add(modelId);
390
+ }
391
+ if (typeof provider.evaluate !== "function") {
392
+ throw new Error(`extension factory returned a decision provider "${provider.id}" whose evaluate is not a function`);
393
+ }
394
+ if (provider.readiness !== undefined &&
395
+ typeof provider.readiness !== "function") {
396
+ throw new Error(`extension factory returned a decision provider "${provider.id}" whose readiness is not a function`);
397
+ }
398
+ if (provider.credentialEnv !== undefined &&
399
+ (typeof provider.credentialEnv !== "string" ||
400
+ !DECISION_CREDENTIAL_ENV.test(provider.credentialEnv))) {
401
+ throw new Error(`extension factory returned a decision provider "${provider.id}" with an invalid credentialEnv ${JSON.stringify(provider.credentialEnv)} (must match ${DECISION_CREDENTIAL_ENV.source})`);
402
+ }
403
+ }
404
+ return value;
405
+ }
265
406
  function validateCurationTargets(value) {
266
407
  if (value === undefined)
267
408
  return undefined;
@@ -290,6 +431,29 @@ function validateCurationTargets(value) {
290
431
  typeof target.maintain !== "function") {
291
432
  throw new Error(`extension factory returned a curation target "${name}" whose maintain is not a function`);
292
433
  }
434
+ // C5: readTools — an array of non-empty strings DISJOINT from
435
+ // tools (a read tool is never wrapped; a name on both lists is a
436
+ // contradiction). The registered-by-the-declaring-extension check
437
+ // lives in the load loop (no registry here).
438
+ const readTools = target?.readTools;
439
+ if (readTools !== undefined) {
440
+ if (!Array.isArray(readTools) ||
441
+ readTools.some((tool) => typeof tool !== "string" || tool.length === 0)) {
442
+ throw new Error(`extension factory returned a curation target "${name}" whose readTools is not an array of non-empty strings`);
443
+ }
444
+ const overlap = readTools.find((tool) => tools.includes(tool));
445
+ if (overlap !== undefined) {
446
+ throw new Error(`extension factory returned a curation target "${name}" whose readTools names "${overlap}" — a tool is either listed (wrapped) or read (never wrapped), never both`);
447
+ }
448
+ }
449
+ if (target?.wantsReview !== undefined &&
450
+ typeof target.wantsReview !== "function") {
451
+ throw new Error(`extension factory returned a curation target "${name}" whose wantsReview is not a function`);
452
+ }
453
+ if (target?.reviewed !== undefined &&
454
+ typeof target.reviewed !== "function") {
455
+ throw new Error(`extension factory returned a curation target "${name}" whose reviewed is not a function`);
456
+ }
293
457
  targets.push(entry);
294
458
  }
295
459
  return targets;
@@ -518,6 +682,18 @@ function validateSurfaces(value) {
518
682
  typeof def.sharedData !== "boolean") {
519
683
  throw new Error(`extension factory returned a surface "${def.id}" whose sharedData is not a boolean`);
520
684
  }
685
+ if (def?.perConversation !== undefined &&
686
+ typeof def.perConversation !== "boolean") {
687
+ throw new Error(`extension factory returned a surface "${def.id}" whose perConversation is not a boolean`);
688
+ }
689
+ // The flag is a PANEL property: a view cannot be attached to a
690
+ // conversation, so declaring it there is a declaration the host
691
+ // would silently ignore — fail loud. (The settings-section list is
692
+ // validated separately by `validateSettingsSections`, which does
693
+ // not read this flag.)
694
+ if (def?.perConversation !== undefined && expected !== "panel") {
695
+ throw new Error(`extension factory returned a surface "${def.id}" declares perConversation but is not a panel`);
696
+ }
521
697
  if (def?.actions !== undefined) {
522
698
  if (typeof def.actions !== "object" ||
523
699
  def.actions === null ||
@@ -1209,6 +1385,9 @@ async function resolvedFromFactory(factory, hostContext) {
1209
1385
  throw new Error("extension factory returned a perUserTools field that is not a boolean");
1210
1386
  }
1211
1387
  const providers = validateProviders(candidate.providers);
1388
+ // Decision providers (warden Task 6): validated with the other
1389
+ // contribution fields, BEFORE the contributor-only check reads them.
1390
+ const decisionProviders = validateDecisionProviders(candidate.decisionProviders);
1212
1391
  const loginProviders = validateLoginProviders(candidate.loginProviders);
1213
1392
  const webuiBundles = validateWebuiBundles(candidate.webuiBundles);
1214
1393
  const jobs = validateJobs(candidate.jobs);
@@ -1237,7 +1416,8 @@ async function resolvedFromFactory(factory, hostContext) {
1237
1416
  loginProviders === undefined &&
1238
1417
  webuiBundles === undefined &&
1239
1418
  channels === undefined &&
1240
- userServices === undefined) {
1419
+ userServices === undefined &&
1420
+ decisionProviders === undefined) {
1241
1421
  return undefined;
1242
1422
  }
1243
1423
  if (typeof candidate.name !== "string" || candidate.name.length === 0) {
@@ -1271,6 +1451,11 @@ async function resolvedFromFactory(factory, hostContext) {
1271
1451
  validateKinds(candidate.ambientKinds);
1272
1452
  if (kindsProblem !== null)
1273
1453
  throw new Error(kindsProblem);
1454
+ // Interactive tools (ask-user Task 3, spec §6): an array of non-empty
1455
+ // strings, each naming a tool the extension's OWN `extension.tools`
1456
+ // registers — hiding a tool the extension never registered is a
1457
+ // declaration bug that must quarantine, never silently no-op.
1458
+ const interactiveTools = validateInteractiveTools(candidate.interactiveTools, extension);
1274
1459
  // The settings-change hook (U4 §13.2): present-but-not-a-function is
1275
1460
  // the same contract violation class as the lifecycle fields — an
1276
1461
  // extension that THINKS it observes settings changes must not
@@ -1288,6 +1473,12 @@ async function resolvedFromFactory(factory, hostContext) {
1288
1473
  // Notice actions (notices Task 3): a plain object of name → handler,
1289
1474
  // every name grammar-checked; a malformed entry quarantines.
1290
1475
  const noticeActions = validateNoticeActions(candidate.noticeActions);
1476
+ // Published hooks (knowledge C7): validated HERE (before the slug
1477
+ // exists — ownership and shadowing are registry concerns, never
1478
+ // load-failures); the declarations attribute to the slug at
1479
+ // collection. A malformed entry or a schema that fails to compile
1480
+ // quarantines the extension.
1481
+ const publishedHooks = validatePublishedHooks(candidate.publishedHooks);
1291
1482
  return {
1292
1483
  ...(candidate.perUserTools === undefined
1293
1484
  ? {}
@@ -1324,12 +1515,15 @@ async function resolvedFromFactory(factory, hostContext) {
1324
1515
  ...(candidate.interactionKinds === undefined
1325
1516
  ? {}
1326
1517
  : { interactionKinds: candidate.interactionKinds }),
1518
+ ...(interactiveTools === undefined ? {} : { interactiveTools }),
1327
1519
  ...(candidate.ambientKinds === undefined
1328
1520
  ? {}
1329
1521
  : { ambientKinds: candidate.ambientKinds }),
1330
1522
  ...(workers === undefined ? {} : { workers }),
1331
1523
  ...(curationTargets === undefined ? {} : { curationTargets }),
1332
1524
  ...(noticeActions === undefined ? {} : { noticeActions }),
1525
+ ...(publishedHooks === undefined ? {} : { publishedHooks }),
1526
+ ...(decisionProviders === undefined ? {} : { decisionProviders }),
1333
1527
  ...(providers === undefined ? {} : { providers }),
1334
1528
  ...(loginProviders === undefined ? {} : { loginProviders }),
1335
1529
  ...(webuiBundles === undefined ? {} : { webuiBundles }),
@@ -1529,10 +1723,21 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1529
1723
  // these into the ONE CurationRegistry (src/curation/targets.ts).
1530
1724
  const curationBindings = [];
1531
1725
  const noticeActionBindings = [];
1726
+ // Published hooks (knowledge C7): flat declarations, in load order,
1727
+ // slug-attributed with the built-in flag — boot folds them into the
1728
+ // registry AFTER `loadExtensions` returns (ownership: configured before
1729
+ // built-ins, first per name wins — the registry's own ordering rule).
1730
+ const publishedHookDeclarations = [];
1731
+ // Decision-provider declarations (warden Task 6), slug-attributed —
1732
+ // boot folds them into the ONE DecisionRegistry.
1733
+ const decisionProviderDeclarations = [];
1532
1734
  const userServiceBindings = [];
1533
1735
  // Jobs Task 3: declared jobs, attributed to the extension slug (the
1534
1736
  // `ext:<slug>:<name>` id's namespace).
1535
1737
  const jobBindings = [];
1738
+ // Interactive tool names (ask-user Task 3): the flat union over loaded
1739
+ // extensions (boot folds it into the one ReadonlySet on OpenedHarness).
1740
+ const interactiveTools = [];
1536
1741
  // HTTP routes (W5 Task 2), attributed to the slug like the job bindings.
1537
1742
  const routeBindings = [];
1538
1743
  // Slash commands (W5 Task 3), attributed the same way.
@@ -1821,6 +2026,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1821
2026
  if (unregistered !== undefined) {
1822
2027
  throw new Error(`extension "${slug}" declares curation target "${target.name}" listing tool "${unregistered}" it does not register`);
1823
2028
  }
2029
+ // C5: the same check for readTools (never wrapped, never fed
2030
+ // to the wrap feed — only registered, so the served filter and
2031
+ // the review gate see a real tool).
2032
+ const unregisteredRead = (target.readTools ?? []).find((tool) => !registered.has(tool));
2033
+ if (unregisteredRead !== undefined) {
2034
+ throw new Error(`extension "${slug}" declares curation target "${target.name}" listing read tool "${unregisteredRead}" it does not register`);
2035
+ }
1824
2036
  // A tool names ONE target (A8): two targets — across any
1825
2037
  // extensions — listing the same tool leave `byTool`
1826
2038
  // first-wins arbitrary and the shadowed target's undos
@@ -1936,6 +2148,26 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1936
2148
  actions: resolved.noticeActions,
1937
2149
  });
1938
2150
  }
2151
+ // Published hooks (knowledge C7): collected AFTER the install
2152
+ // — a quarantined extension never reaches here, so every
2153
+ // declaration in the list comes from a LOADED extension.
2154
+ for (const hook of resolved.publishedHooks ?? []) {
2155
+ publishedHookDeclarations.push({
2156
+ extension: slug,
2157
+ builtin: builtin === true,
2158
+ hook,
2159
+ });
2160
+ }
2161
+ // Decision providers (warden Task 6): collected AFTER the install
2162
+ // — a quarantined extension never reaches here, so every
2163
+ // declaration comes from a LOADED extension. The slug (not the
2164
+ // declared contribution id) is the attribution.
2165
+ for (const contribution of resolved.decisionProviders ?? []) {
2166
+ decisionProviderDeclarations.push({
2167
+ extension: slug,
2168
+ contribution,
2169
+ });
2170
+ }
1939
2171
  if (resolved.curationTargets !== undefined) {
1940
2172
  curationBindings.push({
1941
2173
  extension: slug,
@@ -1951,6 +2183,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1951
2183
  if (resolved.jobs !== undefined) {
1952
2184
  jobBindings.push({ extension: slug, jobs: resolved.jobs });
1953
2185
  }
2186
+ // Interactive tools (ask-user Task 3): collected AFTER the
2187
+ // install, slug-attributed — a quarantined extension never
2188
+ // reaches here, so the union boot folds comes from LOADED
2189
+ // extensions only.
2190
+ if (resolved.interactiveTools !== undefined) {
2191
+ interactiveTools.push(...resolved.interactiveTools);
2192
+ }
1954
2193
  if (resolved.userServices !== undefined) {
1955
2194
  userServiceBindings.push({
1956
2195
  extension: slug,
@@ -2067,7 +2306,10 @@ export async function loadExtensions(specifiers, registry, options = {}) {
2067
2306
  workerBindings,
2068
2307
  curationBindings,
2069
2308
  noticeActionBindings,
2309
+ publishedHookDeclarations,
2310
+ decisionProviderDeclarations,
2070
2311
  jobBindings,
2312
+ interactiveTools,
2071
2313
  routeBindings,
2072
2314
  commandBindings,
2073
2315
  notifierBindings,
@@ -84,6 +84,13 @@ export const HOST_RUNTIME_MODULES = [
84
84
  "@earendil-works/pi-ai",
85
85
  "@earendil-works/pi-durable",
86
86
  "@earendil-works/chord",
87
+ // @sigrun/ext-codemode imports CodemodeSandbox at RUNTIME. Core does not
88
+ // depend on pi-codemode (it arrives transitively via pi-durable), so a
89
+ // packages-dir install of the extension could not resolve it — the
90
+ // extension quarantined with "Cannot find package
91
+ // '@earendil-works/pi-codemode'". Linked like the other Earendil host
92
+ // modules so the extension resolves the copy the host actually runs.
93
+ "@earendil-works/pi-codemode",
87
94
  "typebox",
88
95
  // The shared workspace is host-shipped too (wire + access contracts
89
96
  // every extension family type against; @sigrun/recall is the first
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Task 1 (knowledge plan): the published-hook registry — the pure core of
3
+ * the extension-to-extension call seam. Given the loader's declarations
4
+ * (configured extensions first, then built-ins; first per name owns), it
5
+ * builds a registry whose `call` validates the payload against the
6
+ * provider's compiled input schema, resolves the acting-user identity,
7
+ * runs the provider under a linked abort controller plus a deadline, and
8
+ * bounds payload size, result size, and recursion depth. `call` NEVER
9
+ * throws; validators compile ONCE here at build, and a schema that fails
10
+ * `Compile` throws FROM THE BUILD (Task 2 turns that into a load-time
11
+ * quarantine, so the build never sees one in production).
12
+ */
13
+ import { AsyncLocalStorage } from "node:async_hooks";
14
+ import { Compile } from "typebox/compile";
15
+ /** Legal hook names: dotted lowercase segments (`memory.recall`). */
16
+ export const PUBLISHED_HOOK_NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
17
+ /** Hook names are also bounded in length. */
18
+ export const PUBLISHED_HOOK_NAME_MAX_CHARS = 128;
19
+ /** Maximum hook-call nesting before `failed/too-deep`. */
20
+ export const PUBLISHED_HOOK_MAX_DEPTH = 4;
21
+ /** Payload and result size cap (1 MiB of JSON). */
22
+ export const PUBLISHED_HOOK_MAX_BYTES = 1_048_576;
23
+ const depthStorage = new AsyncLocalStorage();
24
+ function errorMessage(err) {
25
+ return err instanceof Error ? err.message : String(err);
26
+ }
27
+ function describeErrors(validator, value) {
28
+ const out = [];
29
+ for (const err of validator.Errors(value)) {
30
+ out.push({ path: err.instancePath, message: err.message ?? "invalid" });
31
+ }
32
+ return out.length > 0 ? out : [{ path: "", message: "invalid" }];
33
+ }
34
+ export function buildPublishedHookRegistry(declarations, deps) {
35
+ // Ownership: the declarations arrive in loader order; configured
36
+ // extensions sort BEFORE built-ins regardless of arrival order, and
37
+ // the first per name owns it. Shadowed providers record under the
38
+ // owner (configured losers BEFORE built-in losers) and each loser
39
+ // warns once.
40
+ const ordered = [...declarations].sort((a, b) => Number(a.builtin) - Number(b.builtin));
41
+ const owners = new Map();
42
+ const shadowedBy = new Map();
43
+ for (const declaration of ordered) {
44
+ const hook = declaration.hook;
45
+ const existing = owners.get(hook.name);
46
+ if (existing) {
47
+ const owner = existing.declaration.extension;
48
+ const loser = declaration.extension;
49
+ const advice = declaration.builtin
50
+ ? `replace the built-in ${loser} by package name, or remove ${owner}`
51
+ : "reorder extensions[] to switch";
52
+ deps.warn(`published hook "${hook.name}": owned by ${owner}, shadowed ${loser} — ${advice}`);
53
+ const list = shadowedBy.get(hook.name);
54
+ if (list)
55
+ list.push(loser);
56
+ else
57
+ shadowedBy.set(hook.name, [loser]);
58
+ continue;
59
+ }
60
+ owners.set(hook.name, {
61
+ declaration,
62
+ input: Compile(hook.input),
63
+ output: hook.output !== undefined ? Compile(hook.output) : undefined,
64
+ });
65
+ }
66
+ async function call(caller, name, payload, opts = {}) {
67
+ // Depth is checked BEFORE ownership lookup so a nested call under
68
+ // an unknown name still reports too-deep rather than absent.
69
+ const depth = depthStorage.getStore() ?? 0;
70
+ if (depth >= PUBLISHED_HOOK_MAX_DEPTH) {
71
+ return {
72
+ status: "failed",
73
+ provider: owners.get(name)?.declaration.extension ?? "?",
74
+ reason: "too-deep",
75
+ error: `published hook depth ${depth + 1} exceeds ${PUBLISHED_HOOK_MAX_DEPTH}`,
76
+ };
77
+ }
78
+ const entry = owners.get(name);
79
+ if (!entry)
80
+ return { status: "absent" };
81
+ const provider = entry.declaration.extension;
82
+ let serialized;
83
+ try {
84
+ serialized = JSON.stringify(payload);
85
+ }
86
+ catch (err) {
87
+ return {
88
+ status: "rejected",
89
+ provider,
90
+ errors: [{ path: "", message: `payload is not JSON-serializable: ${errorMessage(err)}` }],
91
+ };
92
+ }
93
+ if (serialized.length > PUBLISHED_HOOK_MAX_BYTES) {
94
+ return {
95
+ status: "rejected",
96
+ provider,
97
+ errors: [
98
+ {
99
+ path: "",
100
+ message: `payload is ${serialized.length} bytes; the cap is ${PUBLISHED_HOOK_MAX_BYTES}`,
101
+ },
102
+ ],
103
+ };
104
+ }
105
+ if (!entry.input.Check(payload)) {
106
+ return { status: "rejected", provider, errors: describeErrors(entry.input, payload) };
107
+ }
108
+ let user;
109
+ if (opts.conversationId !== undefined) {
110
+ user = await deps.actingUser(opts.conversationId);
111
+ if (opts.user !== undefined && opts.user !== user) {
112
+ return {
113
+ status: "rejected",
114
+ provider,
115
+ errors: [
116
+ {
117
+ path: "user",
118
+ message: `caller declares user "${opts.user}" but the conversation's acting user is "${user ?? "unknown"}"`,
119
+ },
120
+ ],
121
+ };
122
+ }
123
+ }
124
+ else {
125
+ user = opts.user;
126
+ }
127
+ const controller = new AbortController();
128
+ let timedOut = false;
129
+ let callerAborted = false;
130
+ const timer = setTimeout(() => {
131
+ timedOut = true;
132
+ controller.abort();
133
+ }, deps.deadlineMs);
134
+ const onCallerAbort = () => {
135
+ callerAborted = true;
136
+ controller.abort();
137
+ };
138
+ opts.abortSignal?.addEventListener("abort", onCallerAbort, { once: true });
139
+ const hookCall = {
140
+ caller,
141
+ user,
142
+ conversationId: opts.conversationId,
143
+ abortSignal: controller.signal,
144
+ };
145
+ let value;
146
+ try {
147
+ value = await depthStorage.run(depth + 1, () => entry.declaration.hook.run(payload, hookCall));
148
+ }
149
+ catch (err) {
150
+ const reason = timedOut ? "timeout" : callerAborted ? "aborted" : "threw";
151
+ return { status: "failed", provider, reason, error: errorMessage(err) };
152
+ }
153
+ finally {
154
+ clearTimeout(timer);
155
+ opts.abortSignal?.removeEventListener("abort", onCallerAbort);
156
+ }
157
+ let resultJson;
158
+ try {
159
+ resultJson = JSON.stringify(value);
160
+ }
161
+ catch (err) {
162
+ return {
163
+ status: "failed",
164
+ provider,
165
+ reason: "too-large",
166
+ error: `result is not JSON-serializable: ${errorMessage(err)}`,
167
+ };
168
+ }
169
+ if (resultJson.length > PUBLISHED_HOOK_MAX_BYTES) {
170
+ return {
171
+ status: "failed",
172
+ provider,
173
+ reason: "too-large",
174
+ error: `result is ${resultJson.length} bytes; the cap is ${PUBLISHED_HOOK_MAX_BYTES}`,
175
+ };
176
+ }
177
+ if (entry.output && !entry.output.Check(value)) {
178
+ const first = describeErrors(entry.output, value)[0];
179
+ return {
180
+ status: "failed",
181
+ provider,
182
+ reason: "bad-output",
183
+ error: `result fails the declared output schema at ${first?.path ?? ""}: ${first?.message ?? "invalid"}`,
184
+ };
185
+ }
186
+ return { status: "ok", provider, value };
187
+ }
188
+ return {
189
+ get(name) {
190
+ const entry = owners.get(name);
191
+ if (!entry)
192
+ return undefined;
193
+ return {
194
+ provider: entry.declaration.extension,
195
+ input: entry.declaration.hook.input,
196
+ output: entry.declaration.hook.output,
197
+ };
198
+ },
199
+ call,
200
+ listing() {
201
+ return [...owners.entries()].map(([name, entry]) => ({
202
+ name,
203
+ provider: entry.declaration.extension,
204
+ shadowed: shadowedBy.get(name) ?? [],
205
+ }));
206
+ },
207
+ };
208
+ }