@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
@@ -0,0 +1,145 @@
1
+ /** Host-owned X6 correlation. The room transport never becomes a task/tool authority. */
2
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
3
+ import { HUB, MAX_HOP, newEnvelope, type Envelope } from "./envelope.ts";
4
+ import type { Bus } from "./bus.ts";
5
+ import { argsDigest, boundedInvocationBody, isOpenOutcome, type InvocationLedger, type InvocationOutcome, type InvocationRow } from "./invocations.ts";
6
+ import { canonical, type ShareConfig } from "./capabilities.ts";
7
+ import { authorizeRoomInvocation, type RoomInvocationAuthorization } from "../rooms/boundary.ts";
8
+ import { decodeRoomInvocationBody, encodeRoomInvocationBody, type RoomReadInvocationRequest } from "../rooms/application.ts";
9
+ import type { RoomInvocationIdentity, RoomInvocationIngress } from "../rooms/service.ts";
10
+
11
+ export interface RoomInvocationPorts {
12
+ bus: Bus; ledger: InvocationLedger; shares: () => ShareConfig;
13
+ identity: (peer: string, sender?: string) => RoomInvocationIdentity | undefined;
14
+ target: (input: RoomInvocationIngress, request: RoomReadInvocationRequest) => Promise<void>;
15
+ safeResult: (body: string, current: () => boolean) => Promise<boolean>;
16
+ quarantine?: (row: InvocationRow) => void;
17
+ finish: (row: InvocationRow, outcome: InvocationOutcome, input?: { body?: string; reason?: string }) => void;
18
+ }
19
+ export class RoomInvocations {
20
+ constructor(private readonly p: RoomInvocationPorts) {}
21
+ localActor(env: Envelope): string | undefined {
22
+ const local = env.kind === "invocation" ? env.id : env.refs?.invocation;
23
+ const row = local && this.p.ledger.get(local), route = local && this.p.ledger.relay(local);
24
+ if (!row || !route) return undefined;
25
+ try {
26
+ const identity = this.p.identity(route.remotePeer);
27
+ return identity && this.authorization(env, identity) ? route.direction === "outbound" ? row.caller : row.target : undefined;
28
+ } catch { return undefined; }
29
+ }
30
+ authorization(env: Envelope, identity: RoomInvocationIdentity): RoomInvocationAuthorization | undefined {
31
+ const local = env.kind === "invocation" ? env.id : env.refs?.invocation;
32
+ const row = local && this.p.ledger.get(local), route = local && this.p.ledger.relay(local);
33
+ if (!row || !route || row.class !== "read" || env.from !== HUB || env.to?.length !== 1 || env.to[0] !== route.remotePeer
34
+ || route.room !== identity.room || route.member !== identity.member || route.epoch !== identity.epoch || !identity.current()) return undefined;
35
+ if (env.kind === "invocation" && (route.direction !== "outbound" || !isOpenOutcome(row.outcome) || env.id !== route.request)) return undefined;
36
+ if (env.kind === "invocation_result" && (route.direction !== "inbound" || env.id !== route.result || route.state !== "result_queued")) return undefined;
37
+ if (env.kind !== "invocation" && env.kind !== "invocation_result") return undefined;
38
+ if (env.trace !== row.trace || env.hop !== route.hop + (env.kind === "invocation_result" ? 1 : 0)) return undefined;
39
+ if (env.kind === "invocation" && roomInvocationHash({ id: env.id, trace: env.trace, hop: env.hop, from: route.caller, to: route.target,
40
+ kind: env.kind, priority: "status", body: env.body, ts: env.ts }) !== route.hash) return undefined;
41
+ const payload = decodeRoomInvocationBody(env.kind, env.body);
42
+ if (payload.invocation !== route.request || payload.capability !== route.capability || payload.digest !== route.digest) return undefined;
43
+ if (env.kind === "invocation_result" && (payload as import("../rooms/application.ts").RoomReadInvocationResult).outcome !== row.outcome) return undefined;
44
+ if (env.kind === "invocation" && argsDigest((payload as RoomReadInvocationRequest).args) !== row.argsSha256) return undefined;
45
+ const current = () => {
46
+ const live = this.p.ledger.relay(row.id), candidate = this.p.ledger.get(row.id);
47
+ const pin = this.p.shares()[route.remotePeer]?.[route.capability];
48
+ return identity.current() && !!candidate && live?.room === identity.room && live.member === identity.member && live.epoch === identity.epoch
49
+ && (env.kind === "invocation" ? isOpenOutcome(candidate.outcome) && pin?.digest === row.digest && pin.class === "read" : live.state === "result_queued" && live.result === env.id);
50
+ };
51
+ return authorizeRoomInvocation(env, route.direction === "outbound" ? row.caller : row.target, payload, current);
52
+ }
53
+ /** Called after target execution settles, before the ordinary caller-offline early return. */
54
+ result(row: InvocationRow, outcome: InvocationOutcome, input: { body?: string; reason?: string }): boolean {
55
+ const route = this.p.ledger.relay(row.id);
56
+ if (!route || route.direction !== "inbound") return false;
57
+ const result = newEnvelope(HUB, "", { to: [route.remotePeer], kind: "invocation_result", priority: "status", refs: { invocation: row.id } });
58
+ result.trace = row.trace; result.hop = route.hop + 1;
59
+ result.body = encodeRoomInvocationBody("invocation_result", { invocation: route.request, capability: row.capability, digest: row.digest, class: "read",
60
+ outcome: outcome as Exclude<InvocationOutcome, "queued" | "delivered">, body: outcome === "withheld" ? "" : boundedInvocationBody(input.body ?? input.reason ?? "no result") });
61
+ const identity = this.p.identity(route.remotePeer, row.target);
62
+ if (!identity || identity.room !== route.room || identity.member !== route.member || identity.epoch !== route.epoch || !identity.current()) {
63
+ this.p.ledger.updateRelay(row.id, "needs_review"); this.p.bus.holdInvocation(row.id, "remote invocation result route changed", row.target); return true;
64
+ }
65
+ this.p.ledger.updateRelay(row.id, "result_queued", result.id);
66
+ try { this.p.bus.publish(result); }
67
+ catch { this.p.ledger.updateRelay(row.id, "needs_review"); this.p.bus.holdInvocation(row.id, "remote invocation result publication is uncertain", row.target); }
68
+ return true;
69
+ }
70
+ async receive(input: RoomInvocationIngress): Promise<"admitted" | "refused"> {
71
+ const env = input.envelope;
72
+ if (env.kind === "invocation") {
73
+ const request = decodeRoomInvocationBody("invocation", env.body);
74
+ if (env.hop >= MAX_HOP || !input.current()) return "refused";
75
+ const prior = this.p.ledger.relayByRemote(input.room, input.member, request.invocation, "inbound");
76
+ if (prior) {
77
+ if (prior.hash !== input.hash || prior.epoch !== input.epoch || prior.remotePeer !== input.remotePeer || prior.caller !== env.from || prior.target !== env.to || prior.capability !== request.capability || prior.digest !== request.digest) return "refused";
78
+ if (!input.claim()) throw new Error("room: invocation claim unavailable");
79
+ return "admitted";
80
+ }
81
+ try { await this.p.target(input, request); return "admitted"; }
82
+ catch (error) {
83
+ // Only a proven pre-effect refusal earns a signed NACK; storage/post-claim failures retain uncertainty.
84
+ if (error instanceof PreEffectToolRefusal) return "refused";
85
+ throw error;
86
+ }
87
+ }
88
+ if (env.kind !== "invocation_result") return "refused";
89
+ const result = decodeRoomInvocationBody("invocation_result", env.body), route = this.p.ledger.relay(result.invocation), row = this.p.ledger.get(result.invocation);
90
+ if (!route || !row || route.direction !== "outbound" || route.room !== input.room || route.member !== input.member || route.epoch !== input.epoch
91
+ || route.remotePeer !== input.remotePeer || env.from !== route.target || env.to !== route.caller || env.trace !== row.trace || env.hop !== route.hop + 1
92
+ || result.capability !== row.capability || result.digest !== row.digest || !input.current()) return "refused";
93
+ if (!isOpenOutcome(row.outcome)) { if (!input.claim()) throw new Error("room: invocation claim unavailable"); return "admitted"; }
94
+ const pin = this.p.shares()[route.remotePeer]?.[route.capability];
95
+ const current = () => input.current() && this.p.shares()[route.remotePeer]?.[route.capability]?.digest === row.digest
96
+ && this.p.shares()[route.remotePeer]?.[route.capability]?.class === "read";
97
+ let safe = pin?.digest === row.digest && pin.class === "read" && await this.p.safeResult(result.body, current);
98
+ if (!input.current()) throw new Error("room: invocation trust changed");
99
+ safe &&= current();
100
+ if (!input.claim()) throw new Error("room: invocation claim unavailable");
101
+ const uncertain = result.outcome === "needs_review" || result.outcome === "timeout";
102
+ if (uncertain) this.p.bus.holdInvocation(row.id, "target hub reports an uncertain invocation", row.target);
103
+ this.p.ledger.updateRelay(row.id, route.state, env.id);
104
+ const outcome = uncertain ? "needs_review" : safe ? result.outcome : result.outcome === "refused" ? "refused" : "withheld";
105
+ const visible = safe ? result.outcome === "completed" ? { body: result.body } : { reason: boundedInvocationBody(result.body) }
106
+ : uncertain ? { reason: "the target reports an uncertain outcome; remote details were withheld" }
107
+ : result.outcome === "refused" ? { reason: "the target declined; the reason was withheld" } : {};
108
+ this.p.finish(row, outcome, visible);
109
+ this.p.ledger.updateRelay(row.id, uncertain ? "needs_review" : "completed", env.id);
110
+ return "admitted";
111
+ }
112
+ receipt(id: string, state: "completed" | "failed_safe" | "needs_review"): void {
113
+ const request = this.p.ledger.relay(id), result = this.p.ledger.relayForResult(id);
114
+ if (request?.direction === "outbound") {
115
+ if (state === "completed") { this.p.ledger.markDelivered(id); this.p.ledger.updateRelay(id, "admitted"); }
116
+ else {
117
+ this.p.ledger.updateRelay(id, state === "failed_safe" ? "completed" : "needs_review"); const row = this.p.ledger.get(id);
118
+ if (row) this.p.finish(row, state === "failed_safe" ? "refused" : "needs_review", { reason: state === "failed_safe" ? "the target hub refused read admission" : "remote invocation delivery is uncertain; inspect it by hand" });
119
+ }
120
+ }
121
+ if (result) this.p.ledger.updateRelay(result.local, state === "completed" ? "completed" : "needs_review");
122
+ }
123
+ /** Startup only: never resend an outcome whose publication a crash could have interrupted. */
124
+ reconcile(): void {
125
+ const pending = this.p.bus.queueList(undefined, true);
126
+ for (const route of this.p.ledger.pendingRelays()) {
127
+ if (route.state === "needs_review") continue;
128
+ const row = this.p.ledger.get(route.local);
129
+ if (!row) continue;
130
+ const request = pending.find(delivery => delivery.originals.some(env => env.id === row.id));
131
+ if (isOpenOutcome(row.outcome) && (!request || request.state !== "queued")) {
132
+ this.p.bus.holdInvocation(row.id, "remote invocation handoff is unconfirmed after restart", row.target);
133
+ this.p.ledger.updateRelay(row.id, "needs_review");
134
+ if (this.p.quarantine) this.p.quarantine(row); else this.p.ledger.settle(row.id, "needs_review");
135
+ continue;
136
+ }
137
+ const result = route.result && pending.find(delivery => delivery.originals.some(env => env.id === route.result));
138
+ if (route.direction === "inbound" && !isOpenOutcome(row.outcome) && (route.state !== "result_queued" || !result || result.state !== "queued")) {
139
+ this.p.ledger.updateRelay(row.id, "needs_review");
140
+ if (!route.result) this.p.bus.holdInvocation(row.id, "remote result publication was interrupted", row.target);
141
+ } else if (route.direction === "outbound" && !isOpenOutcome(row.outcome)) this.p.ledger.updateRelay(row.id, "needs_review");
142
+ }
143
+ }
144
+ }
145
+ export const roomInvocationHash = (value: unknown): string => argsDigest(canonical(value));
@@ -28,7 +28,9 @@ export interface SettingDef {
28
28
  key: string;
29
29
  group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
30
30
  label: string;
31
- type: "enum" | "boolean" | "peers" | "roles" | "integer" | "number";
31
+ type: "enum" | "boolean" | "peers" | "roles" | "integer" | "number" | "lines";
32
+ /** Written only by the named hub command, never by `settings set`, a dashboard session or a conductor (#514b). */
33
+ managed?: "learn";
32
34
  min?: number; max?: number;
33
35
  /** The closed values of an enum; a route setting takes the project's own route ids instead. */
34
36
  values?: readonly string[];
@@ -85,7 +87,16 @@ export const SETTINGS: readonly SettingDef[] = [
85
87
  * Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
86
88
  * text go. A key equal to an entry, or below it, is refused by name.
87
89
  */
88
- export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "schedules", "folder_triggers", "corpus", "rooms", "instances", "adapters", "adapter_modules", "local.doc_read", "local.web_fetch"] as const;
90
+ export const NEVER_EDITABLE = ["invocation", "kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "schedules", "folder_triggers", "corpus", "rooms", "instances", "adapters", "adapter_modules", "local.doc_read", "local.web_fetch"] as const;
91
+
92
+ /**
93
+ * The one key `ahub learn accept` writes (#514b): the active profile's review checklist, which replaces as a unit. It is NOT a row of `SETTINGS` and `settingDef` does NOT resolve it, so
94
+ * `settings set|get|list`, the dashboard, a settings session and a conductor all see an unknown setting and nothing about them changed. Only the daemon's learn operation passes this
95
+ * definition to `settingsSet` itself (a function argument no request field can reach), so the write still goes through the same writer, loader check, reload and undo as every other setting.
96
+ */
97
+ export const LEARN_CHECKLIST: SettingDef = Object.freeze({ key: "profile_overrides.review_checklist", group: "Routing", label: "Profile review checklist (written by ahub learn accept)", type: "lines", managed: "learn",
98
+ store: "config", applies: "restart", restartReason: "Session tool schemas and profile contracts are bound at startup", risk: "raises", path: Object.freeze(["profile_overrides", "review_checklist"]) });
99
+ const LEARN_LINES_MAX = 16, LEARN_LINE_MAX = 500;
89
100
 
90
101
  /** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
91
102
  export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
@@ -143,6 +154,11 @@ export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAda
143
154
  return extra.length ? Object.freeze([...settings, ...extra]) : settings;
144
155
  }
145
156
 
157
+ /** What undo may put back: a registry key, or the one managed key `ahub learn accept` writes. Never used to set anything. The CLI passes its project scope; the daemon has its own wrapper over its project-scoped lookup. */
158
+ export function undoableDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
159
+ return key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key, instances, adapterCatalog);
160
+ }
161
+
146
162
  /** The definition of a key, or why there is none. */
147
163
  export function settingDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
148
164
  if (typeof key !== "string" || key.length > 80) return "unknown setting";
@@ -163,6 +179,10 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
163
179
  if (typeof raw !== "number" || !(def.type === "integer" ? Number.isSafeInteger(raw) : Number.isFinite(raw)) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes ${def.type === "integer" ? "an integer" : "a number"} from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
164
180
  return raw;
165
181
  }
182
+ if (def.type === "lines") {
183
+ if (!Array.isArray(raw) || !raw.length || raw.length > LEARN_LINES_MAX || raw.some(line => typeof line !== "string" || !line.trim() || line.length > LEARN_LINE_MAX || /[\x00-\x1f\x7f]/.test(line))) throw new Error(`${def.key} takes one to ${LEARN_LINES_MAX} lines of plain text, each at most ${LEARN_LINE_MAX} characters`);
184
+ return raw.map(line => line.trim());
185
+ }
166
186
  if (def.type === "boolean") {
167
187
  if (typeof raw !== "boolean") throw new Error(`${def.key} takes true or false`);
168
188
  return raw;
@@ -1,8 +1,11 @@
1
1
  import example from "../../templates/packs/example.pack.json" with { type: "text" };
2
+ import writing from "../../templates/packs/writing-pack.pack.json" with { type: "text" };
3
+ import analysis from "../../templates/packs/analysis.pack.json" with { type: "text" };
4
+ import ops from "../../templates/packs/ops.pack.json" with { type: "text" };
2
5
 
3
6
  /**
4
7
  * The packs the hub ships (#513a), by file name without `.pack.json`. Imported as text so the bundle carries them, as `shipped-roles.ts` carries the role catalog.
5
- * Only the format example is here: the writing, analysis and ops packs are #513b, which adds a file to `templates/packs/` and a line here.
8
+ * The format example and the writing, analysis and ops domain packs are advisory data; checks never execute from this registry.
6
9
  */
7
10
  // TypeScript types a `.json` import as its parsed shape even with `type: "text"`; Bun hands over the file text, which `parsePack` parses and bounds itself.
8
- export const SHIPPED_PACKS: Readonly<Record<string, string>> = { example: example as unknown as string };
11
+ export const SHIPPED_PACKS: Readonly<Record<string, string>> = { example: example as unknown as string, "writing-pack": writing as unknown as string, analysis: analysis as unknown as string, ops: ops as unknown as string };
@@ -10,6 +10,13 @@ import role8 from "../../templates/roles/research/analyst.md" with { type: "text
10
10
  import role9 from "../../templates/roles/research/statistician.md" with { type: "text" };
11
11
  import role10 from "../../templates/roles/research/fact-checker.md" with { type: "text" };
12
12
 
13
+ import role11 from "../../templates/roles/writing/editor.md" with { type: "text" };
14
+ import role12 from "../../templates/roles/writing/translator.md" with { type: "text" };
15
+ import role13 from "../../templates/roles/writing/style-checker.md" with { type: "text" };
16
+ import role14 from "../../templates/roles/writing/terminology-checker.md" with { type: "text" };
17
+ import role15 from "../../templates/roles/ops/project-manager.md" with { type: "text" };
18
+ import role16 from "../../templates/roles/ops/meeting-actions-owner.md" with { type: "text" };
19
+
13
20
  export const SHIPPED_ROLES: Readonly<Record<string, string>> = {
14
21
  "software-architect": role0,
15
22
  "backend-engineer": role1,
@@ -22,4 +29,10 @@ export const SHIPPED_ROLES: Readonly<Record<string, string>> = {
22
29
  "analyst": role8,
23
30
  "statistician": role9,
24
31
  "fact-checker": role10,
32
+ "editor": role11,
33
+ "translator": role12,
34
+ "style-checker": role13,
35
+ "terminology-checker": role14,
36
+ "project-manager": role15,
37
+ "meeting-actions-owner": role16,
25
38
  };
package/src/hub/tasks.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { InvocationRow } from "./invocations.ts";
1
2
  import type { ContextAdmission } from "./context-window.ts";
2
3
  import { queueBackpressure, queueBackpressurePolicy, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueueBackpressureStatus, type QueuePressure } from "./queue-backpressure.ts";
3
4
  import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
@@ -35,7 +36,16 @@ import type { MemoryScreen } from "./read-tools.ts";
35
36
  import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
36
37
  import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
37
38
  import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
38
- import { candidateCard, criterionKey, groupUnmetCriteria, LEARNING_DEFAULTS, selectGroups, type CriterionGroup } from "./learning.ts";
39
+ import { candidateCard, criterionKey, groupUnmetCriteria, LEARNING_DEFAULTS, overridesDiff, parseCandidateCard, selectGroups, type CriterionGroup } from "./learning.ts";
40
+ /** #514b: how a closed candidate card says what the person decided; `learnCandidates` reads them back. */
41
+ const LEARN_ACCEPTED = "candidate accepted", LEARN_REJECTED = "candidate rejected";
42
+ /** What the person decided on a candidate card, read from its cancellation: only an entry the person wrote counts, so no other note can pass for a verdict. Undefined while it is open or closed for another reason. */
43
+ const learnVerdict = (card: Pick<Task, "state" | "history">): "accepted" | "rejected" | undefined => {
44
+ if (card.state !== "cancelled") return undefined;
45
+ const closed = card.history.findLast(entry => entry.event === "cancelled");
46
+ if (!closed || closed.by !== USER) return undefined;
47
+ return closed.note?.startsWith(LEARN_ACCEPTED) ? "accepted" : closed.note?.startsWith(LEARN_REJECTED) ? "rejected" : undefined;
48
+ };
39
49
  import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
40
50
  import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
41
51
  import { isAbsolute, relative } from "node:path";
@@ -253,6 +263,7 @@ QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits
253
263
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
254
264
  QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
255
265
  QUIET_EVENTS.add("claim verdict"); // #901: a person's bookkeeping on a plan claim moves nothing, so it must not turn a running check into "check finished late".
266
+ QUIET_EVENTS.add("invocation");
256
267
  QUIET_EVENTS.add("shell branch bound"); // #880: the hub's host-observed branch binding (#845) is bookkeeping; it neither moves nor settles the task.
257
268
  /** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
258
269
  const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
@@ -600,6 +611,17 @@ export class Tasks {
600
611
  });
601
612
  }
602
613
 
614
+ /** Host-only audit: no tool accepts this record and no task state or notification changes. */
615
+ recordInvocation(id: number, row: InvocationRow): void {
616
+ const task = this.d.board.get(id);
617
+ if (!task) throw new Error("invocation audit task is unavailable");
618
+ if (row.task !== id) throw new Error("invocation audit task mismatch");
619
+ if (row.outcome === "queued" && (this.isPii(task) || this.d.privateTurn?.(row.caller) || isClosedTaskState(task.state) || task.state === "backlog" || task.owner !== row.caller && task.reviewer !== row.caller)) throw new Error("invocation audit task scope changed");
620
+ if (task.history.some(entry => entry.invocation?.id === row.id && entry.invocation.outcome === row.outcome)) return;
621
+ const { id: invocationId, caller, target, capability, digest, class: sideEffect, approval, argsSha256, outcome } = row;
622
+ this.d.board.update(id, HUB, "invocation", {}, undefined, { invocation: { id: invocationId, caller, target, capability, digest, class: sideEffect, approval, argsSha256, outcome } });
623
+ }
624
+
603
625
  recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
604
626
  requireLocalTaskPeer(peer);
605
627
  this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
@@ -706,6 +728,74 @@ export class Tasks {
706
728
  }
707
729
  return new Set(rows.map(row => find(row.id))).size;
708
730
  }
731
+ /**
732
+ * #514b: the person's view of the recurring-criterion candidates (`ahub learn list`). The person only: a conductor or an agent gets a refusal, never a list. A card that does not parse as the
733
+ * hub's own is not listed. Ids, counts and the key parts only: the card's text is never echoed, and a PII row is never reached because the cards are already screened when filed.
734
+ */
735
+ learnCandidates(by: PeerId, opts: { all?: boolean } = {}): Record<string, unknown>[] {
736
+ if (by !== USER) throw new PreEffectToolRefusal("ahub learn is a person's command");
737
+ const rows: Record<string, unknown>[] = [];
738
+ for (const card of this.d.board.list()) {
739
+ const parsed = parseCandidateCard(card);
740
+ if ("refused" in parsed) continue;
741
+ const open = card.state === "backlog" || card.state === "proposed";
742
+ const closed = card.history.findLast(entry => entry.event === "cancelled");
743
+ const verdict = learnVerdict(card) ?? (closed ? "closed" : undefined);
744
+ if (!open && !opts.all) continue;
745
+ if (!open && !closed) continue;
746
+ rows.push({ id: card.id, state: card.state, ...parsed, status: open ? "open" : verdict, tasks: this.learnEvidence(parsed).tasks, line: overridesDiff({ ...parsed, tasks: this.learnEvidence(parsed).tasks }).profile_overrides.review_checklist[0] });
747
+ }
748
+ return rows;
749
+ }
750
+ /** The distinct tasks that name this criterion unmet on this profile and class in the window, from the board as it is now: the count the written line states is recomputed, never read from the card. */
751
+ private learnEvidence(card: { profile: string; class: string; criterion: string }): { tasks: number[] } {
752
+ const eligible = this.d.board.list().filter(row => this.candidateTextAllowed(`${row.profile ?? ""} ${row.profileClass ?? ""} ${row.class}`, row));
753
+ const group = groupUnmetCriteria(eligible, { windowMs: LEARNING_DEFAULTS.windowMs, now: Date.now() }).find(g => g.profile === card.profile && g.class === card.class && g.criterion === card.criterion);
754
+ return { tasks: group?.tasks ?? [] };
755
+ }
756
+ /**
757
+ * #514b: what `ahub learn accept` would write for card `id`, or why not. Person only. It reads ONLY the card's key line (`parseCandidateCard`), requires the card to be open, and builds the
758
+ * line from the board's own evidence with `overridesDiff`: nothing of the card's text is parsed as a value. The daemon adds the line to the stored checklist and writes it through the settings path.
759
+ */
760
+ learnAcceptPlan(by: PeerId, id: unknown): { id: number; profile: string; class: string; criterion: string; line: string; tasks: number } {
761
+ if (by !== USER) throw new PreEffectToolRefusal("ahub learn accept is a person's command");
762
+ const card = this.learnCard(id);
763
+ const evidence = this.learnEvidence(card);
764
+ if (!evidence.tasks.length) throw new PreEffectToolRefusal(`no task in the window still names ${card.criterion} unmet on ${card.profile}/${card.class}: nothing to accept; reject the card to close it`);
765
+ const line = overridesDiff({ ...card, tasks: evidence.tasks }).profile_overrides.review_checklist[0]!;
766
+ return { id: card.id, profile: card.profile, class: card.class, criterion: card.criterion, line, tasks: evidence.tasks.length };
767
+ }
768
+ /**
769
+ * #514b: whether `ahub learn accept` already decided this card. Person only. A card the person accepted answers `"accepted"`, so a repeat accept is a no-write success (the plan's idempotency);
770
+ * a rejected card, a card closed some other way and an open one answer undefined and take the normal path, which refuses the closed ones.
771
+ */
772
+ learnAccepted(by: PeerId, id: unknown): { id: number; profile: string; class: string; criterion: string } | undefined {
773
+ if (by !== USER) throw new PreEffectToolRefusal("ahub learn accept is a person's command");
774
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) return undefined;
775
+ const task = this.d.board.get(id);
776
+ if (!task || learnVerdict(task) !== "accepted") return undefined;
777
+ const parsed = parseCandidateCard(task);
778
+ return "refused" in parsed ? undefined : { id, ...parsed };
779
+ }
780
+ /** The card, which must be an open candidate the hub filed. */
781
+ private learnCard(id: unknown): { id: number; profile: string; class: string; criterion: string } {
782
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
783
+ const task = this.d.board.get(id);
784
+ if (!task) throw new PreEffectToolRefusal(`no task #${id}`);
785
+ const parsed = parseCandidateCard(task);
786
+ if ("refused" in parsed) throw new PreEffectToolRefusal(`task #${id}: ${parsed.refused}`);
787
+ if (task.state !== "backlog" && task.state !== "proposed") throw new PreEffectToolRefusal(`task #${id} is already ${task.state === "cancelled" ? "closed" : task.state}`);
788
+ return { id, ...parsed };
789
+ }
790
+ /**
791
+ * #514b: closes a candidate card after the person's verdict, through `cancel` (never a direct board write): the row stays, the verdict is in its history, and because the dedupe key is the card's
792
+ * detail the same criterion is never filed again. `accepted` is recorded only after the write succeeded (the daemon calls this last); a repeat of either verdict is refused by `learnCard`.
793
+ */
794
+ async learnClose(by: PeerId, id: unknown, verdict: "accepted" | "rejected"): Promise<Task> {
795
+ if (by !== USER) throw new PreEffectToolRefusal("ahub learn is a person's command");
796
+ const card = this.learnCard(id);
797
+ return this.cancel(card.id, USER, { reason: verdict === "accepted" ? `${LEARN_ACCEPTED} by the person: its review checklist line was written to config.local.json` : `${LEARN_REJECTED} by the person` });
798
+ }
709
799
  /**
710
800
  * #514a: the same recurrence path for recurring UNMET CRITERIA. A criterion id that reviews keep naming unmet on the same profile and class, on at least
711
801
  * `threshold` tasks that are at least two separate pieces of work (`workUnits`), files one backlog card for a person to review, with the `profile_overrides`
@@ -788,6 +878,7 @@ export class Tasks {
788
878
  ...(h.reviewFindings ? { reviewFindings: !h.withheld && this.findingTextAllowed(task, h.reviewFindings.items) ? h.reviewFindings
789
879
  : { ...(h.reviewFindings.head ? { head: h.reviewFindings.head } : {}), tally: findingTally(h.reviewFindings.items), withheld: true } } : {}),
790
880
  ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
881
+ ...(h.invocation ? { invocation: h.invocation } : {}),
791
882
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
792
883
  ...(h.owner === undefined ? {} : { owner: h.owner }),
793
884
  ...(h.planner === undefined ? {} : { planner: h.planner }),
@@ -1,6 +1,6 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
  import { defaultCapturePeer } from "../adapters/definitions/native-identity-defaults.ts";
3
- import { BOUNDED_TASK_TOOLS } from "../hub/hub-tools.ts";
3
+ import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS } from "../hub/hub-tools.ts";
4
4
  import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
5
5
  import { isDenied } from "../local/deny.ts";
6
6
  import type { MemoryClient } from "./client.ts";
@@ -38,7 +38,7 @@ export class Capture {
38
38
  }
39
39
 
40
40
  observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
41
- if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || this.opts.skip?.includes(call.tool)) return;
41
+ if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || this.opts.skip?.includes(call.tool)) return;
42
42
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
43
43
  if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
44
44
  // ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
@@ -167,6 +167,11 @@ export interface ModelRelayOptions {
167
167
  efficientWaitMs?: number;
168
168
  /** A backend alias's cooldown starts or ends. */
169
169
  onCooldown?: (event: RelayCooldownEvent) => void;
170
+ /** Host-validated policy captured when this relay is constructed. */
171
+ cooldownFailures?: number;
172
+ cooldownMs?: number;
173
+ cooldownCapMs?: number;
174
+ terminalHistoryLimit?: number;
170
175
  /** Cooldown clock; tests inject one. */
171
176
  now?: () => number;
172
177
  /** How long a dispatch waits for a busy MLX slot before it gives up and falls back (default 120 s); tests shorten it. */
@@ -207,8 +212,9 @@ export interface ModelRelay {
207
212
 
208
213
  export interface RelayCooldownEvent { alias: string; event: "start" | "end"; failures: number; ms?: number }
209
214
 
210
- // ponytail: fixed policy, 3 failures and 30 s doubling up to 5 min; routing.toml keys if a backend needs other values.
211
- const COOLDOWN_FAILURES = 3, COOLDOWN_MS = 30_000, COOLDOWN_CAP_MS = 300_000;
215
+ /** Legacy defaults, independently pinned by the operational-settings tests. */
216
+ export const COOLDOWN_FAILURES = 3, COOLDOWN_MS = 30_000, COOLDOWN_CAP_MS = 300_000;
217
+ export const RELAY_TERMINAL_HISTORY_LIMIT = 1000;
212
218
  /** The longest one upstream request may take. */
213
219
  const REQUEST_DEADLINE_MS = 180_000;
214
220
  // ponytail: a moved attempt (a load move or an enforced pin, both only optimizations) gets 15 s to its response headers
@@ -222,7 +228,12 @@ const MOVE_FIRST_BYTE_MS = 15_000;
222
228
  */
223
229
  export class BackendCooldowns {
224
230
  private readonly entries = new Map<string, { failures: number; until?: number; failedAt?: number; countedAt?: number }>();
225
- constructor(private readonly now: () => number = Date.now, private readonly notify: (event: RelayCooldownEvent) => void = () => {}) {}
231
+ private readonly policy: { failures: number; cooldownMs: number; capMs: number };
232
+ constructor(
233
+ private readonly now: () => number = Date.now,
234
+ private readonly notify: (event: RelayCooldownEvent) => void = () => {},
235
+ policy: { failures: number; cooldownMs: number; capMs: number } = { failures: COOLDOWN_FAILURES, cooldownMs: COOLDOWN_MS, capMs: COOLDOWN_CAP_MS },
236
+ ) { this.policy = { ...policy }; }
226
237
 
227
238
  /** The alias's cooldown while it lasts. Its end is recorded when it is first seen to have passed, or at an answer. */
228
239
  cooling(alias: string): { until: number; failures: number } | undefined {
@@ -244,11 +255,11 @@ export class BackendCooldowns {
244
255
  const entry = this.entry(alias);
245
256
  entry.failedAt = this.now();
246
257
  if (cooling || !streak) return;
247
- if (entry.countedAt !== undefined && this.now() - entry.countedAt > 2 * COOLDOWN_CAP_MS) entry.failures = 0;
258
+ if (entry.countedAt !== undefined && this.now() - entry.countedAt > 2 * this.policy.capMs) entry.failures = 0;
248
259
  entry.countedAt = this.now();
249
260
  entry.failures++;
250
- if (entry.failures < COOLDOWN_FAILURES) return;
251
- const ms = Math.min(COOLDOWN_CAP_MS, COOLDOWN_MS * 2 ** (entry.failures - COOLDOWN_FAILURES));
261
+ if (entry.failures < this.policy.failures) return;
262
+ const ms = Math.min(this.policy.capMs, this.policy.cooldownMs * 2 ** (entry.failures - this.policy.failures));
252
263
  entry.until = this.now() + ms;
253
264
  this.notify({ alias, event: "start", failures: entry.failures, ms });
254
265
  }
@@ -265,7 +276,7 @@ export class BackendCooldowns {
265
276
  /** Until when the alias's last dispatch counts as failed for a load move, while it does. */
266
277
  failing(alias: string): number | undefined {
267
278
  const at = this.entries.get(alias)?.failedAt;
268
- return at !== undefined && this.now() < at + COOLDOWN_MS ? at + COOLDOWN_MS : undefined;
279
+ return at !== undefined && this.now() < at + this.policy.cooldownMs ? at + this.policy.cooldownMs : undefined;
269
280
  }
270
281
 
271
282
  private entry(alias: string) {
@@ -430,7 +441,7 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
430
441
  const models = relayModelIds(options);
431
442
  const efficientWaitMs = options.efficientWaitMs;
432
443
  const moveFirstByteMs = options.moveFirstByteMs ?? MOVE_FIRST_BYTE_MS;
433
- const cooldowns = new BackendCooldowns(options.now, (event) => { try { options.onCooldown?.(event); } catch { /* observation cannot fail routing */ } });
444
+ const cooldowns = new BackendCooldowns(options.now, (event) => { try { options.onCooldown?.(event); } catch { /* observation cannot fail routing */ } }, { failures: options.cooldownFailures ?? COOLDOWN_FAILURES, cooldownMs: options.cooldownMs ?? COOLDOWN_MS, capMs: options.cooldownCapMs ?? COOLDOWN_CAP_MS });
434
445
  const autoRoute = options.enableHubAuto ? new AutoRouteSelector({ ...options, dgxMaxInputTokens }, defaultBackend, mlxAlias, estimateInputTokens) : undefined;
435
446
  let mlx: MlxHandle | undefined;
436
447
  let mlxStarting: Promise<MlxHandle> | undefined;
@@ -438,7 +449,7 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
438
449
  const activeRequests = new Set<ActiveRequest>();
439
450
  const activeByAlias = new Map<string, number>();
440
451
  const journal: RelayRequestRecord[] = [];
441
- const JOURNAL_LIMIT = 1000;
452
+ const JOURNAL_LIMIT = options.terminalHistoryLimit ?? RELAY_TERMINAL_HISTORY_LIMIT;
442
453
 
443
454
  // The record object is the generation fence: every update goes through this entry's own closure,
444
455
  // so interleaved requests for the same alias never write into each other's evidence.
@@ -6,7 +6,7 @@ import { canonicalBytes, ROOM_WIRE_LIMITS } from "./wire.ts";
6
6
  export const ROOM_APPLICATION_MAX_BYTES = ROOM_WIRE_LIMITS.plaintext;
7
7
  export interface RoomChatEnvelope {
8
8
  id: string; trace: string; hop: number; from: string; to: string;
9
- kind: "chat" | "status"; priority: Priority; body: string; ts: number;
9
+ kind: "chat" | "status" | "invocation" | "invocation_result"; priority: Priority; body: string; ts: number;
10
10
  }
11
11
  export type RoomReceiptReason = "admitted" | "inbox_admitted" | "inbox_full" | "inbox_oversize" | "inbox_expired" | "inbox_age_unavailable" | "not_exposed" | "boundary_refused" | "invalid_message" | "rate_limited" | "hop" | "person_inbox_unsupported";
12
12
  export type RoomMessage =
@@ -64,10 +64,75 @@ function text(value: unknown): string {
64
64
  if (encoder.encode(value).length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
65
65
  return value;
66
66
  }
67
+ export type RoomInvocationKind = "invocation" | "invocation_result";
68
+ export interface RoomReadInvocationRequest {
69
+ invocation: string; capability: string; digest: string; class: "read"; depth: 1;
70
+ args: Record<string, string | number | boolean>;
71
+ }
72
+ export interface RoomReadInvocationResult {
73
+ invocation: string; capability: string; digest: string; class: "read";
74
+ outcome: "completed" | "refused" | "failed" | "expired" | "timeout" | "needs_review" | "withheld";
75
+ body: string;
76
+ }
77
+ export type RoomInvocationPayload = RoomReadInvocationRequest | RoomReadInvocationResult;
78
+ /** Wire contract bounds; the target still validates the capability's actual schema. */
79
+ const INVOCATION_ARGS_MAX = 4096, INVOCATION_STRING_MAX = 2000, INVOCATION_RESULT_MAX = 8192;
80
+ const CAPABILITY_ID = /^(command|skill|mcp_tool):[A-Za-z0-9_.:-]{1,64}$/;
81
+ const CAPABILITY_DIGEST = /^sha256:[0-9a-f]{64}$/;
82
+ export function roomInvocationArgs(value: unknown): Record<string, string | number | boolean> {
83
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail();
84
+ const keys = Object.keys(value).sort();
85
+ if (keys.length > 16) fail("limit");
86
+ const raw = object(value, keys), args = Object.create(null) as Record<string, string | number | boolean>;
87
+ for (const key of keys) {
88
+ if (/^[A-Za-z0-9_.:-]{1,64}$/.exec(key)?.[0] !== key) fail();
89
+ const arg = raw[key];
90
+ if (typeof arg === "string") { if (!arg.isWellFormed() || arg.length > INVOCATION_STRING_MAX) fail("limit"); }
91
+ else if (typeof arg !== "boolean" && (typeof arg !== "number" || !Number.isFinite(arg))) fail();
92
+ Object.defineProperty(args, key, { value: arg, enumerable: true });
93
+ }
94
+ if (JSON.stringify(args).length > INVOCATION_ARGS_MAX) fail("limit");
95
+ return Object.fromEntries(Object.entries(args));
96
+ }
97
+ function invocationPayload(kind: RoomInvocationKind, value: unknown): RoomInvocationPayload {
98
+ const row = object(value, kind === "invocation" ? ["invocation", "capability", "digest", "class", "depth", "args"] : ["invocation", "capability", "digest", "class", "outcome", "body"]);
99
+ if (row.class !== "read" || typeof row.capability !== "string" || CAPABILITY_ID.exec(row.capability)?.[0] !== row.capability
100
+ || typeof row.digest !== "string" || CAPABILITY_DIGEST.exec(row.digest)?.[0] !== row.digest) fail();
101
+ const common = { invocation: uuid(row.invocation), capability: row.capability, digest: row.digest, class: "read" as const };
102
+ if (kind === "invocation") {
103
+ if (row.depth !== 1) fail();
104
+ return { ...common, depth: 1, args: roomInvocationArgs(row.args) };
105
+ }
106
+ if (typeof row.outcome !== "string" || !["completed", "refused", "failed", "expired", "timeout", "needs_review", "withheld"].includes(row.outcome)) fail();
107
+ const body = text(row.body);
108
+ if (encoder.encode(body).length > INVOCATION_RESULT_MAX) fail("limit");
109
+ if (row.outcome === "withheld" && body !== "") fail();
110
+ return { ...common, outcome: row.outcome as RoomReadInvocationResult["outcome"], body };
111
+ }
112
+ /** Closed canonical JSON inside the encrypted envelope: no refs, approvals or extra authority fields. */
113
+ export function encodeRoomInvocationBody(kind: RoomInvocationKind, value: RoomInvocationPayload): string {
114
+ try { return decoder.decode(canonicalBytes(invocationPayload(kind, value))); }
115
+ catch (error) { return applicationError(error); }
116
+ }
117
+ export function decodeRoomInvocationBody(kind: "invocation", body: string): RoomReadInvocationRequest;
118
+ export function decodeRoomInvocationBody(kind: "invocation_result", body: string): RoomReadInvocationResult;
119
+ export function decodeRoomInvocationBody(kind: RoomInvocationKind, body: string): RoomInvocationPayload;
120
+ export function decodeRoomInvocationBody(kind: RoomInvocationKind, body: string): RoomInvocationPayload {
121
+ try {
122
+ const parsed = invocationPayload(kind, JSON.parse(text(body)));
123
+ if (decoder.decode(canonicalBytes(parsed)) !== body) fail();
124
+ return parsed;
125
+ } catch (error) { return applicationError(error); }
126
+ }
127
+
67
128
  function parseEnvelope(value: unknown): RoomChatEnvelope {
68
129
  const row = object(value, ["id", "trace", "hop", "from", "to", "kind", "priority", "body", "ts"]);
69
- if (row.kind !== "chat" && row.kind !== "status") fail();
130
+ if (row.kind !== "chat" && row.kind !== "status" && row.kind !== "invocation" && row.kind !== "invocation_result") fail();
70
131
  if (row.priority !== "important" && row.priority !== "status" && row.priority !== "fyi") fail();
132
+ if (row.kind === "invocation" || row.kind === "invocation_result") {
133
+ const payload = decodeRoomInvocationBody(row.kind, text(row.body));
134
+ if (row.from === "user" || row.to === "user" || row.priority !== "status" || row.kind === "invocation" && payload.invocation !== row.id) fail();
135
+ }
71
136
  return { id: uuid(row.id), trace: uuid(row.trace), hop: integer(row.hop, 0, MAX_HOP), from: localPeer(row.from), to: localPeer(row.to), kind: row.kind, priority: row.priority, body: text(row.body), ts: integer(row.ts, 0) };
72
137
  }
73
138
  function frameDigest(value: unknown): string {