@staix/agent-hub 0.21.0 → 0.23.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 (100) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +2 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -2
  6. package/docs/agent-notes/daemon.md +5 -4
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/models.md +2 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +2 -2
  11. package/docs/dogfood.md +10 -2
  12. package/docs/operations.md +192 -6
  13. package/docs/profiles.md +29 -4
  14. package/docs/quickstart.md +9 -1
  15. package/docs/security.md +7 -1
  16. package/docs/smoke.md +6 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +94 -1
  18. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  19. package/docs/verification/2026-10-30-0.23.0.md +70 -0
  20. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  21. package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
  22. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  23. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  24. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  25. package/docs/verification/2026-10-30-model-operational-settings.md +211 -0
  26. package/docs/verification/2026-10-30-operational-settings-closure.md +1217 -0
  27. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  28. package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
  29. package/docs/verified.json +15 -15
  30. package/package.json +1 -1
  31. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  32. package/plugins/agent-hub/ROLE-NOTICE.md +1 -1
  33. package/plugins/agent-hub/server.js +559 -115
  34. package/src/adapters/acp.ts +48 -2
  35. package/src/adapters/claude-channel.ts +6 -4
  36. package/src/adapters/codex-appserver.ts +19 -0
  37. package/src/adapters/jcode.ts +1 -1
  38. package/src/adapters/local-worker.ts +3 -3
  39. package/src/adapters/native-capabilities.ts +50 -0
  40. package/src/adapters/registry-types.ts +3 -10
  41. package/src/cli/capabilities-output.ts +30 -0
  42. package/src/cli/console-state.ts +8 -5
  43. package/src/cli/console.ts +27 -4
  44. package/src/cli/help.ts +3 -1
  45. package/src/cli/identity.ts +4 -1
  46. package/src/cli/learn-output.ts +38 -0
  47. package/src/cli/main.ts +28 -2
  48. package/src/cli/recovery-waits.ts +26 -0
  49. package/src/cli/smoke-pty-bridge.ts +67 -18
  50. package/src/cli/smoke-pty.ts +10 -2
  51. package/src/cli/upgrade-runtime.ts +16 -14
  52. package/src/cli/upgrade.ts +2 -1
  53. package/src/hub/board.ts +8 -2
  54. package/src/hub/bus.ts +117 -17
  55. package/src/hub/capabilities.ts +215 -0
  56. package/src/hub/config-apply.ts +8 -4
  57. package/src/hub/config-trust.ts +1 -0
  58. package/src/hub/control-client.ts +41 -8
  59. package/src/hub/daemon.ts +436 -33
  60. package/src/hub/deferred-hub.ts +2 -2
  61. package/src/hub/delivery-hints.ts +2 -2
  62. package/src/hub/delivery-journal.ts +80 -61
  63. package/src/hub/envelope.ts +12 -2
  64. package/src/hub/events.ts +2 -0
  65. package/src/hub/hub-tools.ts +16 -1
  66. package/src/hub/inference.ts +10 -5
  67. package/src/hub/invocations.ts +316 -0
  68. package/src/hub/learning.ts +33 -1
  69. package/src/hub/operational-settings.ts +180 -1
  70. package/src/hub/peers.ts +3 -0
  71. package/src/hub/restart.ts +2 -2
  72. package/src/hub/room-invocations.ts +145 -0
  73. package/src/hub/settings.ts +22 -2
  74. package/src/hub/shipped-packs.ts +5 -2
  75. package/src/hub/shipped-roles.ts +13 -0
  76. package/src/hub/tasks.ts +92 -1
  77. package/src/memory/capture.ts +2 -2
  78. package/src/models/relay.ts +20 -9
  79. package/src/rooms/application.ts +67 -2
  80. package/src/rooms/boundary.ts +43 -11
  81. package/src/rooms/service.ts +40 -2
  82. package/templates/checks/analysis.mjs +59 -0
  83. package/templates/checks/fixtures/analysis-expected.json +1 -0
  84. package/templates/checks/fixtures/analysis-input.json +1 -0
  85. package/templates/checks/fixtures/analysis.mjs +7 -0
  86. package/templates/checks/fixtures/ops.json +4 -0
  87. package/templates/checks/fixtures/writing-rules.json +4 -0
  88. package/templates/checks/fixtures/writing.json +5 -0
  89. package/templates/checks/ops.mjs +40 -0
  90. package/templates/checks/writing.mjs +40 -0
  91. package/templates/packs/analysis.pack.json +14 -0
  92. package/templates/packs/ops.pack.json +14 -0
  93. package/templates/packs/writing-pack.pack.json +14 -0
  94. package/templates/roles/NOTICE.md +1 -1
  95. package/templates/roles/ops/meeting-actions-owner.md +26 -0
  96. package/templates/roles/ops/project-manager.md +26 -0
  97. package/templates/roles/writing/editor.md +26 -0
  98. package/templates/roles/writing/style-checker.md +26 -0
  99. package/templates/roles/writing/terminology-checker.md +26 -0
  100. package/templates/roles/writing/translator.md +26 -0
package/src/hub/bus.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { appendNote, DIGEST, frame, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
1
+ import { remoteEnvelope, appendNote, DIGEST, frame, HUB, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
3
  import type { DoingKind } from "./peer-doing.ts";
4
4
  import type { QueuePressure } from "./queue-backpressure.ts";
@@ -41,6 +41,11 @@ export interface BusOptions {
41
41
  /** How long a status envelope may wait for company before it is delivered on its own. */
42
42
  batchMs: number;
43
43
  queueCap: number;
44
+ /** Accepted live operational settings; read only at the next matching delivery/note operation. */
45
+ maxAttempts?: () => number;
46
+ digestMax?: () => number;
47
+ pullBudgetBytes?: () => number;
48
+ notesKept?: () => number;
44
49
  /** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
45
50
  condense?: (envs: Envelope[]) => Promise<Envelope[]>;
46
51
  journal?: DeliveryJournal;
@@ -118,7 +123,7 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
118
123
  }
119
124
 
120
125
  const SEEN_CAP = 2048;
121
- const MAX_ATTEMPTS = 3;
126
+ export const MAX_ATTEMPTS = 3;
122
127
  // ponytail: provenance is capped at 2048 ids/peer, 4096 peers, 100000 ids total and 256 KiB per snapshot.
123
128
  // Omitted metadata means unknown age only; use a measured per-envelope store if larger queues need provenance.
124
129
  const QUEUE_TIME_CAP = 2048, QUEUE_TIME_PEERS_CAP = 4096, QUEUE_TIME_TOTAL_CAP = 100_000, QUEUE_TIME_SNAPSHOT_BYTES = 256 * 1024;
@@ -127,11 +132,11 @@ const remoteId = (id: string): boolean => id.includes("@");
127
132
  const remoteFingerprint = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
128
133
  // ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
129
134
  // Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
130
- const DIGEST_MAX = 10;
135
+ export const DIGEST_MAX = 10;
131
136
  // #713: a pull-only peer reads between steps, often minutes apart, so one pull drains its queue up to this many bytes of rendered text instead of ten items.
132
137
  // Bounded, not unbounded: a queue larger than the budget takes more than one pull, and the reply says how many are still waiting. Push peers keep DIGEST_MAX.
133
- const PULL_BUDGET_BYTES = 64 * 1024;
134
- const NOTES_KEPT = 10;
138
+ export const PULL_BUDGET_BYTES = 64 * 1024;
139
+ export const NOTES_KEPT = 10;
135
140
 
136
141
  /**
137
142
  * N-peer fan-out. Never delivers back to `from`, caps hops, dedupes by id. Each peer has one queue,
@@ -177,6 +182,41 @@ export class Bus {
177
182
  * and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
178
183
  */
179
184
  onQueues?: () => void;
185
+ /**
186
+ * #445 C2: a peer's answer to a delivery, offered BEFORE anything is built from it. The host returns true when the answer belongs to an invocation it
187
+ * took over: the bus then publishes nothing, stores nothing, emits no event and lets no tap, queue, digest or memory path see it. `answers` is what the
188
+ * peer was handed (the request is in it, never condensed), so a reply that answers an invocation is found by identity, not by addressing.
189
+ */
190
+ onAnswer?: (peer: PeerId, text: string, answers: readonly Envelope[]) => boolean;
191
+ /** Host ledger authority; absent or unknown never permits invocation replay. */
192
+ canRetryInvocation?: (id: string) => boolean;
193
+ onInvocationUncertain?: (id: string) => void;
194
+ /** Validated transport receipts, ids only; application admission is not a capability result. */
195
+ onInvocationReceipt?: (id: string, state: "completed" | "failed_safe" | "needs_review") => void;
196
+ hasInvocationTurn(peer: PeerId): boolean { return this.invocationTurnOpen(peer); }
197
+ private readonly invocationDeliveries = new Map<string, string>();
198
+
199
+ releaseInvocation(id: string): void { this.invocationDeliveries.delete(id); }
200
+ invocationUncertain(id: string): boolean {
201
+ const delivery = this.invocationDeliveries.get(id), row = delivery && this.journal?.get(delivery);
202
+ return !!row && (row.state === "needs_review" || ["dispatching", "accepted"].includes(row.state) && !this.activeDeliveries.has(row.id));
203
+ }
204
+
205
+ /** Hold the existing delivery rather than cancelling native work or replaying an uncertain effect. */
206
+ holdInvocation(id: string, reason: string, target?: PeerId): void {
207
+ const delivery = this.invocationDeliveries.get(id), row = delivery && this.journal?.get(delivery);
208
+ if (!row || !this.journal) {
209
+ this.storageError = "uncertain invocation delivery evidence unavailable; inspect the invocation ledger before recovery";
210
+ if (target) this.recoveryHeldPeers.add(target);
211
+ this.onQueues?.();
212
+ return;
213
+ }
214
+ this.journal.transition(row.id, "needs_review", reason);
215
+ this.activeDeliveries.delete(row.id);
216
+ this.recoveryHeldPeers.add(row.peer);
217
+ this.onQueues?.();
218
+ }
219
+
180
220
  /** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
181
221
  onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
182
222
  /** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
@@ -208,6 +248,7 @@ export class Bus {
208
248
  if (state.revision > 0 && bus && bus.schemaVersion === 1) this.loadSnapshot(bus);
209
249
  for (const id of state.manualPaused) { this.manualPaused.add(id); this.paused.add(id); }
210
250
  for (const row of this.journal.list()) {
251
+ for (const env of row.originals) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, row.id);
211
252
  if (row.state === "needs_review" || row.state === "dispatching" || row.state === "accepted") this.recoveryHeldPeers.add(row.peer);
212
253
  }
213
254
  }
@@ -243,6 +284,17 @@ export class Bus {
243
284
  this.opts.doing?.(peer.id, "reset"); // a new adapter never shows what the one it replaces was doing
244
285
  peer.onMessage = (text, opts) => {
245
286
  if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
287
+ // #445 C2: an answer to an invocation is found by what the peer was handed, never by how the answer is addressed, so a headless agent whose final answer is
288
+ // normally shared with every peer cannot leak it. It is the invocation's answer when it names the request itself (an adapter may report its delivery complete before
289
+ // it answers, so the receipt cannot decide), or when it names nothing while that invocation's own delivery is still the peer's open turn. Once that turn has ended,
290
+ // `lastDelivery` still holds the request until the next delivery replaces it, and a later message from the peer is an ordinary message: it must be published.
291
+ const handed = this.lastDelivery.get(peer.id)?.out ?? [];
292
+ const named = opts?.inReplyTo?.id !== undefined && handed.some((e) => e.id === opts.inReplyTo!.id); // an invocation is delivered alone, so the only envelope in `handed` to name is its request (or the preface that rode with it)
293
+ if (this.onAnswer && handed.some((e) => e.kind === "invocation") && (named || opts?.inReplyTo?.id === undefined && this.invocationTurnOpen(peer.id))) {
294
+ let taken = false;
295
+ try { taken = this.onAnswer(peer.id, text, handed) === true; } catch { taken = true; } // a throwing host never turns a callee's answer into a broadcast
296
+ if (taken) return;
297
+ }
246
298
  const { priority, body } = parseMarker(text);
247
299
  if (!body) return;
248
300
  // What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
@@ -311,6 +363,9 @@ export class Bus {
311
363
  this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence" });
312
364
  }
313
365
  }
366
+ } else if (envs.some(env => env.kind === "invocation")) {
367
+ this.recoveryHeldPeers.add(peer.id);
368
+ for (const env of envs) if (env.kind === "invocation") this.onInvocationUncertain?.(env.id);
314
369
  } else this.failed(peer.id, same ? last.originals : envs, undefined, failure?.quota === true);
315
370
  };
316
371
  peer.onState = () => {
@@ -490,6 +545,7 @@ export class Bus {
490
545
  }
491
546
  }
492
547
  this.loadSnapshot(snapshot);
548
+ if (this.journal) for (const row of this.journal.list()) for (const env of row.originals) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, row.id);
493
549
  this.persist();
494
550
  }
495
551
 
@@ -557,6 +613,7 @@ export class Bus {
557
613
  if (this.closed || this.recoveryHeld || this.storageError || peer === "user" || peer === HUB) return refuse();
558
614
  const row = this.queueShow(id);
559
615
  if (!row || row.peer !== peer || row.state !== "needs_review" || !this.journal || this.journal.retryEvidence(peer, row.originals) !== "unused") return refuse();
616
+ if (row.originals.some(env => env.kind === "invocation")) return refuse();
560
617
  this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
561
618
  }
562
619
 
@@ -671,6 +728,7 @@ export class Bus {
671
728
  const last = this.lastDelivery.get(peer);
672
729
  if (before && last && last.out.length === before.out.length && last.out.every((env, index) => env.id === before.out[index]!.id)) this.lastDelivery.delete(peer);
673
730
  this.refreshRecoveryHold(peer);
731
+ for (const env of before?.originals ?? []) if (isInvocationKind(env)) this.onInvocationReceipt?.(env.id, "failed_safe");
674
732
  this.onQueues?.();
675
733
  void this.drain(peer);
676
734
  return;
@@ -689,7 +747,11 @@ export class Bus {
689
747
  if (state === "completed") { this.failureStreak.delete(peer); this.lastFailure.delete(peer); }
690
748
  if (state === "completed" || state === "failed" || state === "needs_review") this.activeDeliveries.delete(id);
691
749
  if (state === "completed" || state === "failed") { this.refreshRecoveryHold(peer); void this.drain(peer); }
692
- if (state === "needs_review") this.recoveryHeldPeers.add(peer);
750
+ if (receipt.state === "completed" || receipt.state === "needs_review") for (const env of before?.originals ?? []) if (isInvocationKind(env)) this.onInvocationReceipt?.(env.id, receipt.state);
751
+ if (state === "needs_review") {
752
+ this.recoveryHeldPeers.add(peer);
753
+ for (const env of before?.originals ?? []) if (env.kind === "invocation") this.onInvocationUncertain?.(env.id);
754
+ }
693
755
  this.onQueues?.();
694
756
  }
695
757
 
@@ -797,7 +859,7 @@ export class Bus {
797
859
  note(id: PeerId, line: string): void {
798
860
  if (remoteId(id)) return;
799
861
  const existing = this.prefaces.get(id);
800
- this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
862
+ this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), this.opts.notesKept?.() ?? NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
801
863
  this.persist();
802
864
  }
803
865
 
@@ -805,7 +867,7 @@ export class Bus {
805
867
  private restorePreface(id: PeerId, env: Envelope): void {
806
868
  if (remoteId(id)) return;
807
869
  const now = this.prefaces.get(id);
808
- this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
870
+ this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), this.opts.notesKept?.() ?? NOTES_KEPT) });
809
871
  }
810
872
 
811
873
  /**
@@ -1016,7 +1078,7 @@ export class Bus {
1016
1078
  try { for (const id of targets) {
1017
1079
  const peer = this.peers.get(id);
1018
1080
  if (!peer) { this.enqueue(id, env); continue; }
1019
- if (!this.journal && !this.held(id) && env.priority === "important" && !remoteId(id) && peer.steer && this.stateOf(id) === "busy") {
1081
+ if (!this.journal && !this.held(id) && env.priority === "important" && !remoteId(id) && peer.steer && this.stateOf(id) === "busy" && !this.invocationTurnOpen(id)) {
1020
1082
  // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
1021
1083
  this.steering++;
1022
1084
  const deliveryId = crypto.randomUUID();
@@ -1036,7 +1098,7 @@ export class Bus {
1036
1098
  if (!this.flushingHub) this.onQueues?.();
1037
1099
  for (const id of targets) {
1038
1100
  const peer = this.peers.get(id);
1039
- if (!this.held(id) && env.priority === "important" && !remoteId(id) && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
1101
+ if (!this.held(id) && env.priority === "important" && !remoteId(id) && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env); // steerQueued itself refuses to join an invocation's turn
1040
1102
  else void this.drain(id);
1041
1103
  }
1042
1104
  }
@@ -1080,6 +1142,20 @@ export class Bus {
1080
1142
  return this.seen.get(id) ?? this.deferredHub.get(id);
1081
1143
  }
1082
1144
 
1145
+ /**
1146
+ * #445 C2: whether the peer's current turn is one that holds an invocation request. With a journal that is a delivery of the peer's own that has not reached a
1147
+ * terminal receipt and whose payload holds the request (the receipt paths delete it from `activeDeliveries`); without one it is the peer being busy, which is the
1148
+ * best the bus can know (the daemon always has a journal). A steer is never sent into such a turn (the two steer sites and `steerQueued` ask this first): an adapter answers the highest-hop envelope of
1149
+ * everything its turn holds, and a steered one would become what the answer names instead of the request.
1150
+ */
1151
+ private invocationTurnOpen(peer: PeerId): boolean {
1152
+ if (!this.journal) return this.stateOf(peer) === "busy" && (this.lastDelivery.get(peer)?.out ?? []).some((e) => e.kind === "invocation");
1153
+ for (const [deliveryId, owner] of this.activeDeliveries) {
1154
+ if (owner === peer && this.journal.get(deliveryId)?.out.some((e) => e.kind === "invocation")) return true;
1155
+ }
1156
+ return false;
1157
+ }
1158
+
1083
1159
  /** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
1084
1160
  lastDeliveredTo(peer: PeerId): Envelope[] {
1085
1161
  return this.lastDelivery.get(peer)?.out ?? [];
@@ -1305,6 +1381,8 @@ export class Bus {
1305
1381
  const peer = this.peers.get(id);
1306
1382
  const queue = this.queues.get(id);
1307
1383
  if (!this.journal || !peer?.steer || !queue || this.storageError) return;
1384
+ // #445 C2: an invocation's turn answers the request alone. Joining it with a steer would change what its answer names, so the envelope waits in the queue for the next delivery.
1385
+ if (this.invocationTurnOpen(id)) { void this.drain(id); return; }
1308
1386
  const index = queue.findIndex((item) => item.id === env.id);
1309
1387
  if (index < 0) return;
1310
1388
  this.steering++;
@@ -1326,6 +1404,9 @@ export class Bus {
1326
1404
  }
1327
1405
 
1328
1406
  /** Holds on a peer's queue, for a drain and a pull alike: off roster, recovery, an uncertain delivery, a pause (console, budget, conductor). */
1407
+ /** #445 C2: whether a delivery to this peer would wait on a hold (roster, recovery, pause or a lifecycle fence). */
1408
+ isHeld(id: PeerId): boolean { return this.held(id); }
1409
+
1329
1410
  private held(id: PeerId): boolean {
1330
1411
  return !this.isRostered(id) || this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
1331
1412
  }
@@ -1445,6 +1526,7 @@ export class Bus {
1445
1526
  this.activeDeliveries.set(deliveryId, id);
1446
1527
  } else this.remember(out);
1447
1528
  this.lastDelivery.set(id, { out, originals: delivery });
1529
+ for (const env of delivery) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, deliveryId);
1448
1530
  if (remoteId(id)) this.remoteTransport.set(deliveryId, { peer: id, originals: delivery });
1449
1531
  this.preparing.delete(id); // from here on the transport/journal owns settlement
1450
1532
  try {
@@ -1458,7 +1540,10 @@ export class Bus {
1458
1540
  else { await peer.deliver(out); this.delivered(id, delivery); }
1459
1541
  } catch {
1460
1542
  if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
1461
- else this.failed(id, delivery);
1543
+ else if (delivery.some(env => env.kind === "invocation")) {
1544
+ this.recoveryHeldPeers.add(id);
1545
+ for (const env of delivery) if (env.kind === "invocation") this.onInvocationUncertain?.(env.id);
1546
+ } else this.failed(id, delivery);
1462
1547
  break;
1463
1548
  } finally { this.remoteTransport.delete(deliveryId); }
1464
1549
  }
@@ -1505,13 +1590,16 @@ export class Bus {
1505
1590
  * An envelope that already failed once goes alone, so a poison one cannot take a digest down with it.
1506
1591
  */
1507
1592
  private take(id: PeerId, queue: Envelope[], remove = true): Envelope[] {
1593
+ const digestMax = this.opts.digestMax?.() ?? DIGEST_MAX;
1508
1594
  const ordered = [...queue.filter((e) => e.priority === "important"), ...queue.filter((e) => e.priority !== "important")];
1509
1595
  const batch: Envelope[] = [];
1510
1596
  for (const env of ordered) {
1511
1597
  const failedBefore = this.attempts.has(`${id}:${env.id}`);
1512
1598
  if (failedBefore && batch.length) break;
1599
+ // #445 C2: an invocation or its result goes alone, so the reply to the turn answers exactly one of them.
1600
+ if (isInvocationKind(env) && batch.length) break;
1513
1601
  batch.push(env);
1514
- if (failedBefore || remoteId(id) || batch.length === DIGEST_MAX) break;
1602
+ if (failedBefore || remoteId(id) || batch.length === digestMax || isInvocationKind(env)) break;
1515
1603
  }
1516
1604
  if (remove) for (const env of batch) queue.splice(queue.indexOf(env), 1);
1517
1605
  return batch;
@@ -1523,15 +1611,17 @@ export class Bus {
1523
1611
  * envelope cannot stall the queue. An envelope that already failed once still goes alone, as in `take`.
1524
1612
  */
1525
1613
  private takeBudget(id: PeerId, queue: Envelope[]): Envelope[] {
1614
+ const pullBudgetBytes = this.opts.pullBudgetBytes?.() ?? PULL_BUDGET_BYTES;
1526
1615
  const batch: Envelope[] = [];
1527
1616
  let bytes = 0;
1528
1617
  for (const env of queue) {
1529
1618
  const failedBefore = this.attempts.has(`${id}:${env.id}`);
1530
1619
  if (failedBefore && batch.length) break;
1531
1620
  const size = Buffer.byteLength(frame(env)) + 2;
1532
- if (batch.length && bytes + size > PULL_BUDGET_BYTES) break;
1621
+ if (batch.length && bytes + size > pullBudgetBytes) break;
1622
+ if (isInvocationKind(env) && batch.length) break; // #445 C2: alone, as in `take`
1533
1623
  batch.push(env); bytes += size;
1534
- if (failedBefore) break;
1624
+ if (failedBefore || isInvocationKind(env)) break;
1535
1625
  }
1536
1626
  for (const env of batch) queue.splice(queue.indexOf(env), 1);
1537
1627
  return batch;
@@ -1555,11 +1645,12 @@ export class Bus {
1555
1645
  }
1556
1646
 
1557
1647
  private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail", quota = false): void {
1648
+ const maxAttempts = this.opts.maxAttempts?.() ?? MAX_ATTEMPTS;
1558
1649
  if (!this.journal) this.deliveryFailed(id);
1559
1650
  // A quota refusal ran no work. The budget pause fences retries until reset; it is not a poison attempt.
1560
1651
  const quotaPaused = quota && this.paused.has(id);
1561
1652
  this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
1562
- if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
1653
+ if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= maxAttempts)) {
1563
1654
  this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
1564
1655
  }
1565
1656
  const keep = envs.filter((env) => {
@@ -1568,10 +1659,19 @@ export class Bus {
1568
1659
  this.restorePreface(id, env); // rides again on the next delivery, without counting as a failed envelope
1569
1660
  return false;
1570
1661
  }
1662
+ if (env.kind === "invocation") {
1663
+ const key = `${id}:${env.id}`, n = (this.attempts.get(key) ?? 0) + 1;
1664
+ let read = false;
1665
+ try { read = this.canRetryInvocation?.(env.id) === true; } catch { /* Unknown refuses replay. */ }
1666
+ if (read && n < 2) { this.attempts.set(key, n); return true; }
1667
+ this.attempts.delete(key);
1668
+ this.emit({ t: "undeliverable", env, peer: id, reason: "invocation safe delivery refusal; no further automatic retry" });
1669
+ return false;
1670
+ }
1571
1671
  if (quotaPaused) return true;
1572
1672
  const key = `${id}:${env.id}`;
1573
1673
  const n = (this.attempts.get(key) ?? 0) + 1;
1574
- if (n < MAX_ATTEMPTS) return this.attempts.set(key, n);
1674
+ if (n < maxAttempts) return this.attempts.set(key, n);
1575
1675
  this.attempts.delete(key);
1576
1676
  this.emit({ t: "undeliverable", env, peer: id, reason: this.lastFailure.get(id) });
1577
1677
  return false;
@@ -1584,7 +1684,7 @@ export class Bus {
1584
1684
 
1585
1685
  private emit(e: BusEvent): void {
1586
1686
  if (this.flushingHub) { this.deferredEvents.push(e); return; }
1587
- if ("env" in e && (remoteId(e.env.from) || e.env.to?.some(remoteId) || ("peer" in e && remoteId(e.peer)))) {
1687
+ if ("env" in e && (remoteEnvelope(e.env) || e.env.to?.some(remoteId) || ("peer" in e && remoteId(e.peer)))) {
1588
1688
  const { refs: _, ...env } = e.env;
1589
1689
  e = { ...e, env: { ...env, body: "[remote room message withheld]", ...(env.to ? { to: env.to.filter(id => /^[a-z][a-z0-9-]{0,31}$/.test(id) || !!parseRemotePeerId(id)) } : {}) }, ...("reason" in e ? { reason: "remote room delivery" } : {}) };
1590
1690
  }
@@ -0,0 +1,215 @@
1
+ import { createHash } from "node:crypto";
2
+ import { sanitize } from "./envelope.ts";
3
+
4
+ /**
5
+ * #445 C1: what an agent can offer another, and what a person has agreed to share. Pure: no I/O, no clock, no process. The hub never calls any of these capabilities itself (C2 sends a request to the
6
+ * owner's own session); this module only decides what is LISTED.
7
+ *
8
+ * Authority: nothing is shared unless the person wrote it, in `invocation.share` of `.agenthub/config.local.json`, keyed by the exact peer id and the capability id and pinned by the capability's digest.
9
+ * Everything that comes from an agent (names, descriptions, schemas, the publisher's side-effect declaration) is untrusted text and only ever narrows what is shown.
10
+ */
11
+
12
+ export const SIDE_EFFECTS = ["read", "write", "external", "destructive"] as const;
13
+ export type SideEffect = (typeof SIDE_EFFECTS)[number];
14
+ export const CAPABILITY_KINDS = ["command", "skill", "mcp_tool"] as const;
15
+ export const CAPABILITY_SOURCES = ["acp", "codex-skills", "published"] as const;
16
+ export const CAPABILITY_INVOKES = ["native", "prompt"] as const;
17
+
18
+ export const NAME_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
19
+ export const DESCRIPTION_MAX = 300;
20
+ /** A listing is bounded: no source can make the hub hold or show an unbounded manifest. */
21
+ export const MAX_CAPABILITIES = 128;
22
+ const MAX_SCHEMA_PROPERTIES = 16;
23
+ const MAX_SCHEMA_ENUM = 32;
24
+ const MAX_ENUM_VALUE = 64;
25
+ const MAX_PUBLISH_BYTES = 64 * 1024;
26
+
27
+ export interface FlatSchema {
28
+ type: "object";
29
+ properties: Record<string, { type: "string" | "number" | "boolean"; enum?: string[]; description?: string }>;
30
+ required?: string[];
31
+ additionalProperties: false;
32
+ }
33
+ export interface Capability {
34
+ id: string;
35
+ kind: (typeof CAPABILITY_KINDS)[number];
36
+ name: string;
37
+ description: string;
38
+ inputSchema?: FlatSchema;
39
+ /** The publisher's declared class; absent when its source names none (every native listing). */
40
+ sideEffect?: SideEffect;
41
+ source: (typeof CAPABILITY_SOURCES)[number];
42
+ invoke: (typeof CAPABILITY_INVOKES)[number];
43
+ digest: string;
44
+ }
45
+ export interface CapabilityManifest { capabilities: Capability[]; observedAt: number }
46
+
47
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
48
+ const CONTROL_CHARS = "[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069]";
49
+ /** Two regexes on purpose: the global one replaces, the plain one tests (a global regex keeps lastIndex between test calls and would miss the second of two strings). */
50
+ const CONTROL = new RegExp(CONTROL_CHARS, "g"), HAS_CONTROL = new RegExp(CONTROL_CHARS);
51
+
52
+ /** Canonical JSON: sorted keys at every depth, so equal values digest equally whatever order a source wrote them in. */
53
+ export function canonical(value: unknown): string {
54
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
55
+ if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
56
+ return JSON.stringify(value) ?? "null";
57
+ }
58
+
59
+ /**
60
+ * The digest pins what a person agreed to: the identity of the capability, what it takes, the declared class, where it came from and how it is run. The description is NOT in it: it is capped untrusted text
61
+ * a source may reword freely, so rewording must not unlist an entry, while a changed schema, class, source or invoke form must.
62
+ */
63
+ export function capabilityDigest(c: Pick<Capability, "kind" | "name" | "inputSchema" | "sideEffect" | "source" | "invoke">): string {
64
+ return `sha256:${createHash("sha256").update(canonical({ kind: c.kind, name: c.name, inputSchema: c.inputSchema ?? null, sideEffect: c.sideEffect ?? null, source: c.source, invoke: c.invoke })).digest("hex")}`;
65
+ }
66
+
67
+ /** Agent text shown to another model: control and bidirectional characters out, header forgery quoted, whitespace collapsed, cut by code point. */
68
+ export function cleanDescription(raw: unknown): string {
69
+ const text = typeof raw === "string" ? raw : "";
70
+ return Array.from(sanitize(text.replace(CONTROL, " ")).replace(/\s+/g, " ").trim()).slice(0, DESCRIPTION_MAX).join("");
71
+ }
72
+
73
+ /** A flat object of strings, numbers, booleans and string enums, nothing nested, nothing extra. Anything else is not a schema this hub will carry. */
74
+ export function flatSchema(raw: unknown): FlatSchema | undefined {
75
+ if (!object(raw) || raw.type !== "object" || raw.additionalProperties !== false || !object(raw.properties)) return undefined;
76
+ const names = Object.keys(raw.properties);
77
+ if (names.length === 0 || names.length > MAX_SCHEMA_PROPERTIES) return undefined;
78
+ const properties: FlatSchema["properties"] = {};
79
+ for (const name of names) {
80
+ const prop = raw.properties[name];
81
+ if (!NAME_RE.test(name) || !object(prop) || !["string", "number", "boolean"].includes(prop.type as string)) return undefined;
82
+ const extra = Object.keys(prop).filter(key => !["type", "enum", "description"].includes(key));
83
+ if (extra.length) return undefined;
84
+ const out: FlatSchema["properties"][string] = { type: prop.type as "string" | "number" | "boolean" };
85
+ if (prop.enum !== undefined) {
86
+ if (prop.type !== "string" || !Array.isArray(prop.enum) || prop.enum.length === 0 || prop.enum.length > MAX_SCHEMA_ENUM
87
+ || !prop.enum.every(value => typeof value === "string" && value.length >= 1 && value.length <= MAX_ENUM_VALUE && !HAS_CONTROL.test(value))) return undefined;
88
+ out.enum = [...prop.enum] as string[];
89
+ }
90
+ if (prop.description !== undefined) out.description = cleanDescription(prop.description);
91
+ properties[name] = out;
92
+ }
93
+ let required: string[] | undefined;
94
+ if (raw.required !== undefined) {
95
+ if (!Array.isArray(raw.required) || !raw.required.every(name => typeof name === "string" && Object.hasOwn(properties, name))) return undefined;
96
+ required = [...new Set(raw.required as string[])];
97
+ }
98
+ return { type: "object", properties, ...(required?.length ? { required } : {}), additionalProperties: false };
99
+ }
100
+
101
+ /** A class a side wrote: absent stays absent (it contributes nothing, O6), but a value that is not one of the four reads as the most dangerous one. */
102
+ export function declaredClass(raw: unknown): SideEffect | undefined {
103
+ if (raw === undefined) return undefined;
104
+ return typeof raw === "string" && (SIDE_EFFECTS as readonly string[]).includes(raw) ? raw as SideEffect : "destructive";
105
+ }
106
+ const RANK: Record<SideEffect, number> = { read: 0, write: 1, external: 2, destructive: 3 };
107
+ /** The higher of the publisher's declaration and the person's class (owner decision O1): neither side can make a capability look safer than the other said. */
108
+ export function effectiveClass(declared: SideEffect | undefined, person: unknown): SideEffect {
109
+ const chosen = declaredClass(person);
110
+ if (declared === undefined) return chosen ?? "destructive"; // O6: only when neither side names a class
111
+ if (chosen === undefined) return declared;
112
+ return RANK[chosen] >= RANK[declared] ? chosen : declared;
113
+ }
114
+
115
+ export interface Candidate { kind: unknown; name: unknown; description?: unknown; inputSchema?: unknown; sideEffect?: unknown; source: Capability["source"]; invoke: Capability["invoke"] }
116
+ /** One capability from one source, made safe, or undefined when it is not a capability this hub will carry. The id is derived, never taken from the source. */
117
+ export function buildCapability(candidate: Candidate): Capability | undefined {
118
+ if (!(CAPABILITY_KINDS as readonly unknown[]).includes(candidate.kind) || typeof candidate.name !== "string" || !NAME_RE.test(candidate.name)) return undefined;
119
+ let inputSchema: FlatSchema | undefined;
120
+ if (candidate.inputSchema !== undefined) { inputSchema = flatSchema(candidate.inputSchema); if (!inputSchema) return undefined; }
121
+ const kind = candidate.kind as Capability["kind"], sideEffect = declaredClass(candidate.sideEffect);
122
+ const base = { kind, name: candidate.name, ...(inputSchema ? { inputSchema } : {}), ...(sideEffect ? { sideEffect } : {}), source: candidate.source, invoke: candidate.invoke };
123
+ return { id: `${kind}:${candidate.name}`, ...base, description: cleanDescription(candidate.description), digest: capabilityDigest(base) };
124
+ }
125
+
126
+ /** A manifest from a source's raw list: invalid entries are dropped one by one, duplicates by id keep the first, and the whole is bounded. */
127
+ export function buildManifest(raw: readonly Candidate[], observedAt: number): CapabilityManifest {
128
+ const seen = new Set<string>(), capabilities: Capability[] = [];
129
+ for (const candidate of raw) {
130
+ const built = buildCapability(candidate);
131
+ if (!built || seen.has(built.id)) continue;
132
+ seen.add(built.id); capabilities.push(built);
133
+ if (capabilities.length >= MAX_CAPABILITIES) break;
134
+ }
135
+ return { capabilities, observedAt };
136
+ }
137
+
138
+ export class PublishRefusal extends Error {}
139
+ /**
140
+ * `hub_capabilities_publish`: the source for a session whose capabilities the hub cannot read natively. Every field is checked, an unknown field is a refusal, nothing is stored on a refusal, and the invoke
141
+ * form is forced to `prompt`: a publisher cannot claim a native path the hub never verified.
142
+ */
143
+ export function validatePublished(raw: unknown, observedAt: number): CapabilityManifest {
144
+ if (!object(raw) || Object.keys(raw).some(key => key !== "capabilities") || !Array.isArray(raw.capabilities)) throw new PublishRefusal("capabilities must be an array");
145
+ if (raw.capabilities.length === 0) throw new PublishRefusal("capabilities must not be empty (publish a list, or nothing)");
146
+ if (raw.capabilities.length > MAX_CAPABILITIES) throw new PublishRefusal(`at most ${MAX_CAPABILITIES} capabilities`);
147
+ if (JSON.stringify(raw.capabilities).length > MAX_PUBLISH_BYTES) throw new PublishRefusal("the list is too large");
148
+ const seen = new Set<string>(), candidates: Candidate[] = [];
149
+ raw.capabilities.forEach((entry, index) => {
150
+ const at = `capabilities[${index}]`;
151
+ if (!object(entry)) throw new PublishRefusal(`${at} must be an object`);
152
+ const extra = Object.keys(entry).filter(key => !["kind", "name", "description", "inputSchema", "sideEffect"].includes(key));
153
+ if (extra.length) throw new PublishRefusal(`${at}: unknown field ${JSON.stringify(extra[0])}`);
154
+ if (!(CAPABILITY_KINDS as readonly unknown[]).includes(entry.kind)) throw new PublishRefusal(`${at}.kind must be one of ${CAPABILITY_KINDS.join(", ")}`);
155
+ if (typeof entry.name !== "string" || !NAME_RE.test(entry.name)) throw new PublishRefusal(`${at}.name must match ${NAME_RE}`);
156
+ if (typeof entry.description !== "string") throw new PublishRefusal(`${at}.description must be text`);
157
+ if (entry.sideEffect === undefined || !(SIDE_EFFECTS as readonly unknown[]).includes(entry.sideEffect)) throw new PublishRefusal(`${at}.sideEffect must be one of ${SIDE_EFFECTS.join(", ")}`);
158
+ if (entry.inputSchema !== undefined && !flatSchema(entry.inputSchema)) throw new PublishRefusal(`${at}.inputSchema must be a flat object of string, number, boolean or string enum properties with additionalProperties false`);
159
+ const id = `${entry.kind}:${entry.name}`;
160
+ if (seen.has(id)) throw new PublishRefusal(`${at}: ${id} is listed twice`);
161
+ seen.add(id);
162
+ candidates.push({ kind: entry.kind, name: entry.name, description: entry.description, inputSchema: entry.inputSchema, sideEffect: entry.sideEffect, source: "published", invoke: "prompt" });
163
+ });
164
+ return buildManifest(candidates, observedAt);
165
+ }
166
+
167
+ export interface ShareEntry { digest: string; class?: SideEffect }
168
+ export type ShareConfig = Record<string, Record<string, ShareEntry>>;
169
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
170
+ /** `invocation.share` as written by a person: malformed entries are dropped and say nothing is shared for them; it is never an error that stops the hub. */
171
+ export function readShare(raw: unknown): ShareConfig {
172
+ const out: ShareConfig = {};
173
+ if (!object(raw)) return out;
174
+ for (const [peer, entries] of Object.entries(raw)) {
175
+ const address = /^[a-z][a-z0-9-]{0,31}(?:@[a-z][a-z0-9-]{0,31})?$/.exec(peer);
176
+ if (!object(entries) || address?.[0] !== peer || peer.includes("@") && ["hub", "digest"].includes(peer.split("@")[0]!)) continue;
177
+ const kept: Record<string, ShareEntry> = {};
178
+ for (const [id, entry] of Object.entries(entries)) {
179
+ if (!/^(command|skill|mcp_tool):[A-Za-z0-9_.:-]{1,64}$/.test(id) || !object(entry) || typeof entry.digest !== "string" || !DIGEST_RE.test(entry.digest)) continue;
180
+ const cls = entry.class === undefined ? undefined : (SIDE_EFFECTS as readonly unknown[]).includes(entry.class) ? entry.class as SideEffect : null;
181
+ if (cls === null || peer.includes("@") && cls !== "read") continue;
182
+ kept[id] = { digest: entry.digest, ...(cls ? { class: cls } : {}) };
183
+ }
184
+ if (Object.keys(kept).length) out[peer] = kept;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ export interface Listed { id: string; kind: Capability["kind"]; name: string; description: string; inputSchema?: FlatSchema; class: SideEffect; invoke: Capability["invoke"]; source: Capability["source"]; digest: string }
190
+ /**
191
+ * What `peer` offers: the capabilities of its manifest that the person listed BY THIS PEER ID and whose digest still matches. A digest change drops the entry until the person pins the new one, and a peer id
192
+ * never reads another peer's entries (an instance `codex-2` does not inherit `codex`). With no share, or no manifest, the answer is nothing.
193
+ */
194
+ export function listedCapabilities(peer: string, manifest: CapabilityManifest | undefined, share: ShareConfig): Listed[] {
195
+ const entries = Object.hasOwn(share, peer) ? share[peer] : undefined;
196
+ if (!manifest || !entries) return [];
197
+ const out: Listed[] = [];
198
+ for (const cap of manifest.capabilities) {
199
+ const entry = Object.hasOwn(entries, cap.id) ? entries[cap.id] : undefined;
200
+ if (!entry || entry.digest !== cap.digest) continue;
201
+ out.push({ id: cap.id, kind: cap.kind, name: cap.name, description: cap.description, ...(cap.inputSchema ? { inputSchema: cap.inputSchema } : {}), class: effectiveClass(cap.sideEffect, entry.class), invoke: cap.invoke, source: cap.source, digest: cap.digest });
202
+ }
203
+ return out;
204
+ }
205
+
206
+ export type ShareState = "listed" | "unshared" | "digest-changed";
207
+ /** For the person's `ahub capabilities`: every manifest entry, whether the person shares it, and the class that would apply. */
208
+ export function shareView(peer: string, manifest: CapabilityManifest | undefined, share: ShareConfig): Array<{ id: string; kind: Capability["kind"]; name: string; description: string; source: Capability["source"]; invoke: Capability["invoke"]; digest: string; state: ShareState; class: SideEffect }> {
209
+ const entries = Object.hasOwn(share, peer) ? share[peer] : undefined;
210
+ return (manifest?.capabilities ?? []).map(cap => {
211
+ const entry = entries && Object.hasOwn(entries, cap.id) ? entries[cap.id] : undefined;
212
+ const state: ShareState = !entry ? "unshared" : entry.digest === cap.digest ? "listed" : "digest-changed";
213
+ return { id: cap.id, kind: cap.kind, name: cap.name, description: cap.description, source: cap.source, invoke: cap.invoke, digest: cap.digest, state, class: effectiveClass(cap.sideEffect, entry?.class) };
214
+ });
215
+ }
@@ -194,7 +194,11 @@ const schema: Record<string, Check> = {
194
194
  conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
195
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) }),
196
196
  context: block({ gate: number(0, 1), stale_min: timerMinutes, max_assignment_used: number(0, 1) }),
197
- inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
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") }),
198
+ adapter_timing: block({ acp_tool_ceiling_min: operationalNumberCheck("adapter_timing.acp_tool_ceiling_min"), acp_activity_batch_ms: operationalNumberCheck("adapter_timing.acp_activity_batch_ms"), jcode_steer_timeout_s: operationalNumberCheck("adapter_timing.jcode_steer_timeout_s") }),
199
+ model_relay: block({ cooldown_failures: operationalNumberCheck("model_relay.cooldown_failures"), cooldown_s: operationalNumberCheck("model_relay.cooldown_s"), cooldown_cap_s: operationalNumberCheck("model_relay.cooldown_cap_s"), terminal_history_limit: operationalNumberCheck("model_relay.terminal_history_limit") }),
200
+ console: block({ poll_ms: operationalNumberCheck("console.poll_ms"), notice_ms: operationalNumberCheck("console.notice_ms"), history_limit: operationalNumberCheck("console.history_limit"), pending_stream_limit: operationalNumberCheck("console.pending_stream_limit"), attention_limit: operationalNumberCheck("console.attention_limit") }),
201
+ inference: block({ enabled: bool, digest_min_items: operationalNumberCheck("inference.digest_min_items"), digest_min_chars: operationalNumberCheck("inference.digest_min_chars"), triage: bool, backoff_min: operationalNumberCheck("inference.backoff_min") }),
198
202
  omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
199
203
  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,
200
204
  contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
@@ -213,8 +217,8 @@ const schema: Record<string, Check> = {
213
217
  peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
214
218
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
215
219
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
216
- 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
- capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
220
+ 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") }),
221
+ capabilities: map(strings), invocation: block({ share: map(map(() => {})), timeout_s: number(10, 86_400, true) }), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
218
222
  };
219
223
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
220
224
  export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog, inertModuleRoster = false): asserts raw is Record<string, unknown> {
@@ -255,7 +259,7 @@ const restart: Record<string, string> = {
255
259
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
256
260
  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",
257
261
  };
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"]);
262
+ const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet"]);
259
263
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
260
264
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
261
265
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
@@ -33,6 +33,7 @@ export const MACHINE_LOCAL = [
33
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
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
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
36
+ "invocation", // #445 C1: what another agent may be offered from this machine's agents (a digest-pinned allowlist); a committed config may not share a person's agents' commands with someone else's peers
36
37
  ] as const;
37
38
  /** Legacy exported field list stays compatible; new policies use the complete list below. */
38
39
  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;