@staix/agent-hub 0.31.0 → 0.33.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 (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/bus.md +3 -1
  4. package/docs/agent-notes/daemon.md +1 -0
  5. package/docs/agent-notes/tests.md +2 -2
  6. package/docs/design/bus.md +109 -4
  7. package/docs/dogfood.md +2 -0
  8. package/docs/memory.md +9 -3
  9. package/docs/operations.md +98 -17
  10. package/docs/quickstart.md +18 -2
  11. package/docs/security.md +52 -9
  12. package/docs/specs/2026-09-19-agent-hub-design.md +75 -5
  13. package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
  14. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +55 -1
  15. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  16. package/docs/verification/2026-11-06-0.33.0.md +222 -0
  17. package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
  18. package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
  19. package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
  20. package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
  21. package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
  22. package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
  23. package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
  24. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  25. package/docs/verified.json +11 -11
  26. package/package.json +2 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +528 -206
  29. package/src/adapters/compat.ts +11 -11
  30. package/src/broker/protocol.ts +5 -0
  31. package/src/broker/server.ts +54 -4
  32. package/src/broker/store.ts +6 -3
  33. package/src/cli/console-state.ts +1 -1
  34. package/src/cli/output.ts +5 -1
  35. package/src/cli/room.ts +34 -12
  36. package/src/cli/status-lines.ts +8 -1
  37. package/src/cli/terminal-recovery.ts +37 -15
  38. package/src/hub/bus.ts +25 -0
  39. package/src/hub/daemon.ts +20 -9
  40. package/src/rooms/application.ts +15 -0
  41. package/src/rooms/commands.ts +135 -30
  42. package/src/rooms/health.ts +32 -0
  43. package/src/rooms/link.ts +394 -56
  44. package/src/rooms/liveness.ts +11 -0
  45. package/src/rooms/remote-peer.ts +18 -3
  46. package/src/rooms/service.ts +167 -28
  47. package/src/rooms/status.ts +65 -2
  48. package/src/rooms/store.ts +30 -1
  49. package/src/rooms/wire.ts +10 -8
  50. package/src/ui/index.html +5 -3
package/src/hub/daemon.ts CHANGED
@@ -4,7 +4,7 @@ import { readSdlcPr } from "./sdlc.ts";
4
4
  import type { ActionMeta } from "@staix/agent-console/contract";
5
5
  import { isReviewObserverExcluded, reviewCaptureExcluded } from "./memory-policy.ts";
6
6
  import { RoomStoreError } from "../rooms/store.ts";
7
- import { RoomCommands, type RoomPreview } from "../rooms/commands.ts";
7
+ import { RoomCommands, RoomRotationHoldError, type RoomPreview } from "../rooms/commands.ts";
8
8
  import { queueBackpressurePolicy, type QueueBackpressurePolicy } from "./queue-backpressure.ts";
9
9
  import { RoomChat, type RoomInvocationIngress } from "../rooms/service.ts";
10
10
  import { RoomInvocations, roomInvocationHash } from "./room-invocations.ts";
@@ -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(),
@@ -10788,11 +10793,17 @@ export async function startDaemon(opts: DaemonOptions) {
10788
10793
  const personRoom = c.role === "console";
10789
10794
  const roomCurrent = () => !stopping && !recoveryActive() && !bus.isRecoveryHeld
10790
10795
  && sock.data === client && sock.readyState === WebSocket.OPEN && personRoom;
10791
- const roomFailure = () => reply({ ok: false, error: "room operation refused; check room state, authority and arguments" });
10796
+ const roomFailure = (error?: unknown) => error instanceof RoomRotationHoldError
10797
+ ? reply({ ok: false, code: "epoch_rotated_hold_unavailable", error: error.message })
10798
+ : reply({ ok: false, error: "room operation refused; check room state, authority and arguments" });
10792
10799
  if (msg.op === "list" || msg.op === "status") {
10793
10800
  if (msg.input !== undefined && (!msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)
10794
10801
  || Object.keys(msg.input).some(key => key !== "name") || (msg.input.name !== undefined && typeof msg.input.name !== "string"))) return roomFailure();
10795
- 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
+ });
10796
10807
  return;
10797
10808
  }
10798
10809
  if (["inbox_list", "inbox_show", "inbox_done", "inbox_discard"].includes(msg.op)) {
@@ -10831,7 +10842,7 @@ export async function startDaemon(opts: DaemonOptions) {
10831
10842
  if (!roomCurrent()) return roomFailure();
10832
10843
  const ticket = crypto.randomUUID();
10833
10844
  roomTickets.set(sock, { id: ticket, expires: Date.now() + 30_000, preview });
10834
- reply({ ok: true, owner: preview.owner, member: preview.member, ticket });
10845
+ reply({ ok: true, owner: preview.owner, member: preview.member, ticket, ...(preview.outstanding ? { outstanding: preview.outstanding } : {}) });
10835
10846
  }, roomFailure);
10836
10847
  return;
10837
10848
  }
@@ -11312,7 +11323,7 @@ export async function startDaemon(opts: DaemonOptions) {
11312
11323
  async function stopOnce(): Promise<void> {
11313
11324
  stopping = true;
11314
11325
  controlWriteTelemetry?.stop();
11315
- roomCommands.stop();
11326
+ const roomsStopped = roomCommands.stop().catch(() => { log("room shutdown failed; inspect ahub room status after restart"); });
11316
11327
  tasks.stopQueuePressureRechecks();
11317
11328
  const studiesStopped = tasks.stopStudies();
11318
11329
  const reviewCommentsStopped = tasks.stopReviewComments();
@@ -11346,7 +11357,7 @@ export async function startDaemon(opts: DaemonOptions) {
11346
11357
  await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses, studiesStopped, reviewCommentsStopped]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
11347
11358
  clearTimeout(timer);
11348
11359
  }
11349
- await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
11360
+ await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped, roomsStopped]);
11350
11361
  await terminalLaunches.settled();
11351
11362
  // A peer can still be inside memory recall or its native handshake when kill arrives.
11352
11363
  // Drain those starts before taking the final owned-process snapshot.
@@ -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;
@@ -3,14 +3,18 @@ import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { assertRoomCryptoReady, decodeBase64url, encodeBase64url, signBytes, verifyBytes } from "./crypto.ts";
5
5
  import { acceptRoomEpoch, brokerUrl, canonicalBytes, createInvite, createJoin, createRoomEpoch, decodeInvite, decodeJoin, decodeRoster, roomMembers } from "./wire.ts";
6
- import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInboxItem } from "./store.ts";
6
+ import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInboxItem, type RoomOutgoing } from "./store.ts";
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 }
13
- export interface RoomPreview { owner: string; member: string; commit: () => Promise<RoomCommandResult> }
14
+ export interface RoomPreview { owner: string; member: string; outstanding?: readonly string[]; commit: () => Promise<RoomCommandResult> }
15
+ export class RoomRotationHoldError extends Error {
16
+ constructor() { super("room epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation"); this.name = "RoomRotationHoldError"; }
17
+ }
14
18
  const FP = /^[0-9a-f]{64}$/;
15
19
  function refuse(code: string): never { throw new Error(`room: ${code}`); }
16
20
  function fields(input: unknown, keys: string[], optional: string[] = []): Record<string, any> {
@@ -47,38 +51,83 @@ export class RoomCommands {
47
51
  private pending?: Promise<RoomStore>;
48
52
  private store?: RoomStore;
49
53
  private stopped = false;
54
+ private openFailure?: RoomHealthCause;
55
+ private openRetry?: ReturnType<typeof setTimeout>;
56
+ private openAttempts = 0;
57
+ private stopWork?: Promise<void>;
50
58
  private readonly links = new Map<string, RoomLink>();
51
59
  private readonly policy: RoomPolicy;
52
- constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => 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
+ }
53
73
  private async open(): Promise<RoomStore> {
54
74
  if (this.stopped) refuse("stopped");
55
- 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 => {
56
78
  if (this.stopped) { store.close(); return refuse("stopped"); }
57
- 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;
58
89
  });
59
90
  return this.pending;
60
91
  }
61
92
  async start(): Promise<void> {
62
- if (!existsSync(join(this.options.stateDir, "rooms"))) return;
63
- const store = await this.open();
64
- 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
+ }
108
+ }
109
+ stop(): Promise<void> {
110
+ if (this.stopWork) return this.stopWork;
111
+ this.stopped = true; clearTimeout(this.presenceTimer); clearTimeout(this.openRetry); this.options.chat?.stop();
112
+ const stops = [...this.links.values()].map(link => link.stop()); this.links.clear();
113
+ this.stopWork = Promise.allSettled([...(this.pending ? [this.pending] : []), ...stops]).then(() => { this.store?.close(); });
114
+ return this.stopWork;
65
115
  }
66
- stop(): void { this.stopped = true; clearTimeout(this.presenceTimer); this.options.chat?.stop(); for (const link of this.links.values()) link.stop(); this.links.clear(); this.store?.close(); }
67
116
  private link(name: string): void {
68
117
  if (this.stopped || !this.store || this.store.get(name)?.left) return;
69
118
  let link = this.links.get(name);
70
119
  if (!link) {
71
120
  const store = this.store;
72
- const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application,
121
+ const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application, refreshMs: this.options.refreshMs, now: this.options.clock,
73
122
  onPresence: value => this.options.chat?.presence(store, name, created, value),
74
- 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: () => {
75
124
  const allowed = new Set(this.policy.get(name));
76
125
  return (this.options.presence?.() ?? []).filter(row => allowed.has(row.id));
77
126
  } });
78
127
  this.links.set(name, created); link = created;
79
128
  this.options.chat?.bind(store, name, created);
80
129
  void created.start().catch(() => undefined);
81
- } else { this.options.chat?.bind(this.store, name, link); link.refresh(); }
130
+ } else { this.options.chat?.bind(this.store, name, link); link.refresh(); void link.start().catch(() => undefined); }
82
131
  }
83
132
  private presenceTimer?: ReturnType<typeof setTimeout>;
84
133
  changed(): void {
@@ -91,6 +140,26 @@ export class RoomCommands {
91
140
  this.presenceTimer.unref?.();
92
141
  }
93
142
  private current(check: () => boolean): void { if (this.stopped || !check()) refuse("state_changed"); }
143
+ private async rotate(store: RoomStore, name: string, digest: string, expected: readonly RoomOutgoing[], check: () => boolean,
144
+ operation: (current: () => boolean) => Promise<RoomState>, after?: () => void): Promise<RoomState> {
145
+ const ids = expected.map(row => row.deliveryId).sort(), release = await this.links.get(name)?.quiesceApplications();
146
+ let next: RoomState | undefined, returned = false;
147
+ const current = () => !this.stopped && check() && store.get(name)?.current.digest === digest
148
+ && JSON.stringify(store.rotationPending(name).map(row => row.deliveryId).sort()) === JSON.stringify(ids);
149
+ try {
150
+ if (!current()) refuse("state_changed");
151
+ next = await operation(current);
152
+ const held = next.current.digest === digest || this.options.chat?.epochRotated(store, name, digest, expected) !== false;
153
+ this.current(check); after?.();
154
+ if (!held) throw new RoomRotationHoldError();
155
+ returned = true; return next;
156
+ } finally {
157
+ release?.();
158
+ // A committed epoch still needs publication if its journal label or local
159
+ // policy update failed; its old sealed intents were already fenced atomically.
160
+ if (next && !returned && next.current.digest !== digest) this.link(name);
161
+ }
162
+ }
94
163
  private active(store: RoomStore, name: string): RoomState {
95
164
  const state = store.get(name); if (!state || state.left) refuse("unavailable"); return state;
96
165
  }
@@ -136,21 +205,42 @@ export class RoomCommands {
136
205
  async rows(name?: string, details = name !== undefined): Promise<Record<string, unknown>[]> {
137
206
  if (!this.store && !existsSync(join(this.options.stateDir, "rooms"))) return [];
138
207
  const store = await this.open(); if (name !== undefined) roomName(name);
139
- 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
+ });
140
223
  }
141
- /** Polling uses only already-loaded public metadata; it never opens room state or reads message rows. */
142
- statusView(): RoomStatusView[] | null {
143
- if (this.stopped) return null;
144
- 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: [] };
145
230
  try {
146
- return projectRoomStatus(this.store.statusMetadata().map(row => ({
231
+ return { rooms: projectRoomStatus(this.store.statusMetadata().map(row => ({
147
232
  name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch,
148
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),
149
235
  remotePeers: this.options.chat?.peersForStatus(row.name) ?? null,
150
236
  relays: this.options.chat?.metrics(row.name, row.room) ?? null,
151
- })));
152
- } catch { return null; }
237
+ }))) };
238
+ } catch (error) { return unavailable(roomHealthCause(error)); }
153
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; }
154
244
  async preview(action: unknown, raw: unknown, check: () => boolean): Promise<RoomPreview> {
155
245
  if (typeof action !== "string") refuse("invalid_operation");
156
246
  await assertRoomCryptoReady(); this.current(check);
@@ -196,9 +286,12 @@ export class RoomCommands {
196
286
  ? { room: existing.current.room, owner, minEpoch: existing.current.epoch, digest: existing.current.digest }
197
287
  : { room: invitation!.room, owner, minEpoch: "0" } });
198
288
  this.current(check);
199
- return { owner, member: own, commit: async () => {
289
+ const outstanding = existing ? store.rotationPending(name) : [];
290
+ return { owner, member: own, ...(existing ? { outstanding: outstanding.map(row => row.deliveryId).sort() } : {}), commit: async () => {
200
291
  this.current(check);
201
- const state = await store.install({ name, broker, wire, expectedDigest: existing?.current.digest ?? null, current: check, reactivate: true });
292
+ const state = existing ? await this.rotate(store, name, existing.current.digest, outstanding, check, current =>
293
+ store.install({ name, broker, wire, expectedDigest: existing.current.digest, current, reactivate: true }))
294
+ : await store.install({ name, broker, wire, expectedDigest: null, current: check, reactivate: true });
202
295
  return completed(state);
203
296
  } };
204
297
  }
@@ -235,7 +328,11 @@ export class RoomCommands {
235
328
  const state = this.active(store, name), owner = this.owner(state), digest = state.current.digest;
236
329
  if (action === "leave") return { owner, member: own, commit: async () => {
237
330
  this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
238
- store.leave(name); this.links.get(name)?.stop(); this.links.delete(name); return { name, room: state.current.room, owner, member: own };
331
+ await this.links.get(name)?.stop();
332
+ try {
333
+ this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
334
+ store.leave(name); this.links.delete(name); return { name, room: state.current.room, owner, member: own };
335
+ } catch (error) { this.link(name); throw error; }
239
336
  } };
240
337
  if (action === "invite") {
241
338
  if (owner !== own) refuse("owner_only");
@@ -254,9 +351,12 @@ export class RoomCommands {
254
351
  if (!sender || exposure.room !== state.current.room || exposure.owner !== owner || exposure.digest !== digest || !await verifyBytes({ fingerprint: sender.fingerprint, signingKey: sender.signingKey, agreementKey: sender.agreementKey }, exposureBytes(body), decodeBase64url(signature, 64))) refuse("untrusted");
255
352
  }
256
353
  this.current(check);
257
- return { owner, member, commit: async () => {
354
+ const outstanding = store.rotationPending(name);
355
+ return { owner, member, outstanding: outstanding.map(row => row.deliveryId).sort(), commit: async () => {
258
356
  this.current(check);
259
- const next = exposure ? await store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === member ? { ...m, exposedPeers: exposedPeers(exposure.peers) } : m), digest, check) : await store.enroll(name, request, check);
357
+ const next = await this.rotate(store, name, digest, outstanding, check, current => exposure
358
+ ? store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === member ? { ...m, exposedPeers: exposedPeers(exposure.peers) } : m), digest, current)
359
+ : store.enroll(name, request, current));
260
360
  this.current(check); completed(next);
261
361
  return { name, owner, member, kind: "roster", artifact: encodeBase64url(next.wire) };
262
362
  } };
@@ -264,19 +364,24 @@ export class RoomCommands {
264
364
  if (action === "revoke") {
265
365
  if (owner !== own) refuse("owner_only");
266
366
  if (typeof row.member !== "string" || !FP.test(row.member) || row.member === own || !roomMembers(state.current).some(m => m.fingerprint === row.member)) refuse("invalid_member");
267
- return { owner, member: row.member, commit: async () => {
268
- this.current(check); const next = await store.rotate(name, roomMembers(state.current).filter(m => m.fingerprint !== row.member), digest, check); return completed(next);
367
+ const outstanding = store.rotationPending(name);
368
+ return { owner, member: row.member, outstanding: outstanding.map(row => row.deliveryId).sort(), commit: async () => {
369
+ this.current(check); const next = await this.rotate(store, name, digest, outstanding, check, current =>
370
+ store.rotate(name, roomMembers(state.current).filter(m => m.fingerprint !== row.member), digest, current)); return completed(next);
269
371
  } };
270
372
  }
271
373
  if (action === "expose") {
272
374
  const peers = exposedPeers(row.peers ?? []);
273
375
  this.policy.assertWritable(name, peers, check);
274
- return { owner, member: own, commit: async () => {
376
+ const outstanding = owner === own ? store.rotationPending(name) : [];
377
+ return { owner, member: own, ...(owner === own ? { outstanding: outstanding.map(row => row.deliveryId).sort() } : {}), commit: async () => {
275
378
  this.current(check); this.policy.assertWritable(name, peers, check);
276
379
  if (store.get(name)?.current.digest !== digest) refuse("state_changed");
277
380
  if (owner === own) {
278
- const next = await store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === own ? { ...m, exposedPeers: peers } : m), digest, check);
279
- this.current(check); this.policy.set(name, peers, check); return completed(next);
381
+ const next = await this.rotate(store, name, digest, outstanding, check, current =>
382
+ store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === own ? { ...m, exposedPeers: peers } : m), digest, current),
383
+ () => this.policy.set(name, peers, check));
384
+ return completed(next);
280
385
  }
281
386
  const body: Omit<ExposureRequest, "signature"> = { v: 1, type: "exposure", room: state.current.room, owner, member: own, digest, peers };
282
387
  const request = canonicalBytes({ ...body, signature: encodeBase64url(await signBytes(store.identity, exposureBytes(body))) });
@@ -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
+ }