@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
@@ -107,10 +107,82 @@ export const RECOVERY_TERMINAL_IDLE_WAIT_SETTING = recoveryNumber({
107
107
  min: 30, max: 600, defaultValue: 120, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:waitForIdle",
108
108
  });
109
109
 
110
+ const adapterNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies" | "type">>(def: T) => ({ group: "Start", store: "config", risk: "raises", applies: "next use", type: "integer", ...def }) as const satisfies OperationalNumberSetting;
111
+ export const ACP_TOOL_CEILING_SETTING = adapterNumber({
112
+ key: "adapter_timing.acp_tool_ceiling_min", path: ["adapter_timing", "acp_tool_ceiling_min"], label: "Next ACP launch: open-tool ceiling (minutes, at least the watchdog)",
113
+ min: 1, max: 30, defaultValue: 30, unit: "minutes", inventory: "src/adapters/acp.ts:TOOL_CEILING_MS",
114
+ });
115
+ export const ACP_ACTIVITY_BATCH_SETTING = adapterNumber({
116
+ key: "adapter_timing.acp_activity_batch_ms", path: ["adapter_timing", "acp_activity_batch_ms"], label: "Next ACP launch: activity display batch delay (milliseconds)",
117
+ min: 10, max: 50, defaultValue: 50, unit: "milliseconds", inventory: "src/adapters/acp.ts:setTimeout",
118
+ });
119
+ export const JCODE_STEER_TIMEOUT_SETTING = adapterNumber({
120
+ key: "adapter_timing.jcode_steer_timeout_s", path: ["adapter_timing", "jcode_steer_timeout_s"], label: "Next Jcode launch: total native steer deadline (seconds)",
121
+ min: 5, max: 60, defaultValue: 5, unit: "seconds", inventory: "src/adapters/jcode.ts:STEER_TIMEOUT_MS",
122
+ });
123
+
124
+ /** #489 H6: relay policy is captured at construction; inference consumers are daemon-start bound. */
125
+ const modelNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "type">>(def: T) => ({ group: "Routing", store: "config", risk: "raises", type: "integer", ...def }) as const satisfies OperationalNumberSetting;
126
+ export const MODEL_COOLDOWN_FAILURES_SETTING = modelNumber({
127
+ key: "model_relay.cooldown_failures", path: ["model_relay", "cooldown_failures"], label: "Model relay: consecutive transport failures before cooldown",
128
+ min: 1, max: 100, defaultValue: 3, applies: "restart", restartReason: "Model relays and their health state persist until daemon shutdown", unit: "failures", inventory: "src/models/relay.ts:COOLDOWN_FAILURES",
129
+ });
130
+ export const MODEL_COOLDOWN_SETTING = modelNumber({
131
+ key: "model_relay.cooldown_s", path: ["model_relay", "cooldown_s"], label: "Model relay: initial cooldown and recent-failure window (seconds)",
132
+ min: 1, max: 300, defaultValue: 30, applies: "restart", restartReason: "Model relays and their health state persist until daemon shutdown", unit: "seconds", inventory: "src/models/relay.ts:COOLDOWN_MS",
133
+ });
134
+ export const MODEL_COOLDOWN_CAP_SETTING = modelNumber({
135
+ key: "model_relay.cooldown_cap_s", path: ["model_relay", "cooldown_cap_s"], label: "Model relay: maximum cooldown (seconds)",
136
+ min: 300, max: 3600, defaultValue: 300, applies: "restart", restartReason: "Model relays and their health state persist until daemon shutdown", unit: "seconds", inventory: "src/models/relay.ts:COOLDOWN_CAP_MS",
137
+ });
138
+ export const MODEL_TERMINAL_HISTORY_SETTING = modelNumber({
139
+ key: "model_relay.terminal_history_limit", path: ["model_relay", "terminal_history_limit"], label: "Model relay: retained completed request records",
140
+ min: 1, max: 1000, defaultValue: 1000, applies: "restart", restartReason: "Model relays and their health state persist until daemon shutdown", unit: "records", inventory: "src/models/relay.ts:JOURNAL_LIMIT",
141
+ });
142
+ const inferenceRestart = "The inference consumer captures its configuration at daemon startup";
143
+ export const INFERENCE_BACKOFF_SETTING = modelNumber({
144
+ key: "inference.backoff_min", path: ["inference", "backoff_min"], label: "Inference: cooldown after a failed optional call (minutes)",
145
+ min: 1, max: 60, defaultValue: 5, applies: "restart", restartReason: inferenceRestart, unit: "minutes", inventory: "src/hub/inference.ts:BACKOFF_MS",
146
+ });
147
+ export const INFERENCE_DIGEST_ITEMS_SETTING = modelNumber({
148
+ key: "inference.digest_min_items", path: ["inference", "digest_min_items"], label: "Inference: condense status chatter above this item count",
149
+ min: 1, max: 100_000, defaultValue: 5, applies: "restart", restartReason: inferenceRestart, unit: "items", inventory: "src/hub/inference.ts:digest_min_items",
150
+ });
151
+ export const INFERENCE_DIGEST_CHARS_SETTING = modelNumber({
152
+ key: "inference.digest_min_chars", path: ["inference", "digest_min_chars"], label: "Inference: condense status chatter above this character count",
153
+ min: 1, max: 10_000_000, defaultValue: 8000, applies: "restart", restartReason: inferenceRestart, unit: "characters", inventory: "src/hub/inference.ts:digest_min_chars",
154
+ });
155
+
156
+ /** #489 H7: accepted presentation values are captured once by the next console launch. */
157
+ const consoleNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies" | "type">>(def: T) => ({ group: "Start", store: "config", risk: "raises", applies: "next use", type: "integer", ...def }) as const satisfies OperationalNumberSetting;
158
+ export const CONSOLE_POLL_SETTING = consoleNumber({
159
+ key: "console.poll_ms", path: ["console", "poll_ms"], label: "Next console launch: refresh interval (milliseconds)",
160
+ min: 2000, max: 60_000, defaultValue: 4000, unit: "milliseconds", inventory: "src/cli/console.ts:options.pollMs ?? 4000",
161
+ });
162
+ export const CONSOLE_NOTICE_SETTING = consoleNumber({
163
+ key: "console.notice_ms", path: ["console", "notice_ms"], label: "Next console launch: notice visibility (milliseconds)",
164
+ min: 10_000, max: 60_000, defaultValue: 10_000, unit: "milliseconds", inventory: "src/cli/console-state.ts:NOTICE_MS",
165
+ });
166
+ export const CONSOLE_HISTORY_SETTING = consoleNumber({
167
+ key: "console.history_limit", path: ["console", "history_limit"], label: "Next console launch: retained command history",
168
+ min: 1, max: 100, defaultValue: 100, unit: "commands", inventory: "src/cli/console-state.ts:[...s.history, input].slice",
169
+ });
170
+ export const CONSOLE_PENDING_STREAM_SETTING = consoleNumber({
171
+ key: "console.pending_stream_limit", path: ["console", "pending_stream_limit"], label: "Next console launch: buffered offscreen stream events",
172
+ min: 1, max: 1000, defaultValue: 1000, unit: "events", inventory: "src/cli/console.ts:pendingStream.length > 1000",
173
+ });
174
+ export const CONSOLE_ATTENTION_SETTING = consoleNumber({
175
+ key: "console.attention_limit", path: ["console", "attention_limit"], label: "Next console launch: retained message attention entries",
176
+ min: 1, max: 1000, defaultValue: 1000, unit: "messages", inventory: "src/cli/console.ts:[...(state.attentionMessages ?? []), { id: msg.e.env.id, peer: msg.e.env.from, important: msg.e.env.priority === important, since: msg.e.env.ts }].slice",
177
+ });
178
+
110
179
  /** Fixed metadata; never registration from a file, request, adapter or environment variable. */
111
180
  export const OPERATIONAL_NUMBERS: readonly OperationalNumberSetting[] = [GROOM_FYI_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, CONTRADICTION_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_MIN_SETTING, SPLIT_FAILURE_SHARE_SETTING,
112
181
  BUS_MAX_ATTEMPTS_SETTING, BUS_DIGEST_MAX_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_NOTES_KEPT_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING,
113
- RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING];
182
+ RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING,
183
+ ACP_TOOL_CEILING_SETTING, ACP_ACTIVITY_BATCH_SETTING, JCODE_STEER_TIMEOUT_SETTING,
184
+ MODEL_COOLDOWN_FAILURES_SETTING, MODEL_COOLDOWN_SETTING, MODEL_COOLDOWN_CAP_SETTING, MODEL_TERMINAL_HISTORY_SETTING, INFERENCE_BACKOFF_SETTING, INFERENCE_DIGEST_ITEMS_SETTING, INFERENCE_DIGEST_CHARS_SETTING,
185
+ CONSOLE_POLL_SETTING, CONSOLE_NOTICE_SETTING, CONSOLE_HISTORY_SETTING, CONSOLE_PENDING_STREAM_SETTING, CONSOLE_ATTENTION_SETTING];
114
186
  /** The `tasks` leaves the six #489 H2 readers need; `HubConfig.tasks` has them all. */
115
187
  export interface TaskOperationalConfig { tasks: { demote_at?: number; demotion_half_life_h?: number; contradiction_window_days?: number; rescreen_max?: number; split_min_observations?: number; split_max_failure_share?: number } }
116
188
  /**
@@ -151,4 +223,33 @@ export const recoveryWaits = (config: () => RecoveryOperationalConfig | undefine
151
223
  terminalIdleWaitMs: recoveryMs(RECOVERY_TERMINAL_IDLE_WAIT_SETTING, leaf?.terminal_idle_wait_s),
152
224
  };
153
225
  };
226
+ export interface AdapterTimingConfig { adapter_timing?: { acp_tool_ceiling_min?: number; acp_activity_batch_ms?: number; jcode_steer_timeout_s?: number } }
227
+ /** The host captures primitive option values for a new adapter; an accepted edit never retimes an existing launch. */
228
+ export const adapterOperationalReaders = (config: () => AdapterTimingConfig) => ({
229
+ acpToolCeilingMs: () => (config().adapter_timing?.acp_tool_ceiling_min ?? ACP_TOOL_CEILING_SETTING.defaultValue) * 60_000,
230
+ acpActivityBatchMs: () => config().adapter_timing?.acp_activity_batch_ms ?? ACP_ACTIVITY_BATCH_SETTING.defaultValue,
231
+ jcodeSteerTimeoutMs: () => (config().adapter_timing?.jcode_steer_timeout_s ?? JCODE_STEER_TIMEOUT_SETTING.defaultValue) * 1000,
232
+ });
154
233
  export const operationalNumber = (key: string): OperationalNumberSetting | undefined => OPERATIONAL_NUMBERS.find(def => def.key === key);
234
+
235
+ export interface ModelOperationalConfig { model_relay?: { cooldown_failures?: number; cooldown_s?: number; cooldown_cap_s?: number; terminal_history_limit?: number } }
236
+ /** The host captures these primitives when it constructs a relay. No current request, cooldown or journal is rewritten by reload. */
237
+ export const modelOperationalReaders = (config: () => ModelOperationalConfig) => ({
238
+ cooldownFailures: () => config().model_relay?.cooldown_failures ?? MODEL_COOLDOWN_FAILURES_SETTING.defaultValue,
239
+ cooldownMs: () => (config().model_relay?.cooldown_s ?? MODEL_COOLDOWN_SETTING.defaultValue) * 1000,
240
+ cooldownCapMs: () => (config().model_relay?.cooldown_cap_s ?? MODEL_COOLDOWN_CAP_SETTING.defaultValue) * 1000,
241
+ terminalHistoryLimit: () => config().model_relay?.terminal_history_limit ?? MODEL_TERMINAL_HISTORY_SETTING.defaultValue,
242
+ });
243
+
244
+ export interface ConsoleOperationalConfig { console?: { poll_ms?: number; notice_ms?: number; history_limit?: number; pending_stream_limit?: number; attention_limit?: number } }
245
+ /** Existing authenticated settings_get rows only: pending file values and client-supplied bounds never choose a value. */
246
+ export function consoleOperationalSnapshot(rows: unknown) {
247
+ const read = (def: OperationalNumberSetting): number => {
248
+ if (!Array.isArray(rows)) return def.defaultValue;
249
+ const matches = rows.filter(row => row && typeof row === "object" && !Array.isArray(row) && row.key === def.key);
250
+ if (matches.length !== 1) return def.defaultValue;
251
+ const value: unknown = matches[0].value;
252
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= def.min && value <= def.max ? value : def.defaultValue;
253
+ };
254
+ return Object.freeze({ pollMs: read(CONSOLE_POLL_SETTING), noticeMs: read(CONSOLE_NOTICE_SETTING), historyLimit: read(CONSOLE_HISTORY_SETTING), pendingStreamLimit: read(CONSOLE_PENDING_STREAM_SETTING), attentionLimit: read(CONSOLE_ATTENTION_SETTING) });
255
+ }
package/src/hub/peers.ts CHANGED
@@ -2,6 +2,7 @@ import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
2
2
  import type { PermissionMode } from "./permission-mode.ts";
3
3
  import type { StartForm } from "../adapters/registry-types.ts";
4
4
  import type { DoingKind } from "./peer-doing.ts";
5
+ import type { CapabilityManifest } from "./capabilities.ts";
5
6
 
6
7
  export interface DeliveryReceipt {
7
8
  id: string;
@@ -66,6 +67,8 @@ export interface PeerAdapter {
66
67
  readonly statusNotice?: { code: "unidentified-cancellation" | "qwen-detached" | "qwen-stalled"; message: string };
67
68
  /** Fixed builtins with session.nativeRevert only. Native conversation effects remain in the adapter. */
68
69
  revertTurn?(nativeTurn: string): Promise<void>;
70
+ /** #445 C1: what this peer's native source lists right now (a probed version only), or an empty manifest. Never throws for an unprobed version; a transport failure rejects. */
71
+ capabilityManifest?(): Promise<CapabilityManifest>;
69
72
  /** Leave the roster without stopping a person's terminal. */
70
73
  detachForRoster?(resetMode: PermissionMode): void | Promise<void>;
71
74
  /** Return to the roster after detachForRoster. */
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
5
  import type { Envelope, PeerId, PeerState } from "./envelope.ts";
6
- import { MAX_HOP } from "./envelope.ts";
6
+ import { KINDS, MAX_HOP } from "./envelope.ts";
7
7
  import { validateJournalSnapshot } from "./delivery-journal.ts";
8
8
  import { atomicPrivateJSON } from "./recovery-store.ts";
9
9
  import { RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
@@ -76,7 +76,7 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
76
76
  const isEnvelope = (value: unknown, seen = false): boolean => {
77
77
  if (!value || typeof value !== "object") return false;
78
78
  const e = value as Record<string, unknown>;
79
- return typeof e.id === "string" && e.id.length > 0 && e.id.length <= 256 && typeof e.trace === "string" && e.trace.length > 0 && e.trace.length <= 256 && peerId(e.from) && typeof e.body === "string" && Number.isSafeInteger(e.hop) && (e.hop as number) >= 0 && (seen || (e.hop as number) <= MAX_HOP) && Number.isFinite(e.ts) && ["chat", "task", "review", "status", "budget", "presence"].includes(e.kind as string) && ["important", "status", "fyi"].includes(e.priority as string) && (e.to === undefined || (Array.isArray(e.to) && e.to.every(peerId)));
79
+ return typeof e.id === "string" && e.id.length > 0 && e.id.length <= 256 && typeof e.trace === "string" && e.trace.length > 0 && e.trace.length <= 256 && peerId(e.from) && typeof e.body === "string" && Number.isSafeInteger(e.hop) && (e.hop as number) >= 0 && (seen || (e.hop as number) <= MAX_HOP) && Number.isFinite(e.ts) && (KINDS as readonly string[]).includes(e.kind as string) && ["important", "status", "fyi"].includes(e.priority as string) && (e.to === undefined || (Array.isArray(e.to) && e.to.every(peerId)));
80
80
  };
81
81
  const bus = s.bus as Partial<BusSnapshot>;
82
82
  if (bus.schemaVersion !== 1 || !bus.queues || typeof bus.queues !== "object" || !bus.prefaces || typeof bus.prefaces !== "object" || !Array.isArray(bus.seen) || !bus.attempts || typeof bus.attempts !== "object" || !Array.isArray(bus.withdrawn)) return undefined;
@@ -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));
@@ -87,7 +87,7 @@ export const SETTINGS: readonly SettingDef[] = [
87
87
  * Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
88
88
  * text go. A key equal to an entry, or below it, is refused by name.
89
89
  */
90
- 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
91
 
92
92
  /**
93
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
@@ -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";
@@ -262,6 +263,7 @@ QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits
262
263
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
263
264
  QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
264
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");
265
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.
266
268
  /** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
267
269
  const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
@@ -609,6 +611,17 @@ export class Tasks {
609
611
  });
610
612
  }
611
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
+
612
625
  recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
613
626
  requireLocalTaskPeer(peer);
614
627
  this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
@@ -865,6 +878,7 @@ export class Tasks {
865
878
  ...(h.reviewFindings ? { reviewFindings: !h.withheld && this.findingTextAllowed(task, h.reviewFindings.items) ? h.reviewFindings
866
879
  : { ...(h.reviewFindings.head ? { head: h.reviewFindings.head } : {}), tally: findingTally(h.reviewFindings.items), withheld: true } } : {}),
867
880
  ...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
881
+ ...(h.invocation ? { invocation: h.invocation } : {}),
868
882
  ...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
869
883
  ...(h.owner === undefined ? {} : { owner: h.owner }),
870
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 {