@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
@@ -1,4 +1,4 @@
1
- import { HUB, MAX_HOP, type Envelope } from "./envelope.ts";
1
+ import { HUB, KINDS, MAX_HOP, type Envelope } from "./envelope.ts";
2
2
 
3
3
  /** ponytail: cap recovery at 2048 unpublished hub originals; replace with a paged durable outbox if measured recovery workloads exceed it. */
4
4
  export const MAX_DEFERRED_HUB = 2048;
@@ -22,7 +22,7 @@ export function validateDeferredEnvelope(value: unknown, name: string): Envelope
22
22
  typeof env.body !== "string" || env.body.length > 1_000_000 ||
23
23
  !Number.isSafeInteger(env.hop) || (env.hop as number) < 0 || (env.hop as number) > MAX_HOP ||
24
24
  !Number.isSafeInteger(env.ts) || (env.ts as number) < 0 ||
25
- !["chat", "task", "review", "status", "budget", "presence"].includes(env.kind as string) ||
25
+ !(KINDS as readonly string[]).includes(env.kind as string) ||
26
26
  !["important", "status", "fyi"].includes(env.priority as string) ||
27
27
  (!Array.isArray(env.to) || env.to.length > 256 || !env.to.every(peer => text(peer, 256))) ||
28
28
  (env.private !== undefined && typeof env.private !== "boolean"))
@@ -1,4 +1,4 @@
1
- import { sanitize, type Kind } from "./envelope.ts";
1
+ import { KINDS, sanitize, type Kind } from "./envelope.ts";
2
2
 
3
3
  export const DELIVERY_RETRY_WARNING = sanitize("Hub redelivery warning: An earlier attempt of this delivery is uncertain; check for its effects before acting.");
4
4
  export interface HeldDeliveryHints {
@@ -18,7 +18,7 @@ export function heldDeliveryHints(rows: readonly HeldDeliveryHints["rows"][numbe
18
18
  export function renderHeldHint(value: unknown, now = Date.now(), recipientRetry = true): string {
19
19
  if (!value || typeof value !== "object" || Array.isArray(value)) return "";
20
20
  const held = value as HeldDeliveryHints;
21
- const kinds = new Set(["chat", "task", "review", "status", "budget", "presence"]);
21
+ const kinds = new Set<string>(KINDS);
22
22
  try {
23
23
  if (!Number.isSafeInteger(held.count) || held.count < 1 || !Array.isArray(held.rows) || held.rows.length > 32 || held.rows.length > held.count || typeof held.truncated !== "boolean" || Buffer.byteLength(JSON.stringify(held), "utf8") > 4096) return "";
24
24
  if (held.rows.some(row => !row || typeof row !== "object" || typeof row.id !== "string" || !row.id.length || row.id.length > 256 || !Number.isFinite(row.createdAt) || !Array.isArray(row.senders) || !Array.isArray(row.kinds) || row.senders.some(sender => typeof sender !== "string" || sender.length > 256) || row.kinds.some(kind => !kinds.has(kind)))) return "";
@@ -3,7 +3,7 @@ import { dirname } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
5
  import type { QueuePressure } from "./queue-backpressure.ts";
6
- import { HUB, USER, type Envelope, type Kind } from "./envelope.ts";
6
+ import { HUB, KINDS, USER, type Envelope, type Kind } from "./envelope.ts";
7
7
  import { heldDeliveryHints } from "./delivery-hints.ts";
8
8
  import { processLiveness } from "../pi/process-signature.ts";
9
9
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
@@ -20,8 +20,8 @@ export type JournalDeliveryState =
20
20
  | "discarded";
21
21
  export type JournalResolution = "completed" | "retry" | "discard";
22
22
  export const MAX_ENVELOPE_BODY = 1_000_000;
23
+ export const TERMINAL_CAP = 2048;
23
24
  const SCHEMA_VERSION = 1,
24
- TERMINAL_CAP = 2048,
25
25
  MAX_ROWS = 100_000,
26
26
  MAX_JSON = 20 * 1024 * 1024;
27
27
  const STATES = new Set<JournalDeliveryState>([
@@ -56,6 +56,8 @@ export interface DeliveryJournalOptions {
56
56
  projectId: string;
57
57
  instanceId: string;
58
58
  operationId?: string;
59
+ /** Accepted live history target; only terminal mutations apply it, other pruning keeps the legacy cap. */
60
+ terminalHistoryLimit?: () => number;
59
61
  }
60
62
  export interface JournalSnapshot {
61
63
  schemaVersion: number;
@@ -118,9 +120,7 @@ function validateEnvelope(value: unknown, name: string, seen = false): Envelope
118
120
  if (typeof e.body !== "string" || e.body.length > MAX_ENVELOPE_BODY) fail(`${name}.body is invalid`);
119
121
  integer(e.ts, `${name}.ts`);
120
122
  if (
121
- !new Set(["chat", "task", "review", "status", "budget", "presence"]).has(
122
- e.kind as string,
123
- )
123
+ !(KINDS as readonly string[]).includes(e.kind as string)
124
124
  )
125
125
  fail(`${name}.kind is invalid`);
126
126
  if (!new Set(["important", "status", "fyi"]).has(e.priority as string))
@@ -271,13 +271,16 @@ export class DeliveryJournal {
271
271
  private closed = false;
272
272
  private queueMetadataTracking = false;
273
273
  private queueMetadataInvalidated = false;
274
+ private pruningDeferred = 0;
274
275
  private readonly operationId?: string;
276
+ private readonly terminalHistoryLimit?: () => number;
275
277
  constructor(options: DeliveryJournalOptions) {
276
278
  this.file = options.file;
277
279
  this.projectRoot = text(options.projectRoot, "projectRoot", 4096);
278
280
  this.projectId = text(options.projectId, "projectId", 256);
279
281
  this.instanceId = text(options.instanceId, "instanceId", 256);
280
282
  this.operationId = options.operationId;
283
+ this.terminalHistoryLimit = options.terminalHistoryLimit;
281
284
  mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
282
285
  this.db = new Database(options.file, { create: true });
283
286
  this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
@@ -704,53 +707,56 @@ export class DeliveryJournal {
704
707
  updatedAt: delivery.createdAt,
705
708
  });
706
709
  return this.queueMetadataTransaction(trackQueuePressure, () => {
707
- const evidence = this.retryEvidence(delivery.peer, delivery.originals);
708
- if (evidence === "used") {
709
- // Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
710
- this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
711
- delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
712
- } else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
713
- const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
714
- for (const row of this.list(delivery.peer)) {
715
- if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
716
- this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
717
- if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
718
- delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
719
- if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
710
+ // Grouped parents are not linked until the handoff INSERT.
711
+ return this.withPruningDeferred(() => {
712
+ const evidence = this.retryEvidence(delivery.peer, delivery.originals);
713
+ if (evidence === "used") {
714
+ // Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
715
+ this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
716
+ delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
717
+ } else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
718
+ const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
719
+ for (const row of this.list(delivery.peer)) {
720
+ if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
721
+ this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
722
+ if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
723
+ delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
724
+ if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
725
+ }
720
726
  }
721
- }
722
- const revision = this.bump();
723
- const now = Date.now();
724
- this.db
725
- .query(
726
- "UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
727
- )
728
- .run(
729
- JSON.stringify(bus),
730
- JSON.stringify(bus.manualPaused ?? []),
731
- this.projectId,
732
- );
733
- this.writeQueueMetadata(bus, revision, trackQueuePressure);
734
- this.db
735
- .query(
736
- "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
737
- )
738
- .run(
739
- delivery.id,
740
- this.projectId,
741
- delivery.peer,
742
- delivery.state,
743
- revision,
744
- delivery.createdAt,
745
- now,
746
- JSON.stringify(delivery.originals),
747
- JSON.stringify(delivery.out),
748
- delivery.reason ?? null,
749
- delivery.attempt ?? null,
750
- delivery.previousId ?? null,
751
- );
752
- this.harden();
753
- return { ...delivery, revision, updatedAt: now };
727
+ const revision = this.bump();
728
+ const now = Date.now();
729
+ this.db
730
+ .query(
731
+ "UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
732
+ )
733
+ .run(
734
+ JSON.stringify(bus),
735
+ JSON.stringify(bus.manualPaused ?? []),
736
+ this.projectId,
737
+ );
738
+ this.writeQueueMetadata(bus, revision, trackQueuePressure);
739
+ this.db
740
+ .query(
741
+ "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
742
+ )
743
+ .run(
744
+ delivery.id,
745
+ this.projectId,
746
+ delivery.peer,
747
+ delivery.state,
748
+ revision,
749
+ delivery.createdAt,
750
+ now,
751
+ JSON.stringify(delivery.originals),
752
+ JSON.stringify(delivery.out),
753
+ delivery.reason ?? null,
754
+ delivery.attempt ?? null,
755
+ delivery.previousId ?? null,
756
+ );
757
+ this.harden();
758
+ return { ...delivery, revision, updatedAt: now };
759
+ });
754
760
  });
755
761
  }
756
762
  createDelivery(
@@ -785,7 +791,7 @@ export class DeliveryJournal {
785
791
  input.attempt ?? null,
786
792
  input.previousId ?? null,
787
793
  );
788
- this.pruneTerminal();
794
+ this.pruneTerminal(input.state, input.id);
789
795
  return { ...input, revision, updatedAt };
790
796
  })();
791
797
  }
@@ -802,9 +808,14 @@ export class DeliveryJournal {
802
808
  return this.db.transaction((): JournalDelivery => {
803
809
  const row = this.get(deliveryId);
804
810
  if (!row || row.peer !== peer || !["dispatching", "accepted"].includes(row.state)) throw new Error("delivery is not active for this peer");
805
- const completed = this.transition(deliveryId, "completed", reason, row.revision);
806
- const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
807
- for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
811
+ const completed = this.withPruningDeferred(() => {
812
+ const result = this.transition(deliveryId, "completed", reason, row.revision);
813
+ const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
814
+ for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
815
+ return result;
816
+ });
817
+ // Proof is now durable in this transaction, and the return receipt is captured.
818
+ this.pruneTerminal("completed", "");
808
819
  return completed;
809
820
  })();
810
821
  }
@@ -834,7 +845,7 @@ export class DeliveryJournal {
834
845
  "UPDATE deliveries SET state = ?, revision = ?, updated_at = ?, reason = ? WHERE id = ? AND project_id = ?",
835
846
  )
836
847
  .run(state, revision, Date.now(), reason ?? null, id, this.projectId);
837
- this.pruneTerminal();
848
+ this.pruneTerminal(state, id);
838
849
  return this.get(id)!;
839
850
  })();
840
851
  }
@@ -1023,7 +1034,7 @@ export class DeliveryJournal {
1023
1034
  JSON.stringify(retryOriginals(current.originals).map(env => env.id)),
1024
1035
  );
1025
1036
  if (action === "retry") this.recordRetryEvidence(current.peer, current.originals, id, actor === `peer:${current.peer}`);
1026
- this.pruneTerminal();
1037
+ this.pruneTerminal(result.state, result.id);
1027
1038
  return result;
1028
1039
  })();
1029
1040
  }
@@ -1031,7 +1042,15 @@ export class DeliveryJournal {
1031
1042
  const row = this.db.query("SELECT action,reason,observed_revision,actor FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number; actor: string } | null;
1032
1043
  return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision, actor: row.actor } : undefined;
1033
1044
  }
1034
- private pruneTerminal(): void {
1045
+ private withPruningDeferred<T>(body: () => T): T {
1046
+ this.pruningDeferred++;
1047
+ try { return body(); } finally { this.pruningDeferred--; }
1048
+ }
1049
+ private pruneTerminal(state: JournalDeliveryState, currentId: string): void {
1050
+ if (this.pruningDeferred) return;
1051
+ const terminal = state === "completed" || state === "discarded" || state === "failed";
1052
+ const terminalCap = terminal ? this.terminalHistoryLimit?.() ?? TERMINAL_CAP : TERMINAL_CAP;
1053
+ // Never delete the receipt this mutation/resolve is still using, at any target.
1035
1054
  const count = Number(
1036
1055
  (
1037
1056
  this.db
@@ -1041,12 +1060,12 @@ export class DeliveryJournal {
1041
1060
  .get(this.projectId) as { n: number }
1042
1061
  ).n,
1043
1062
  );
1044
- if (count <= TERMINAL_CAP) return;
1063
+ if (count <= terminalCap) return;
1045
1064
  this.db
1046
1065
  .query(
1047
- "DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
1066
+ "DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND d.id != ? AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
1048
1067
  )
1049
- .run(this.projectId, count - TERMINAL_CAP);
1068
+ .run(this.projectId, currentId, count - terminalCap);
1050
1069
  }
1051
1070
  importSnapshot(snapshot: JournalSnapshot, operationId: string, trackQueuePressure = false): boolean {
1052
1071
  this.ensureOpen();
@@ -3,7 +3,9 @@ import { randomUUID } from "node:crypto";
3
3
  export type PeerId = string;
4
4
  export type PeerState = "idle" | "busy" | "paused" | "offline";
5
5
  export type Priority = "important" | "status" | "fyi";
6
- export type Kind = "chat" | "task" | "review" | "status" | "budget" | "presence";
6
+ /** Every envelope kind, once: the journal, the restart reader, the deferred-notice check and the held-delivery hints all read this list. `invocation` and `invocation_result` are #445 C2. */
7
+ export const KINDS = ["chat", "task", "review", "status", "budget", "presence", "invocation", "invocation_result"] as const;
8
+ export type Kind = (typeof KINDS)[number];
7
9
 
8
10
  export interface Envelope {
9
11
  id: string;
@@ -14,7 +16,7 @@ export interface Envelope {
14
16
  kind: Kind;
15
17
  priority: Priority;
16
18
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
19
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[]; invocation?: string; invocationRemote?: true };
18
20
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
21
  private?: boolean;
20
22
  ts: number;
@@ -28,6 +30,11 @@ export const HUB: PeerId = "hub";
28
30
  /** A hub-written condensation of several status messages. Not a peer: a reply to it is for the senders it replaced. */
29
31
  export const DIGEST: PeerId = "digest";
30
32
 
33
+ /** Host-marked room provenance survives local invocation rewriting; it grants no authority. */
34
+ export function remoteEnvelope(env: Pick<Envelope, "from" | "kind" | "refs">): boolean {
35
+ return env.from.includes("@") || env.from === HUB && (env.kind === "invocation" || env.kind === "invocation_result") && env.refs?.invocationRemote === true;
36
+ }
37
+
31
38
  export interface EnvelopeOpts {
32
39
  to?: PeerId[];
33
40
  kind?: Kind;
@@ -104,6 +111,9 @@ export function renderDigest(envs: Envelope[], primed: boolean): string {
104
111
  return primed ? items : `${STANDING_INSTRUCTION}\n\n${items}`;
105
112
  }
106
113
 
114
+ /** #445 C2: an invocation request or its result. Each is delivered alone and never condensed, so a turn's reply maps to exactly one invocation. */
115
+ export const isInvocationKind = (env: Pick<Envelope, "kind">): boolean => env.kind === "invocation" || env.kind === "invocation_result";
116
+
107
117
  /** Who a reply to a delivery is for: everyone whose message it answers, each once, in the order they arrived. */
108
118
  export function replyAudience(envs: Envelope[]): PeerId[] {
109
119
  return [...new Set(envs.filter((e) => !(e.from === HUB && e.kind === "presence")).map((e) => e.from))];
package/src/hub/events.ts CHANGED
@@ -20,6 +20,8 @@ export type HubEvent =
20
20
  | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal" | "conductor"; peer?: string; undo?: boolean; reason?: string }
21
21
  /** A structural config edit (#689): the file, key paths with short hashes of their values before and after (never a value), the file revisions and the person who made it. */
22
22
  | { type: "config_edit"; file: "config.local.json" | "sdlc.toml"; paths: { path: string; before: string | null; after: string | null }[]; fileBefore: string; fileAfter: string; tier: 2 | 3; by: "person"; source: "dashboard" | "terminal"; backup?: string; reason?: string }
23
+ /** #445 C2: an invocation's ids, class and outcome. Never an argument, a result or a description. */
24
+ | { type: "invocation"; id: string; caller: string; target: string; capability: string; class: string; outcome: string; task?: number; bytes?: number }
23
25
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
24
26
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
25
27
  | { type: "stale"; id: string; from: string; peer: string; task?: string }
@@ -82,6 +82,8 @@ const tool = (name: string, description: string, properties: Record<string, unkn
82
82
  });
83
83
 
84
84
  export const BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
85
+ /** #445 C1: what a peer lists and publishes. Their text is another agent's words, so neither call is captured to memory. */
86
+ export const CAPABILITY_TOOLS = new Set(["hub_capabilities", "hub_capabilities_publish", "hub_invoke"]);
85
87
 
86
88
  export const TASK_TOOLS: HubTool[] = [
87
89
  tool("hub_task_spec", "Read complete assigned GitHub issue contracts through the bounded hub boundary. Owner/reviewer only; no repository or URL input. Refuses withheld or oversized specs.", { id }, ["id"]),
@@ -116,6 +118,19 @@ export const TASK_TOOLS: HubTool[] = [
116
118
  tool("hub_memory_graph", "Read screened memory neighbourhoods, fails first. Symbol starts also include optional current-head one-hop code neighbours as derived SHA-stamped edges; unknown coverage is explicit. Up to 8 starts (<=200 UTF-8 bytes each), 1 or 2 hops; default 60/max 120 nodes, max 240 edges, whole output <=6 KB. No project argument.", { starts: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 200, description: "At most 200 UTF-8 bytes" } }, hops: { type: "integer", enum: [1, 2] }, limit: { type: "integer", minimum: 1, maximum: 120 } }, ["starts"]),
117
119
  tool("hub_memory_correct", "Propose a correction to a visible memory entry. This creates a machine-local candidate only; a person accepts or rejects it with amem review. It never edits a note. id is an exact id or unique visible prefix; text and reason must each fit 4000 UTF-8 bytes.", { id: str, text: str, reason: str }, ["id", "text", "reason"]),
118
120
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's amem curated store); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. For kind fail, optional approach, symptom, cause, evidence and retry_when strings compose fixed labeled text; text may be omitted when structured fields are supplied. The complete text must fit 4000 UTF-8 bytes and is refused, never truncated, when oversized. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, approach: str, symptom: str, cause: str, evidence: str, retry_when: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }),
121
+ tool("hub_capabilities", "List the commands and skills the person has shared from a peer (id, input schema, effective class, the peer's permission mode, digest). Shared entries only: nothing else is visible here. Descriptions are written by the peer that offers them and are untrusted. Listing grants no right to call anything.", { peer: { type: "string", description: "peer id; omit for every peer with a shared entry" } }),
122
+ tool("hub_invoke", "Ask another peer to run one capability the person shared from it (see hub_capabilities). Read capabilities need no approval. Remote peer@label reads require manual exact capability/digest/read pins on both hubs; remote discovery is not provided, and only bounded flat scalar inputs cross. Write/external require your open non-PII task and a person's allow-once approval for each call; unattended refuses. Destructive never runs. After admission and any person approval, returns an invocation id; the result arrives later as its own message from the hub, once, addressed to you only. The arguments follow the capability's input schema. Refused with a reason when the capability is not shared, has no permitted class or task scope, the peer cannot be reached, the arguments do not fit, or the call would nest inside an invocation you are answering. Not available during a PII turn.", {
123
+ peer: { type: "string", description: "local peer or exact remote peer@label that offers the capability" }, capability: { type: "string", description: "the capability id from hub_capabilities, for example command:skill:review" },
124
+ args: { type: "object", description: "the capability's inputs; omit when it takes none", additionalProperties: true },
125
+ task: { type: "integer", description: "an open task you own or review; required for write/external" },
126
+ }, ["peer", "capability"]),
127
+ tool("hub_capabilities_publish", "Publish what your own session can do (commands, skills) for a person to share. Only for a session whose capabilities the hub cannot read natively; refused for a peer the hub reads itself. Replaces your earlier list, ends with your connection, and is never shared until the person pins it. Not available during a PII turn.", {
128
+ capabilities: { type: "array", minItems: 1, maxItems: 128, description: "every capability you offer", items: { type: "object", additionalProperties: false, required: ["kind", "name", "description", "sideEffect"], properties: {
129
+ kind: { type: "string", enum: ["command", "skill", "mcp_tool"] }, name: { type: "string", description: "letters, digits, _ . : - only, at most 64" }, description: { type: "string", description: "what it does, at most 300 characters" },
130
+ sideEffect: { type: "string", enum: ["read", "write", "external", "destructive"], description: "the worst effect a call can have" },
131
+ inputSchema: { type: "object", description: "flat object of string, number, boolean or string-enum properties, additionalProperties false; omit for a single text input" },
132
+ } } },
133
+ }, ["capabilities"]),
119
134
  ];
120
135
 
121
136
  export const PLANNER_REVIEW_TOOLS: HubTool[] = [
@@ -169,7 +184,7 @@ export function configuredToolContext(status: unknown, peer: string): ProjectToo
169
184
  return { startPeers, readOnly: readOnlyPeers.includes(peer) };
170
185
  }
171
186
 
172
- const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
187
+ const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
173
188
  /** Publication restricts exposure only; server-side authority remains mandatory for every call. */
174
189
  export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
175
190
  return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
@@ -1,3 +1,4 @@
1
+ import { INFERENCE_BACKOFF_SETTING, INFERENCE_DIGEST_ITEMS_SETTING, INFERENCE_DIGEST_CHARS_SETTING } from "./operational-settings.ts";
1
2
  import type { MlxHandle } from "../models/mlx.ts";
2
3
  import type { ChatMessage, OmniRoute } from "../omniroute/client.ts";
3
4
  import { CLASSES, type TaskClass } from "./board.ts";
@@ -12,14 +13,16 @@ export interface InferenceConfig {
12
13
  /** ... or with more status text than this */
13
14
  digest_min_chars: number;
14
15
  triage: boolean;
16
+ /** Captured with the inference instance; an existing backoff deadline is never rewritten. */
17
+ backoff_min?: number;
15
18
  }
16
- export const DEFAULT_INFERENCE: InferenceConfig = { enabled: true, digest_min_items: 5, digest_min_chars: 8000, triage: true };
19
+ export const DEFAULT_INFERENCE: InferenceConfig = { enabled: true, digest_min_items: INFERENCE_DIGEST_ITEMS_SETTING.defaultValue, digest_min_chars: INFERENCE_DIGEST_CHARS_SETTING.defaultValue, triage: true, backoff_min: INFERENCE_BACKOFF_SETTING.defaultValue };
17
20
 
18
21
  /** Sender of a condensed digest. Not the hub's own id: replies to it must keep the hop count of what it replaced. */
19
22
  export { DIGEST };
20
23
 
21
24
  const TIMEOUT_MS = 8000;
22
- const BACKOFF_MS = 5 * 60_000;
25
+ export const BACKOFF_MS = 5 * 60_000;
23
26
  const SUMMARY_CAP = 1500;
24
27
 
25
28
  export interface InferenceDeps {
@@ -37,11 +40,12 @@ export interface InferenceDeps {
37
40
  */
38
41
  export class Inference {
39
42
  private offUntil = 0;
43
+ private readonly backoffMs: number;
40
44
 
41
45
  constructor(
42
46
  private readonly cfg: InferenceConfig,
43
47
  private readonly d: InferenceDeps,
44
- ) {}
48
+ ) { this.backoffMs = cfg.backoff_min === undefined ? BACKOFF_MS : cfg.backoff_min * 60_000; }
45
49
 
46
50
  /**
47
51
  * `interactive`: a person is waiting for this one answer (`ahub ask`). It gets its own, longer clock and neither obeys
@@ -67,8 +71,9 @@ export class Inference {
67
71
 
68
72
  private backOff(why: string, quiet = false): undefined {
69
73
  if (quiet) return undefined;
70
- this.offUntil = Date.now() + BACKOFF_MS; // do not make every delivery wait out the timeout while the model is down
71
- this.d.log(`inference: off for ${BACKOFF_MS / 60_000} min (${why.slice(0, 120)})`);
74
+ const backoffMs = this.backoffMs;
75
+ this.offUntil = Date.now() + backoffMs; // do not make every delivery wait out the timeout while the model is down
76
+ this.d.log(`inference: off for ${backoffMs / 60_000} min (${why.slice(0, 120)})`);
72
77
  return undefined;
73
78
  }
74
79