@sigrun/sigrun 0.8.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 (43) hide show
  1. package/CHANGELOG.md +35 -87
  2. package/dist/config.js +47 -0
  3. package/dist/curation/host.js +125 -21
  4. package/dist/curation/runs.js +15 -4
  5. package/dist/extension.d.ts +268 -3
  6. package/dist/harness/boot.js +113 -3
  7. package/dist/harness/complete.js +127 -0
  8. package/dist/harness/decision-tool.js +93 -0
  9. package/dist/harness/decisions.js +477 -0
  10. package/dist/harness/event-bus.js +12 -0
  11. package/dist/harness/interactions.js +81 -0
  12. package/dist/harness/interactive-tools.js +97 -0
  13. package/dist/harness/loader.js +243 -1
  14. package/dist/harness/packages.js +7 -0
  15. package/dist/harness/published-hooks.js +208 -0
  16. package/dist/harness/seam-host.js +136 -1
  17. package/dist/harness/settings.js +41 -0
  18. package/dist/history/git.js +6 -2
  19. package/dist/jobs/fire.js +10 -0
  20. package/dist/jobs/observe.js +27 -2
  21. package/dist/server/conversation-ops.js +31 -0
  22. package/dist/server/core-views/skills-tree.js +3 -12
  23. package/dist/server/core-views/skills.js +11 -23
  24. package/dist/server/host-bridge.js +53 -1
  25. package/dist/server/routes/extensions.js +5 -0
  26. package/dist/server/routes/interactions.js +48 -1
  27. package/dist/server/routes/meta.js +17 -0
  28. package/dist/server/routes/providers.js +65 -8
  29. package/dist/server/routes/submissions.js +3 -0
  30. package/dist/settings/registry.js +18 -0
  31. package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
  32. package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
  33. package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
  34. package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
  35. package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
  36. package/node_modules/@sigrun/shared/dist/views.d.ts +58 -0
  37. package/node_modules/@sigrun/shared/package.json +6 -2
  38. package/package.json +6 -6
  39. package/webui/dist/assets/index-D5KowS7k.css +1 -0
  40. package/webui/dist/assets/index-DGIrLC6F.js +55 -0
  41. package/webui/dist/index.html +2 -2
  42. package/webui/dist/assets/index-B3uMNF59.css +0 -1
  43. package/webui/dist/assets/index-BXsCyod4.js +0 -55
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ 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
+
8
43
  ## [0.8.0] - 2026-11-04
9
44
 
10
45
  Core notices: a per-user notice system for extensions. An extension posts a short notice
@@ -91,93 +126,6 @@ fresh-eyes clean-room install verification.
91
126
 
92
127
  - Release-pin bump to 0.6.1 (root, shared, tui, sandbox platform packages, chart appVersion, compose defaults).
93
128
 
94
- ## [Unreleased]
95
-
96
- ### Added
97
- - **Core notices** — a per-user message channel with buttons. An
98
- extension posts a short notice to ONE directory user through
99
- `host.notices` (`post`/`clear`/`list`); `noticeActions` on the factory
100
- result declares the handlers its `extension` buttons may name. A
101
- notice is title + body + up to three actions (`navigate`, `open` —
102
- https only, `extension`), bounded by `NOTICE_LIMITS` and capped at 10
103
- per extension / 50 per user. Nothing expires on a timer: a notice
104
- clears on its ✕, the user's next prompt in that conversation, a
105
- navigate/open button, a successful extension action, Clear all, the
106
- posting extension's `clear`, or eviction past a cap. Nobody but the
107
- addressed user ever sees one (a foreign id answers 404, never 403),
108
- forks never copy them, and a revoked share drops that conversation's
109
- notices on the next read. `GET /v1/notices`,
110
- `POST /v1/notices/{id}/dismiss`, `POST /v1/notices/dismiss-all`,
111
- `POST /v1/notices/{id}/actions/{actionId}`, and the user-only
112
- `notice.changed` event. Store: `<dataDir>/users/<user>/notices.json`
113
- (inside the `data` backup section).
114
- - **Notices UI** — a bell with a count on every tab (rail on desktop,
115
- tab header on phones), a notices pane that docks on Chats and slides
116
- over every other tab, an inline stack above the composer (at most
117
- three, then "N more"), and a 6 s peek that never takes focus. The TUI
118
- gains an inline line, a status-bar count, and an overlay
119
- (`ctrl+g` / `gN` / `M-N`).
120
- - **Ambient buttons work (webui).** The webui's ambient renderer now
121
- dispatches `navigate` and `open` actions (links open with
122
- `rel="noopener noreferrer"`) instead of dropping them. The TUI's
123
- ambient dispatcher is wired the same way but has no production caller
124
- yet.
125
- - **Views are tabs (webui).** The webui shell registers views as rail
126
- tabs with remembered routes; the chat view keeps the conversation
127
- list and right panel to itself. Below the tablet breakpoint a phone
128
- bottom tab bar (with a More sheet) replaces the rail.
129
- - **`page.selection`** — a server-declared, presentation-only
130
- list-first drill-in for compact widths (`{selected, back}` on a
131
- `page`; webui and TUI render it, `toText` ignores it).
132
- - **Core views.** Core attaches its own view surfaces under the
133
- reserved `core` slug and lists them as `coreViews` (one
134
- `CoreViewInfo {id, title, icon, refreshMs?}` per view) on
135
- `/v1/extensions` and `/v1/meta`; they are never `extensions[]` rows,
136
- so the health dot is unaffected. `job.changed` now bumps every
137
- rendered core/jobs instance.
138
- - **The Jobs tab** (`core/jobs`): the jobs surface as a core blocks
139
- view — sidebar of the caller-visible jobs, drill-in with summary,
140
- paged runs and revisions (a selected revision diffs its spec JSON
141
- against the previous one), Run now / Reset state for write access,
142
- and per-row navigation into a run's conversation. The Jobs panel is
143
- gone.
144
- - **The Skills tab** (`core/skills`): every skill root the deployment
145
- serves — Workspace / Learned / Config dir (the deployment-wide
146
- `<config-dir>/skills/`, read-only) / one group per installed package
147
- (`· <pkg>`, with a per-row package badge) / Deleted — with shadowing visible, owned-skill CRUD, per-skill git
148
- history (25 entries per page, per-file diffs, restore), deleted-skill
149
- restore, and per-user overlays as the only write to the read-only
150
- shared roots. Some surface actions return `navigate`; the webui
151
- applies that navigation, the TUI does not yet (a known channel gap).
152
- - **Every-root skill discovery** (`src/skills/roots.ts`): workspace,
153
- learned, user dir, and every installed package's skills listed in one
154
- pass, with precedence/shadowing resolution and symlink-safe reads.
155
- - **GitHistory** (`src/history/git.ts`) — isolated local git for
156
- sigrun-owned history stores: execFile with explicit `--git-dir` /
157
- `--work-tree`, a hermetic git env, per-repo locking, and fail-soft
158
- writes (a broken repo is quarantined aside and re-initialized;
159
- mutations never fail because history did).
160
- - **Curation stores on git** (Task 9): each user's learned skills,
161
- overlays, and curator state live in a per-user git history repo; the
162
- change feed's `actor` widens to `"outside"` and its `id`/`before`/
163
- `after` are commit SHAs. A once-per-process migration imports the
164
- pre-git store (the legacy files are left read-only, outside the
165
- repo's pathspec).
166
- - **Workspace skills history** (Task 10): the workspace `skills/`
167
- tree gets its own per-user repo (git dir outside the work tree),
168
- caught up on settled runs and tab opens; ownerless conversations
169
- skip it.
170
-
171
- ### Changed
172
- - **The curation seal**: workspaces and grants that sit inside or
173
- contain a user's `curation/` tree are rejected; boot revalidates
174
- stored records and names each failing one loudly. A user whose
175
- workspace was the bare user area loses agent tools until an admin
176
- re-points it: `PATCH /v1/users/<u> {"workspace": "<userArea>/workspace"}`.
177
- History tamper-resistance holds under active sandbox isolation; a
178
- single-user install without isolation runs the agent unsandboxed
179
- (the existing trust model).
180
-
181
129
  ## [0.6.0] - 2026-11-02
182
130
 
183
131
  ### Added
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 });