@staix/agent-hub 0.20.0 → 0.22.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 (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  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 +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  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 +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. 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
  }
@@ -2,7 +2,7 @@ import { parsePrEvidence, type SdlcGh } from "./sdlc.ts";
2
2
 
3
3
  /**
4
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 the first CI result or on this deadline, and is cancelled by a newer head or any task move. It is a timer in the
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
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
7
  * The longest hosted run is about 30 minutes (the job limits in `.github/workflows/check.yml` are 35 and 40 minutes).
8
8
  */
@@ -11,7 +11,7 @@ export const CI_WATCH_POLL_MS = 60_000;
11
11
  /** Same default set as `readReviewReadiness`, which is the authoritative read the review admission makes. */
12
12
  const DEFAULT_CHECKS = ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
13
13
  const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
14
- /** How many finished heads are remembered, so one head never notifies twice. */
14
+ /** How many finished heads are remembered, so one head never tells the same outcome twice. */
15
15
  const SETTLED_CAP = 256;
16
16
 
17
17
  /** What one poll found. `incomplete` is every required check finished with none failed and not all passed (skipped, neutral, duplicated). */
@@ -19,6 +19,8 @@ export type CiPoll = "pending" | "passed" | "failed" | "incomplete" | "supersede
19
19
  export type CiOutcome = "passed" | "failed" | "incomplete" | "deadline";
20
20
 
21
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 };
22
24
 
23
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. */
24
26
  export async function pollCi(target: { repo: string; pr: number }, head: string, required: readonly string[], run: SdlcGh): Promise<CiPoll> {
@@ -49,13 +51,15 @@ export interface CiWatchersOptions {
49
51
  }
50
52
 
51
53
  /**
52
- * At most one watcher per task, and one per exact head. Timers are chained `setTimeout`s (never an interval), unref'd, and cleared
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
53
57
  * on every end, so nothing outlives its deadline, a newer head or `stopAll`. Watchers are not restored after a hub restart:
54
58
  * the next `hub_task_done` starts one (ponytail: persist them only if restarts during CI turn out to be common).
55
59
  */
56
60
  export class CiWatchers {
57
- private readonly active = new Map<number, { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }>();
58
- private readonly settled = new Map<number, { head: string; outcome: CiOutcome }>();
61
+ private readonly active = new Map<number, Entry>();
62
+ private readonly settled = new Map<number, { head: string; outcomes: Set<CiOutcome>; last: CiOutcome }>();
59
63
  private readonly deadlineMs: number;
60
64
  private readonly pollMs: number;
61
65
  private readonly now: () => number;
@@ -68,21 +72,26 @@ export class CiWatchers {
68
72
 
69
73
  get size(): number { return this.active.size; }
70
74
 
71
- /** Starts a watcher for this exact head; false when this head is already watched or already reported. A different head replaces the old watcher. */
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. */
72
76
  start(target: { id: number; head: string; repo: string; pr: number }): boolean {
73
- const have = this.active.get(target.id);
74
- if (have?.watch.head === target.head || this.settled.get(target.id)?.head === target.head) return false;
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;
75
79
  if (have) this.cancel(target.id);
76
- const entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> } = { watch: { ...target, deadlineAt: this.now() + this.deadlineMs } };
80
+ const entry: Entry = { watch: { ...target, deadlineAt: this.now() + this.deadlineMs }, reported: !!done?.size };
77
81
  this.active.set(target.id, entry);
78
82
  this.schedule(entry);
79
83
  return true;
80
84
  }
81
85
 
82
- /** The result already reported for this exact head, if any. */
86
+ /** The latest result reported for this exact head, if any. */
83
87
  outcomeOf(id: number, head: string): CiOutcome | undefined {
84
88
  const done = this.settled.get(id);
85
- return done?.head === head ? done.outcome : undefined;
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;
86
95
  }
87
96
 
88
97
  /** Ends the task's watcher (a given head only when `head` is named). No notice. */
@@ -98,43 +107,56 @@ export class CiWatchers {
98
107
  this.active.clear();
99
108
  }
100
109
 
101
- private schedule(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): void {
110
+ private schedule(entry: Entry): void {
102
111
  const delay = Math.max(0, Math.min(this.pollMs, entry.watch.deadlineAt - this.now()));
103
112
  entry.timer = setTimeout(() => { void this.tick(entry); }, delay);
104
113
  entry.timer.unref?.();
105
114
  }
106
115
 
107
- private end(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): boolean {
116
+ private end(entry: Entry): boolean {
108
117
  if (this.active.get(entry.watch.id) !== entry) return false;
109
118
  clearTimeout(entry.timer);
110
119
  this.active.delete(entry.watch.id);
111
120
  return true;
112
121
  }
113
122
 
114
- private report(entry: { watch: CiWatch }, outcome: CiOutcome): void {
123
+ private report(entry: Entry, outcome: CiOutcome): void {
115
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;
116
129
  this.settled.delete(entry.watch.id);
117
- this.settled.set(entry.watch.id, { head: entry.watch.head, outcome });
130
+ this.settled.set(entry.watch.id, { head: entry.watch.head, outcomes, last: outcome });
118
131
  while (this.settled.size > SETTLED_CAP) this.settled.delete(this.settled.keys().next().value as number);
119
132
  }
120
133
  void Promise.resolve().then(() => this.o.finish(entry.watch, outcome)).catch(() => { /* the finisher guards its own notices */ });
121
134
  }
122
135
 
123
136
  /** A watcher never throws out of its timer: a `current` or `finish` that fails (a closed board at shutdown) ends it. */
124
- private async tick(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): Promise<void> {
137
+ private async tick(entry: Entry): Promise<void> {
125
138
  try { await this.step(entry); } catch { this.end(entry); }
126
139
  }
127
140
 
128
- private async step(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): Promise<void> {
141
+ private async step(entry: Entry): Promise<void> {
129
142
  entry.timer = undefined;
130
143
  if (this.active.get(entry.watch.id) !== entry) return;
131
144
  if (!this.o.current(entry.watch)) { this.end(entry); return; }
132
- if (this.now() >= entry.watch.deadlineAt) { if (this.end(entry)) this.report(entry, "deadline"); 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; }
133
147
  let result: CiPoll;
134
148
  try { result = await this.o.poll(entry.watch); } catch { result = "pending"; }
135
149
  if (this.active.get(entry.watch.id) !== entry) return; // cancelled or replaced during the read
136
150
  if (!this.o.current(entry.watch) || result === "superseded" || result === "closed") { this.end(entry); return; }
137
- if (result === "pending") { this.schedule(entry); return; }
138
- if (this.end(entry)) this.report(entry, result);
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);
139
161
  }
140
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
+ }
@@ -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,18 @@ 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) }),
197
+ bus: block({ max_attempts: operationalNumberCheck("bus.max_attempts"), digest_max: operationalNumberCheck("bus.digest_max"), pull_budget_bytes: operationalNumberCheck("bus.pull_budget_bytes"), notes_kept: operationalNumberCheck("bus.notes_kept"), terminal_history_limit: operationalNumberCheck("bus.terminal_history_limit") }),
174
198
  inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
175
199
  omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
176
200
  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
201
  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), auto_pull: bool }),
202
+ 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) }) }),
203
+ schedules: v => { schedulesConfig(v); },
204
+ folder_triggers: v => { foldersConfig(v); },
179
205
  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
206
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
181
207
  if (value === undefined) continue;
@@ -188,7 +214,7 @@ const schema: Record<string, Check> = {
188
214
  peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
189
215
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
190
216
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
191
- review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool }),
217
+ review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool, reconnect_wait_s: operationalNumberCheck("recovery.reconnect_wait_s"), unmanaged_reconnect_wait_s: operationalNumberCheck("recovery.unmanaged_reconnect_wait_s"), control_timeout_s: operationalNumberCheck("recovery.control_timeout_s"), shutdown_wait_s: operationalNumberCheck("recovery.shutdown_wait_s"), terminal_idle_wait_s: operationalNumberCheck("recovery.terminal_idle_wait_s") }),
192
218
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
193
219
  };
194
220
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
@@ -228,9 +254,9 @@ const restart: Record<string, string> = {
228
254
  checks: "Bounded runner and completion-check contracts are constructed together",
229
255
  pii: "Existing native sessions retain their startup privacy boundary", data: "Existing native sessions retain their startup read-only policy",
230
256
  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",
257
+ 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
258
  };
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", "tasks.auto_pull", "approvals.notify", "pet"]);
259
+ 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
260
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
235
261
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
236
262
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
@@ -247,13 +273,15 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
247
273
  if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
248
274
  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
275
  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;
276
+ else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "schedules", "folder_triggers", "peers", "instances", "adapters", "adapter_modules"].includes(top)) break;
251
277
  else return fail(key, "unknown configuration key");
252
278
  }
253
279
  }
254
280
  if (top === "routing" && !ADAPTERS.some(adapter => Object.keys(adapter.configuration?.restartReasons ?? {}).some(prefix => prefix.startsWith("routing.") && key.startsWith(prefix))) && key !== "routing") {
255
281
  return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
256
282
  }
283
+ const operational = operationalNumber(key);
284
+ if (operational) return { applies: operational.applies, ...(operational.restartReason ? { reason: operational.restartReason } : {}) };
257
285
  if (nextMemory.has(key)) return { applies: "next use" };
258
286
  for (const [path, reason] of Object.entries(restart)) if (key === path || key.startsWith(`${path}.`)) return { applies: "restart", reason };
259
287
  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 };
@@ -26,11 +26,16 @@ export const MACHINE_LOCAL = [
26
26
  "local.read_allow",
27
27
  "local.bash_network",
28
28
  "local.network_allow", // the hosts the egress proxy opens (#65)
29
+ "local.doc_read", // #515: gives the local worker a document reader; a committed config may not hand a model more tools on someone else's machine
30
+ "local.web_fetch", // #515b: lets a model request pages from listed hosts; a committed config may not choose where a model's requests go on someone else's machine
29
31
  "terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
30
32
  "tasks.auto_pull", // #785 P1c: lets the hub start work on its own; a committed config may not turn it on for someone else's machine
33
+ "folder_triggers", // #512c: entries make the hub read a folder and propose a task per new file; a committed config may not point the hub at a folder on someone else's machine
34
+ "schedules", // #512: entries make the hub propose tasks on a timer; a committed config may not create work on someone else's machine
35
+ "corpus.paths", // #510: folders the hub reads as evidence; a committed config may not point the hub at a folder on someone else's machine
31
36
  ] as const;
32
37
  /** Legacy exported field list stays compatible; new policies use the complete list below. */
33
- export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
38
+ export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "rooms", "tasks.queue_backpressure", "context.max_assignment_used", "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
34
39
 
35
40
  /** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
36
41
  export function configRepositoryPresent(cwd: string): boolean {
@@ -96,6 +101,7 @@ const drop = (file: Record<string, unknown>, path: string) => {
96
101
  * once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
97
102
  */
98
103
  export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
104
+ if (name !== "config.local.json") delete file.rooms;
99
105
  const localOnly = name !== "config.local.json" && file.action_policy !== undefined;
100
106
  if (localOnly) delete file.action_policy;
101
107
  const originNotice = localOnly ? "action_policy ignored: only .agenthub/config.local.json may set it" : undefined;
@@ -9,7 +9,7 @@ export function readNativeContext(definition: AdapterDefinition | undefined, val
9
9
 
10
10
  /** Native active-context occupancy, separate from accumulated billable usage (#185). */
11
11
  export interface ContextReading {
12
- source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
12
+ source: "claude_statusline" | "codex_token_usage" | "acp_usage_update" | "jcode_usage_update";
13
13
  sessionId: string;
14
14
  measuredAt: number;
15
15
  tokens: number | null;
@@ -24,8 +24,8 @@ export interface ContextView {
24
24
  used: number | null;
25
25
  freshness: "fresh" | "stale" | "unknown";
26
26
  }
27
- export interface ContextConfig { gate: number; stale_min: number }
28
- export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30 };
27
+ export interface ContextConfig { gate: number; stale_min: number; max_assignment_used?: number }
28
+ export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30, max_assignment_used: 0 };
29
29
  export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
30
30
  /** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
31
31
  export class ContextWindows {
@@ -46,6 +46,11 @@ export class ContextWindows {
46
46
  if (over && !crossed && this.crossing(peer, reading) === false) return;
47
47
  this.high.set(peer, { sessionId: reading.sessionId, crossed: over });
48
48
  }
49
+ /** Copy native evidence without changing the checkpoint latch or collecting new telemetry. */
50
+ admission(peer: string, currentSession: string | undefined, attached: boolean): ContextAdmissionSnapshot {
51
+ const reading = this.readings.get(peer);
52
+ return { ...(reading ? { reading: { ...reading } } : {}), sessionId: currentSession, attached };
53
+ }
49
54
  /** Reconsider a held crossing after release, using only the still-fresh reading and current native session. */
50
55
  retry(peer: string, currentSession: string | undefined): void {
51
56
  const reading = this.readings.get(peer);
@@ -59,3 +64,28 @@ export class ContextWindows {
59
64
  return { source: r.source, measuredAt: positive(r.measuredAt) ? r.measuredAt : null, tokens: valid ? r.tokens : null, window: r.window, used: valid ? r.used : null, freshness: !valid ? stale ? "stale" : "unknown" : r.used === null ? "unknown" : "fresh" };
60
65
  }
61
66
  }
67
+
68
+ /** Host-only snapshot: session identifiers never enter routing traces or task history. */
69
+ export interface ContextAdmissionSnapshot {
70
+ reading?: ContextReading;
71
+ sessionId?: string;
72
+ attached: boolean;
73
+ }
74
+ export interface ContextAdmissionPolicy { max_used: number; stale_min: number }
75
+ export interface ContextAdmission {
76
+ policy: ContextAdmissionPolicy;
77
+ peers: Readonly<Record<string, ContextAdmissionSnapshot>>;
78
+ }
79
+ /** Pure new-work admission; unknown supplies no positive or negative context evidence. */
80
+ export function contextHeadroom(policy: ContextAdmissionPolicy, snapshot: ContextAdmissionSnapshot | undefined, now: number): { blocked: boolean; note: string } | undefined {
81
+ if (!Number.isFinite(policy.max_used) || policy.max_used <= 0 || policy.max_used > 1) return undefined;
82
+ const r = snapshot?.reading;
83
+ if (!r || !snapshot.attached || !snapshot.sessionId || r.sessionId !== snapshot.sessionId
84
+ || !positive(now) || !positive(r.measuredAt) || r.measuredAt > now
85
+ || !positive(policy.stale_min) || now - r.measuredAt > policy.stale_min * 60_000
86
+ || r.used === null || !nonnegative(r.used) || r.used > 1) {
87
+ return { blocked: false, note: "context headroom: unknown (no fresh current-session reading)" };
88
+ }
89
+ const blocked = r.used >= policy.max_used;
90
+ return { blocked, note: `context headroom: used ${r.used} ${blocked ? "at or above" : "below"} max ${policy.max_used}` };
91
+ }