@staix/agent-hub 0.19.0 → 0.21.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 (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. package/templates/packs/example.pack.json +13 -0
@@ -8,6 +8,8 @@ import type { ChildProcess } from "node:child_process";
8
8
  export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
9
9
  const env = { ...source };
10
10
  delete env.AGENTHUB_RECOVERY_OPERATION;
11
+ delete env.AGENTHUB_RECOVERY_CONDUCTOR;
12
+ delete env.AGENTHUB_RECOVERY_CONTINUATION;
11
13
  delete env.AGENTHUB_DELIVERY_ONLY;
12
14
  return env;
13
15
  }
@@ -0,0 +1,162 @@
1
+ import { parsePrEvidence, type SdlcGh } from "./sdlc.ts";
2
+
3
+ /**
4
+ * #785 P1b: one bounded watcher per exact PR head. It starts when `hub_task_done` names a head whose CI was not green,
5
+ * ends on a passed CI result or on this deadline (a failed or incomplete one is told and then watched for a rerun), and is cancelled by a newer head or any task move. It is a timer in the
6
+ * hub, never a peer turn, and its gh reads (one `gh pr view` per poll) are the only periodic gh calls the hub makes.
7
+ * The longest hosted run is about 30 minutes (the job limits in `.github/workflows/check.yml` are 35 and 40 minutes).
8
+ */
9
+ export const CI_WATCH_DEADLINE_MS = 45 * 60_000;
10
+ export const CI_WATCH_POLL_MS = 60_000;
11
+ /** Same default set as `readReviewReadiness`, which is the authoritative read the review admission makes. */
12
+ const DEFAULT_CHECKS = ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
13
+ const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
14
+ /** How many finished heads are remembered, so one head never tells the same outcome twice. */
15
+ const SETTLED_CAP = 256;
16
+
17
+ /** What one poll found. `incomplete` is every required check finished with none failed and not all passed (skipped, neutral, duplicated). */
18
+ export type CiPoll = "pending" | "passed" | "failed" | "incomplete" | "superseded" | "closed";
19
+ export type CiOutcome = "passed" | "failed" | "incomplete" | "deadline";
20
+
21
+ export interface CiWatch { id: number; head: string; repo: string; pr: number; deadlineAt: number }
22
+ /** `reported`: a result was told, so a deadline ends silently. `renew`: the next pending read may renew the deadline once (set by each new told result). */
23
+ type Entry = { watch: CiWatch; timer?: ReturnType<typeof setTimeout>; reported?: boolean; renew?: boolean };
24
+
25
+ /** One read of the PR: the head, its state and the required checks of that head. A failed or unreadable read is `pending`; the deadline bounds the retries. */
26
+ export async function pollCi(target: { repo: string; pr: number }, head: string, required: readonly string[], run: SdlcGh): Promise<CiPoll> {
27
+ if (!REPO.test(target.repo) || !Number.isSafeInteger(target.pr) || target.pr < 1) return "closed";
28
+ try {
29
+ const view = await run(["pr", "view", String(target.pr), "--repo", target.repo, "--json", "headRefOid,state,statusCheckRollup"], 10_000, undefined, 512 * 1024);
30
+ if (view.code !== 0) return "pending";
31
+ const body = JSON.parse(view.stdout);
32
+ if (body?.headRefOid !== head) return "superseded";
33
+ if (body.state !== "OPEN") return "closed";
34
+ // The same check-state mapping as every other PR read; files are not requested here.
35
+ const evidence = parsePrEvidence({ ...body, files: [], changedFiles: 0 });
36
+ const rows = (required.length ? required : DEFAULT_CHECKS).map(name => evidence.checks.filter(check => check.name === name));
37
+ if (rows.some(row => row.some(check => check.state === "failed"))) return "failed";
38
+ if (rows.some(row => row.length === 0 || row.some(check => check.state === "pending"))) return "pending";
39
+ return rows.every(row => row.length === 1 && row[0]!.state === "passed") ? "passed" : "incomplete";
40
+ } catch { return "pending"; }
41
+ }
42
+
43
+ export interface CiWatchersOptions {
44
+ deadlineMs?: number;
45
+ pollMs?: number;
46
+ now?: () => number;
47
+ poll: (watch: CiWatch) => Promise<CiPoll>;
48
+ /** False once the task moved or its head changed: the watcher ends without a notice. */
49
+ current: (watch: CiWatch) => boolean;
50
+ finish: (watch: CiWatch, outcome: CiOutcome) => void | Promise<void>;
51
+ }
52
+
53
+ /**
54
+ * At most one watcher per task, and one per exact head. Only `passed` settles a head for good (#540): after `failed` or
55
+ * `incomplete` the watcher keeps reading until its deadline, so a rerun of the same head (a new run attempt reads as pending,
56
+ * which renews the deadline once per newly told result) is reported too, and a later `start` at that head watches again. Each (head, outcome) is told once. Timers are chained `setTimeout`s (never an interval), unref'd, and cleared
57
+ * on every end, so nothing outlives its deadline, a newer head or `stopAll`. Watchers are not restored after a hub restart:
58
+ * the next `hub_task_done` starts one (ponytail: persist them only if restarts during CI turn out to be common).
59
+ */
60
+ export class CiWatchers {
61
+ private readonly active = new Map<number, Entry>();
62
+ private readonly settled = new Map<number, { head: string; outcomes: Set<CiOutcome>; last: CiOutcome }>();
63
+ private readonly deadlineMs: number;
64
+ private readonly pollMs: number;
65
+ private readonly now: () => number;
66
+
67
+ constructor(private readonly o: CiWatchersOptions) {
68
+ this.deadlineMs = o.deadlineMs ?? CI_WATCH_DEADLINE_MS;
69
+ this.pollMs = o.pollMs ?? CI_WATCH_POLL_MS;
70
+ this.now = o.now ?? Date.now;
71
+ }
72
+
73
+ get size(): number { return this.active.size; }
74
+
75
+ /** Starts a watcher for this exact head; false when this head is already watched or already passed. A different head replaces the old watcher. */
76
+ start(target: { id: number; head: string; repo: string; pr: number }): boolean {
77
+ const have = this.active.get(target.id), done = this.reported(target.id, target.head);
78
+ if (have?.watch.head === target.head || done?.has("passed")) return false;
79
+ if (have) this.cancel(target.id);
80
+ const entry: Entry = { watch: { ...target, deadlineAt: this.now() + this.deadlineMs }, reported: !!done?.size };
81
+ this.active.set(target.id, entry);
82
+ this.schedule(entry);
83
+ return true;
84
+ }
85
+
86
+ /** The latest result reported for this exact head, if any. */
87
+ outcomeOf(id: number, head: string): CiOutcome | undefined {
88
+ const done = this.settled.get(id);
89
+ return done?.head === head ? done.last : undefined;
90
+ }
91
+
92
+ private reported(id: number, head: string): Set<CiOutcome> | undefined {
93
+ const done = this.settled.get(id);
94
+ return done?.head === head ? done.outcomes : undefined;
95
+ }
96
+
97
+ /** Ends the task's watcher (a given head only when `head` is named). No notice. */
98
+ cancel(id: number, head?: string): void {
99
+ const entry = this.active.get(id);
100
+ if (!entry || head !== undefined && entry.watch.head !== head) return;
101
+ clearTimeout(entry.timer);
102
+ this.active.delete(id);
103
+ }
104
+
105
+ stopAll(): void {
106
+ for (const entry of this.active.values()) clearTimeout(entry.timer);
107
+ this.active.clear();
108
+ }
109
+
110
+ private schedule(entry: Entry): void {
111
+ const delay = Math.max(0, Math.min(this.pollMs, entry.watch.deadlineAt - this.now()));
112
+ entry.timer = setTimeout(() => { void this.tick(entry); }, delay);
113
+ entry.timer.unref?.();
114
+ }
115
+
116
+ private end(entry: Entry): boolean {
117
+ if (this.active.get(entry.watch.id) !== entry) return false;
118
+ clearTimeout(entry.timer);
119
+ this.active.delete(entry.watch.id);
120
+ return true;
121
+ }
122
+
123
+ private report(entry: Entry, outcome: CiOutcome): void {
124
+ if (outcome !== "deadline") {
125
+ const outcomes = this.reported(entry.watch.id, entry.watch.head) ?? new Set<CiOutcome>();
126
+ outcomes.add(outcome);
127
+ entry.reported = true;
128
+ entry.renew = true;
129
+ this.settled.delete(entry.watch.id);
130
+ this.settled.set(entry.watch.id, { head: entry.watch.head, outcomes, last: outcome });
131
+ while (this.settled.size > SETTLED_CAP) this.settled.delete(this.settled.keys().next().value as number);
132
+ }
133
+ void Promise.resolve().then(() => this.o.finish(entry.watch, outcome)).catch(() => { /* the finisher guards its own notices */ });
134
+ }
135
+
136
+ /** A watcher never throws out of its timer: a `current` or `finish` that fails (a closed board at shutdown) ends it. */
137
+ private async tick(entry: Entry): Promise<void> {
138
+ try { await this.step(entry); } catch { this.end(entry); }
139
+ }
140
+
141
+ private async step(entry: Entry): Promise<void> {
142
+ entry.timer = undefined;
143
+ if (this.active.get(entry.watch.id) !== entry) return;
144
+ if (!this.o.current(entry.watch)) { this.end(entry); return; }
145
+ // A deadline after a reported result ends silently: that result was already told.
146
+ if (this.now() >= entry.watch.deadlineAt) { if (this.end(entry) && !entry.reported) this.report(entry, "deadline"); return; }
147
+ let result: CiPoll;
148
+ try { result = await this.o.poll(entry.watch); } catch { result = "pending"; }
149
+ if (this.active.get(entry.watch.id) !== entry) return; // cancelled or replaced during the read
150
+ if (!this.o.current(entry.watch) || result === "superseded" || result === "closed") { this.end(entry); return; }
151
+ if (result === "pending") {
152
+ // The first pending read after a newly told result is a new run attempt of this head: it gets one full deadline of its own.
153
+ // Later pending reads (an unreadable gh is pending too) and an unchanged failed result keep that deadline, so the watcher always ends.
154
+ if (entry.renew) { entry.renew = false; entry.watch.deadlineAt = this.now() + this.deadlineMs; }
155
+ this.schedule(entry); return;
156
+ }
157
+ if (result === "passed") { if (this.end(entry)) this.report(entry, result); return; }
158
+ // failed or incomplete: told once per outcome, then keep reading for a rerun of the same head.
159
+ if (!this.reported(entry.watch.id, entry.watch.head)?.has(result)) this.report(entry, result);
160
+ this.schedule(entry);
161
+ }
162
+ }
@@ -0,0 +1,85 @@
1
+ export type ClaudeFailureStage = "pre_permission" | "invalid_permission_request" | "wrapper_refused" | "manual_queue_expired" | "native_error";
2
+ export interface ClaudeFailureEvidence {
3
+ stage: ClaudeFailureStage;
4
+ requestId?: string;
5
+ instanceId?: string;
6
+ nativeVersion?: string;
7
+ }
8
+ type SpecificFailure = Exclude<ClaudeFailureStage, "pre_permission" | "native_error">;
9
+ export interface ClaudeFailureTrace {
10
+ permissionObserved(requestId?: string): boolean;
11
+ record(stage: SpecificFailure, requestId?: string): boolean;
12
+ seal(): void;
13
+ take(): Readonly<ClaudeFailureEvidence> | undefined;
14
+ discard(): void;
15
+ }
16
+
17
+ const WITHHELD = "Claude implementer turn failed (details withheld)";
18
+ function ownData(value: unknown, key: string): unknown {
19
+ try {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
21
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
22
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : undefined;
23
+ } catch { return; }
24
+ }
25
+ function requestId(value: unknown): string | undefined {
26
+ return typeof value === "string" && value.length === 8 && /^[a-f0-9]{8}$/.test(value) ? value : undefined;
27
+ }
28
+ function instanceId(value: unknown): string | undefined {
29
+ return typeof value === "string" && value.length === 36 && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value) ? value : undefined;
30
+ }
31
+ function nativeVersion(value: unknown): string | undefined {
32
+ return typeof value === "string" && value.length <= 20 && value === value.trim() && /^(?:0|[1-9][0-9]{0,5})\.(?:0|[1-9][0-9]{0,5})\.(?:0|[1-9][0-9]{0,5})$/.test(value) ? value : undefined;
33
+ }
34
+ function specific(value: unknown): value is SpecificFailure {
35
+ return value === "invalid_permission_request" || value === "wrapper_refused" || value === "manual_queue_expired";
36
+ }
37
+ function stage(value: unknown): ClaudeFailureStage {
38
+ return specific(value) || value === "pre_permission" ? value : "native_error";
39
+ }
40
+ function metadata(value: unknown): Pick<ClaudeFailureEvidence, "instanceId" | "nativeVersion"> {
41
+ const instance = instanceId(ownData(value, "instanceId")), version = nativeVersion(ownData(value, "nativeVersion"));
42
+ return { ...(instance ? { instanceId: instance } : {}), ...(version ? { nativeVersion: version } : {}) };
43
+ }
44
+
45
+ /** Render only fixed host-observed stages and validated metadata, never native diagnostic text. */
46
+ export function formatClaudeFailure(evidence: unknown, privateTurn: boolean): string {
47
+ if (privateTurn !== false) return WITHHELD;
48
+ const fields = [`stage=${stage(ownData(evidence, "stage"))}`];
49
+ const request = requestId(ownData(evidence, "requestId")), meta = metadata(evidence);
50
+ if (request) fields.push(`request=${request}`);
51
+ if (meta.instanceId) fields.push(`instance=${meta.instanceId}`);
52
+ if (meta.nativeVersion) fields.push(`native=${meta.nativeVersion}`);
53
+ return `Claude implementer turn failed (${fields.join("; ")})`;
54
+ }
55
+
56
+ /** Host-only per-owned-launch observations. Seal before revoking child authority; consume after exit.
57
+ * pre_permission reports only the absence of a host permission observation, never a native cause. */
58
+ export function createClaudeFailureTrace(options: { instanceId?: string; nativeVersion?: string; current: () => boolean }): ClaudeFailureTrace {
59
+ const meta = Object.freeze(metadata(options)), current = ownData(options, "current");
60
+ let sealed = false, consumed = false, discarded = false, seenPermission = false;
61
+ let failure: SpecificFailure | undefined, request: string | undefined;
62
+ const writable = () => {
63
+ if (sealed || consumed || discarded) return false;
64
+ try { return typeof current === "function" && current() === true && !sealed && !consumed && !discarded; } catch { return false; }
65
+ };
66
+ return Object.freeze({
67
+ permissionObserved(id?: string): boolean {
68
+ if (!writable()) return false;
69
+ seenPermission = true; failure = undefined; request = requestId(id);
70
+ return true;
71
+ },
72
+ record(value: SpecificFailure, id?: string): boolean {
73
+ if (!specific(value) || !writable()) return false;
74
+ seenPermission = true; failure = value; request = requestId(id);
75
+ return true;
76
+ },
77
+ seal(): void { sealed = true; },
78
+ take(): Readonly<ClaudeFailureEvidence> | undefined {
79
+ if (!sealed || consumed || discarded) return;
80
+ consumed = true;
81
+ return Object.freeze({ stage: failure ?? (seenPermission ? "native_error" : "pre_permission"), ...meta, ...(request ? { requestId: request } : {}) });
82
+ },
83
+ discard(): void { discarded = true; failure = undefined; request = undefined; },
84
+ });
85
+ }
@@ -1,8 +1,12 @@
1
1
  import { closeSync, fstatSync, openSync, readSync, constants } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { claudeStateFile } from "./claude-state.ts";
4
+ import { readFileSync } from "node:fs";
5
+ import { isWsPeer, type WsPeer } from "../adapters/channel-peer.ts";
6
+ import { isFixedBuiltinDefinition, type AdapterDefinition } from "../adapters/registry.ts";
7
+ import type { PeerAdapter } from "./peers.ts";
4
8
 
5
- export interface ClaudeLaunchProof {
9
+ export interface ClaudeLaunchRecordProof {
6
10
  launchId: string;
7
11
  executionCwd?: string;
8
12
  privateRecord: boolean;
@@ -12,7 +16,7 @@ export interface ClaudeLaunchProof {
12
16
  }
13
17
 
14
18
  /** One bounded reader for current managed launch metadata; no native identity or payload escapes. */
15
- export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string): ClaudeLaunchProof | undefined {
19
+ export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string): ClaudeLaunchRecordProof | undefined {
16
20
  let fd: number | undefined;
17
21
  try {
18
22
  fd = openSync(join(stateDir, claudeStateFile("claude-launch", peer)), constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -31,3 +35,64 @@ export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId
31
35
  } catch { return; }
32
36
  finally { if (fd !== undefined) closeSync(fd); }
33
37
  }
38
+
39
+ /** Host-only launch and socket-generation evidence; adapter-reported generations cannot establish it. */
40
+ export interface ClaudeLaunchProof {
41
+ permissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined;
42
+ helloLaunch(peer: string, launchId: unknown): "admit" | "refuse";
43
+ expectedLaunch(peer: string): string | undefined;
44
+ owner(peer: string): WsPeer | undefined;
45
+ bindGeneration(peer: string, generation: string): void;
46
+ verified(peer: string): boolean;
47
+ }
48
+
49
+ export function createClaudeLaunchProof(opts: {
50
+ stateDir: string;
51
+ cwd: string;
52
+ instanceId: string;
53
+ definitionOf(peer: string): AdapterDefinition | undefined;
54
+ peer(peer: string): PeerAdapter | undefined;
55
+ }): ClaudeLaunchProof {
56
+ const generations = new Map<string, string>();
57
+ function channel(peer: string): boolean {
58
+ const definition = opts.definitionOf(peer);
59
+ return isFixedBuiltinDefinition(definition) && definition?.transport === "claude-channel";
60
+ }
61
+ function owner(peer: string): WsPeer | undefined {
62
+ if (!channel(peer)) return;
63
+ const current = opts.peer(peer);
64
+ return isWsPeer(current) ? current : undefined;
65
+ }
66
+ function permissionLaunch(peer: string): ReturnType<ClaudeLaunchProof["permissionLaunch"]> {
67
+ if (!channel(peer)) return;
68
+ const row = readClaudeLaunchProof(opts.stateDir, peer, opts.instanceId);
69
+ return row?.permissionHook ? row : undefined;
70
+ }
71
+ function helloLaunch(peer: string, launchId: unknown): "admit" | "refuse" {
72
+ if (!channel(peer) || launchId === undefined) return "admit";
73
+ let currentLaunch: unknown;
74
+ try {
75
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, opts.instanceId);
76
+ if (launch) currentLaunch = launch.launchId;
77
+ } catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
78
+ return currentLaunch !== undefined && launchId !== currentLaunch ? "refuse" : "admit";
79
+ }
80
+ function expectedLaunch(peer: string): string | undefined {
81
+ if (!channel(peer)) return;
82
+ let expectedLaunch: string | undefined;
83
+ try {
84
+ const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
85
+ expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && opts.definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === opts.instanceId)?.launchId : undefined;
86
+ } catch { /* an ordinary terminal has no Orca recovery identity */ }
87
+ try {
88
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, opts.instanceId);
89
+ if (launch) expectedLaunch = launch.launchId;
90
+ } catch { /* older/manual launches do not have a launcher marker */ }
91
+ return expectedLaunch;
92
+ }
93
+ return {
94
+ permissionLaunch, helloLaunch, expectedLaunch, owner,
95
+ bindGeneration(peer, generation) { if (owner(peer)) generations.set(peer, generation); },
96
+ verified(peer) { const current = owner(peer); return !!current && generations.get(peer) === current.sessionGeneration; },
97
+ };
98
+ }
@@ -16,6 +16,8 @@ export interface ShellGrant {
16
16
  peer: string; task: number; generation: string; root: string; branch: string;
17
17
  executable: string; argv: readonly string[]; env: Readonly<Record<string, string>>;
18
18
  signal: AbortSignal; timeoutMs: number; current(): boolean; activity?: () => void;
19
+ /** Host-only observation of a matched redemption refusal; it proves no execution outcome. */
20
+ onRefused?: () => void;
19
21
  sandbox?: { profile: string; readOnlyData: boolean };
20
22
  }
21
23
  type Stamp = { path: string; identity: string };
@@ -83,7 +85,8 @@ function snapshot(source: ShellGrant): Readonly<ShellGrant> | undefined {
83
85
  || !Array.isArray(source.argv) || source.argv.length > 256 || source.argv.some(a => typeof a !== "string" || a.includes("\0") || a.length > 64 * 1024)
84
86
  || !source.env || typeof source.env !== "object" || Array.isArray(source.env) || !(source.signal instanceof AbortSignal) || source.signal.aborted
85
87
  || !Number.isSafeInteger(source.timeoutMs) || source.timeoutMs < 1 || source.timeoutMs > WIRE.maximumMs || typeof source.current !== "function"
86
- || source.activity !== undefined && typeof source.activity !== "function") return;
88
+ || source.activity !== undefined && typeof source.activity !== "function"
89
+ || source.onRefused !== undefined && typeof source.onRefused !== "function") return;
87
90
  if (source.sandbox !== undefined && (!source.sandbox || typeof source.sandbox !== "object" || Array.isArray(source.sandbox)
88
91
  || Object.keys(source.sandbox).sort().join(",") !== "profile,readOnlyData" || typeof source.sandbox.profile !== "string" || !source.sandbox.profile
89
92
  || source.sandbox.profile.includes("\0") || Buffer.byteLength(source.sandbox.profile) > 128 * 1024 || typeof source.sandbox.readOnlyData !== "boolean"
@@ -99,7 +102,7 @@ function unchanged(ticket: Ticket): boolean {
99
102
  const a = ticket.source, b = ticket.grant;
100
103
  try {
101
104
  return a.peer === b.peer && a.task === b.task && a.generation === b.generation && a.root === b.root && a.branch === b.branch
102
- && a.executable === b.executable && a.signal === b.signal && a.timeoutMs === b.timeoutMs && a.current === b.current && a.activity === b.activity
105
+ && a.executable === b.executable && a.signal === b.signal && a.timeoutMs === b.timeoutMs && a.current === b.current && a.activity === b.activity && a.onRefused === b.onRefused
103
106
  && a.argv.length === b.argv.length && a.argv.every((v, i) => v === b.argv[i])
104
107
  && (a.sandbox === undefined ? b.sandbox === undefined : b.sandbox !== undefined && Object.keys(a.sandbox).sort().join(",") === "profile,readOnlyData"
105
108
  && a.sandbox.profile === b.sandbox.profile && a.sandbox.readOnlyData === b.sandbox.readOnlyData)
@@ -181,7 +184,10 @@ export class HostShellWrapper {
181
184
  } catch { return; }
182
185
  }
183
186
 
184
- private refuse(response: ServerResponse): void {
187
+ private refuse(response: ServerResponse, ticket?: Ticket): void {
188
+ // Only the issued immutable callback may observe a matched ticket. It grants
189
+ // nothing and must not change refusal, cleanup or target-exit classification.
190
+ try { ticket?.grant.onRefused?.(); } catch { /* An observer is never authority. */ }
185
191
  if (response.destroyed || response.writableEnded) return;
186
192
  if (!response.headersSent) { response.statusCode = 403; response.setHeader("connection", "close"); response.end(`${WIRE.refusal}\n`); }
187
193
  else response.destroy();
@@ -204,7 +210,7 @@ export class HostShellWrapper {
204
210
  const ticket = this.tickets.get(body.ticket);
205
211
  if (!ticket) return this.refuse(response);
206
212
  this.forget(body.ticket, ticket); // Consume before schema, expiry, authority or metadata validation.
207
- if (Object.keys(body).length !== 1 || ticket.expires <= Date.now()) return this.refuse(response);
213
+ if (Object.keys(body).length !== 1 || ticket.expires <= Date.now()) return this.refuse(response, ticket);
208
214
  const execution: Execution = { ticket, response, cancelled: false, failedCleanup: false, done: Promise.resolve(), cancel: async () => { execution.cancelled = true; await this.stop(execution); } };
209
215
  this.active.add(execution);
210
216
  execution.done = this.execute(execution);
@@ -307,7 +313,7 @@ export class HostShellWrapper {
307
313
  } catch {
308
314
  execution.cancelled = true;
309
315
  try { await this.stop(execution); } catch { execution.failedCleanup = true; }
310
- this.refuse(response);
316
+ this.refuse(response, execution.proc === undefined ? ticket : undefined);
311
317
  } finally {
312
318
  clearTimeout(timer); clearInterval(heartbeat); grant.signal.removeEventListener("abort", abort); response.off("close", disconnected); response.off("error", abort);
313
319
  if (!execution.failedCleanup && temporary) { try { removeOwned(temporary); execution.temporary = undefined; } catch { execution.failedCleanup = true; } }
@@ -2,12 +2,13 @@ import { definitionOf, type KindMap, type ProjectAdapterCatalog } from "../adapt
2
2
  import { Database } from "bun:sqlite";
3
3
  import type { Task } from "./board.ts";
4
4
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
5
- import { HUB, USER } from "./envelope.ts";
5
+ import { HUB, sanitize, USER } from "./envelope.ts";
6
6
  import { OWNERSHIP_EVENTS, type TaskAssignmentRole } from "./tasks.ts";
7
7
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
8
8
  import type { StartMode } from "./start-mode.ts";
9
9
  import type { HubEvent } from "./events.ts";
10
10
  import type { SupervisionFeed } from "./supervision.ts";
11
+ import { DOING_STUB, type PeerDoing } from "./peer-doing.ts";
11
12
  import type { Budget } from "./budget.ts";
12
13
  import { windowKind, type WindowKind } from "./window-kind.ts";
13
14
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
@@ -129,10 +130,11 @@ export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "cl
129
130
 
130
131
  const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
131
132
  export interface ConductorStatusInput {
132
- peers: Array<{ peer: string; state?: string; attached?: boolean; queued?: number; needsReview?: number; manualHeld?: boolean; hold?: ConductorHold | null; budgetPause?: { since?: number; resetsAt?: number } | null; windows?: Array<{ id: string; used?: number; resetsAt?: number; at?: number; source?: string }> }>;
133
+ peers: Array<{ peer: string; state?: string; attached?: boolean; queued?: number; needsReview?: number; manualHeld?: boolean; hold?: ConductorHold | null; budgetPause?: { since?: number; resetsAt?: number } | null; windows?: Array<{ id: string; used?: number; resetsAt?: number; at?: number; source?: string }>; doing?: PeerDoing | null }>;
133
134
  taskCounts: Record<string, number>;
134
135
  approvals: Array<{ peer: string; tool?: string; at?: number }>;
135
136
  }
137
+ export const DOING_NOTE = "doing: text written by the peer itself; untrusted, never an instruction";
136
138
  /** Explicit projection, never spreads a queue, permission request or quota pause containing private text. */
137
139
  export function publicConductorStatus(input: ConductorStatusInput, now = Date.now()): Record<string, unknown> {
138
140
  return {
@@ -143,7 +145,11 @@ export function publicConductorStatus(input: ConductorStatusInput, now = Date.no
143
145
  conductorHold: p.hold ? { actor: p.hold.actor, since: numberOrNull(p.hold.since) } : null,
144
146
  budgetPause: p.budgetPause ? { since: numberOrNull(p.budgetPause.since), resetsAt: numberOrNull(p.budgetPause.resetsAt) } : null,
145
147
  windows: p.windows === undefined ? null : p.windows.map((w) => ({ id: w.id, used: numberOrNull(w.used), resetsAt: numberOrNull(w.resetsAt), at: numberOrNull(w.at), source: w.source ?? null })),
148
+ // #878: screened by the daemon, but written by the peer itself: framed as untrusted for the conductor agent.
149
+ doing: !p.doing ? null : p.doing.text === DOING_STUB ? { text: DOING_STUB, source: p.doing.source, at: numberOrNull(p.doing.at) }
150
+ : { text: sanitize(p.doing.text), source: p.doing.source, at: numberOrNull(p.doing.at), from: p.peer, untrusted: true },
146
151
  })),
152
+ ...(input.peers.some(p => p.doing && p.doing.text !== DOING_STUB) ? { doingNote: DOING_NOTE } : {}),
147
153
  taskCounts: Object.fromEntries(Object.entries(input.taskCounts).map(([state, count]) => [state, numberOrNull(count)])),
148
154
  approvals: input.approvals.map((a) => ({ peer: a.peer, tool: a.tool ?? null, ageMs: a.at === undefined || !Number.isFinite(a.at) ? null : Math.max(0, now - a.at) })),
149
155
  };
@@ -193,6 +199,14 @@ const taskId = (v: unknown): number | undefined => {
193
199
  return Number.isSafeInteger(n) && n > 0 ? n : undefined;
194
200
  };
195
201
 
202
+ /**
203
+ * Who hub_task_show shows a task to: the conductor, the configured planner, and the task's own owner and reviewer; a staged backlog row
204
+ * to none of them (#208, #263, #812 AC5). #933: `ahub deliverables` from an agent keeps only the rows this admits.
205
+ */
206
+ export function taskShowAllowed(actor: string, task: Pick<Task, "state" | "owner" | "reviewer">, roles: unknown, planner: boolean): boolean {
207
+ return task.state !== "backlog" && (conductorPeer(roles) === actor || task.owner === actor || task.reviewer === actor || planner);
208
+ }
209
+
196
210
  export class Conductor {
197
211
  constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
198
212
  async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
@@ -203,10 +217,9 @@ export class Conductor {
203
217
  const id = taskId(args.id);
204
218
  const task = id === undefined ? undefined : this.hooks.task(id);
205
219
  // A task's current owner and reviewer read its public view without the role (#208); nobody else does.
206
- if (task && task.state !== "backlog" && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
207
220
  // #812 AC5: the configured planner reads any task the conductor can, through the same public view (a PII task stays a stub). A staged backlog row stays hidden from agent task
208
221
  // tools (#263: backlog is read through planner tools), and a peer that is also the conductor takes the audited conductor path above this branch. Read only: no hold or assignment.
209
- if (task && task.state !== "backlog" && tool === "hub_task_show" && this.hooks.isPlanner?.(actor)) return publicConductorTask(task, this.hooks.publicView);
222
+ if (task && tool === "hub_task_show" && taskShowAllowed(actor, task, this.hooks.roles(), this.hooks.isPlanner?.(actor) === true)) return publicConductorTask(task, this.hooks.publicView);
210
223
  // Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
211
224
  // worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
212
225
  // reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
@@ -1,3 +1,5 @@
1
+ import { operationalNumber, OPERATIONAL_NUMBERS } from "./operational-settings.ts";
2
+ import { QUEUE_BACKPRESSURE_LIMITS } from "./queue-backpressure.ts";
1
3
  import { operationalSettingKey } from "./operational-authority.ts";
2
4
  import { remoteRunnerConfig } from "./check-runner.ts";
3
5
  import { ACTION_CLASSES } from "./action-class.ts";
@@ -14,6 +16,8 @@ import { permissionDefaults } from "./permission-mode.ts";
14
16
  import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
15
17
  import { catalogAssignments } from "./roles.ts";
16
18
  import { resolveProfile } from "./profiles.ts";
19
+ import { foldersConfig, schedulesConfig } from "./schedules.ts";
20
+ import { corpusConfig } from "./corpus-config.ts";
17
21
  import { taskSweepConfig } from "./task-sweep.ts";
18
22
  import { projectionConfig } from "./text-projection.ts";
19
23
  import { validateInstances, type InstancesConfig } from "./instances.ts";
@@ -120,6 +124,24 @@ const text: Check = (v, k) => { if (typeof v !== "string" || v.length > 16_384 |
120
124
  const number = (min = 0, max = Number.MAX_SAFE_INTEGER, integer = false): Check => (v, k) => {
121
125
  if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max || integer && !Number.isSafeInteger(v)) fail(k, "number outside bounds");
122
126
  };
127
+ /** Reuse the existing numeric schema validator with the operational row's one set of bounds. */
128
+ const operationalNumberCheck = (key: string): Check => {
129
+ const def = operationalNumber(key);
130
+ if (!def) return fail(key, "unknown operational setting");
131
+ return number(def.min, def.max, def.type === "integer");
132
+ };
133
+ /** Startup and reload share the same fixed descriptors and numeric schema checks. */
134
+ export function validateOperationalConfig(raw: unknown): void {
135
+ for (const def of OPERATIONAL_NUMBERS) {
136
+ let value: unknown = raw;
137
+ for (const part of def.path) {
138
+ if (value === undefined) break;
139
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fail(def.key, "parent must be an object");
140
+ value = Object.hasOwn(value, part) ? (value as Record<string, unknown>)[part] : undefined;
141
+ }
142
+ if (value !== undefined) operationalNumberCheck(def.key)(value, def.key);
143
+ }
144
+ }
123
145
  const words = (values: readonly string[]): Check => (v, k) => { if (typeof v !== "string" || !values.includes(v)) fail(k, "unknown choice"); };
124
146
  const strings: Check = (v, k) => { if (!Array.isArray(v) || v.length > 1024) return fail(k, "must be a bounded string list"); for (const x of v) text(x, k); };
125
147
  const argv: Check = (v, k) => { strings(v, k); if (!(v as string[]).length || (v as string[]).some(x => !x)) fail(k, "must be a nonempty argv"); };
@@ -168,14 +190,17 @@ const schema: Record<string, Check> = {
168
190
  catalog_roles: (v, k) => { map(() => {})(v, k); catalogAssignments(v); },
169
191
  pii: block({ paths: strings }), data: block({ readonly: strings }),
170
192
  text_projection: v => { projectionConfig(v); },
193
+ corpus: v => { corpusConfig(v); },
171
194
  conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
172
195
  budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
173
- context: block({ gate: number(0, 1), stale_min: timerMinutes }),
196
+ context: block({ gate: number(0, 1), stale_min: timerMinutes, max_assignment_used: number(0, 1) }),
174
197
  inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
175
198
  omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
176
199
  mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
177
200
  contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
178
- approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
201
+ approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ groom_fyi_window_min: operationalNumberCheck("tasks.groom_fyi_window_min"), demote_at: operationalNumberCheck("tasks.demote_at"), demotion_half_life_h: operationalNumberCheck("tasks.demotion_half_life_h"), contradiction_window_days: operationalNumberCheck("tasks.contradiction_window_days"), rescreen_max: operationalNumberCheck("tasks.rescreen_max"), split_min_observations: operationalNumberCheck("tasks.split_min_observations"), split_max_failure_share: operationalNumberCheck("tasks.split_max_failure_share"), release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true), auto_pull: bool, queue_backpressure: block({ max_queued: number(0, QUEUE_BACKPRESSURE_LIMITS.max_queued, true), max_oldest_s: number(0, QUEUE_BACKPRESSURE_LIMITS.max_oldest_s, true) }) }),
202
+ schedules: v => { schedulesConfig(v); },
203
+ folder_triggers: v => { foldersConfig(v); },
179
204
  task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, no_writes_turns: number(0, Number.MAX_SAFE_INTEGER, true), interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
180
205
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
181
206
  if (value === undefined) continue;
@@ -228,9 +253,9 @@ const restart: Record<string, string> = {
228
253
  checks: "Bounded runner and completion-check contracts are constructed together",
229
254
  pii: "Existing native sessions retain their startup privacy boundary", data: "Existing native sessions retain their startup read-only policy",
230
255
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
231
- text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
256
+ text_projection: "Converter and cache ownership are bound at startup", corpus: "The corpus index is bound to its roots at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
232
257
  };
233
- const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
258
+ const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet"]);
234
259
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
235
260
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
236
261
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
@@ -247,13 +272,15 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
247
272
  if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
248
273
  else if (shape?.item) { if (shape.keys && !shape.keys.includes(part) && !(top === "roster" && scopes.some(scope => scope.catalog.definitionOf(part, scope.instances)))) return fail(key, "unknown configuration key"); check = shape.item; }
249
274
  else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
250
- else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances", "adapters", "adapter_modules"].includes(top)) break;
275
+ else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "schedules", "folder_triggers", "peers", "instances", "adapters", "adapter_modules"].includes(top)) break;
251
276
  else return fail(key, "unknown configuration key");
252
277
  }
253
278
  }
254
279
  if (top === "routing" && !ADAPTERS.some(adapter => Object.keys(adapter.configuration?.restartReasons ?? {}).some(prefix => prefix.startsWith("routing.") && key.startsWith(prefix))) && key !== "routing") {
255
280
  return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
256
281
  }
282
+ const operational = operationalNumber(key);
283
+ if (operational) return { applies: operational.applies, ...(operational.restartReason ? { reason: operational.restartReason } : {}) };
257
284
  if (nextMemory.has(key)) return { applies: "next use" };
258
285
  for (const [path, reason] of Object.entries(restart)) if (key === path || key.startsWith(`${path}.`)) return { applies: "restart", reason };
259
286
  if ([...live].some(path => key === path || key.startsWith(`${path}.`))) return { applies: "live" };
@@ -1,4 +1,4 @@
1
- import { ADAPTERS, createProjectAdapterCatalog } from "../adapters/registry.ts";
1
+ import { ADAPTERS, createProjectAdapterCatalog, type AdapterDefinition } from "../adapters/registry.ts";
2
2
  import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
3
3
  import { createHash } from "node:crypto";
4
4
  import { readFileSync } from "node:fs";
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { validateRawConfig, type ConfigLayer, type ConfigIdentitySnapshot } from "./config-apply.ts";
7
7
  import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
8
8
  import { CLASSES } from "./board.ts";
9
- import { readInstanceConfig } from "./instance-config.ts";
9
+ import { readInstanceConfig, withoutInertInstancePolicy, type DeclaredExternalInstance } from "./instance-config.ts";
10
10
  import { validateInstances } from "./instances.ts";
11
11
 
12
12
  export const CONFIG_SOURCE_FILES = ["config.json", "config.local.json", "routing.toml", "routing.local.toml"] as const;
@@ -87,7 +87,7 @@ function validateRoutingRaw(value: unknown): void {
87
87
  }
88
88
 
89
89
  /** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
90
- export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing, cwd?: string, externalAdapters?: readonly ExternalAdapterMetadata[]): ConfigSources {
90
+ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing, cwd?: string, externalAdapters?: readonly ExternalAdapterMetadata[], externalDefinitions?: readonly AdapterDefinition[]): ConfigSources {
91
91
  const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
92
92
  const jsonLayers: ConfigLayer[] = [];
93
93
  for (const name of ["config.json", "config.local.json"] as const) {
@@ -103,13 +103,14 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
103
103
  }
104
104
  const localRaw = jsonLayers.find(layer => layer.name === "config.local.json")?.raw ?? {};
105
105
  let declared;
106
+ let inertInstances: readonly DeclaredExternalInstance[] | undefined;
106
107
  let identityAdapters: ConfigIdentitySnapshot["adapters"];
107
108
  let inertModuleRoster = false;
108
109
  let catalog = createProjectAdapterCatalog();
109
110
  try {
110
111
  if (cwd) {
111
- const accepted = readInstanceConfig(cwd, jsonLayers, externalAdapters === undefined ? undefined : externalMetadataDefinitions(externalAdapters));
112
- catalog = accepted.catalog; declared = accepted.instances; identityAdapters = accepted.adapters;
112
+ const accepted = readInstanceConfig(cwd, jsonLayers, externalDefinitions);
113
+ catalog = accepted.catalog; declared = accepted.instances; inertInstances = accepted.declaredExternalInstances; identityAdapters = accepted.adapters;
113
114
  inertModuleRoster = !!accepted.adapter_modules?.length;
114
115
  } else {
115
116
  catalog = createProjectAdapterCatalog(localRaw?.adapters, externalMetadataDefinitions(externalAdapters));
@@ -133,6 +134,7 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
133
134
  continue;
134
135
  }
135
136
  if (name === "config.json" || name === "config.local.json") {
137
+ raw = withoutInertInstancePolicy(raw, inertInstances);
136
138
  if (!fallbackRouting) {
137
139
  try {
138
140
  const structural = { ...raw };