@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +78 -59
  2. package/README.md +32 -0
  3. package/dist/config.js +47 -0
  4. package/dist/curation/host.js +125 -21
  5. package/dist/curation/runs.js +15 -4
  6. package/dist/extension.d.ts +331 -4
  7. package/dist/harness/boot.js +114 -3
  8. package/dist/harness/complete.js +127 -0
  9. package/dist/harness/decision-tool.js +93 -0
  10. package/dist/harness/decisions.js +477 -0
  11. package/dist/harness/event-bus.js +12 -0
  12. package/dist/harness/interactions.js +81 -0
  13. package/dist/harness/interactive-tools.js +97 -0
  14. package/dist/harness/loader.js +285 -1
  15. package/dist/harness/packages.js +7 -0
  16. package/dist/harness/published-hooks.js +208 -0
  17. package/dist/harness/seam-host.js +149 -1
  18. package/dist/harness/settings.js +41 -0
  19. package/dist/history/git.js +6 -2
  20. package/dist/jobs/fire.js +10 -0
  21. package/dist/jobs/observe.js +27 -2
  22. package/dist/notices/events.js +8 -0
  23. package/dist/notices/service.js +297 -0
  24. package/dist/notices/store.js +331 -0
  25. package/dist/notices/validate.js +311 -0
  26. package/dist/server/conversation-ops.js +50 -0
  27. package/dist/server/core-views/skills-tree.js +3 -12
  28. package/dist/server/core-views/skills.js +11 -23
  29. package/dist/server/host-bridge.js +62 -1
  30. package/dist/server/index.js +26 -0
  31. package/dist/server/routes/events.js +20 -0
  32. package/dist/server/routes/extensions.js +5 -0
  33. package/dist/server/routes/interactions.js +48 -1
  34. package/dist/server/routes/meta.js +17 -0
  35. package/dist/server/routes/notices.js +94 -0
  36. package/dist/server/routes/providers.js +65 -8
  37. package/dist/server/routes/submissions.js +3 -0
  38. package/dist/server/routes/users.js +4 -0
  39. package/dist/server/surfaces/dedup.js +6 -0
  40. package/dist/server/visibility.js +30 -0
  41. package/dist/settings/registry.js +18 -0
  42. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  43. package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
  44. package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
  45. package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
  46. package/node_modules/@sigrun/shared/dist/notices.d.ts +112 -0
  47. package/node_modules/@sigrun/shared/dist/notices.js +49 -0
  48. package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
  49. package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
  50. package/node_modules/@sigrun/shared/package.json +9 -1
  51. package/package.json +7 -7
  52. package/webui/dist/assets/index-D5KowS7k.css +1 -0
  53. package/webui/dist/assets/index-DGIrLC6F.js +55 -0
  54. package/webui/dist/index.html +2 -2
  55. package/webui/dist/assets/index-BI5k-Oci.js +0 -55
  56. package/webui/dist/assets/index-EGluyRXk.css +0 -1
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Notice input validation (notices spec §1): shape, the `NOTICE_LIMITS`
3
+ * bounds, the key and action-id grammars, https-only `open`, duplicate
4
+ * action ids, and — on the post path only — that every `extension`
5
+ * action names a handler the posting extension declared.
6
+ *
7
+ * Two entry points, because a STORED notice outlives its extension:
8
+ * `validateNoticeShape` is what the store re-validates a loaded entry
9
+ * with (spec §3: a notice whose extension is disabled or unloaded stays
10
+ * dismissible), while `validateNoticeInput` adds the declared-handler
11
+ * gate a fresh post must pass. Both rebuild the accepted input from the
12
+ * validated fields only, so an unknown key — including a stored
13
+ * `id`/`owner`/`at`, which are the store's own checks — never rides into
14
+ * the record.
15
+ *
16
+ * Every rejection names the offending field (`actions[0].url`); the
17
+ * caller renders `<field>: <message>`, the spec's fail-loud posture.
18
+ * Access checks (does the user exist, can they see the conversation)
19
+ * belong to the service, not here.
20
+ */
21
+ import { ICON_NAMES, canonicalJson } from "@sigrun/shared/blocks";
22
+ import { NOTICE_ACTION_ID_RE, NOTICE_KEY_RE, NOTICE_LIMITS, } from "@sigrun/shared/notices";
23
+ /**
24
+ * Every `Tone` member as a runtime set. The `Record<Tone, true>` annotation
25
+ * is the drift guard: a member added to the blocks union without a line
26
+ * here fails the typecheck rather than being rejected at post time.
27
+ */
28
+ const TONES = new Set(Object.keys({
29
+ neutral: true,
30
+ accent: true,
31
+ good: true,
32
+ warn: true,
33
+ bad: true,
34
+ info: true,
35
+ }));
36
+ /** The closed icon set (the block contract's own names). */
37
+ const ICONS = new Set(ICON_NAMES);
38
+ /** The three action kinds (spec §1). */
39
+ const ACTION_KINDS = ["navigate", "open", "extension"];
40
+ /** The core targets a `navigate` action may name (the blocks `CoreTarget`). */
41
+ const TARGET_KEYS = ["conversation", "job", "settings", "view"];
42
+ /**
43
+ * Validate a notice's shape, bounds and grammars — no handler check.
44
+ * Used on load (a stored entry) and as the first half of the post path.
45
+ */
46
+ export function validateNoticeShape(input) {
47
+ if (!isRecord(input)) {
48
+ return invalid("notice", "a notice must be an object");
49
+ }
50
+ const user = input.user;
51
+ if (typeof user !== "string" || user === "") {
52
+ return invalid("user", "must be a non-empty string");
53
+ }
54
+ const title = input.title;
55
+ if (typeof title !== "string" || title === "") {
56
+ return invalid("title", "must be a non-empty string");
57
+ }
58
+ if (title.length > NOTICE_LIMITS.title) {
59
+ return invalid("title", `longer than ${NOTICE_LIMITS.title} characters`);
60
+ }
61
+ const conversationId = input.conversationId;
62
+ if (conversationId !== undefined) {
63
+ if (typeof conversationId !== "number" || !Number.isInteger(conversationId)) {
64
+ return invalid("conversationId", "must be an integer");
65
+ }
66
+ }
67
+ const key = input.key;
68
+ if (key !== undefined) {
69
+ if (typeof key !== "string" || !NOTICE_KEY_RE.test(key)) {
70
+ return invalid("key", `must match ${NOTICE_KEY_RE.source}`);
71
+ }
72
+ }
73
+ const body = input.body;
74
+ if (body !== undefined) {
75
+ if (typeof body !== "string") {
76
+ return invalid("body", "must be a string");
77
+ }
78
+ if (body.length > NOTICE_LIMITS.body) {
79
+ return invalid("body", `longer than ${NOTICE_LIMITS.body} characters`);
80
+ }
81
+ }
82
+ const tone = input.tone;
83
+ if (tone !== undefined && !isTone(tone)) {
84
+ return invalid("tone", `must be one of ${[...TONES].join("|")}`);
85
+ }
86
+ const actions = validateActions(input.actions);
87
+ if (!actions.ok)
88
+ return actions;
89
+ return {
90
+ ok: true,
91
+ value: {
92
+ user,
93
+ title,
94
+ ...(conversationId === undefined ? {} : { conversationId }),
95
+ ...(key === undefined ? {} : { key }),
96
+ ...(body === undefined ? {} : { body }),
97
+ ...(tone === undefined ? {} : { tone }),
98
+ ...(actions.value === undefined ? {} : { actions: actions.value }),
99
+ },
100
+ };
101
+ }
102
+ /**
103
+ * `validateNoticeShape` plus the declared-handler gate: every `extension`
104
+ * action must name a handler the posting extension declared (the loader's
105
+ * `noticeActions` keys). Used on post only — a stored notice survives its
106
+ * extension being unloaded, so load re-validation never calls this.
107
+ */
108
+ export function validateNoticeInput(input, declaredHandlers) {
109
+ const shape = validateNoticeShape(input);
110
+ if (!shape.ok)
111
+ return shape;
112
+ for (const [index, action] of (shape.value.actions ?? []).entries()) {
113
+ if (action.kind !== "extension")
114
+ continue;
115
+ if (!declaredHandlers.has(action.name)) {
116
+ return invalid(`actions[${index}].name`, `no declared handler named ${JSON.stringify(action.name)}`);
117
+ }
118
+ }
119
+ return shape;
120
+ }
121
+ /**
122
+ * The action list: the count bound, then each action's own shape (the
123
+ * duplicate-id check rides on the id pass, so the first offending action
124
+ * wins). Undefined = the notice carries no actions.
125
+ */
126
+ function validateActions(input) {
127
+ if (input === undefined)
128
+ return { ok: true };
129
+ if (!Array.isArray(input)) {
130
+ return invalid("actions", "must be an array");
131
+ }
132
+ if (input.length > NOTICE_LIMITS.actions) {
133
+ return invalid("actions", `more than ${NOTICE_LIMITS.actions} actions`);
134
+ }
135
+ const actions = [];
136
+ const ids = new Set();
137
+ for (let index = 0; index < input.length; index += 1) {
138
+ const action = validateAction(input[index], `actions[${index}]`);
139
+ if (!action.ok)
140
+ return action;
141
+ if (ids.has(action.value.id)) {
142
+ return invalid(`actions[${index}].id`, `duplicate action id ${JSON.stringify(action.value.id)}`);
143
+ }
144
+ ids.add(action.value.id);
145
+ actions.push(action.value);
146
+ }
147
+ return { ok: true, value: actions };
148
+ }
149
+ /** One action: the shared id/label/icon, then its kind's own fields. */
150
+ function validateAction(input, field) {
151
+ if (!isRecord(input)) {
152
+ return invalid(field, "must be an object");
153
+ }
154
+ const id = input.id;
155
+ if (typeof id !== "string" || !NOTICE_ACTION_ID_RE.test(id)) {
156
+ return invalid(`${field}.id`, `must match ${NOTICE_ACTION_ID_RE.source}`);
157
+ }
158
+ const label = input.label;
159
+ if (typeof label !== "string" || label === "") {
160
+ return invalid(`${field}.label`, "must be a non-empty string");
161
+ }
162
+ if (label.length > NOTICE_LIMITS.label) {
163
+ return invalid(`${field}.label`, `longer than ${NOTICE_LIMITS.label} characters`);
164
+ }
165
+ const icon = input.icon;
166
+ if (icon !== undefined && !isIconName(icon)) {
167
+ return invalid(`${field}.icon`, "must be a known icon name");
168
+ }
169
+ const base = { id, label, ...(icon === undefined ? {} : { icon }) };
170
+ const kind = input.kind;
171
+ if (!ACTION_KINDS.some((known) => known === kind)) {
172
+ return invalid(`${field}.kind`, `must be one of ${ACTION_KINDS.join("|")}`);
173
+ }
174
+ if (kind === "navigate") {
175
+ const target = validateTarget(input.target, `${field}.target`);
176
+ if (!target.ok)
177
+ return target;
178
+ return { ok: true, value: { ...base, kind, target: target.value } };
179
+ }
180
+ if (kind === "open") {
181
+ const url = input.url;
182
+ if (typeof url !== "string" || !url.startsWith("https://")) {
183
+ return invalid(`${field}.url`, "must be an https URL");
184
+ }
185
+ if (url.length > NOTICE_LIMITS.url) {
186
+ return invalid(`${field}.url`, `longer than ${NOTICE_LIMITS.url} characters`);
187
+ }
188
+ return { ok: true, value: { ...base, kind, url } };
189
+ }
190
+ const name = input.name;
191
+ if (typeof name !== "string" || name === "") {
192
+ return invalid(`${field}.name`, "must be a non-empty string");
193
+ }
194
+ const payload = input.payload;
195
+ if (payload !== undefined) {
196
+ if (!isJsonObject(payload)) {
197
+ return invalid(`${field}.payload`, "must be a JSON object");
198
+ }
199
+ // The bound is canonical JSON bytes (spec §1): key order never
200
+ // changes whether a payload fits.
201
+ if (Buffer.byteLength(canonicalJson(payload), "utf8") > NOTICE_LIMITS.payloadBytes) {
202
+ return invalid(`${field}.payload`, `larger than ${NOTICE_LIMITS.payloadBytes} bytes`);
203
+ }
204
+ }
205
+ const confirm = input.confirm;
206
+ if (confirm !== undefined) {
207
+ if (typeof confirm !== "string") {
208
+ return invalid(`${field}.confirm`, "must be a string");
209
+ }
210
+ if (confirm.length > NOTICE_LIMITS.confirm) {
211
+ return invalid(`${field}.confirm`, `longer than ${NOTICE_LIMITS.confirm} characters`);
212
+ }
213
+ }
214
+ return {
215
+ ok: true,
216
+ value: {
217
+ ...base,
218
+ kind: "extension",
219
+ name,
220
+ ...(payload === undefined ? {} : { payload }),
221
+ ...(confirm === undefined ? {} : { confirm }),
222
+ },
223
+ };
224
+ }
225
+ /**
226
+ * A `navigate` target: exactly ONE core key, its value shaped as the
227
+ * blocks `CoreTarget` member declares it (a view target carries the
228
+ * extension slug, the view id and optional params).
229
+ */
230
+ function validateTarget(input, field) {
231
+ if (!isRecord(input)) {
232
+ return invalid(field, "must be a CoreTarget object");
233
+ }
234
+ const present = TARGET_KEYS.filter((key) => input[key] !== undefined);
235
+ if (present.length !== 1) {
236
+ return invalid(field, `must carry exactly one of ${TARGET_KEYS.join("|")}`);
237
+ }
238
+ const key = present[0];
239
+ const raw = input[key];
240
+ if (key === "conversation") {
241
+ if (typeof raw !== "number" || !Number.isInteger(raw)) {
242
+ return invalid(`${field}.conversation`, "must be an integer");
243
+ }
244
+ return { ok: true, value: { conversation: raw } };
245
+ }
246
+ if (key === "job") {
247
+ if (typeof raw !== "string" || raw === "") {
248
+ return invalid(`${field}.job`, "must be a non-empty string");
249
+ }
250
+ return { ok: true, value: { job: raw } };
251
+ }
252
+ if (key === "settings") {
253
+ if (typeof raw !== "string" || raw === "") {
254
+ return invalid(`${field}.settings`, "must be a non-empty string");
255
+ }
256
+ return { ok: true, value: { settings: raw } };
257
+ }
258
+ if (!isRecord(raw)) {
259
+ return invalid(`${field}.view`, "must be an object");
260
+ }
261
+ const extension = raw.extension;
262
+ const id = raw.id;
263
+ if (typeof extension !== "string" || extension === "") {
264
+ return invalid(`${field}.view.extension`, "must be a non-empty string");
265
+ }
266
+ if (typeof id !== "string" || id === "") {
267
+ return invalid(`${field}.view.id`, "must be a non-empty string");
268
+ }
269
+ const params = raw.params;
270
+ if (params !== undefined && !isJsonObject(params)) {
271
+ return invalid(`${field}.view.params`, "must be a JSON object");
272
+ }
273
+ return {
274
+ ok: true,
275
+ value: {
276
+ view: { extension, id, ...(params === undefined ? {} : { params }) },
277
+ },
278
+ };
279
+ }
280
+ function invalid(field, message) {
281
+ return { ok: false, field, message };
282
+ }
283
+ /** A plain record (arrays and null excluded). */
284
+ function isRecord(value) {
285
+ return typeof value === "object" && value !== null && !Array.isArray(value);
286
+ }
287
+ /** A member of the blocks `Tone` union. */
288
+ function isTone(value) {
289
+ return typeof value === "string" && TONES.has(value);
290
+ }
291
+ /** A member of the block contract's closed icon set. */
292
+ function isIconName(value) {
293
+ return typeof value === "string" && ICONS.has(value);
294
+ }
295
+ /** A structurally valid JSON object (a `JsonObject`, never an array). */
296
+ function isJsonObject(value) {
297
+ return isRecord(value) && Object.values(value).every(isJsonValue);
298
+ }
299
+ /** A structurally valid JSON value. */
300
+ function isJsonValue(value) {
301
+ if (value === null)
302
+ return true;
303
+ const type = typeof value;
304
+ if (type === "string" || type === "boolean")
305
+ return true;
306
+ if (type === "number")
307
+ return Number.isFinite(value);
308
+ if (Array.isArray(value))
309
+ return value.every(isJsonValue);
310
+ return isRecord(value) && Object.values(value).every(isJsonValue);
311
+ }
@@ -20,7 +20,9 @@ import { AgentDoc, configure } from "@earendil-works/pi-durable";
20
20
  import { accessOf, isSharing, } from "@sigrun/shared/access";
21
21
  import { extname } from "node:path";
22
22
  import { touchActivity } from "../harness/activity.js";
23
+ import { excludeInteractiveTools } from "../harness/interactive-tools.js";
23
24
  import { ensureRequestId, recordAuthor, } from "../curation/attribution.js";
25
+ import { QueuedHumanInputsDoc, declinePendingOnMessage, } from "../harness/interactions.js";
24
26
  import { resolveServedModelRefreshing } from "../harness/providers/refresh.js";
25
27
  import { isSuperuser } from "./auth/principal.js";
26
28
  import { isConfinedPath, parseSubmissionInput } from "./content-parts.js";
@@ -243,6 +245,16 @@ export async function createConversationOp(ctx, input = {}) {
243
245
  if (input.source !== undefined)
244
246
  draft.source = input.source;
245
247
  await opened.rewriteUserSelection(tx, conversation.id, undefined, input.owner);
248
+ // Ask-user Task 3 (spec §6): a conversation created FOR a job hides
249
+ // the interactive tools — the creation-time filter edits the stored
250
+ // enabled-tools filter, so the tool is neither offered nor reachable
251
+ // from nested `executeTool()` calls. An explicit `agent.tools`
252
+ // selection naming one loses it the same way. A SECOND commit after
253
+ // `harness.createConversation` is fine: document access is
254
+ // unrestricted here.
255
+ if (input.job !== undefined) {
256
+ await excludeInteractiveTools(tx, conversation.id, opened.interactiveTools);
257
+ }
246
258
  }, BACKGROUND_CONTEXT);
247
259
  const view = await readView(conversation);
248
260
  return { id: conversation.id, view };
@@ -390,7 +402,45 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
390
402
  // The submit pass-through is retired (§6.7): the memory seam's
391
403
  // runSettled owns capture, so submissions admit directly.
392
404
  const submission = await conversation.submit(draft, BACKGROUND_CONTEXT);
405
+ // Ask-user Task 2: a human message (no entry, declinesQuestions: true)
406
+ // declines every pending declinable question and records itself in the
407
+ // queued-inputs ledger when it was admitted while busy. A rejected
408
+ // submit (whenBusy: "reject" while busy) throws above and reaches none
409
+ // of this.
410
+ if (opts.declinesQuestions === true && opts.entry === undefined) {
411
+ const declined = await declinePendingOnMessage(opened.harness, conversation.id, opts.user, Date.now(), BACKGROUND_CONTEXT);
412
+ void declined; // the returned callIds are observational only
413
+ const admitted = await submission.status(BACKGROUND_CONTEXT);
414
+ if (admitted.status === "queued") {
415
+ await opened.harness.commit(async (tx) => {
416
+ const doc = await tx.doc(QueuedHumanInputsDoc, conversation.id);
417
+ const byId = doc.byId;
418
+ byId[String(submission.id)] = {
419
+ ...(opts.user !== undefined ? { user: opts.user } : {}),
420
+ };
421
+ }, BACKGROUND_CONTEXT);
422
+ }
423
+ }
393
424
  await recordAuthor(conversation, { submissionId: submission.id }, opts.user, BACKGROUND_CONTEXT);
425
+ // The implied clear (notices spec §3.2 rule 2): the user's next prompt
426
+ // in a conversation is them dealing with that conversation's notices,
427
+ // so every one of THEIR notices naming it goes. Attribution is the
428
+ // submit path's own (`opts.user` — the route's principal, the bridge's
429
+ // mapped user, the answer's acting user), exactly like authorship. A
430
+ // job or curator internal run submits through `conversation.submit`
431
+ // directly, never this op, so a scheduled run clears nothing. It sits
432
+ // behind the attribution pair so the notice store's file I/O never
433
+ // widens the window where the live run's input has no `bySubmission`
434
+ // key; a failing clear is logged, never fatal — a notice system must
435
+ // not break a turn.
436
+ if (opts.user !== undefined) {
437
+ try {
438
+ await ctx.notices.clearConversationFor(opts.user, Number(conversation.id));
439
+ }
440
+ catch (error) {
441
+ console.warn(`notices: clearing conversation ${String(conversation.id)} for ${opts.user} failed: ${String(error)}`);
442
+ }
443
+ }
394
444
  // Admission stamps activity: the sidebar's activity sort must move the
395
445
  // moment a prompt lands — before the run settles.
396
446
  await touchActivity(opened.harness, conversation.id, opened.now, BACKGROUND_CONTEXT);
@@ -346,20 +346,11 @@ function skillActions(params, historyAvailable, deleted) {
346
346
  }
347
347
  return buttons;
348
348
  }
349
- /** The form fields (new skill vs edit: the name is read-only on an existing skill). */
349
+ /** The form fields (new skill vs edit: the name is read-only on an
350
+ * existing skill). A new skill always lands in the workspace: the
351
+ * learned root is the curator's, so the form offers no choice. */
350
352
  function formFields(editingExisting) {
351
- const whereChoice = {
352
- type: "choice",
353
- name: "where",
354
- label: "Where",
355
- required: true,
356
- options: [
357
- { id: "workspace", label: "Workspace" },
358
- { id: "learned", label: "Learned" },
359
- ],
360
- };
361
353
  return [
362
- ...(editingExisting ? [] : [whereChoice]),
363
354
  {
364
355
  type: "text",
365
356
  name: "name",
@@ -436,7 +436,7 @@ export function createSkillsView(deps) {
436
436
  /** The form's pre-filled values (a new skill starts empty; an edit reads the current text). */
437
437
  async function formDataOf(params, selection) {
438
438
  if (params.skill === undefined || selection === undefined) {
439
- return { name: "", description: "", body: "", where: "workspace" };
439
+ return { name: "", description: "", body: "" };
440
440
  }
441
441
  try {
442
442
  const text = await readSkillFile(selection.skill.rootDir, selection.skill.name, "SKILL.md");
@@ -445,7 +445,6 @@ export function createSkillsView(deps) {
445
445
  name: selection.skill.name,
446
446
  description: parsed?.description ?? selection.skill.description,
447
447
  body: parsed?.body ?? "",
448
- where: "workspace",
449
448
  };
450
449
  }
451
450
  catch {
@@ -453,7 +452,6 @@ export function createSkillsView(deps) {
453
452
  name: selection.skill.name,
454
453
  description: selection.skill.description,
455
454
  body: "",
456
- where: "workspace",
457
455
  };
458
456
  }
459
457
  }
@@ -722,34 +720,24 @@ export function createSkillsView(deps) {
722
720
  const user = userOf(ctx);
723
721
  if (user === undefined)
724
722
  return { ok: false, error: { message: "forbidden" } };
725
- const where = fieldOf(payload, "where") ?? "workspace";
723
+ // New skills land in the workspace only: the learned root is
724
+ // the curator's (a user edits, pins or deletes learned skills,
725
+ // never creates one).
726
+ const where = fieldOf(payload, "where");
727
+ if (where !== undefined && where !== "workspace") {
728
+ return {
729
+ ok: false,
730
+ error: { message: "new skills go in the workspace; learned skills come from the curator", field: "where" },
731
+ };
732
+ }
726
733
  const name = fieldOf(payload, "name");
727
734
  const description = fieldOf(payload, "description");
728
- const body = fieldOf(payload, "body");
729
735
  if (name === undefined || !validSkillName(name)) {
730
736
  return { ok: false, error: { message: "a valid skill name is required (lowercase letters, digits, dashes)", field: "name" } };
731
737
  }
732
738
  if (description === undefined || description.trim() === "") {
733
739
  return { ok: false, error: { message: "a description is required", field: "description" } };
734
740
  }
735
- if (where === "learned") {
736
- const curation = deps.curationOf(user);
737
- if (curation === undefined) {
738
- return { ok: false, error: { message: "learned skills are unavailable" } };
739
- }
740
- try {
741
- await curation.store.apply(name, { kind: "create", content: skillFileText(name, description.trim(), body ?? "") }, { actor: "user" });
742
- }
743
- catch (error) {
744
- return failure(error);
745
- }
746
- deps.bump();
747
- return {
748
- ok: true,
749
- toast: `Created ${name}`,
750
- navigate: { type: "navigate", params: { skill: name, root: "learned" } },
751
- };
752
- }
753
741
  return workspaceWrite(ctx, payload, { mode: "create" });
754
742
  },
755
743
  save: async (ctx, payload) => {
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
19
  import { accessOf as accessOfShared } from "@sigrun/shared/access";
20
+ import { completeOnce } from "../harness/complete.js";
20
21
  import { effectiveAdmin } from "../harness/identity/groups.js";
21
22
  import { userWire } from "./routes/users.js";
22
23
  import { accessPrincipalOf } from "./auth/access-principal.js";
@@ -24,6 +25,7 @@ import { abortOp, compactOp, configureOp, ConversationOpError, createConversatio
24
25
  import { listModels } from "./helpers.js";
25
26
  import { notifyExtension } from "./notifications.js";
26
27
  import { SessionMetaDoc } from "./session-meta.js";
28
+ import { isJobRunConversation } from "../harness/interactive-tools.js";
27
29
  import { accountLinkWire, serializeConversationView } from "./views.js";
28
30
  /**
29
31
  * Host-side callers see effective admin derived from stored groups;
@@ -70,6 +72,9 @@ export function createHostBridge(ctx) {
70
72
  ...(opts?.whenBusy === undefined
71
73
  ? {}
72
74
  : { whenBusy: opts.whenBusy }),
75
+ // Ask-user Task 2: a host submit counts as a human message
76
+ // (declines pending questions) only when it carries a user.
77
+ declinesQuestions: opts?.user !== undefined,
73
78
  });
74
79
  },
75
80
  async watch(id, listener) {
@@ -193,6 +198,32 @@ export function createHostBridge(ctx) {
193
198
  state: job.state,
194
199
  };
195
200
  },
201
+ async activeRun(conversationId) {
202
+ const jobs = await opened.jobs.list();
203
+ let best;
204
+ for (const job of jobs) {
205
+ const run = job.lastRun;
206
+ if (run === undefined)
207
+ continue;
208
+ if (run.outcome !== "running")
209
+ continue;
210
+ if (run.conversationId !== Number(conversationId))
211
+ continue;
212
+ if (best !== undefined && run.startedAt <= best.startedAt)
213
+ continue;
214
+ best = { jobId: job.id, owner: job.owner, startedAt: run.startedAt };
215
+ }
216
+ // startedAt is the tiebreak only — the wire shape is {jobId, owner}.
217
+ return best === undefined ? undefined : { jobId: best.jobId, owner: best.owner };
218
+ },
219
+ async isJobRun(conversationId) {
220
+ // The detection reads COMMITTED durable state (session meta,
221
+ // the live doc, submission records) — it never participates in
222
+ // the caller's transaction, so the bridge's own background
223
+ // context is the honest one (the HandlerContext arg is a
224
+ // cancellation surface only).
225
+ return await isJobRunConversation(opened.harness, conversationId, BACKGROUND_CONTEXT);
226
+ },
196
227
  },
197
228
  users: {
198
229
  // The directory in wire form (emails are admin material; the
@@ -252,11 +283,32 @@ export function createHostBridge(ctx) {
252
283
  },
253
284
  },
254
285
  // Gateway C4b: the model catalog — the SAME source function the
255
- // `GET /v1/models` route serves (one construction, never two).
286
+ // `GET /v1/models` route serves (one construction, never two) —
287
+ // plus one-shot completions (warden Task 4), read-live settings
288
+ // per call so a `namingModel` change applies without a restart.
289
+ // Warden Task 6: the decision registry — the seam host's
290
+ // `decisions` members delegate here; `slug` is the CALLING
291
+ // extension's identity (read at call time), which the registry
292
+ // stamps as `caller` on the provider's context.
293
+ decisions: {
294
+ async evaluate(slug, request, opts = {}) {
295
+ return opened.decisions.evaluate(slug, request, opts);
296
+ },
297
+ async list() {
298
+ return opened.decisions.list();
299
+ },
300
+ },
256
301
  models: {
257
302
  async list() {
258
303
  return listModels(opened);
259
304
  },
305
+ async complete(input) {
306
+ return completeOnce({
307
+ models: opened.models,
308
+ defaultModel: opened.defaultModel,
309
+ readSettings: () => opened.settings.read(),
310
+ }, input);
311
+ },
260
312
  },
261
313
  commands: {
262
314
  // The merged catalog rows (`GET /v1/commands`' core + extension
@@ -304,6 +356,15 @@ export function createHostBridge(ctx) {
304
356
  },
305
357
  },
306
358
  notify: (slug, input) => notifyExtension(ctx, slug, input),
359
+ // Notices Task 3: the calling extension's slug is the posting owner
360
+ // (the service enforces the owner scoping); rejections surface to the
361
+ // caller as rejected promises, the same fail-loud posture as ambient
362
+ // writes.
363
+ notices: {
364
+ post: (slug, input) => ctx.notices.post(slug, input),
365
+ clear: (slug, selector) => ctx.notices.clear(slug, selector),
366
+ list: (slug, user) => ctx.notices.listForExtension(slug, user),
367
+ },
307
368
  surfaces: {
308
369
  // UI-blocks U3 Task 4 (spec §7.3): the same bumpRev the actions
309
370
  // route takes — the rev advance and the hub event are one step.
@@ -35,6 +35,8 @@ import { join } from "node:path";
35
35
  import { LoginFlows } from "./login/flows.js";
36
36
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
37
37
  import { SessionMetaDoc } from "./session-meta.js";
38
+ import { NoticesService } from "../notices/service.js";
39
+ import { visibleToUser } from "./visibility.js";
38
40
  import { buildCommandCatalog, SLASH_COMMANDS } from "./commands.js";
39
41
  import { effectiveMethods } from "../config.js";
40
42
  import { registerAuthPipeline } from "./auth/pipeline.js";
@@ -64,6 +66,7 @@ import { registerInteractionRoutes } from "./routes/interactions.js";
64
66
  import { registerMeRoutes } from "./routes/me.js";
65
67
  import { registerMetaRoutes } from "./routes/meta.js";
66
68
  import { registerMetricsRoutes } from "./routes/metrics.js";
69
+ import { registerNoticeRoutes } from "./routes/notices.js";
67
70
  import { registerOnboardingRoutes } from "./routes/onboarding.js";
68
71
  import { registerProbeRoutes } from "./routes/probes.js";
69
72
  import { registerProviderRoutes } from "./routes/providers.js";
@@ -201,6 +204,27 @@ export async function createServer(opened, config, contributedBundles = [], opti
201
204
  // effective method list), and the boot env (the settings routes'
202
205
  // `effective` block reads it).
203
206
  const sse = new SseRegistry();
207
+ // The notices service (notices Task 3): over the per-user store files,
208
+ // with `visibleToUser` as its arbitrary-user visibility answer and the
209
+ // hub as its notice.changed sink (the events route's notice.changed
210
+ // case narrows delivery to the notice user's own streams).
211
+ const noticesService = new NoticesService({
212
+ opened,
213
+ userArea: (user) => join(opened.extensionStores.roots.dataDir, "users", user),
214
+ visible: (conversationId, user) => visibleToUser(
215
+ // SAFETY: `visibleToUser` reads only `opened` off the context
216
+ // (its own doc); the full ServerContext does not exist yet.
217
+ { opened }, conversationId, user),
218
+ publish: (event) => {
219
+ opened.events.publish(event);
220
+ },
221
+ now: () => new Date(),
222
+ log: (line) => console.warn(line),
223
+ }, {
224
+ ...(options?.noticeActionTimeoutMs === undefined
225
+ ? {}
226
+ : { actionTimeoutMs: options.noticeActionTimeoutMs }),
227
+ });
204
228
  // The chat-surface seam's unsubscribe (filled in just below, after
205
229
  // the events hub exists to publish onto; never reassigned after).
206
230
  let unsubscribeChatSurfaceChanged = () => { };
@@ -366,6 +390,7 @@ export async function createServer(opened, config, contributedBundles = [], opti
366
390
  publishSettingsChanged: opened.publishSettingsChanged,
367
391
  watchersByConversation: new Map(),
368
392
  surfaces: surfacesService,
393
+ notices: noticesService,
369
394
  sse,
370
395
  imageProxy: { cache: imageCache, deps: imageDeps, key: imageKey },
371
396
  ...(options?.gateway === undefined ? {} : { gateway: options.gateway }),
@@ -452,6 +477,7 @@ export async function createServer(opened, config, contributedBundles = [], opti
452
477
  registerEventRoutes(app, ctx);
453
478
  registerSubmissionRoutes(app, ctx);
454
479
  registerInteractionRoutes(app, ctx);
480
+ registerNoticeRoutes(app, ctx);
455
481
  registerFileRoutes(app, ctx);
456
482
  registerProviderRoutes(app, ctx);
457
483
  registerSettingsRoutes(app, ctx);
@@ -178,6 +178,26 @@ export function registerEventRoutes(app, ctx) {
178
178
  treeRev: event.treeRev,
179
179
  };
180
180
  }
181
+ // Notices (notices Task 4, spec §4): the hub copy carries the
182
+ // recipient user (NoticeChangedInternal — routing metadata,
183
+ // never wire); the stream delivers ONLY to the notice user's
184
+ // own principal, and the DELIVERED frame is the wire shape
185
+ // (the user stripped — a notice event never crosses to another
186
+ // principal's stream, and its wire carries no user).
187
+ case "notice.changed": {
188
+ // SAFETY: every notice.changed the hub sees comes from the
189
+ // notices service, which always attaches the recipient user
190
+ // (NoticeChangedInternal) — the wire union's member omits it
191
+ // because the DELIVERED frame never carries it.
192
+ const internal = event;
193
+ if (principal.user !== internal.user)
194
+ return undefined;
195
+ return {
196
+ type: "notice.changed",
197
+ ids: internal.ids,
198
+ change: internal.change,
199
+ };
200
+ }
181
201
  // An unknown member is NEVER delivered — deny by construction,
182
202
  // not by enumerating the leak shapes. The satisfies check keeps
183
203
  // this arm exhaustive: a new HostEventWire member without a