@sigrun/sigrun 0.7.0 → 0.9.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.
- package/CHANGELOG.md +78 -59
- package/README.md +32 -0
- package/dist/config.js +47 -0
- package/dist/curation/host.js +125 -21
- package/dist/curation/runs.js +15 -4
- package/dist/extension.d.ts +331 -4
- package/dist/harness/boot.js +114 -3
- package/dist/harness/complete.js +127 -0
- package/dist/harness/decision-tool.js +93 -0
- package/dist/harness/decisions.js +477 -0
- package/dist/harness/event-bus.js +12 -0
- package/dist/harness/interactions.js +81 -0
- package/dist/harness/interactive-tools.js +97 -0
- package/dist/harness/loader.js +285 -1
- package/dist/harness/packages.js +7 -0
- package/dist/harness/published-hooks.js +208 -0
- package/dist/harness/seam-host.js +149 -1
- package/dist/harness/settings.js +41 -0
- package/dist/history/git.js +6 -2
- package/dist/jobs/fire.js +10 -0
- package/dist/jobs/observe.js +27 -2
- package/dist/notices/events.js +8 -0
- package/dist/notices/service.js +297 -0
- package/dist/notices/store.js +331 -0
- package/dist/notices/validate.js +311 -0
- package/dist/server/conversation-ops.js +50 -0
- package/dist/server/core-views/skills-tree.js +3 -12
- package/dist/server/core-views/skills.js +11 -23
- package/dist/server/host-bridge.js +62 -1
- package/dist/server/index.js +26 -0
- package/dist/server/routes/events.js +20 -0
- package/dist/server/routes/extensions.js +5 -0
- package/dist/server/routes/interactions.js +48 -1
- package/dist/server/routes/meta.js +17 -0
- package/dist/server/routes/notices.js +94 -0
- package/dist/server/routes/providers.js +65 -8
- package/dist/server/routes/submissions.js +3 -0
- package/dist/server/routes/users.js +4 -0
- package/dist/server/surfaces/dedup.js +6 -0
- package/dist/server/visibility.js +30 -0
- package/dist/settings/registry.js +18 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
- package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
- package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/notices.d.ts +112 -0
- package/node_modules/@sigrun/shared/dist/notices.js +49 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-D5KowS7k.css +1 -0
- package/webui/dist/assets/index-DGIrLC6F.js +55 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BI5k-Oci.js +0 -55
- package/webui/dist/assets/index-EGluyRXk.css +0 -1
|
@@ -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
|
+
}
|
package/dist/harness/loader.js
CHANGED
|
@@ -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
|
|
@@ -232,6 +257,152 @@ function validateWorkers(value) {
|
|
|
232
257
|
* does not register) and the cross-extension duplicate qualified id
|
|
233
258
|
* live in the load loop, where the registry and the slug are known.
|
|
234
259
|
*/
|
|
260
|
+
/**
|
|
261
|
+
* The notice-action handler-name grammar (notices Task 3): the key a
|
|
262
|
+
* posted notice's `extension` action names, and the loader's per-key
|
|
263
|
+
* check on the factory field.
|
|
264
|
+
*/
|
|
265
|
+
const NOTICE_ACTION_NAME_RE = /^[A-Za-z][A-Za-z0-9-]{0,31}$/;
|
|
266
|
+
/**
|
|
267
|
+
* `noticeActions` must be a plain object whose keys satisfy the
|
|
268
|
+
* handler-name grammar and whose values are functions (notices Task 3);
|
|
269
|
+
* anything else quarantines the extension — an extension that THINKS it
|
|
270
|
+
* serves notice buttons must not silently lose them.
|
|
271
|
+
*/
|
|
272
|
+
function validateNoticeActions(value) {
|
|
273
|
+
if (value === undefined)
|
|
274
|
+
return undefined;
|
|
275
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
276
|
+
throw new Error("extension factory returned a noticeActions field that is not a plain object");
|
|
277
|
+
}
|
|
278
|
+
const actions = {};
|
|
279
|
+
for (const [name, handler] of Object.entries(value)) {
|
|
280
|
+
if (!NOTICE_ACTION_NAME_RE.test(name)) {
|
|
281
|
+
throw new Error(`extension factory returned a noticeActions entry with an invalid name "${name}"`);
|
|
282
|
+
}
|
|
283
|
+
if (typeof handler !== "function") {
|
|
284
|
+
throw new Error(`extension factory returned a noticeActions entry "${name}" that is not a function`);
|
|
285
|
+
}
|
|
286
|
+
actions[name] = handler;
|
|
287
|
+
}
|
|
288
|
+
return actions;
|
|
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
|
+
}
|
|
235
406
|
function validateCurationTargets(value) {
|
|
236
407
|
if (value === undefined)
|
|
237
408
|
return undefined;
|
|
@@ -260,6 +431,29 @@ function validateCurationTargets(value) {
|
|
|
260
431
|
typeof target.maintain !== "function") {
|
|
261
432
|
throw new Error(`extension factory returned a curation target "${name}" whose maintain is not a function`);
|
|
262
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
|
+
}
|
|
263
457
|
targets.push(entry);
|
|
264
458
|
}
|
|
265
459
|
return targets;
|
|
@@ -488,6 +682,18 @@ function validateSurfaces(value) {
|
|
|
488
682
|
typeof def.sharedData !== "boolean") {
|
|
489
683
|
throw new Error(`extension factory returned a surface "${def.id}" whose sharedData is not a boolean`);
|
|
490
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
|
+
}
|
|
491
697
|
if (def?.actions !== undefined) {
|
|
492
698
|
if (typeof def.actions !== "object" ||
|
|
493
699
|
def.actions === null ||
|
|
@@ -1179,6 +1385,9 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1179
1385
|
throw new Error("extension factory returned a perUserTools field that is not a boolean");
|
|
1180
1386
|
}
|
|
1181
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);
|
|
1182
1391
|
const loginProviders = validateLoginProviders(candidate.loginProviders);
|
|
1183
1392
|
const webuiBundles = validateWebuiBundles(candidate.webuiBundles);
|
|
1184
1393
|
const jobs = validateJobs(candidate.jobs);
|
|
@@ -1207,7 +1416,8 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1207
1416
|
loginProviders === undefined &&
|
|
1208
1417
|
webuiBundles === undefined &&
|
|
1209
1418
|
channels === undefined &&
|
|
1210
|
-
userServices === undefined
|
|
1419
|
+
userServices === undefined &&
|
|
1420
|
+
decisionProviders === undefined) {
|
|
1211
1421
|
return undefined;
|
|
1212
1422
|
}
|
|
1213
1423
|
if (typeof candidate.name !== "string" || candidate.name.length === 0) {
|
|
@@ -1241,6 +1451,11 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1241
1451
|
validateKinds(candidate.ambientKinds);
|
|
1242
1452
|
if (kindsProblem !== null)
|
|
1243
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);
|
|
1244
1459
|
// The settings-change hook (U4 §13.2): present-but-not-a-function is
|
|
1245
1460
|
// the same contract violation class as the lifecycle fields — an
|
|
1246
1461
|
// extension that THINKS it observes settings changes must not
|
|
@@ -1255,6 +1470,15 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1255
1470
|
// qualified-id duplicate check runs in the load loop where the slug
|
|
1256
1471
|
// and the already-claimed ids are known).
|
|
1257
1472
|
const curationTargets = validateCurationTargets(candidate.curationTargets);
|
|
1473
|
+
// Notice actions (notices Task 3): a plain object of name → handler,
|
|
1474
|
+
// every name grammar-checked; a malformed entry quarantines.
|
|
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);
|
|
1258
1482
|
return {
|
|
1259
1483
|
...(candidate.perUserTools === undefined
|
|
1260
1484
|
? {}
|
|
@@ -1291,11 +1515,15 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1291
1515
|
...(candidate.interactionKinds === undefined
|
|
1292
1516
|
? {}
|
|
1293
1517
|
: { interactionKinds: candidate.interactionKinds }),
|
|
1518
|
+
...(interactiveTools === undefined ? {} : { interactiveTools }),
|
|
1294
1519
|
...(candidate.ambientKinds === undefined
|
|
1295
1520
|
? {}
|
|
1296
1521
|
: { ambientKinds: candidate.ambientKinds }),
|
|
1297
1522
|
...(workers === undefined ? {} : { workers }),
|
|
1298
1523
|
...(curationTargets === undefined ? {} : { curationTargets }),
|
|
1524
|
+
...(noticeActions === undefined ? {} : { noticeActions }),
|
|
1525
|
+
...(publishedHooks === undefined ? {} : { publishedHooks }),
|
|
1526
|
+
...(decisionProviders === undefined ? {} : { decisionProviders }),
|
|
1299
1527
|
...(providers === undefined ? {} : { providers }),
|
|
1300
1528
|
...(loginProviders === undefined ? {} : { loginProviders }),
|
|
1301
1529
|
...(webuiBundles === undefined ? {} : { webuiBundles }),
|
|
@@ -1494,10 +1722,22 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1494
1722
|
// entry per loaded extension whose result carried them — boot folds
|
|
1495
1723
|
// these into the ONE CurationRegistry (src/curation/targets.ts).
|
|
1496
1724
|
const curationBindings = [];
|
|
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 = [];
|
|
1497
1734
|
const userServiceBindings = [];
|
|
1498
1735
|
// Jobs Task 3: declared jobs, attributed to the extension slug (the
|
|
1499
1736
|
// `ext:<slug>:<name>` id's namespace).
|
|
1500
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 = [];
|
|
1501
1741
|
// HTTP routes (W5 Task 2), attributed to the slug like the job bindings.
|
|
1502
1742
|
const routeBindings = [];
|
|
1503
1743
|
// Slash commands (W5 Task 3), attributed the same way.
|
|
@@ -1786,6 +2026,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1786
2026
|
if (unregistered !== undefined) {
|
|
1787
2027
|
throw new Error(`extension "${slug}" declares curation target "${target.name}" listing tool "${unregistered}" it does not register`);
|
|
1788
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
|
+
}
|
|
1789
2036
|
// A tool names ONE target (A8): two targets — across any
|
|
1790
2037
|
// extensions — listing the same tool leave `byTool`
|
|
1791
2038
|
// first-wins arbitrary and the shadowed target's undos
|
|
@@ -1895,6 +2142,32 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1895
2142
|
workers: resolved.workers,
|
|
1896
2143
|
});
|
|
1897
2144
|
}
|
|
2145
|
+
if (resolved.noticeActions !== undefined) {
|
|
2146
|
+
noticeActionBindings.push({
|
|
2147
|
+
extension: slug,
|
|
2148
|
+
actions: resolved.noticeActions,
|
|
2149
|
+
});
|
|
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
|
+
}
|
|
1898
2171
|
if (resolved.curationTargets !== undefined) {
|
|
1899
2172
|
curationBindings.push({
|
|
1900
2173
|
extension: slug,
|
|
@@ -1910,6 +2183,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1910
2183
|
if (resolved.jobs !== undefined) {
|
|
1911
2184
|
jobBindings.push({ extension: slug, jobs: resolved.jobs });
|
|
1912
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
|
+
}
|
|
1913
2193
|
if (resolved.userServices !== undefined) {
|
|
1914
2194
|
userServiceBindings.push({
|
|
1915
2195
|
extension: slug,
|
|
@@ -2025,7 +2305,11 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
2025
2305
|
lifecycleBindings,
|
|
2026
2306
|
workerBindings,
|
|
2027
2307
|
curationBindings,
|
|
2308
|
+
noticeActionBindings,
|
|
2309
|
+
publishedHookDeclarations,
|
|
2310
|
+
decisionProviderDeclarations,
|
|
2028
2311
|
jobBindings,
|
|
2312
|
+
interactiveTools,
|
|
2029
2313
|
routeBindings,
|
|
2030
2314
|
commandBindings,
|
|
2031
2315
|
notifierBindings,
|
package/dist/harness/packages.js
CHANGED
|
@@ -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
|