@staix/agent-hub 0.32.0 → 0.34.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 (58) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -2
  3. package/docs/design/bus.md +68 -2
  4. package/docs/dogfood.md +3 -1
  5. package/docs/memory.md +9 -3
  6. package/docs/operations.md +124 -20
  7. package/docs/quickstart.md +32 -2
  8. package/docs/security.md +28 -9
  9. package/docs/specs/2026-09-19-agent-hub-design.md +36 -6
  10. package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
  11. package/docs/verification/2026-11-06-0.33.0.md +222 -0
  12. package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
  13. package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
  14. package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
  15. package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
  16. package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
  17. package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
  18. package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
  19. package/docs/verification/2026-11-07-0.34.0.md +71 -0
  20. package/docs/verification/2026-11-07-compat-receipt-audit-1248.md +256 -0
  21. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  22. package/docs/verified.json +11 -11
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +528 -206
  26. package/src/adapters/compat.ts +11 -11
  27. package/src/adapters/definitions/native-cli-claude-setup.ts +14 -5
  28. package/src/broker/protocol.ts +5 -0
  29. package/src/broker/server.ts +54 -4
  30. package/src/broker/store.ts +6 -3
  31. package/src/cli/autonomy-homes.ts +12 -3
  32. package/src/cli/claude-homes.ts +5 -5
  33. package/src/cli/claude-profile-hook.ts +38 -9
  34. package/src/cli/install-paths.ts +71 -0
  35. package/src/cli/main.ts +20 -1
  36. package/src/cli/output.ts +5 -1
  37. package/src/cli/room.ts +17 -5
  38. package/src/cli/runtime-version-warning.ts +8 -6
  39. package/src/cli/setup-homes.ts +13 -10
  40. package/src/cli/status-lines.ts +8 -1
  41. package/src/cli/terminal-recovery.ts +96 -18
  42. package/src/cli/upgrade-interactive.ts +29 -6
  43. package/src/cli/upgrade-output.ts +3 -2
  44. package/src/cli/upgrade-runtime.ts +34 -4
  45. package/src/cli/upgrade.ts +3 -1
  46. package/src/hub/bus.ts +7 -0
  47. package/src/hub/daemon.ts +13 -4
  48. package/src/hub/handoff.ts +1 -1
  49. package/src/rooms/application.ts +15 -0
  50. package/src/rooms/commands.ts +76 -16
  51. package/src/rooms/health.ts +32 -0
  52. package/src/rooms/link.ts +267 -43
  53. package/src/rooms/remote-peer.ts +18 -3
  54. package/src/rooms/service.ts +27 -12
  55. package/src/rooms/status.ts +57 -1
  56. package/src/rooms/store.ts +20 -1
  57. package/src/rooms/wire.ts +10 -8
  58. package/src/ui/index.html +1 -1
package/src/hub/bus.ts CHANGED
@@ -266,6 +266,8 @@ export class Bus {
266
266
  private condensing = 0;
267
267
  private closed = false;
268
268
  storageError?: string;
269
+ /** Verified durable-write recovery edge; observation never changes settlement. */
270
+ onStorageRecovered?: () => void;
269
271
 
270
272
  constructor(opts: Partial<BusOptions> = {}) {
271
273
  this.opts = { ...DEFAULT_BUS, ...opts };
@@ -423,6 +425,7 @@ export class Bus {
423
425
  private persist(): void {
424
426
  if (!this.journal) return;
425
427
  const tracking = this.trackingQueuePressure();
428
+ const wasUnavailable = this.storageError !== undefined;
426
429
  try {
427
430
  this.journal.transaction(() => {
428
431
  for (const outcome of this.pendingOutcomes) this.journal!.createDelivery(outcome);
@@ -430,6 +433,10 @@ export class Bus {
430
433
  });
431
434
  this.pendingOutcomes.length = 0;
432
435
  this.storageError = undefined;
436
+ if (wasUnavailable) queueMicrotask(() => {
437
+ if (this.closed || this.storageError) return;
438
+ try { this.onStorageRecovered?.(); } catch { /* Recovery observers cannot undo the durable write. */ }
439
+ });
433
440
  }
434
441
  catch { this.storageError = "delivery journal unavailable"; throw new Error("delivery journal unavailable"); }
435
442
  }
package/src/hub/daemon.ts CHANGED
@@ -5693,7 +5693,7 @@ export async function startDaemon(opts: DaemonOptions) {
5693
5693
  screen: opts.piiScreen ?? (text => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
5694
5694
  policyVersion: () => configRevision,
5695
5695
  limits: () => config.limits,
5696
- notice: value => notify(value.code === "application_capacity" ? `room ${value.room}: application capacity reached (${value.count}); resolve incoming uncertainty with ahub room resolve` : `room ${value.room}: signed frame sequence gap ${value.count} from ${value.sender}`),
5696
+ notice: value => notify(`room ${value.room}: application capacity reached (${value.count}); resolve incoming uncertainty with ahub room resolve`),
5697
5697
  invocationAuthorization: (env, identity) => roomInvocations.authorization(env, identity), invocation: input => roomInvocations.receive(input),
5698
5698
  roster: ids => { remoteRoster.clear(); for (const id of ids) remoteRoster.add(id); },
5699
5699
  changed: () => { if (ready && !stopping) writeStatus(); },
@@ -5702,9 +5702,14 @@ export async function startDaemon(opts: DaemonOptions) {
5702
5702
  remoteQueueChanged = () => roomChat.queueChanged();
5703
5703
  const roomCommands = new RoomCommands({ stateDir: opts.stateDir, cwd: opts.cwd,
5704
5704
  presence: () => [...bus.peers].filter(([id]) => !id.includes("@") && membershipAllowed(id)).map(([id, peer]) => ({ id, state: peer.state })), chat: roomChat,
5705
+ changed: () => { if (ready && !stopping) writeStatus(); },
5705
5706
  });
5706
5707
  // Room crypto/state failure disables only this optional outbound service, never local work.
5707
- void roomCommands.start().catch(() => log("rooms unavailable"));
5708
+ bus.onStorageRecovered = () => { if (!stopping) void roomChat.retryPending().catch(() => undefined); };
5709
+ void roomCommands.start().catch(() => {
5710
+ const failure = roomCommands.unavailable();
5711
+ log(`rooms unavailable (${failure?.code ?? "unavailable"}); ${failure?.nextStep ?? "check room storage and restart the hub"}`);
5712
+ });
5708
5713
  let roomInboxExpiryFailed = false;
5709
5714
  const roomInboxExpiry = setInterval(() => {
5710
5715
  if (stopping || recoveryActive() || bus.isRecoveryHeld) return;
@@ -6021,7 +6026,7 @@ export async function startDaemon(opts: DaemonOptions) {
6021
6026
  ...(crashLines.length ? { crash: crashLines } : {}),
6022
6027
  projectId,
6023
6028
  instanceId,
6024
- rooms: roomCommands.statusView(),
6029
+ ...roomCommands.statusSnapshot(),
6025
6030
  version: VERSION,
6026
6031
  pluginBundle: SERVER_JS,
6027
6032
  pendingRestart: allPendingRestart(),
@@ -10794,7 +10799,11 @@ export async function startDaemon(opts: DaemonOptions) {
10794
10799
  if (msg.op === "list" || msg.op === "status") {
10795
10800
  if (msg.input !== undefined && (!msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)
10796
10801
  || Object.keys(msg.input).some(key => key !== "name") || (msg.input.name !== undefined && typeof msg.input.name !== "string"))) return roomFailure();
10797
- void roomCommands.rows(msg.input?.name, msg.op === "status").then(rooms => reply({ ok: true, rooms }), roomFailure);
10802
+ void roomCommands.rows(msg.input?.name, msg.op === "status").then(rooms => reply({ ok: true, rooms }), error => {
10803
+ const roomsUnavailable = roomCommands.unavailable();
10804
+ if (roomsUnavailable) reply({ ok: false, code: "rooms_unavailable", roomsUnavailable });
10805
+ else roomFailure(error);
10806
+ });
10798
10807
  return;
10799
10808
  }
10800
10809
  if (["inbox_list", "inbox_show", "inbox_done", "inbox_discard"].includes(msg.op)) {
@@ -130,7 +130,7 @@ export function resolveTarget(name: string, projects: readonly RegisteredProject
130
130
  export function handoffConnectFailure(error: unknown, target: { id: string; root: string }): { code: HandoffRefusalCode; reach: "unreachable"; cause: string; message: string } {
131
131
  const code = typeof (error as { code?: unknown })?.code === "number" || typeof (error as { code?: unknown })?.code === "string" ? (error as { code: number | string }).code : undefined;
132
132
  if (code === 4426) return { code: "older_target", reach: "unreachable", cause: "wire version differs",
133
- message: `the hub of ${target.id} speaks another wire version than this one: upgrade whichever is older (\`ahub upgrade\` in ${target.root}, and the global CLI with \`bun add -g @staix/agent-hub\`), then hand the task off again` };
133
+ message: `the hub of ${target.id} speaks another wire version than this one: upgrade whichever is older (\`ahub upgrade\` in ${target.root}, and the global CLI the way it was installed; \`ahub doctor\` names the exact step), then hand the task off again` };
134
134
  if (code === 4404) return { code: "mismatch", reach: "unreachable", cause: "another hub or instance answered",
135
135
  message: `the hub answering for ${target.root} is not the instance this project's registry names (it was restarted, or another project took its port): run \`ahub projects\` to see what is registered, and \`ahub up\` in ${target.root} if it is not running` };
136
136
  if (code === "AHUB_CONNECT_TIMEOUT") return { code: "not_running", reach: "unreachable", cause: "no answer in time",
@@ -4,6 +4,21 @@ import { decodeBase64url } from "./crypto.ts";
4
4
  import { canonicalBytes, ROOM_WIRE_LIMITS } from "./wire.ts";
5
5
 
6
6
  export const ROOM_APPLICATION_MAX_BYTES = ROOM_WIRE_LIMITS.plaintext;
7
+ /** Compatibility evidence only: never a permission, privacy or capability grant. */
8
+ export const ROOM_CODEC_FEATURES = ["chat_v1", "person_inbox_v1", "read_invocation_v1"] as const;
9
+ export type RoomCodecFeature = typeof ROOM_CODEC_FEATURES[number];
10
+ export const ROOM_CODEC_FEATURE_LIMIT = 16, ROOM_CODEC_FEATURE_NAME_LIMIT = 64;
11
+ export function roomCodecFeatures(value: unknown): readonly string[] {
12
+ if (!Array.isArray(value) || value.length > ROOM_CODEC_FEATURE_LIMIT
13
+ || value.some((item, index) => typeof item !== "string" || item.length > ROOM_CODEC_FEATURE_NAME_LIMIT
14
+ || !/^[a-z][a-z0-9_]*$/.test(item) || index > 0 && value[index - 1] >= item)) fail();
15
+ return [...value] as string[];
16
+ }
17
+ export function roomRequiredCodecFeature(message: RoomMessage): RoomCodecFeature | undefined {
18
+ if (message.type !== "chat") return undefined;
19
+ if (message.envelope.kind === "invocation" || message.envelope.kind === "invocation_result") return "read_invocation_v1";
20
+ return message.envelope.to === "user" ? "person_inbox_v1" : undefined;
21
+ }
7
22
  export interface RoomChatEnvelope {
8
23
  id: string; trace: string; hop: number; from: string; to: string;
9
24
  kind: "chat" | "status" | "invocation" | "invocation_result"; priority: Priority; body: string; ts: number;
@@ -7,6 +7,7 @@ import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInb
7
7
  import { RoomLink } from "./link.ts";
8
8
  import { exposedPeers, roomName, RoomPolicy } from "./policy.ts";
9
9
  import type { RoomChat } from "./service.ts";
10
+ import { roomHealthCause, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
10
11
  import { projectRoomStatus, type RoomStatusView } from "./status.ts";
11
12
 
12
13
  export interface RoomCommandResult { kind?: string; artifact?: string; name?: string; room?: string; owner?: string; member?: string }
@@ -50,26 +51,64 @@ export class RoomCommands {
50
51
  private pending?: Promise<RoomStore>;
51
52
  private store?: RoomStore;
52
53
  private stopped = false;
54
+ private openFailure?: RoomHealthCause;
55
+ private openRetry?: ReturnType<typeof setTimeout>;
56
+ private openAttempts = 0;
53
57
  private stopWork?: Promise<void>;
54
58
  private readonly links = new Map<string, RoomLink>();
55
59
  private readonly policy: RoomPolicy;
56
- constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; refreshMs?: number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat }) { this.policy = new RoomPolicy(options.cwd); }
60
+ constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; refreshMs?: number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat; openStore?: typeof RoomStore.open; retryMs?: number; changed?: () => void }) { this.policy = new RoomPolicy(options.cwd); }
61
+ private changedAvailability(): void {
62
+ try { this.options.changed?.(); } catch { /* Diagnostics cannot change store admission. */ }
63
+ }
64
+ private retryOpen(): void {
65
+ if (this.stopped || this.openRetry) return;
66
+ const delay = Math.min(30_000, (this.options.retryMs ?? 1000) * 2 ** Math.min(this.openAttempts++, 5));
67
+ this.openRetry = setTimeout(() => {
68
+ this.openRetry = undefined;
69
+ if (!this.stopped) void this.start().catch(() => undefined);
70
+ }, delay);
71
+ this.openRetry.unref?.();
72
+ }
57
73
  private async open(): Promise<RoomStore> {
58
74
  if (this.stopped) refuse("stopped");
59
- this.pending ??= RoomStore.open({ stateDir: join(this.options.stateDir, "rooms"), ...(this.options.clock ? { clock: this.options.clock } : {}) }).then(store => {
75
+ if (this.store) return this.store;
76
+ if (this.openRetry) throw new Error(`rooms unavailable (${this.openFailure ?? "unavailable"}); ${roomHealthNextStep(this.openFailure ?? "unavailable")}`);
77
+ this.pending ??= (this.options.openStore ?? RoomStore.open)({ stateDir: join(this.options.stateDir, "rooms"), ...(this.options.clock ? { clock: this.options.clock } : {}) }).then(store => {
60
78
  if (this.stopped) { store.close(); return refuse("stopped"); }
61
- this.store = store; return store;
79
+ this.store = store; this.openFailure = undefined; this.openAttempts = 0;
80
+ this.changedAvailability(); return store;
81
+ }).catch(error => {
82
+ this.pending = undefined;
83
+ if (!this.stopped) {
84
+ const cause = roomHealthCause(error), changed = this.openFailure !== cause;
85
+ this.openFailure = cause; this.retryOpen();
86
+ if (changed) this.changedAvailability();
87
+ }
88
+ throw error;
62
89
  });
63
90
  return this.pending;
64
91
  }
65
92
  async start(): Promise<void> {
66
- if (!existsSync(join(this.options.stateDir, "rooms"))) return;
67
- const store = await this.open();
68
- if (!this.stopped) for (const state of store.list()) if (!state.left) this.link(state.name);
93
+ if (this.stopped || !existsSync(join(this.options.stateDir, "rooms"))) return;
94
+ try {
95
+ const store = await this.open();
96
+ if (!this.stopped) for (const state of store.list()) if (!state.left) this.link(state.name);
97
+ const recovered = this.openFailure !== undefined;
98
+ this.openFailure = undefined; this.openAttempts = 0;
99
+ if (recovered) this.changedAvailability();
100
+ } catch (error) {
101
+ if (!this.stopped && !this.openRetry) {
102
+ const cause = roomHealthCause(error), changed = this.openFailure !== cause;
103
+ this.openFailure = cause; this.retryOpen();
104
+ if (changed) this.changedAvailability();
105
+ }
106
+ throw error;
107
+ }
69
108
  }
70
109
  stop(): Promise<void> {
71
110
  if (this.stopWork) return this.stopWork;
72
- this.stopped = true; clearTimeout(this.presenceTimer); this.options.chat?.stop();
111
+ this.stopped = true; clearTimeout(this.presenceTimer); clearTimeout(this.openRetry); this.options.chat?.stop();
73
112
  const stops = [...this.links.values()].map(link => link.stop()); this.links.clear();
74
113
  this.stopWork = Promise.allSettled([...(this.pending ? [this.pending] : []), ...stops]).then(() => { this.store?.close(); });
75
114
  return this.stopWork;
@@ -81,7 +120,7 @@ export class RoomCommands {
81
120
  const store = this.store;
82
121
  const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application, refreshMs: this.options.refreshMs, now: this.options.clock,
83
122
  onPresence: value => this.options.chat?.presence(store, name, created, value),
84
- onState: state => this.options.chat?.linkState(store, name, created, state), presence: () => {
123
+ onState: state => { this.options.chat?.linkState(store, name, created, state); this.changedAvailability(); }, presence: () => {
85
124
  const allowed = new Set(this.policy.get(name));
86
125
  return (this.options.presence?.() ?? []).filter(row => allowed.has(row.id));
87
126
  } });
@@ -166,21 +205,42 @@ export class RoomCommands {
166
205
  async rows(name?: string, details = name !== undefined): Promise<Record<string, unknown>[]> {
167
206
  if (!this.store && !existsSync(join(this.options.stateDir, "rooms"))) return [];
168
207
  const store = await this.open(); if (name !== undefined) roomName(name);
169
- return store.list().filter(row => name === undefined || row.name === name).map(row => ({ name: row.name, room: row.current.room, owner: this.owner(row), member: store.identity.public.fingerprint, epoch: row.current.epoch, state: row.left ? "left" : this.links.get(row.name)?.status().state ?? "offline", ...(details && this.options.chat ? { deliveries: this.options.chat.rows(row.name), remotePeers: this.options.chat.peers(row.name) } : {}) }));
208
+ return store.list().filter(row => name === undefined || row.name === name).map(row => {
209
+ const link = this.links.get(row.name)?.status();
210
+ const result: Record<string, unknown> = { name: row.name, room: row.current.room, owner: this.owner(row), member: store.identity.public.fingerprint, epoch: row.current.epoch,
211
+ state: row.left ? "left" : link?.state ?? "offline" };
212
+ if (details) {
213
+ result.linkCause = null;
214
+ if (!row.left) result.linkCause = link?.cause ?? null;
215
+ result.health = store.health(row.name);
216
+ if (this.options.chat) {
217
+ result.deliveries = this.options.chat.rows(row.name);
218
+ result.remotePeers = this.options.chat.peers(row.name);
219
+ }
220
+ }
221
+ return result;
222
+ });
170
223
  }
171
- /** Polling uses only already-loaded public metadata; it never opens room state or reads message rows. */
172
- statusView(): RoomStatusView[] | null {
173
- if (this.stopped) return null;
174
- if (!this.store) return this.pending ? null : [];
224
+ /** One metadata snapshot binds unavailable diagnostics to the reads that failed. */
225
+ statusSnapshot(): { rooms: RoomStatusView[] | null; roomsOpening?: true; roomsUnavailable?: { code: RoomHealthCause; nextStep: string } } {
226
+ const unavailable = (code: RoomHealthCause) => ({ rooms: null, roomsUnavailable: { code, nextStep: roomHealthNextStep(code) } });
227
+ if (this.stopped) return { rooms: null };
228
+ if (this.openFailure) return unavailable(this.openFailure);
229
+ if (!this.store) return this.pending ? { rooms: null, roomsOpening: true } : { rooms: [] };
175
230
  try {
176
- return projectRoomStatus(this.store.statusMetadata().map(row => ({
231
+ return { rooms: projectRoomStatus(this.store.statusMetadata().map(row => ({
177
232
  name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch,
178
233
  state: row.left ? "left" : this.links.get(row.name)?.connectionState ?? "offline", members: row.members,
234
+ linkCause: row.left ? null : this.links.get(row.name)?.connectionCause ?? null, health: this.store!.health(row.name),
179
235
  remotePeers: this.options.chat?.peersForStatus(row.name) ?? null,
180
236
  relays: this.options.chat?.metrics(row.name, row.room) ?? null,
181
- })));
182
- } catch { return null; }
237
+ }))) };
238
+ } catch (error) { return unavailable(roomHealthCause(error)); }
183
239
  }
240
+ /** Closed cause metadata only, never raw errors; unavailable is distinct from no rooms. */
241
+ unavailable(): { code: RoomHealthCause; nextStep: string } | undefined { return this.statusSnapshot().roomsUnavailable; }
242
+ /** Polling uses loaded public metadata and aggregate counts; never opens storage or loads payloads. */
243
+ statusView(): RoomStatusView[] | null { return this.statusSnapshot().rooms; }
184
244
  async preview(action: unknown, raw: unknown, check: () => boolean): Promise<RoomPreview> {
185
245
  if (typeof action !== "string") refuse("invalid_operation");
186
246
  await assertRoomCryptoReady(); this.current(check);
@@ -0,0 +1,32 @@
1
+ import { RoomStoreError } from "./store.ts";
2
+
3
+ /** Closed diagnostics only; never carry a filesystem error, path or message body. */
4
+ export const ROOM_HEALTH_CAUSES = ["invalid", "busy", "corrupt", "unsupported", "limit", "untrusted", "state_changed", "clock_rollback", "closed", "inbox_list_limit", "unavailable", "connecting", "transport_closed", "backpressure", "broker_refused", "codec_unsupported", "codec_pending"] as const;
5
+ export type RoomHealthCause = typeof ROOM_HEALTH_CAUSES[number];
6
+ export function roomHealthCause(error: unknown): RoomHealthCause {
7
+ return error instanceof RoomStoreError && ROOM_HEALTH_CAUSES.includes(error.code) ? error.code : "unavailable";
8
+ }
9
+ export function roomHealthNextStep(code: RoomHealthCause): string {
10
+ switch (code) {
11
+ case "clock_rollback": return "Correct the system clock to the last recorded room time; room links retry automatically.";
12
+ case "busy": return "Release the competing room store writer; the hub retries automatically.";
13
+ case "corrupt": case "invalid": case "untrusted": return "Inspect local room state and restore a trusted backup before retrying; do not delete replay records.";
14
+ case "unsupported": return "Upgrade agent-hub to a version supporting this room store, then restart the hub.";
15
+ case "limit": case "inbox_list_limit": return "Inspect ahub room status and ahub room inbox; resolve pending work before retrying.";
16
+ case "connecting": return "Wait for the broker connection; inspect ahub room status if it stays connecting.";
17
+ case "backpressure": return "Wait for the broker queue to drain; stored frames retry automatically.";
18
+ case "codec_pending": return "Wait for the member's signed codec advertisement; queued messages keep their original expiry. Inspect ahub room status if waiting continues.";
19
+ case "codec_unsupported": return "Upgrade both hubs and the broker, reconnect the room, then retry after signed codec support is available.";
20
+ case "broker_refused": return "Check broker availability and the signed room membership, then retry.";
21
+ case "transport_closed": return "Check the broker address and network connection; the link retries automatically.";
22
+ case "closed": return "Restart the hub to reopen room storage.";
23
+ case "state_changed": return "Read ahub room status again before retrying the operation.";
24
+ default: return "Check room storage permissions and broker availability; the hub retries store open automatically.";
25
+ }
26
+ }
27
+ export interface RoomHealth {
28
+ refused: Partial<Record<"invalid_presence" | "wrong_epoch" | "trust_changed" | "stale" | "invalid_frame", number>>;
29
+ outboxDepth: number;
30
+ oldestOutboxAgeMs: number | null;
31
+ lastReceiptAgeMs: number | null;
32
+ }