@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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,84 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.0] - 2026-11-05
9
+
10
+ ### Added
11
+
12
+ - Decision providers: a factory result's `decisionProviders` contributes typed-question models
13
+ (noul / choice / score) keyed in `auth.json` with a `credentialEnv` fallback; credentials write
14
+ through `PUT/DELETE /v1/providers/:id/credential` (a decision-only id re-reads readiness
15
+ instead of refreshing). `host.decisions.evaluate` (never throws; validated against the wire
16
+ schema and the model's limits; a 15 s deadline capped by `server.decisions.timeoutMs` /
17
+ `SIGRUN_DECISION_TIMEOUT_MS`), the `decisionModel` setting (live), decision entries with
18
+ `kind` + model ids on `GET /v1/providers`, a `decisions` projection on `GET /v1/meta`, and the
19
+ opt-in `decision_evaluate` tool (`decisions.tool`, default false). Docs:
20
+ "Contributing decision providers" in docs/extending.md.
21
+ - `host.jobs.activeRun(conversationId)`: the job whose fire is CURRENTLY running in a
22
+ conversation (both fire shapes), with its effective owner — the unattended-run marker
23
+ extensions need to route destructive calls. Fixed-conversation fires settle their runs via the
24
+ submission's `job:` requestId (no meta stamp; a fire into an existing conversation does not
25
+ mark its later runs job-driven).
26
+ - `host.models.complete(input)`: one-shot chat completions for extensions (no conversation);
27
+ explicit model → `namingModel` → default, `role: "utility"`, a 60 s default timeout, both the
28
+ timer and the caller's signal bounding the wait.
29
+ - Declinable interaction records: `declinable: true` marks a pending record declinable;
30
+ `POST /v1/conversations/:id/interactions/:callId/decline` (write access) settles it
31
+ `skipped` with `declinedVia: "button"` (404 `no_such_interaction` / 404 `not_pending` /
32
+ 409 `not_declinable`); `host.declineInteraction(conversationId, callId, context, {user})`
33
+ is the extension-side peer; `interactionSettled` payloads carry `declinedVia`. Declining
34
+ sweeps the record's unread secret delivery inside the same commit.
35
+ - Interactive tools: a factory result's `interactiveTools` names the registered tools that
36
+ need a human present. Core hides them in job-run conversations (`conversation: "new"`
37
+ fires, `host.conversations.create({job})`) and curator runs (digest agents exclude them;
38
+ every run's `allow` list loses them with one warning; fork reviews keep them listed for
39
+ prompt-cache parity and the gate blocks a call instead). `host.jobs.isJobRun` tells an
40
+ interactive tool's execute whether the current run is job-driven (a job firing into an
41
+ existing conversation is not covered by the creation-time filter — refuse there).
42
+
43
+ ## [0.8.0] - 2026-11-04
44
+
45
+ Core notices: a per-user notice system for extensions. An extension posts a short notice
46
+ (title, body, tone, up to 3 actions) to ONE directory user; only that user ever sees it,
47
+ by route or by event; it clears on dismiss, the user's own next prompt in the conversation,
48
+ acting on it, clear-all, eviction past the caps, or deleting the user (the file is removed
49
+ and a re-created username starts empty). Notices are transient by design — nothing expires
50
+ on a timer.
51
+
52
+ ### Core
53
+
54
+ - `host.notices` (`post`/`clear`/`list`, extension-scoped keys, capped 10 per extension and
55
+ 50 per user), the `noticeActions` factory field (handlers run with the clicker's
56
+ principal, idempotent, 10s timeout, dedup + rate limit reusing the surface-action
57
+ primitives), `/v1/notices` routes (404-never-403 for foreign ids; `GET` prunes notices
58
+ whose conversation share was revoked), and the user-only `notice.changed` event.
59
+ - Storage: one stamped, atomically-written `notices.json` per user under
60
+ `<dataDir>/users/<user>/` (backup-archived by the existing `data` section); invalid
61
+ entries are dropped on load; the agent sandbox cannot write it.
62
+ - Ambient `navigate`/`open` buttons work in the web UI (the no-op dispatcher is gone;
63
+ `open` uses `noopener noreferrer`).
64
+
65
+ ### Web UI
66
+
67
+ - Bell with a count (rail on desktop, every tab's header on phones), a notices pane
68
+ (docked right panel on Chats, overlay on other tabs, full-height sheet on phones),
69
+ inline notices above the composer (max 3, then "N more"), and a 6s peek (pause on
70
+ hover, arrivals merge, none for an on-screen conversation's notice or while the tab
71
+ is hidden).
72
+
73
+ ### TUI
74
+
75
+ - Inline notice line above the editor, `notices:N` in the status bar, and an overlay
76
+ with dismiss, Clear-all-free navigation, and the buttons (`open` prints the URL; no
77
+ peek — the count is the announcement).
78
+
79
+ ### Companions
80
+
81
+ - ext-curator 0.3.0 (published first): the "Learned: …" review notice moved onto core
82
+ notices with Undo as an extension action on the notice; ext-gateway 0.1.16 widened its
83
+ `@sigrun/shared` peer to the 0.8.0 line (0.1.15 is a dead scar — tagged before its fake
84
+ host carried `SigrunHost.notices`, CI failed against core main).
85
+
8
86
  ## [0.7.0] - 2026-11-04
9
87
 
10
88
  The polish pass: three waves of behaviour fixes, the full deferred-minors sweep, and a
@@ -48,65 +126,6 @@ fresh-eyes clean-room install verification.
48
126
 
49
127
  - Release-pin bump to 0.6.1 (root, shared, tui, sandbox platform packages, chart appVersion, compose defaults).
50
128
 
51
- ## [Unreleased]
52
-
53
- ### Added
54
- - **Views are tabs (webui).** The webui shell registers views as rail
55
- tabs with remembered routes; the chat view keeps the conversation
56
- list and right panel to itself. Below the tablet breakpoint a phone
57
- bottom tab bar (with a More sheet) replaces the rail.
58
- - **`page.selection`** — a server-declared, presentation-only
59
- list-first drill-in for compact widths (`{selected, back}` on a
60
- `page`; webui and TUI render it, `toText` ignores it).
61
- - **Core views.** Core attaches its own view surfaces under the
62
- reserved `core` slug and lists them as `coreViews` (one
63
- `CoreViewInfo {id, title, icon, refreshMs?}` per view) on
64
- `/v1/extensions` and `/v1/meta`; they are never `extensions[]` rows,
65
- so the health dot is unaffected. `job.changed` now bumps every
66
- rendered core/jobs instance.
67
- - **The Jobs tab** (`core/jobs`): the jobs surface as a core blocks
68
- view — sidebar of the caller-visible jobs, drill-in with summary,
69
- paged runs and revisions (a selected revision diffs its spec JSON
70
- against the previous one), Run now / Reset state for write access,
71
- and per-row navigation into a run's conversation. The Jobs panel is
72
- gone.
73
- - **The Skills tab** (`core/skills`): every skill root the deployment
74
- serves — Workspace / Learned / Config dir (the deployment-wide
75
- `<config-dir>/skills/`, read-only) / one group per installed package
76
- (`· <pkg>`, with a per-row package badge) / Deleted — with shadowing visible, owned-skill CRUD, per-skill git
77
- history (25 entries per page, per-file diffs, restore), deleted-skill
78
- restore, and per-user overlays as the only write to the read-only
79
- shared roots. Some surface actions return `navigate`; the webui
80
- applies that navigation, the TUI does not yet (a known channel gap).
81
- - **Every-root skill discovery** (`src/skills/roots.ts`): workspace,
82
- learned, user dir, and every installed package's skills listed in one
83
- pass, with precedence/shadowing resolution and symlink-safe reads.
84
- - **GitHistory** (`src/history/git.ts`) — isolated local git for
85
- sigrun-owned history stores: execFile with explicit `--git-dir` /
86
- `--work-tree`, a hermetic git env, per-repo locking, and fail-soft
87
- writes (a broken repo is quarantined aside and re-initialized;
88
- mutations never fail because history did).
89
- - **Curation stores on git** (Task 9): each user's learned skills,
90
- overlays, and curator state live in a per-user git history repo; the
91
- change feed's `actor` widens to `"outside"` and its `id`/`before`/
92
- `after` are commit SHAs. A once-per-process migration imports the
93
- pre-git store (the legacy files are left read-only, outside the
94
- repo's pathspec).
95
- - **Workspace skills history** (Task 10): the workspace `skills/`
96
- tree gets its own per-user repo (git dir outside the work tree),
97
- caught up on settled runs and tab opens; ownerless conversations
98
- skip it.
99
-
100
- ### Changed
101
- - **The curation seal**: workspaces and grants that sit inside or
102
- contain a user's `curation/` tree are rejected; boot revalidates
103
- stored records and names each failing one loudly. A user whose
104
- workspace was the bare user area loses agent tools until an admin
105
- re-points it: `PATCH /v1/users/<u> {"workspace": "<userArea>/workspace"}`.
106
- History tamper-resistance holds under active sandbox isolation; a
107
- single-user install without isolation runs the agent unsandboxed
108
- (the existing trust model).
109
-
110
129
  ## [0.6.0] - 2026-11-02
111
130
 
112
131
  ### Added
package/README.md CHANGED
@@ -1299,6 +1299,34 @@ curl -X PATCH localhost:3000/v1/users/<u> \
1299
1299
  -d '{"workspace": "<userArea>/workspace"}'
1300
1300
  ```
1301
1301
 
1302
+ ## Notices
1303
+
1304
+ A **notice** is a short message from an extension aimed at you alone:
1305
+ a title, a line or two of text, and up to three buttons (open a view,
1306
+ open a link, or run an action such as Undo) — the curator's
1307
+ "Learned: …" line is the first one you will see. Notices are per-user,
1308
+ never shared, and never copied into a fork. The **bell** —
1309
+ at the top of the rail on desktop, in the tab header on phones, and as
1310
+ a count in the TUI's status bar — opens the **notices pane**: a docked
1311
+ panel on the Chats tab and a slide-over on every other tab, newest
1312
+ first, with ✕ per notice and a Clear all. A conversation's own notices
1313
+ appear inline above the composer, and a new one **peeks** by the bell
1314
+ for six seconds (not when its conversation is already on screen),
1315
+ staying put while you hover it — several arriving
1316
+ together merge into "N new notices". In the TUI the newest notice for
1317
+ the open conversation (else the newest app-wide one) is a line above
1318
+ the editor (`alt+d` standard, `gx` vim, `M-d` emacs dismisses it), the
1319
+ status bar carries the count, and the overlay opens with `ctrl+g`
1320
+ (standard), `gN` (vim), or `M-N` (emacs).
1321
+
1322
+ What clears a notice: its ✕, **your next prompt in that conversation**,
1323
+ a button that navigates or opens a link, an extension button that
1324
+ succeeds, Clear all, the extension that posted it, or — when the pile
1325
+ outgrows 10 notices from one extension or 50 in total — eviction of the
1326
+ oldest. Nothing expires on a timer, and another user's prompt never
1327
+ clears yours. A revoked share drops that conversation's notices on
1328
+ your next read; an archived conversation keeps its own.
1329
+
1302
1330
  ## Jobs
1303
1331
 
1304
1332
  A job is a schedule plus an action, durable by construction — one
@@ -1440,6 +1468,10 @@ curl -X POST -H "Authorization: Bearer $TOKEN" \
1440
1468
  (SSE streaming views included). A submission body is
1441
1469
  `{"input": "..."}` (or a `{"input": [parts]}` array of
1442
1470
  text/image/file content parts).
1471
+ - `GET /v1/notices`, `POST /v1/notices/{id}/dismiss`,
1472
+ `POST /v1/notices/dismiss-all`,
1473
+ `POST /v1/notices/{id}/actions/{actionId}` — the caller's own notices
1474
+ (see [Notices](#notices)); a foreign id answers 404.
1443
1475
  - `GET /v1/providers` — per-provider readiness, statuses only (never
1444
1476
  credentials); `GET /v1/auth/check` is the deprecated alias.
1445
1477
  - `GET /v1/onboarding`, `POST /v1/onboarding/claim` — the unclaimed
package/dist/config.js CHANGED
@@ -20,6 +20,28 @@ import { deriveUsername, isUsernameKey } from "@sigrun/shared/usernames";
20
20
  export const DEFAULT_MODEL_ENV = "SIGRUN_DEFAULT_MODEL";
21
21
  /** Deprecated alias for {@link DEFAULT_MODEL_ENV} (warns once per boot). */
22
22
  export const DEPRECATED_MODEL_ENV = "SIGRUN_MODEL";
23
+ /**
24
+ * The published-hook call deadline (knowledge C7, Task 2):
25
+ * `SIGRUN_PUBLISHED_HOOK_DEADLINE`, over the file's
26
+ * `server.publishedHookDeadlineMs`, default 30000 ms. Its own knob, NOT
27
+ * the memory-seam `SIGRUN_HOOK_DEADLINE` (hook calls can sit on the
28
+ * reply path; the memory knob tunes background handlers). A positive
29
+ * integer; `parseMemorySeamCount`'s posture (fail loud, `""` is unset).
30
+ */
31
+ export const PUBLISHED_HOOK_DEADLINE_ENV = "SIGRUN_PUBLISHED_HOOK_DEADLINE";
32
+ /** The published-hook deadline default (30 s). */
33
+ export const DEFAULT_PUBLISHED_HOOK_DEADLINE_MS = 30_000;
34
+ /**
35
+ * The decision-provider call timeout (warden Task 6):
36
+ * `SIGRUN_DECISION_TIMEOUT_MS`, over the file's
37
+ * `server.decisions.timeoutMs`, default 15000 ms — both the default AND
38
+ * the cap for one `host.decisions.evaluate` call (`min(timeoutMs, cap)`).
39
+ * A positive integer; `parseMemorySeamCount`'s posture (fail loud,
40
+ * `""` is unset).
41
+ */
42
+ export const DECISION_TIMEOUT_ENV = "SIGRUN_DECISION_TIMEOUT_MS";
43
+ /** The decision timeout default (15 s). */
44
+ export const DEFAULT_DECISION_TIMEOUT_MS = 15_000;
23
45
  /** The provider id used when the model spec carries no `provider/` prefix. */
24
46
  export const DEFAULT_PROVIDER = "openai";
25
47
  /**
@@ -820,6 +842,8 @@ export function resolvedServerSection(config) {
820
842
  ...(webui === undefined ? {} : { webui }),
821
843
  compaction: { ...config.compaction },
822
844
  memorySeam: { ...config.memorySeam },
845
+ publishedHookDeadlineMs: config.publishedHookDeadlineMs,
846
+ decisions: { ...config.decisions },
823
847
  isolation: { ...config.isolation },
824
848
  providers: { ...config.providers },
825
849
  ...(auth === undefined ? {} : { auth }),
@@ -860,6 +884,8 @@ export function resolvedServerSection(config) {
860
884
  * | `SIGRUN_IDLE_THRESHOLD` | `memorySeam.idleThresholdMs` |
861
885
  * | `SIGRUN_WORKER_DEADLINE` | `memorySeam.workerDeadlineMs` |
862
886
  * | `SIGRUN_QUEUE_MAX` | `memorySeam.queueMax` |
887
+ * | `SIGRUN_PUBLISHED_HOOK_DEADLINE` | `publishedHookDeadlineMs` |
888
+ * | `SIGRUN_DECISION_TIMEOUT_MS` | `decisions.timeoutMs` |
863
889
  * | `SIGRUN_ISOLATION` | `isolation.mode` |
864
890
  * | `SIGRUN_PROVIDER_REFRESH_MINUTES` | `providers.refreshIntervalMinutes` |
865
891
  * | `SIGRUN_BACKUP_*` | `backup` |
@@ -917,6 +943,14 @@ export function envServerSection(env) {
917
943
  });
918
944
  if (memorySeam !== undefined)
919
945
  section.memorySeam = memorySeam;
946
+ const publishedHookDeadlineMs = parseMemorySeamCount(env, PUBLISHED_HOOK_DEADLINE_ENV);
947
+ if (publishedHookDeadlineMs !== undefined) {
948
+ section.publishedHookDeadlineMs = publishedHookDeadlineMs;
949
+ }
950
+ const decisionTimeoutMs = parseMemorySeamCount(env, DECISION_TIMEOUT_ENV);
951
+ if (decisionTimeoutMs !== undefined) {
952
+ section.decisions = { timeoutMs: decisionTimeoutMs };
953
+ }
920
954
  const isolationMode = parseIsolationMode(env);
921
955
  if (isolationMode !== undefined)
922
956
  section.isolation = { mode: isolationMode };
@@ -1020,6 +1054,11 @@ export function effectiveServerSection(env, fileServer, config) {
1020
1054
  "memorySeam",
1021
1055
  "queueMax",
1022
1056
  ]);
1057
+ emit("publishedHookDeadlineMs", config.publishedHookDeadlineMs, ["publishedHookDeadlineMs"]);
1058
+ emit("decisions.timeoutMs", config.decisions.timeoutMs, [
1059
+ "decisions",
1060
+ "timeoutMs",
1061
+ ]);
1023
1062
  emit("auth.sessionDays", config.auth.sessionDays, ["auth", "sessionDays"]);
1024
1063
  emit("isolation.mode", config.isolation.mode, ["isolation", "mode"]);
1025
1064
  emit("isolation.osUsers", config.isolation.osUsers, ["isolation", "osUsers"]);
@@ -1173,6 +1212,14 @@ export function loadConfig(env, settingsFile) {
1173
1212
  backupStaleAfterHours: server?.backup?.staleAfterHours ?? DEFAULT_BACKUP_STALE_AFTER_HOURS,
1174
1213
  compaction: parseCompaction(env, server?.compaction),
1175
1214
  memorySeam: parseMemorySeam(env, server?.memorySeam),
1215
+ publishedHookDeadlineMs: parseMemorySeamCount(env, PUBLISHED_HOOK_DEADLINE_ENV) ??
1216
+ server?.publishedHookDeadlineMs ??
1217
+ DEFAULT_PUBLISHED_HOOK_DEADLINE_MS,
1218
+ decisions: {
1219
+ timeoutMs: parseMemorySeamCount(env, DECISION_TIMEOUT_ENV) ??
1220
+ server?.decisions?.timeoutMs ??
1221
+ DEFAULT_DECISION_TIMEOUT_MS,
1222
+ },
1176
1223
  isolation: parseIsolation(env, server?.isolation),
1177
1224
  providers: {
1178
1225
  refreshIntervalMinutes: parseRefreshIntervalMinutes(env, server?.providers?.refreshIntervalMinutes),
@@ -25,6 +25,8 @@ import { digestRunRun, forkReviewRun, setChangeTargetResolver } from "./runs.js"
25
25
  import { resolveUpstream } from "./overlays.js";
26
26
  import { jobSkillRefs } from "../jobs/skills-refs.js";
27
27
  import { SessionMetaDoc } from "../server/session-meta.js";
28
+ /** Per-target bound for one `wantsReview` answer (C5). */
29
+ const WANTS_REVIEW_BOUND_MS = 2000;
28
30
  /** modelCallsSince's entry-page size (cursor-walked, never a cap). */
29
31
  const MODEL_CALLS_PAGE = 500;
30
32
  /** Scan bound for the cursor walk: a broken cursor loops, a count does not. */
@@ -62,7 +64,14 @@ export function createCurationHost(deps) {
62
64
  */
63
65
  const digestFor = async (user, input, tools, deadline) => {
64
66
  const remaining = Math.max(1, deadline - Date.now());
65
- return await digestRunRun({ harness, registry }, user, {
67
+ const runDeps = {
68
+ harness,
69
+ registry,
70
+ ...(deps.interactiveTools === undefined
71
+ ? {}
72
+ : { interactiveTools: deps.interactiveTools }),
73
+ };
74
+ return await digestRunRun(runDeps, user, {
66
75
  instruction: input,
67
76
  allow: tools,
68
77
  maxToolCalls: 8,
@@ -151,37 +160,83 @@ export function createCurationHost(deps) {
151
160
  return await takeHints(conversation, BACKGROUND_CONTEXT);
152
161
  },
153
162
  async targets(user, id) {
154
- // The selection filter: a target is served for a conversation
155
- // only when EVERY listed tool is model-visible in that
156
- // conversation's resolved agent. No id: every target.
157
- const visible = id === undefined ? undefined : await modelVisibleTools(id);
158
- const result = [];
159
- for (const target of curation.targets()) {
160
- if (visible !== undefined) {
161
- if (target.tools.some((tool) => !visible.has(tool)))
162
- continue;
163
+ // The projection drops the C5 callbacks (the info is data —
164
+ // readTools rides only when the target declared it).
165
+ return (await servedTargets(user, id)).map((target) => ({
166
+ id: target.id,
167
+ tools: target.tools,
168
+ ...(target.readTools.length === 0
169
+ ? {}
170
+ : { readTools: target.readTools }),
171
+ guidance: target.guidance,
172
+ maintain: target.maintain,
173
+ }));
174
+ },
175
+ async wantsReview(user, id) {
176
+ // C5: any ONE served target requesting a review is enough. Each
177
+ // target's answer is bounded — a throw or a slow target counts
178
+ // as `false` with one log line naming the target (the review
179
+ // loop must never stall on one target's clock).
180
+ for (const target of await servedTargets(user, id)) {
181
+ if (target.wantsReview === undefined)
182
+ continue;
183
+ let wants = false;
184
+ try {
185
+ wants = await bounded(Promise.resolve(target.wantsReview(user, id)));
163
186
  }
164
- const guidance = await target.guidance(user);
165
- if (guidance.length === 0)
187
+ catch (error) {
188
+ console.warn(`curation: wantsReview of ${target.id} failed: ${error instanceof Error ? error.message : String(error)}`);
166
189
  continue;
167
- result.push({
168
- id: target.id,
169
- tools: [...target.tools],
170
- guidance: [...guidance],
171
- maintain: target.maintain !== undefined,
172
- });
190
+ }
191
+ if (wants === true)
192
+ return true;
173
193
  }
174
- return result;
194
+ return false;
175
195
  },
176
196
  async tools(id) {
177
197
  const visible = await modelVisibleTools(id);
178
198
  return [...visible];
179
199
  },
180
200
  async forkReview(id, opts) {
181
- return await forkReviewRun({ harness, registry }, id, opts);
201
+ const result = await forkReviewRun({
202
+ harness,
203
+ registry,
204
+ ...(deps.interactiveTools === undefined
205
+ ? {}
206
+ : { interactiveTools: deps.interactiveTools }),
207
+ }, id, opts);
208
+ // C5: after a COMPLETED review, tell every served target the
209
+ // review consumed its hints — the target's signal to retire
210
+ // them (errors caught and logged; a target never blocks the
211
+ // result). Owner read from the conversation's SessionMetaDoc,
212
+ // as the run driver does; other statuses and an ownerless
213
+ // conversation fire nothing.
214
+ if (result.status !== "completed")
215
+ return result;
216
+ const meta = await harness.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT);
217
+ const owner = meta?.owner;
218
+ if (owner === undefined)
219
+ return result;
220
+ for (const target of await servedTargets(owner, id)) {
221
+ if (target.reviewed === undefined)
222
+ continue;
223
+ try {
224
+ await target.reviewed(owner, id);
225
+ }
226
+ catch (error) {
227
+ console.warn(`curation: reviewed of ${target.id} failed: ${error instanceof Error ? error.message : String(error)}`);
228
+ }
229
+ }
230
+ return result;
182
231
  },
183
232
  async digestRun(user, opts) {
184
- return await digestRunRun({ harness, registry }, user, opts, deps.defaultModel, deps.defaultAgent);
233
+ return await digestRunRun({
234
+ harness,
235
+ registry,
236
+ ...(deps.interactiveTools === undefined
237
+ ? {}
238
+ : { interactiveTools: deps.interactiveTools }),
239
+ }, user, opts, deps.defaultModel, deps.defaultAgent);
185
240
  },
186
241
  async maintain(user, targetId, budget) {
187
242
  const target = curation
@@ -280,6 +335,55 @@ export function createCurationHost(deps) {
280
335
  const agent = await conversation.agent(BACKGROUND_CONTEXT);
281
336
  return new Set(agent.tools.map((tool) => tool.name));
282
337
  }
338
+ /**
339
+ * The targets served for `(user, id)`: the selection filter (every
340
+ * listed tool — `tools` AND `readTools` — model-visible) and the
341
+ * guidance filter (`[]` = unavailable for the user), projected for
342
+ * the caller. No id: every target (selection-independent).
343
+ */
344
+ async function servedTargets(user, id) {
345
+ const visible = id === undefined ? undefined : await modelVisibleTools(id);
346
+ const result = [];
347
+ for (const target of curation.targets()) {
348
+ if (visible !== undefined) {
349
+ if (target.tools.some((tool) => !visible.has(tool)))
350
+ continue;
351
+ if ((target.readTools ?? []).some((tool) => !visible.has(tool))) {
352
+ continue;
353
+ }
354
+ }
355
+ const guidance = await target.guidance(user);
356
+ if (guidance.length === 0)
357
+ continue;
358
+ result.push({
359
+ id: target.id,
360
+ tools: [...target.tools],
361
+ readTools: [...(target.readTools ?? [])],
362
+ guidance: [...guidance],
363
+ maintain: target.maintain !== undefined,
364
+ ...(target.wantsReview === undefined
365
+ ? {}
366
+ : { wantsReview: target.wantsReview }),
367
+ ...(target.reviewed === undefined
368
+ ? {}
369
+ : { reviewed: target.reviewed }),
370
+ });
371
+ }
372
+ return result;
373
+ }
374
+ /** Bound one target answer at the C5 deadline; rejects past it. */
375
+ async function bounded(answer) {
376
+ return await new Promise((resolve, reject) => {
377
+ const timer = setTimeout(() => reject(new Error("wantsReview timed out")), WANTS_REVIEW_BOUND_MS);
378
+ answer.then((value) => {
379
+ clearTimeout(timer);
380
+ resolve(value === true);
381
+ }, (error) => {
382
+ clearTimeout(timer);
383
+ reject(error instanceof Error ? error : new Error(String(error)));
384
+ });
385
+ });
386
+ }
283
387
  /** The feed entries one review caused (newest-first walk). */
284
388
  async function feedOf(store, reviewId) {
285
389
  const found = [];
@@ -42,6 +42,7 @@ import { ensureRequestId, recordAuthor } from "./attribution.js";
42
42
  import { isInternal, markInternal } from "./internal.js";
43
43
  import { curationRun, CurationRunDoc } from "./run-doc.js";
44
44
  import { CURATION_GATE_EXTENSION } from "./gate.js";
45
+ import { excludeInteractiveTools, withoutInteractive, } from "../harness/interactive-tools.js";
45
46
  import { SessionMetaDoc } from "../server/session-meta.js";
46
47
  /** The target-id resolver for collected changes (the host installs it). */
47
48
  let targetOfTool = (tool) => tool === "skill_manage" ? "sigrun.skills" : `tool:${tool}`;
@@ -495,7 +496,12 @@ async function answerText(conversation, answer) {
495
496
  * when the parent refuses.
496
497
  */
497
498
  export async function forkReviewRun(deps, id, opts) {
498
- checkRunOpts(opts);
499
+ // Ask-user Task 3: the allow list loses interactive tools BEFORE the
500
+ // non-empty check (an all-interactive list still fails loudly, with
501
+ // the strip warning logged first). Fork reviews KEEP them listed
502
+ // (cache parity) — the gate blocks a call instead.
503
+ const allow = withoutInteractive(opts.allow, deps.interactiveTools ?? new Set(), console.warn, "fork review");
504
+ checkRunOpts({ ...opts, allow });
499
505
  const { harness } = deps;
500
506
  const parent = await harness.conversation(id, BACKGROUND_CONTEXT);
501
507
  if (parent === undefined) {
@@ -537,7 +543,7 @@ export async function forkReviewRun(deps, id, opts) {
537
543
  const doc = await tx.doc(CurationRunDoc, forkId);
538
544
  doc.user = owner;
539
545
  doc.reviewId = opts.reviewId;
540
- doc.allow = [...opts.allow];
546
+ doc.allow = [...allow];
541
547
  doc.parent = id;
542
548
  // The fork's copied meta carries the parent's sharing; an
543
549
  // internal run is nobody's shared copy (A2's invisibility
@@ -561,7 +567,8 @@ export async function forkReviewRun(deps, id, opts) {
561
567
  * default + gate, same run doc and driver.
562
568
  */
563
569
  export async function digestRunRun(deps, user, opts, defaultModel, defaultAgent) {
564
- checkRunOpts(opts);
570
+ const allow = withoutInteractive(opts.allow, deps.interactiveTools ?? new Set(), console.warn, "digest run");
571
+ checkRunOpts({ ...opts, allow });
565
572
  const { harness } = deps;
566
573
  const conversation = await harness.createConversation({
567
574
  ownership: { kind: "ownerless" },
@@ -576,10 +583,14 @@ export async function digestRunRun(deps, user, opts, defaultModel, defaultAgent)
576
583
  },
577
584
  init: async (tx, id) => {
578
585
  await markInternal(tx, id, user);
586
+ // Ask-user Task 3: the digest conversation's fresh agent
587
+ // selection loses the interactive tools (the tool is never
588
+ // listed, so the model never tries).
589
+ await excludeInteractiveTools(tx, id, deps.interactiveTools ?? new Set());
579
590
  const doc = await tx.doc(CurationRunDoc, id);
580
591
  doc.user = user;
581
592
  doc.reviewId = opts.reviewId;
582
- doc.allow = [...opts.allow];
593
+ doc.allow = [...allow];
583
594
  },
584
595
  }, BACKGROUND_CONTEXT);
585
596
  return await driveRun(deps, async () => conversation, { ...opts, instruction: opts.input });