@staix/agent-hub 0.22.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 (86) hide show
  1. package/CHANGELOG.md +12 -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 +4 -1
  6. package/docs/agent-notes/daemon.md +3 -2
  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 +1 -0
  10. package/docs/agent-notes/tests.md +1 -1
  11. package/docs/dogfood.md +7 -1
  12. package/docs/operations.md +147 -6
  13. package/docs/profiles.md +28 -3
  14. package/docs/quickstart.md +5 -1
  15. package/docs/security.md +7 -1
  16. package/docs/specs/2026-09-19-agent-hub-design.md +66 -4
  17. package/docs/verification/2026-10-30-0.23.0.md +70 -0
  18. package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
  19. package/docs/verification/2026-10-30-model-operational-settings.md +211 -0
  20. package/docs/verification/2026-10-30-operational-settings-closure.md +1217 -0
  21. package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
  22. package/docs/verified.json +15 -15
  23. package/package.json +1 -1
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/ROLE-NOTICE.md +1 -1
  26. package/plugins/agent-hub/server.js +559 -115
  27. package/src/adapters/acp.ts +48 -2
  28. package/src/adapters/claude-channel.ts +6 -4
  29. package/src/adapters/codex-appserver.ts +19 -0
  30. package/src/adapters/jcode.ts +1 -1
  31. package/src/adapters/local-worker.ts +3 -3
  32. package/src/adapters/native-capabilities.ts +50 -0
  33. package/src/adapters/registry-types.ts +3 -10
  34. package/src/cli/capabilities-output.ts +30 -0
  35. package/src/cli/console-state.ts +8 -5
  36. package/src/cli/console.ts +27 -4
  37. package/src/cli/help.ts +2 -1
  38. package/src/cli/identity.ts +1 -0
  39. package/src/cli/main.ts +8 -0
  40. package/src/hub/board.ts +8 -2
  41. package/src/hub/bus.ts +100 -8
  42. package/src/hub/capabilities.ts +215 -0
  43. package/src/hub/config-apply.ts +6 -3
  44. package/src/hub/config-trust.ts +1 -0
  45. package/src/hub/control-client.ts +41 -8
  46. package/src/hub/daemon.ts +376 -20
  47. package/src/hub/deferred-hub.ts +2 -2
  48. package/src/hub/delivery-hints.ts +2 -2
  49. package/src/hub/delivery-journal.ts +2 -4
  50. package/src/hub/envelope.ts +12 -2
  51. package/src/hub/events.ts +2 -0
  52. package/src/hub/hub-tools.ts +16 -1
  53. package/src/hub/inference.ts +10 -5
  54. package/src/hub/invocations.ts +316 -0
  55. package/src/hub/operational-settings.ts +102 -1
  56. package/src/hub/peers.ts +3 -0
  57. package/src/hub/restart.ts +2 -2
  58. package/src/hub/room-invocations.ts +145 -0
  59. package/src/hub/settings.ts +1 -1
  60. package/src/hub/shipped-packs.ts +5 -2
  61. package/src/hub/shipped-roles.ts +13 -0
  62. package/src/hub/tasks.ts +14 -0
  63. package/src/memory/capture.ts +2 -2
  64. package/src/models/relay.ts +20 -9
  65. package/src/rooms/application.ts +67 -2
  66. package/src/rooms/boundary.ts +43 -11
  67. package/src/rooms/service.ts +40 -2
  68. package/templates/checks/analysis.mjs +59 -0
  69. package/templates/checks/fixtures/analysis-expected.json +1 -0
  70. package/templates/checks/fixtures/analysis-input.json +1 -0
  71. package/templates/checks/fixtures/analysis.mjs +7 -0
  72. package/templates/checks/fixtures/ops.json +4 -0
  73. package/templates/checks/fixtures/writing-rules.json +4 -0
  74. package/templates/checks/fixtures/writing.json +5 -0
  75. package/templates/checks/ops.mjs +40 -0
  76. package/templates/checks/writing.mjs +40 -0
  77. package/templates/packs/analysis.pack.json +14 -0
  78. package/templates/packs/ops.pack.json +14 -0
  79. package/templates/packs/writing-pack.pack.json +14 -0
  80. package/templates/roles/NOTICE.md +1 -1
  81. package/templates/roles/ops/meeting-actions-owner.md +26 -0
  82. package/templates/roles/ops/project-manager.md +26 -0
  83. package/templates/roles/writing/editor.md +26 -0
  84. package/templates/roles/writing/style-checker.md +26 -0
  85. package/templates/roles/writing/terminology-checker.md +26 -0
  86. 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";
@@ -182,6 +182,41 @@ export class Bus {
182
182
  * and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
183
183
  */
184
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
+
185
220
  /** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
186
221
  onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
187
222
  /** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
@@ -213,6 +248,7 @@ export class Bus {
213
248
  if (state.revision > 0 && bus && bus.schemaVersion === 1) this.loadSnapshot(bus);
214
249
  for (const id of state.manualPaused) { this.manualPaused.add(id); this.paused.add(id); }
215
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);
216
252
  if (row.state === "needs_review" || row.state === "dispatching" || row.state === "accepted") this.recoveryHeldPeers.add(row.peer);
217
253
  }
218
254
  }
@@ -248,6 +284,17 @@ export class Bus {
248
284
  this.opts.doing?.(peer.id, "reset"); // a new adapter never shows what the one it replaces was doing
249
285
  peer.onMessage = (text, opts) => {
250
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
+ }
251
298
  const { priority, body } = parseMarker(text);
252
299
  if (!body) return;
253
300
  // What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
@@ -316,6 +363,9 @@ export class Bus {
316
363
  this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence" });
317
364
  }
318
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);
319
369
  } else this.failed(peer.id, same ? last.originals : envs, undefined, failure?.quota === true);
320
370
  };
321
371
  peer.onState = () => {
@@ -495,6 +545,7 @@ export class Bus {
495
545
  }
496
546
  }
497
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);
498
549
  this.persist();
499
550
  }
500
551
 
@@ -562,6 +613,7 @@ export class Bus {
562
613
  if (this.closed || this.recoveryHeld || this.storageError || peer === "user" || peer === HUB) return refuse();
563
614
  const row = this.queueShow(id);
564
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();
565
617
  this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
566
618
  }
567
619
 
@@ -676,6 +728,7 @@ export class Bus {
676
728
  const last = this.lastDelivery.get(peer);
677
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);
678
730
  this.refreshRecoveryHold(peer);
731
+ for (const env of before?.originals ?? []) if (isInvocationKind(env)) this.onInvocationReceipt?.(env.id, "failed_safe");
679
732
  this.onQueues?.();
680
733
  void this.drain(peer);
681
734
  return;
@@ -694,7 +747,11 @@ export class Bus {
694
747
  if (state === "completed") { this.failureStreak.delete(peer); this.lastFailure.delete(peer); }
695
748
  if (state === "completed" || state === "failed" || state === "needs_review") this.activeDeliveries.delete(id);
696
749
  if (state === "completed" || state === "failed") { this.refreshRecoveryHold(peer); void this.drain(peer); }
697
- 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
+ }
698
755
  this.onQueues?.();
699
756
  }
700
757
 
@@ -1021,7 +1078,7 @@ export class Bus {
1021
1078
  try { for (const id of targets) {
1022
1079
  const peer = this.peers.get(id);
1023
1080
  if (!peer) { this.enqueue(id, env); continue; }
1024
- 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)) {
1025
1082
  // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
1026
1083
  this.steering++;
1027
1084
  const deliveryId = crypto.randomUUID();
@@ -1041,7 +1098,7 @@ export class Bus {
1041
1098
  if (!this.flushingHub) this.onQueues?.();
1042
1099
  for (const id of targets) {
1043
1100
  const peer = this.peers.get(id);
1044
- 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
1045
1102
  else void this.drain(id);
1046
1103
  }
1047
1104
  }
@@ -1085,6 +1142,20 @@ export class Bus {
1085
1142
  return this.seen.get(id) ?? this.deferredHub.get(id);
1086
1143
  }
1087
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
+
1088
1159
  /** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
1089
1160
  lastDeliveredTo(peer: PeerId): Envelope[] {
1090
1161
  return this.lastDelivery.get(peer)?.out ?? [];
@@ -1310,6 +1381,8 @@ export class Bus {
1310
1381
  const peer = this.peers.get(id);
1311
1382
  const queue = this.queues.get(id);
1312
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; }
1313
1386
  const index = queue.findIndex((item) => item.id === env.id);
1314
1387
  if (index < 0) return;
1315
1388
  this.steering++;
@@ -1331,6 +1404,9 @@ export class Bus {
1331
1404
  }
1332
1405
 
1333
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
+
1334
1410
  private held(id: PeerId): boolean {
1335
1411
  return !this.isRostered(id) || this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
1336
1412
  }
@@ -1450,6 +1526,7 @@ export class Bus {
1450
1526
  this.activeDeliveries.set(deliveryId, id);
1451
1527
  } else this.remember(out);
1452
1528
  this.lastDelivery.set(id, { out, originals: delivery });
1529
+ for (const env of delivery) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, deliveryId);
1453
1530
  if (remoteId(id)) this.remoteTransport.set(deliveryId, { peer: id, originals: delivery });
1454
1531
  this.preparing.delete(id); // from here on the transport/journal owns settlement
1455
1532
  try {
@@ -1463,7 +1540,10 @@ export class Bus {
1463
1540
  else { await peer.deliver(out); this.delivered(id, delivery); }
1464
1541
  } catch {
1465
1542
  if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
1466
- 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);
1467
1547
  break;
1468
1548
  } finally { this.remoteTransport.delete(deliveryId); }
1469
1549
  }
@@ -1516,8 +1596,10 @@ export class Bus {
1516
1596
  for (const env of ordered) {
1517
1597
  const failedBefore = this.attempts.has(`${id}:${env.id}`);
1518
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;
1519
1601
  batch.push(env);
1520
- if (failedBefore || remoteId(id) || batch.length === digestMax) break;
1602
+ if (failedBefore || remoteId(id) || batch.length === digestMax || isInvocationKind(env)) break;
1521
1603
  }
1522
1604
  if (remove) for (const env of batch) queue.splice(queue.indexOf(env), 1);
1523
1605
  return batch;
@@ -1537,8 +1619,9 @@ export class Bus {
1537
1619
  if (failedBefore && batch.length) break;
1538
1620
  const size = Buffer.byteLength(frame(env)) + 2;
1539
1621
  if (batch.length && bytes + size > pullBudgetBytes) break;
1622
+ if (isInvocationKind(env) && batch.length) break; // #445 C2: alone, as in `take`
1540
1623
  batch.push(env); bytes += size;
1541
- if (failedBefore) break;
1624
+ if (failedBefore || isInvocationKind(env)) break;
1542
1625
  }
1543
1626
  for (const env of batch) queue.splice(queue.indexOf(env), 1);
1544
1627
  return batch;
@@ -1576,6 +1659,15 @@ export class Bus {
1576
1659
  this.restorePreface(id, env); // rides again on the next delivery, without counting as a failed envelope
1577
1660
  return false;
1578
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
+ }
1579
1671
  if (quotaPaused) return true;
1580
1672
  const key = `${id}:${env.id}`;
1581
1673
  const n = (this.attempts.get(key) ?? 0) + 1;
@@ -1592,7 +1684,7 @@ export class Bus {
1592
1684
 
1593
1685
  private emit(e: BusEvent): void {
1594
1686
  if (this.flushingHub) { this.deferredEvents.push(e); return; }
1595
- 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)))) {
1596
1688
  const { refs: _, ...env } = e.env;
1597
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" } : {}) };
1598
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
+ }
@@ -195,7 +195,10 @@ const schema: Record<string, Check> = {
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
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
- inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
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") }),
199
202
  omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
200
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,
201
204
  contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
@@ -215,7 +218,7 @@ const schema: Record<string, Check> = {
215
218
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
216
219
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
217
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") }),
218
- capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
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"]) }),
219
222
  };
220
223
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
221
224
  export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog, inertModuleRoster = false): asserts raw is Record<string, unknown> {
@@ -256,7 +259,7 @@ const restart: Record<string, string> = {
256
259
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
257
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",
258
261
  };
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"]);
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"]);
260
263
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
261
264
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
262
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;
@@ -9,7 +9,7 @@ export function stateDirFor(cwd: string): string {
9
9
 
10
10
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
11
11
  export const PROTOCOL = 16;
12
- export const DAEMON_FEATURES = ["conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
12
+ export const DAEMON_FEATURES = ["conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
13
13
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
14
14
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
15
15
 
@@ -86,15 +86,25 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
86
86
 
87
87
  const instancePins = new Map<string, { instanceId: string }>();
88
88
 
89
+ /** Maximum supported 3600 s approval plus admission/transport margin; applies to every hub_invoke client path. */
90
+ export const INVOCATION_CONTROL_TIMEOUT_MS = 3_665_000;
91
+ export function controlRequestTimeout(msg: Record<string, unknown>, requested = 30_000): number {
92
+ return msg.t === "task" && msg.op === "hub_invoke" && requested !== 0
93
+ ? Math.max(Number.isFinite(requested) ? requested : 0, INVOCATION_CONTROL_TIMEOUT_MS) : requested;
94
+ }
95
+
96
+
89
97
  /** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
90
98
  export class ControlClient {
91
99
  features: readonly string[] = [];
92
100
  private welcomeInstanceId?: string;
93
101
  /** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
94
102
  get instanceId(): string | undefined { return this.welcomeInstanceId; }
95
- get isOpen(): boolean { return this.ws.readyState === WebSocket.OPEN; }
103
+ get isOpen(): boolean { return !this.closing && this.ws.readyState === WebSocket.OPEN; }
96
104
  private nextRid = 1;
97
105
  private readonly pending = new Map<number, (msg: any) => void>();
106
+ private readonly invocations = new Set<number>();
107
+ private closing = false;
98
108
  onPush: (msg: any) => void = () => {};
99
109
  /** `code`/`reason`: what the hub closed with, e.g. 4000 when another client attached as the same peer. */
100
110
  onClose: (code: number, reason: string) => void = () => {};
@@ -179,13 +189,32 @@ export class ControlClient {
179
189
  }
180
190
 
181
191
  /** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
182
- request(msg: Record<string, unknown>, timeoutMs = 30_000): Promise<any> {
183
- if (this.ws.readyState !== WebSocket.OPEN) return Promise.resolve({ ok: false, error: "hub connection is not open" });
192
+ request(msg: Record<string, unknown>, timeoutMs = 30_000, signal?: AbortSignal): Promise<any> {
193
+ if (this.ws.readyState !== WebSocket.OPEN || this.closing && msg.t !== "request_cancel") return Promise.resolve({ ok: false, error: "hub connection is not open" });
194
+ const invocation = msg.t === "task" && msg.op === "hub_invoke";
195
+ timeoutMs = controlRequestTimeout(msg, timeoutMs);
196
+ if (signal?.aborted) return Promise.resolve({ ok: false, error: "hub request cancelled before sending" });
184
197
  const rid = this.nextRid++;
185
198
  return new Promise((resolve) => {
186
- const timer = timeoutMs ? setTimeout(() => (this.pending.delete(rid), resolve({ ok: false, error: `no answer from the hub within ${Math.round(timeoutMs / 1000)} s` })), timeoutMs) : undefined;
187
- this.pending.set(rid, (reply) => (clearTimeout(timer), resolve(reply)));
188
- this.ws.send(JSON.stringify({ ...msg, rid }));
199
+ let timer: ReturnType<typeof setTimeout> | undefined;
200
+ const done = (reply: any) => {
201
+ this.pending.delete(rid); this.invocations.delete(rid); clearTimeout(timer); signal?.removeEventListener("abort", aborted); resolve(reply);
202
+ };
203
+ const abandon = (error: string) => {
204
+ if (!this.pending.has(rid)) return;
205
+ if (invocation && this.ws.readyState === WebSocket.OPEN) {
206
+ try { this.ws.send(JSON.stringify({ t: "request_cancel", request: rid })); } catch { /* Socket close also withdraws admission. */ }
207
+ }
208
+ done({ ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
209
+ };
210
+ const aborted = () => abandon("hub request cancelled");
211
+ this.pending.set(rid, done);
212
+ if (invocation) this.invocations.add(rid);
213
+ signal?.addEventListener("abort", aborted, { once: true });
214
+ if (signal?.aborted) { aborted(); return; }
215
+ timer = timeoutMs ? setTimeout(() => abandon(`no answer from the hub within ${Math.round(timeoutMs / 1000)} s`), timeoutMs) : undefined;
216
+ try { this.ws.send(JSON.stringify({ ...msg, rid })); }
217
+ catch { abandon("hub request could not be sent"); }
189
218
  });
190
219
  }
191
220
 
@@ -194,6 +223,10 @@ export class ControlClient {
194
223
  }
195
224
 
196
225
  close(): void {
197
- this.ws.close();
226
+ if (this.closing) return;
227
+ this.closing = true;
228
+ if (!this.invocations.size || this.ws.readyState !== WebSocket.OPEN) { this.ws.close(); return; }
229
+ // Withdraw admissions before reporting a graceful close to callers. An abrupt transport loss is fenced by the daemon close handler.
230
+ void Promise.allSettled([...this.invocations].map(request => this.request({ t: "request_cancel", request }, 3000))).finally(() => this.ws.close());
198
231
  }
199
232
  }