@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
@@ -0,0 +1,11 @@
1
+ /** Host timing only; signed presence remains the existing v1 ids/state payload. */
2
+ export const ROOM_REFRESH_MS = 30_000;
3
+ export const ROOM_PRESENCE_MISSES = 3;
4
+ export const ROOM_STOP_TIMEOUT_MS = 2_000;
5
+
6
+ export function roomRefreshPeriod(value = ROOM_REFRESH_MS): number {
7
+ if (!Number.isSafeInteger(value) || value < 1 || value > Math.floor(2_147_483_647 / ROOM_PRESENCE_MISSES)) {
8
+ throw new Error("room refresh period is invalid; use a positive whole duration within the timer bound");
9
+ }
10
+ return value;
11
+ }
@@ -3,7 +3,7 @@ import { BasePeer, type DeliveryReceipt } from "../hub/peers.ts";
3
3
  import type { Envelope, PeerState } from "../hub/envelope.ts";
4
4
  import { encodeRoomMessage, parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
5
5
  import { readRoomOutbound, roomBoundary, type RoomBoundaryContext } from "./boundary.ts";
6
- import { RoomNotSentError } from "./link.ts";
6
+ import { RoomCodecPendingError, RoomCodecUnsupportedError, RoomNotSentError } from "./link.ts";
7
7
 
8
8
  export interface RemotePeerPorts {
9
9
  member: string;
@@ -32,10 +32,12 @@ export class RemotePeer extends BasePeer {
32
32
  private running = false;
33
33
  private online = false;
34
34
  private blockedNotSent = false;
35
+ private codecWaiting = false;
35
36
  private observed: PeerState = "offline";
36
37
  private readonly pending = new Set<string>();
37
38
  private readonly waitingForReady = new Set<string>();
38
39
  private readonly sending = new Set<string>();
40
+ private readonly codecRechecks = new Map<string, () => boolean>();
39
41
  private readonly deadlines = new Map<string, number>();
40
42
  constructor(id: string, readonly remoteRoom: string, readonly remoteQueueTtlMs: number, private readonly ports: RemotePeerPorts) {
41
43
  super(id);
@@ -60,6 +62,7 @@ export class RemotePeer extends BasePeer {
60
62
  // Active journal rows and their sealed room frames survive a sender restart.
61
63
  // Stop only fences in-flight callbacks; it is not evidence of an uncertain send.
62
64
  this.pending.clear();
65
+ this.codecWaiting = false; this.codecRechecks.clear();
63
66
  this.waitingForReady.clear();
64
67
  this.deadlines.clear(); this.update();
65
68
  }
@@ -125,6 +128,7 @@ export class RemotePeer extends BasePeer {
125
128
  }
126
129
  /** A link becoming ready is an actual wake-up after a typed not-sent refusal. */
127
130
  wake(): void { this.blockedNotSent = false; this.update(); }
131
+ wakeCodec(): void { if (this.codecWaiting) { this.codecWaiting = false; this.wake(); } }
128
132
  setRemoteDeadline(envelopeId: string, expiresAt: number): void {
129
133
  if (Number.isSafeInteger(expiresAt) && expiresAt > 0 && this.deadlines.size < 200) this.deadlines.set(envelopeId, expiresAt);
130
134
  }
@@ -159,7 +163,11 @@ export class RemotePeer extends BasePeer {
159
163
  if (this.sending.has(deliveryId)) return;
160
164
  this.sending.add(deliveryId);
161
165
  try { await this.publishOnce(env, deliveryId, expiresAt, generation, immutable); }
162
- finally { this.sending.delete(deliveryId); }
166
+ finally {
167
+ this.sending.delete(deliveryId);
168
+ const changed = this.codecRechecks.get(deliveryId); this.codecRechecks.delete(deliveryId);
169
+ if (changed?.()) { this.codecWaiting = false; this.wake(); } // Recheck only after this send lane released, closing observation-before-requeue races.
170
+ }
163
171
  }
164
172
  private async publishOnce(env: Envelope, deliveryId: string, expiresAt: number, generation: number, immutable?: RoomChatEnvelope): Promise<void> {
165
173
  let context: RoomBoundaryContext;
@@ -216,12 +224,19 @@ export class RemotePeer extends BasePeer {
216
224
  }
217
225
  } catch (error) {
218
226
  if (!this.pending.has(deliveryId)) return;
227
+ if (error instanceof RoomCodecUnsupportedError && !immutable) {
228
+ let queued: boolean | undefined;
229
+ try { queued = this.ports.queued?.(deliveryId); } catch { queued = undefined; }
230
+ if (queued === false) { this.receipt({ id: deliveryId, state: "failed_safe", reason: error.message }); return; }
231
+ }
232
+ if (error instanceof RoomCodecPendingError) this.codecWaiting = true;
219
233
  if (error instanceof RoomNotSentError) {
220
234
  let retained: boolean | undefined;
221
235
  try { retained = this.ports.queued?.(deliveryId); } catch { retained = undefined; }
222
236
  if (immutable || retained === true) {
223
- if (!this.ready()) this.waitForReady(deliveryId);
237
+ if (!this.ready() || error instanceof RoomCodecPendingError) this.waitForReady(deliveryId);
224
238
  } else this.notSent(deliveryId);
239
+ if (error instanceof RoomCodecPendingError) this.codecRechecks.set(deliveryId, error.changed);
225
240
  return;
226
241
  }
227
242
  if (!trusted()) {
@@ -1,5 +1,5 @@
1
1
  /** Host integration for X4. Remote identities carry chat, never local control authority. */
2
- import { ROOM_PROOF_UNAVAILABLE_REASON, type Bus } from "../hub/bus.ts";
2
+ import { ROOM_EPOCH_ROTATED_REASON, ROOM_PROOF_UNAVAILABLE_REASON, type Bus } from "../hub/bus.ts";
3
3
  import { USER, MAX_HOP, isInvocationKind, type Envelope, type PeerState } from "../hub/envelope.ts";
4
4
  import type { RoomInvocationAuthorization } from "./boundary.ts";
5
5
  import { roomInvocationHash } from "../hub/room-invocations.ts";
@@ -11,8 +11,9 @@ import { roomMembers, RoomWireError } from "./wire.ts";
11
11
  import { RoomStore, RoomStoreError, RoomApplicationLimitError, type RoomOutgoing } from "./store.ts";
12
12
  import { RoomPolicy } from "./policy.ts";
13
13
  import { RemotePeer } from "./remote-peer.ts";
14
- import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope } from "./application.ts";
14
+ import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope, type RoomCodecFeature } from "./application.ts";
15
15
  import { RoomApplicationDeferredError, RoomNotSentError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
16
+ import { ROOM_PRESENCE_MISSES, ROOM_REFRESH_MS, roomRefreshPeriod } from "./liveness.ts";
16
17
 
17
18
  export interface RoomInvocationIdentity { room: string; member: string; epoch: string; current: () => boolean }
18
19
  export interface RoomInvocationIngress {
@@ -20,7 +21,7 @@ export interface RoomInvocationIngress {
20
21
  current: () => boolean; claim: () => boolean;
21
22
  }
22
23
  export interface RoomChatOptions {
23
- bus: Bus; cwd: string; now?: () => number;
24
+ bus: Bus; cwd: string; now?: () => number; refreshMs?: number;
24
25
  routing: () => Routing; piiPaths: () => readonly string[];
25
26
  senderPrivate: (peer: string) => boolean | undefined;
26
27
  taskPrivate: (id: number) => boolean | undefined;
@@ -31,10 +32,10 @@ export interface RoomChatOptions {
31
32
  roster?: (ids: readonly string[]) => void;
32
33
  changed?: () => void;
33
34
  limits?: () => LimitsConfig;
34
- notice?: (value: { code: "application_capacity"; room: string; count: number } | { code: "sequence_gap"; room: string; sender: string; count: string }) => void;
35
+ notice?: (value: { code: "application_capacity"; room: string; count: number }) => void;
35
36
  }
36
37
  export interface RoomMetrics { since: number; outbound: number | null; inbound: number | null; withheld: number | null }
37
- interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; online: boolean; states: Map<string, PeerState> }
38
+ interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; rosterMembers: string; online: boolean; states: Map<string, PeerState>; observedAt: Map<string, number>; observedMembers: Set<string>; tracked?: boolean }
38
39
  interface Target { binding: Binding; member: string; peer: string }
39
40
  const ACTIVE = new Set(["dispatching", "accepted", "needs_review"]);
40
41
  function completedText(reason?: RoomReceiptReason): string {
@@ -69,6 +70,9 @@ export class RoomChat {
69
70
  private readonly outgoing = new Map<string, { binding: Binding; row: RoomOutgoing }>();
70
71
  private readonly resuming = new Set<string>();
71
72
  private readonly personHeldInvocations = new Map<string, Set<string>>();
73
+ private readonly refreshMs: number;
74
+ private presenceClockHighWater?: number;
75
+ private presenceClockInvalid = false;
72
76
  private evidenceCache?: Map<string, { peer: string; roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string }>;
73
77
  private timer?: ReturnType<typeof setTimeout>;
74
78
  private stopped = false;
@@ -78,8 +82,9 @@ export class RoomChat {
78
82
  readonly application: RoomLinkApplication;
79
83
  constructor(private readonly options: RoomChatOptions) {
80
84
  this.policy = new RoomPolicy(options.cwd);
85
+ this.refreshMs = roomRefreshPeriod(options.refreshMs ?? ROOM_REFRESH_MS);
81
86
  let since: number | null = null;
82
- try { const value = this.now(); if (Number.isSafeInteger(value) && value >= 0) since = value; } catch { /* Observation failure never disables transport construction. */ }
87
+ try { const value = this.now(); if (Number.isSafeInteger(value) && value >= 0) { since = value; this.presenceClockHighWater = value; } } catch { /* Observation failure never disables transport construction. */ }
83
88
  this.metricsSince = since;
84
89
  this.application = {
85
90
  members: context => { if (context.current()) this.bind(context.store, context.name, context.link); },
@@ -101,14 +106,53 @@ export class RoomChat {
101
106
  const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding);
102
107
  },
103
108
  capacityChanged: context => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding); this.capacityChanged(); } },
104
- gap: (context, input) => {
105
- if (context.current()) this.options.notice?.({ code: "sequence_gap", room: context.store.get(context.name)!.current.room, sender: input.sender, count: input.count });
106
- },
109
+ codecsChanged: (context, input) => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) { this.wakeRemotes(binding, input.member, true); void this.resumeActive(binding, input.member, input.features); } } },
110
+ // The signed sequence is room-wide, while chat recipients are selective.
111
+ // A numeric gap is not evidence of loss and must not become an operator notice.
107
112
  };
108
113
  }
109
114
  private now(): number { return this.options.now?.() ?? Date.now(); }
110
- private wakeRemotes(binding: Binding): void {
111
- for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.wake();
115
+ /** Local receipt time only; remote timestamps never participate in freshness. */
116
+ private presenceNow(mutate = true): number | undefined {
117
+ let now: number;
118
+ try { now = this.now(); } catch { now = NaN; }
119
+ if (!Number.isSafeInteger(now) || now < 0 || this.presenceClockHighWater !== undefined && now < this.presenceClockHighWater) {
120
+ if (mutate && !this.presenceClockInvalid) {
121
+ for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) binding.observedAt.clear();
122
+ }
123
+ if (mutate) this.presenceClockInvalid = true;
124
+ return undefined;
125
+ }
126
+ if (mutate) {
127
+ if (this.presenceClockHighWater === undefined || now > this.presenceClockHighWater) this.presenceClockHighWater = now;
128
+ this.presenceClockInvalid = false;
129
+ }
130
+ return now;
131
+ }
132
+ private livenessTracked(binding: Binding): boolean | undefined {
133
+ return binding.tracked;
134
+ }
135
+ private memberFresh(binding: Binding, member: string, now: number | undefined): boolean {
136
+ if (this.livenessTracked(binding) === false) return true;
137
+ if (now === undefined || this.presenceClockInvalid) return false;
138
+ const seen = binding.observedAt.get(member);
139
+ return seen !== undefined && now >= seen && now - seen < this.refreshMs * ROOM_PRESENCE_MISSES;
140
+ }
141
+ private advisoryPresence(id: string, target: Target): PeerState | "unknown" {
142
+ if (this.livenessTracked(target.binding) !== false && !this.memberFresh(target.binding, target.member, this.presenceNow(false))) return "unknown";
143
+ return target.peer === USER ? "idle" : target.binding.states.get(id) ?? "offline";
144
+ }
145
+ private advisoryLiveness(target: Target): "untracked" | undefined {
146
+ return this.livenessTracked(target.binding) === false ? "untracked" : undefined;
147
+ }
148
+ private targetSendable(id: string): boolean {
149
+ const target = this.targets.get(id);
150
+ return !!target && target.binding.online && this.valid(target.binding);
151
+ }
152
+ private wakeRemotes(binding: Binding, member?: string, codecOnly = false): void {
153
+ for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding && (member === undefined || this.targets.get(id)?.member === member)) {
154
+ if (codecOnly) remote.wakeCodec(); else remote.wake();
155
+ }
112
156
  }
113
157
  metrics(name: string, room?: string): RoomMetrics | null {
114
158
  const binding = this.bindings.get(name);
@@ -145,11 +189,18 @@ export class RoomChat {
145
189
  bind(store: RoomStore, name: string, link: RoomLink): void {
146
190
  if (this.stopped) return;
147
191
  const state = store.get(name), digest = state?.current.digest; if (!digest || !state) return;
148
- const room = state.current.room, counted = this.counts.get(name);
192
+ const room = state.current.room, rosterMembers = roomMembers(state.current).map(member => member.fingerprint).sort().join(","), counted = this.counts.get(name);
149
193
  if (counted?.room !== room && (counted || this.counts.size < 64)) this.counts.set(name, { room, outbound: 0, inbound: 0, withheld: 0 });
150
194
  const saved = this.bindings.get(name);
151
- const binding = saved?.store === store && saved.link === link && saved.room === room ? saved : { store, name, room, link, digest, online: false, states: new Map<string, PeerState>() };
152
- if (binding.digest !== digest) { binding.states.clear(); binding.online = false; binding.digest = digest; }
195
+ const binding: Binding = saved?.store === store && saved.link === link && saved.room === room ? saved : {
196
+ store, name, room, link, digest, rosterMembers, online: false, states: new Map<string, PeerState>(), observedAt: new Map<string, number>(), observedMembers: new Set<string>(),
197
+ };
198
+ const tracked = link.livenessTracked;
199
+ if (binding.digest !== digest || binding.rosterMembers !== rosterMembers
200
+ || tracked !== undefined && binding.tracked !== undefined && tracked !== binding.tracked) {
201
+ binding.states.clear(); binding.observedAt.clear(); binding.observedMembers.clear(); binding.online = false; binding.digest = digest; binding.rosterMembers = rosterMembers;
202
+ }
203
+ if (tracked !== undefined) binding.tracked = tracked;
153
204
  this.bindings.set(name, binding);
154
205
  this.reconcileDispositions(binding);
155
206
  this.refreshInvocationHolds(binding);
@@ -165,6 +216,14 @@ export class RoomChat {
165
216
  } };
166
217
  }
167
218
  private refreshTargets(): void {
219
+ const now = this.presenceNow();
220
+ // Consume an expired observation once. Keeping its timestamp makes every
221
+ // later queue callback revisit this branch and can re-reserve settled rows.
222
+ if (now !== undefined) for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) {
223
+ for (const [member, seenAt] of binding.observedAt) {
224
+ if (now >= seenAt && now - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES) binding.observedAt.delete(member);
225
+ }
226
+ }
168
227
  const candidates = new Map<string, Target[]>();
169
228
  for (const binding of this.bindings.values()) {
170
229
  const state = binding.store.get(binding.name);
@@ -225,7 +284,7 @@ export class RoomChat {
225
284
  this.count(binding, "outbound", withheld === true);
226
285
  }
227
286
  finally { this.refreshOutgoing(binding); this.queueChanged(); }
228
- }, ready: () => this.options.bus.remoteSendAllowed(remote!), waiting: () => this.queueChanged(),
287
+ }, ready: () => this.options.bus.remoteSendAllowed(remote!) && this.targetSendable(id), waiting: () => this.queueChanged(),
229
288
  notSent: deliveryId => this.options.bus.requeueRemoteNotSent(remote!, deliveryId),
230
289
  queued: deliveryId => {
231
290
  const selected = this.targets.get(id);
@@ -292,7 +351,8 @@ export class RoomChat {
292
351
  }
293
352
  for (const [id, remote] of this.remotes) {
294
353
  const target = this.targets.get(id);
295
- remote.presence(target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline", target?.binding.online === true);
354
+ const state = target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline";
355
+ remote.presence(state, target?.binding.online === true);
296
356
  }
297
357
  this.options.roster?.([...this.targets.keys()]);
298
358
  }
@@ -302,14 +362,23 @@ export class RoomChat {
302
362
  && this.policy.get(binding.name).includes(peer)
303
363
  && roomMembers(state.current).some(member => member.fingerprint === binding.store.identity.public.fingerprint && member.exposedPeers.includes(peer));
304
364
  }
305
- presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[] }): void {
365
+ presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true; observedAt?: number | null }): void {
306
366
  const binding = this.bindings.get(name), state = store.get(name);
307
367
  if (!binding || binding.store !== store || binding.link !== link || !state || !this.valid(binding)) return;
308
368
  const member = roomMembers(state.current).find(row => row.fingerprint === value.member);
309
369
  if (!member) return;
370
+ if (value.restored && binding.observedMembers.has(value.member)) return;
310
371
  for (const peer of member.exposedPeers) binding.states.delete(`${peer}@${member.label}`);
311
372
  for (const peer of value.peers) if (member.exposedPeers.includes(peer.id)) binding.states.set(`${peer.id}@${member.label}`, peer.state);
373
+ const now = this.presenceNow();
374
+ if (value.restored !== true) {
375
+ binding.observedMembers.add(member.fingerprint);
376
+ const receivedAt = Object.hasOwn(value, "observedAt") ? value.observedAt : now;
377
+ if (typeof receivedAt === "number" && Number.isSafeInteger(receivedAt) && receivedAt >= 0 && now !== undefined && receivedAt <= now) binding.observedAt.set(member.fingerprint, receivedAt);
378
+ else binding.observedAt.delete(member.fingerprint);
379
+ }
312
380
  this.refreshTargets();
381
+ this.queueChanged();
313
382
  }
314
383
  linkState(store: RoomStore, name: string, link: RoomLink, state: RoomLinkState): void {
315
384
  // stop() fences callbacks as part of ordinary shutdown; it is not a room
@@ -339,6 +408,9 @@ export class RoomChat {
339
408
  return [...new Set([...this.outgoing.values()].filter(({ binding, row }) => binding.store.get(binding.name)?.current.room === room && ACTIVE.has(row.state) && !row.disposition).map(({ row }) => row.application))];
340
409
  }
341
410
  private outcome(binding: Binding, row: RoomOutgoing): void {
411
+ // Refresh this snapshot before settlement callbacks synchronously re-enter
412
+ // queueChanged through Bus.onQueues.
413
+ this.refreshOutgoing(binding);
342
414
  const remote = this.remotes.get(row.remotePeer);
343
415
  if (row.state === "needs_review" && !row.disposition) {
344
416
  const journalRow = this.options.bus.queueShow(row.deliveryId);
@@ -356,6 +428,7 @@ export class RoomChat {
356
428
  }
357
429
  private settleStoredOutcome(binding: Binding, remote: RemotePeer | undefined, row: RoomOutgoing): boolean {
358
430
  if ((row.state !== "completed" && row.state !== "failed_safe") || row.disposition) return false;
431
+ this.refreshOutgoing(binding);
359
432
  const outcome = row.lateReceipt?.outcome ?? row.state;
360
433
  const reason = row.lateReceipt?.reason ?? row.reason ?? (outcome === "completed" ? "admitted" : "boundary_refused");
361
434
  const journalRow = this.options.bus.queueShow(row.deliveryId);
@@ -433,6 +506,7 @@ export class RoomChat {
433
506
  if (!this.valid(binding)) return;
434
507
  let rows: RoomOutgoing[];
435
508
  try { rows = binding.store.applicationOutbox(binding.name); } catch { return; }
509
+ this.refreshOutgoing(binding);
436
510
  for (const row of rows) {
437
511
  if (row.disposition) continue;
438
512
  const remote = this.remotes.get(row.remotePeer);
@@ -443,7 +517,7 @@ export class RoomChat {
443
517
  const current = this.currentOutgoingRow(binding, row);
444
518
  if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) continue;
445
519
  const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current);
446
- if (quarantined) remote.settled(row.deliveryId);
520
+ if (quarantined) { this.refreshOutgoing(binding); remote.settled(row.deliveryId); }
447
521
  } else if (row.state === "needs_review") this.holdStoreNeedsReview(binding, row, remote);
448
522
  }
449
523
  this.refreshOutgoing(binding);
@@ -566,6 +640,16 @@ export class RoomChat {
566
640
  if (this.timer) clearTimeout(this.timer);
567
641
  this.timer = undefined; if (this.stopped || !this.bindings.size) return;
568
642
  let next = this.options.bus.nextRemoteExpiry();
643
+ const presenceNow = this.presenceNow();
644
+ if (presenceNow !== undefined) {
645
+ if ([...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
646
+ presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
647
+ for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) for (const seenAt of binding.observedAt.values()) {
648
+ const staleAt = seenAt + this.refreshMs * ROOM_PRESENCE_MISSES;
649
+ if (staleAt <= presenceNow) continue;
650
+ next = next === undefined ? staleAt : Math.min(next, staleAt);
651
+ }
652
+ }
569
653
  for (const { binding, row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
570
654
  const due = Math.min(row.expiresAt, binding.online ? row.ackDeadline ?? Infinity : Infinity); next = next === undefined ? due : Math.min(next, due);
571
655
  }
@@ -588,6 +672,9 @@ export class RoomChat {
588
672
  }
589
673
  tick(): void {
590
674
  if (this.stopped) return;
675
+ const presenceNow = this.presenceNow();
676
+ if (presenceNow === undefined || [...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
677
+ presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
591
678
  this.options.bus.expireRemoteQueues(this.now());
592
679
  for (const binding of this.bindings.values()) if (this.valid(binding)) {
593
680
  try {
@@ -603,11 +690,18 @@ export class RoomChat {
603
690
  }
604
691
  void this.retryPending(); this.queueChanged();
605
692
  }
606
- private async resumeActive(binding: Binding): Promise<void> {
693
+ private async resumeActive(binding: Binding, codecMember?: string, changedFeatures?: readonly RoomCodecFeature[]): Promise<void> {
607
694
  if (!binding.online || !this.valid(binding)) return;
608
695
  try {
609
- const active = binding.store.applicationOutbox(binding.name, true).filter(row => (row.state === "dispatching" || row.state === "accepted")
610
- && !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId));
696
+ const active = binding.store.applicationOutbox(binding.name, true).filter(row => {
697
+ if (row.state !== "dispatching" && row.state !== "accepted" || this.personHeldInvocations.get(binding.name)?.has(row.deliveryId)) return false;
698
+ if (codecMember === undefined) return true;
699
+ if (row.recipient !== codecMember) return false;
700
+ let required: RoomCodecFeature | undefined;
701
+ if (row.peer === USER) required = "person_inbox_v1";
702
+ else if (this.options.bus.queueShow(row.deliveryId)?.originals.some(isInvocationKind)) required = "read_invocation_v1";
703
+ return required !== undefined && (changedFeatures === undefined || changedFeatures.includes(required));
704
+ });
611
705
  for (const row of active) this.remotes.get(row.remotePeer)?.reservePending(row.deliveryId);
612
706
  for (const row of active) this.launchResume(binding, row);
613
707
  } catch { /* The next authenticated online edge will retry durable rows. */ }
@@ -664,6 +758,10 @@ export class RoomChat {
664
758
  const digest = state?.current.digest;
665
759
  const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
666
760
  if (!target || !state || target.binding !== binding || !proofCurrent()) { remote.deferResume(row.deliveryId); return; }
761
+ let required: RoomCodecFeature | undefined;
762
+ if (isInvocationKind(original)) required = "read_invocation_v1";
763
+ else if (row.peer === USER) required = "person_inbox_v1";
764
+ if (required && !binding.link.codecSupported(row.recipient, required)) { remote.deferResume(row.deliveryId); return; }
667
765
  const ready = () => binding.online && this.options.bus.remoteSendAllowed(remote);
668
766
  if (!ready()) { remote.waitForReady(row.deliveryId); return; }
669
767
  let readinessLost = false;
@@ -713,16 +811,18 @@ export class RoomChat {
713
811
  }
714
812
  private reconcileDispositions(binding: Binding): boolean {
715
813
  let complete = true;
814
+ const settled: Array<{ peer: string; deliveryId: string }> = [];
716
815
  for (const row of binding.store.applicationOutbox(binding.name)) if (!row.disposition) {
717
816
  const action = this.options.bus.remoteDisposition(row.deliveryId);
718
817
  if (action) try {
719
818
  const disposed = binding.store.disposeApplication(binding.name, row.deliveryId, action, true, () => this.valid(binding)
720
819
  && binding.store.get(binding.name)?.current.digest === binding.digest && this.options.bus.remoteDisposition(row.deliveryId) === action);
721
- if (disposed?.disposition === action) this.remotes.get(row.remotePeer)?.settled(row.deliveryId);
820
+ if (disposed?.disposition === action) settled.push({ peer: row.remotePeer, deliveryId: row.deliveryId });
722
821
  }
723
822
  catch { complete = false; }
724
823
  }
725
824
  this.refreshOutgoing(binding);
825
+ for (const row of settled) this.remotes.get(row.peer)?.settled(row.deliveryId);
726
826
  return complete;
727
827
  }
728
828
  disposition(peer: string, deliveryId: string, action: "completed" | "retry" | "discard"): void {
@@ -734,7 +834,10 @@ export class RoomChat {
734
834
  const disposed = binding.store.disposeApplication(binding.name, deliveryId, action, true, () => this.valid(binding)
735
835
  && binding.store.get(binding.name)?.current.room === room && binding.store.get(binding.name)?.current.digest === digest
736
836
  && this.options.bus.remoteDisposition(deliveryId) === action);
737
- if (disposed?.disposition === action) this.remotes.get(peer)?.settled(deliveryId);
837
+ if (disposed?.disposition === action) {
838
+ this.refreshOutgoing(binding);
839
+ this.remotes.get(peer)?.settled(deliveryId);
840
+ }
738
841
  this.personHeldInvocations.get(binding.name)?.delete(deliveryId);
739
842
  this.refreshOutgoing(binding); break;
740
843
  }
@@ -756,20 +859,56 @@ export class RoomChat {
756
859
  const row = this.evidenceCache.get(deliveryId); if (!row || row.peer !== peer) return undefined;
757
860
  const { peer: _peer, ...view } = row; return view;
758
861
  }
862
+ /** Hold only the sealed intents captured by the person's committed epoch change. */
863
+ epochRotated(store: RoomStore, name: string, previousDigest: string, expected: readonly RoomOutgoing[]): boolean {
864
+ const state = store.get(name);
865
+ if (this.stopped || !state || state.left || state.current.digest === previousDigest) return false;
866
+ const binding = this.bindings.get(name);
867
+ if (binding?.store === store && binding.room === state.current.room) {
868
+ // Releasing an old FIFO head must not drain queued work using old presence.
869
+ binding.online = false; binding.states.clear(); binding.observedAt.clear();
870
+ for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.presence("offline", false);
871
+ }
872
+ let complete = true;
873
+ for (const before of expected) {
874
+ const current = () => {
875
+ const room = store.get(name), row = store.applicationOutbox(name).find(row => row.deliveryId === before.deliveryId);
876
+ return !this.stopped && !!room && !room.left && room.current.digest === state.current.digest
877
+ && !!row && row.application === before.application && row.remotePeer === before.remotePeer && row.recipient === before.recipient
878
+ && row.state === "needs_review" && row.reason === "boundary_refused" && !row.disposition;
879
+ };
880
+ if (!current()) { complete = false; continue; }
881
+ if (!this.options.bus.holdRemoteEpochRotated(before.remotePeer, before.deliveryId, state.current.room, before.recipient, current)) {
882
+ complete = false; continue;
883
+ }
884
+ const remote = this.remotes.get(before.remotePeer);
885
+ if (remote?.remoteRoom === state.current.room && remote.remoteMember === before.recipient) remote.settled(before.deliveryId);
886
+ }
887
+ this.evidenceCache = undefined;
888
+ return complete;
889
+ }
759
890
  rows(name: string): Record<string, unknown>[] {
760
891
  const binding = this.bindings.get(name); if (!binding) return [];
761
892
  const incoming = binding.store.applicationStatus(name);
762
- return [...binding.store.applicationOutbox(name).sort((a, b) => Number(!ACTIVE.has(a.state) || !!a.disposition) - Number(!ACTIVE.has(b.state) || !!b.disposition) || b.createdAt - a.createdAt).slice(0, 200).map(row => ({ direction: "outbound", id: row.deliveryId, application: row.application, peer: row.remotePeer, state: row.state === "completed" ? completedText(row.reason) : row.state, ...(row.disposition ? { disposition: row.disposition } : {}), ...(row.lateReceipt ? { lateReceipt: lateReceiptText(row.lateReceipt) } : {}), ...(refusalReason(row) ? { refusalReason: refusalReason(row) } : {}) })),
893
+ const outgoing = binding.store.applicationOutbox(name).sort((a, b) => Number(!ACTIVE.has(a.state) || !!a.disposition) - Number(!ACTIVE.has(b.state) || !!b.disposition) || b.createdAt - a.createdAt).slice(0, 200), epochHolds = new Set<string>();
894
+ for (const peer of new Set(outgoing.map(row => row.remotePeer))) {
895
+ for (const row of this.options.bus.queueList(peer, true)) if (row.state === "needs_review" && row.reason === `${ROOM_EPOCH_ROTATED_REASON} ${row.id}`) epochHolds.add(row.id);
896
+ }
897
+ return [...outgoing.map(row => ({ direction: "outbound", id: row.deliveryId, application: row.application, peer: row.remotePeer, state: row.state === "completed" ? completedText(row.reason) : row.state, ...(row.disposition ? { disposition: row.disposition } : {}), ...(row.lateReceipt ? { lateReceipt: lateReceiptText(row.lateReceipt) } : {}), ...(epochHolds.has(row.deliveryId) ? { holdReason: "epoch rotated" } : refusalReason(row) ? { refusalReason: refusalReason(row) } : {}) })),
763
898
  ...incoming.rows.map(row => ({ direction: "inbound", id: row.digest, application: row.application, state: row.state === "disposed" ? `disposed (${row.disposition}, by person)` : row.state, peer: row.peer, ...(row.disposedAt ? { disposedAt: row.disposedAt } : {}) })),
764
899
  { direction: "inbound", pending: incoming.pending, needsReview: incoming.needsReview, truncated: incoming.truncated }];
765
900
  }
766
- peers(name: string): { id: string; state: PeerState }[] {
767
- return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER).map(([id]) => ({ id, state: this.remotes.get(id)?.state ?? "offline" })).sort((a, b) => a.id.localeCompare(b.id));
901
+ peers(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] {
902
+ return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER)
903
+ .map(([id, target]) => ({ id, state: this.remotes.get(id)?.state ?? "offline", presence: this.advisoryPresence(id, target), ...(this.advisoryLiveness(target) ? { liveness: "untracked" as const } : {}) })).sort((a, b) => a.id.localeCompare(b.id));
768
904
  }
769
905
  /** A bounded status list, separate from the explicit detail query above. */
770
- peersForStatus(name: string): { id: string; state: PeerState }[] | null {
906
+ peersForStatus(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] | null {
771
907
  const ids = this.statusTargets.get(name);
772
- return ids ? ids.map(id => ({ id, state: this.remotes.get(id)?.state ?? "offline" })) : null;
908
+ return ids ? ids.map(id => {
909
+ const target = this.targets.get(id)!;
910
+ return { id, state: this.remotes.get(id)?.state ?? "offline", presence: this.advisoryPresence(id, target), ...(this.advisoryLiveness(target) ? { liveness: "untracked" as const } : {}) };
911
+ }) : null;
773
912
  }
774
913
  stop(): void {
775
914
  if (this.stopped) return; this.stopped = true;
@@ -2,6 +2,7 @@ import { types } from "node:util";
2
2
  import type { PeerState } from "../hub/envelope.ts";
3
3
  import { decodeBase64url, encodeBase64url } from "./crypto.ts";
4
4
  import { parseRemotePeerId } from "./application.ts";
5
+ import { ROOM_HEALTH_CAUSES, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
5
6
 
6
7
  const ROOM_LIMIT = 64;
7
8
  const REMOTE_PEER_LIMIT = 4096;
@@ -15,9 +16,18 @@ const EPOCH = /^[1-9][0-9]{0,19}$/;
15
16
  const LINK_STATES = new Set(["online", "connecting", "offline", "left"]);
16
17
  const PEER_STATES = new Set<PeerState>(["idle", "busy", "paused", "offline"]);
17
18
 
19
+ export interface RoomStatusHealth {
20
+ refused: { invalid_presence: number; wrong_epoch: number; trust_changed: number; stale: number; invalid_frame: number } | null;
21
+ outboxDepth: number | null;
22
+ oldestOutboxAgeMs: number | null;
23
+ lastReceiptAgeMs: number | null;
24
+ }
25
+
18
26
  export interface RoomStatusPeer {
19
27
  id: string;
20
28
  state: PeerState;
29
+ presence?: PeerState | "unknown";
30
+ liveness?: "untracked";
21
31
  }
22
32
 
23
33
  export interface RoomRelayStats {
@@ -27,8 +37,15 @@ export interface RoomRelayStats {
27
37
  withheld: number | null;
28
38
  }
29
39
 
40
+ export interface RoomStatusDiagnostics {
41
+ health?: RoomStatusHealth | null;
42
+ linkCause?: RoomHealthCause | null;
43
+ linkNextStep?: string | null;
44
+ }
45
+ export interface RoomStatusUnavailable { code: RoomHealthCause | null; nextStep: string | null }
46
+
30
47
  /** Allowlisted room metadata. Missing member counts and peer lists stay unknown. */
31
- export interface RoomStatusView {
48
+ export interface RoomStatusView extends RoomStatusDiagnostics {
32
49
  name: string;
33
50
  room: string;
34
51
  owner: string;
@@ -95,8 +112,13 @@ function remotePeers(value: unknown, remaining: number): RoomStatusPeer[] | null
95
112
  if (!plainRecord(item)) return null;
96
113
  const id = ownData(item, "id"), state = ownData(item, "state");
97
114
  if (typeof id !== "string" || !parseRemotePeerId(id) || typeof state !== "string" || !PEER_STATES.has(state as PeerState) || ids.has(id)) return null;
115
+ const presenceField = ownField(item, "presence"), livenessField = ownField(item, "liveness");
116
+ if (presenceField.present && !Object.hasOwn(presenceField, "value") || livenessField.present && !Object.hasOwn(livenessField, "value")) return null;
117
+ const presence = presenceField.value, liveness = livenessField.value;
118
+ if (presence !== undefined && presence !== "unknown" && (typeof presence !== "string" || !PEER_STATES.has(presence as PeerState))) return null;
119
+ if (liveness !== undefined && liveness !== "untracked") return null;
98
120
  ids.add(id);
99
- result.push({ id, state: state as PeerState });
121
+ result.push({ id, state: state as PeerState, ...(presence !== undefined ? { presence: presence as RoomStatusPeer["presence"] } : {}), ...(liveness === "untracked" ? { liveness } : {}) });
100
122
  }
101
123
  return result;
102
124
  }
@@ -116,6 +138,46 @@ function relayStats(value: unknown): RoomRelayStats | null {
116
138
  return { since, outbound, inbound, withheld };
117
139
  }
118
140
 
141
+ /** Counts and ages remain unknown when an older daemon cannot report them. */
142
+ export function projectRoomHealth(value: unknown): RoomStatusHealth | null {
143
+ if (!plainRecord(value)) return null;
144
+ const rawRefused = ownData(value, "refused");
145
+ let refused: RoomStatusHealth["refused"] = null;
146
+ if (plainRecord(rawRefused)) {
147
+ const invalid_presence = relayCount(ownData(rawRefused, "invalid_presence")), wrong_epoch = relayCount(ownData(rawRefused, "wrong_epoch"));
148
+ const trust_changed = relayCount(ownData(rawRefused, "trust_changed")), stale = relayCount(ownData(rawRefused, "stale")), invalid_frame = relayCount(ownData(rawRefused, "invalid_frame"));
149
+ if (invalid_presence !== null && wrong_epoch !== null && trust_changed !== null && stale !== null && invalid_frame !== null)
150
+ refused = { invalid_presence, wrong_epoch, trust_changed, stale, invalid_frame };
151
+ }
152
+ return { refused, outboxDepth: relayCount(ownData(value, "outboxDepth")),
153
+ oldestOutboxAgeMs: relayCount(ownData(value, "oldestOutboxAgeMs")), lastReceiptAgeMs: relayCount(ownData(value, "lastReceiptAgeMs")) };
154
+ }
155
+
156
+ function healthCause(value: unknown): RoomHealthCause | null {
157
+ return typeof value === "string" && ROOM_HEALTH_CAUSES.includes(value as RoomHealthCause) ? value as RoomHealthCause : null;
158
+ }
159
+
160
+ /** Regenerate guidance from closed causes; arbitrary daemon text is never copied. */
161
+ export function projectRoomDiagnostics(value: unknown): RoomStatusDiagnostics {
162
+ if (!plainRecord(value)) return {};
163
+ const cause = healthCause(ownData(value, "linkCause"));
164
+ const result: RoomStatusDiagnostics = {};
165
+ if (ownField(value, "health").present) result.health = projectRoomHealth(ownData(value, "health"));
166
+ if (ownField(value, "linkCause").present) {
167
+ result.linkCause = cause;
168
+ result.linkNextStep = null;
169
+ if (cause !== null) result.linkNextStep = roomHealthNextStep(cause);
170
+ }
171
+ return result;
172
+ }
173
+
174
+ /** Absent or malformed availability metadata stays unknown without evaluating getters. */
175
+ export function projectRoomUnavailable(value: unknown): RoomStatusUnavailable | null {
176
+ if (!plainRecord(value)) return null;
177
+ const code = healthCause(ownData(value, "code"));
178
+ return { code, nextStep: code === null ? null : roomHealthNextStep(code) };
179
+ }
180
+
119
181
  /** Project RoomCommands.rows-compatible data to body-free room status metadata. */
120
182
  export function projectRoomStatus(value: unknown): RoomStatusView[] {
121
183
  try {
@@ -139,6 +201,7 @@ export function projectRoomStatus(value: unknown): RoomStatusView[] {
139
201
  memberCount: memberCount(rawMemberCount),
140
202
  remotePeers: peers,
141
203
  relays: relayStats(ownData(item, "relays")),
204
+ ...projectRoomDiagnostics(item),
142
205
  });
143
206
  }
144
207
  return views;
@@ -2,6 +2,7 @@
2
2
  import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { Database } from "bun:sqlite";
5
+ import type { RoomHealth } from "./health.ts";
5
6
  import { realPath } from "../hub/project.ts";
6
7
  import { MAX_HOP, USER } from "../hub/envelope.ts";
7
8
  import { assertRoomCryptoReady, exportHubIdentityForStorage, generateHubIdentity, importHubIdentity, decodeBase64url, encodeBase64url, sha256, type HubIdentity, type LocalIdentityRecord } from "./crypto.ts";
@@ -673,6 +674,12 @@ export class RoomStore {
673
674
  ON CONFLICT(name) DO UPDATE SET epoch=excluded.epoch,digest=excluded.digest,wire=excluded.wire,previous_wire=excluded.previous_wire,previous_digest=excluded.previous_digest,previous_epoch=excluded.previous_epoch,previous_expires=excluded.previous_expires,left=0`)
674
675
  .run(name, current.room, broker, decodeRoster(wire).owner, current.epoch, current.digest, wire, prior ? priorWire! : null, prior?.epoch.digest ?? null, prior?.epoch.epoch ?? null, prior?.expiresAt ?? null);
675
676
  this.db.query("DELETE FROM pending_joins WHERE name=?").run(name);
677
+ if (previous && !same) {
678
+ // Epoch adoption and the old sealed intents' send fence commit together.
679
+ // The local Bus names the rotation; no new signed receipt reason is introduced.
680
+ this.db.query("UPDATE application_outbox SET state='needs_review',reason='boundary_refused' WHERE room=? AND state IN ('dispatching','accepted') AND disposition IS NULL").run(current.room);
681
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT t.digest FROM application_attempts t JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id WHERE t.room=? AND a.state='needs_review' AND a.reason='boundary_refused')").run(current.room, current.room);
682
+ }
676
683
  return next;
677
684
  }
678
685
  async install(input: { name: string; broker: string; wire: Uint8Array; expectedDigest: string | null; current?: () => boolean; reactivate?: boolean }): Promise<RoomState> {
@@ -1102,6 +1109,10 @@ export class RoomStore {
1102
1109
  const filter = activeOnly ? " AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL" : "";
1103
1110
  return this.db.query<OutgoingRow, [string]>(`SELECT * FROM application_outbox WHERE room=?${filter} ORDER BY rowid`).all(state.current.room).map(outgoing);
1104
1111
  }
1112
+ /** Sealed, unresolved intents that a person must see before changing the epoch. */
1113
+ rotationPending(name: string): RoomOutgoing[] {
1114
+ return this.applicationOutbox(name, true).filter(row => row.state === "dispatching" || row.state === "accepted");
1115
+ }
1105
1116
  /** Compatibility no-op for retryable rows; only original TTL expiry creates needs_review. */
1106
1117
  quarantineOutgoing(name: string, deliveryId: string): RoomOutgoing | undefined {
1107
1118
  const state = this.get(name); if (!state) return undefined;
@@ -1548,9 +1559,27 @@ export class RoomStore {
1548
1559
  });
1549
1560
  }
1550
1561
  refused(name: string): RoomRefusal[] {
1551
- const state = this.get(name); if (!state) return [];
1562
+ this.now(); named(name); const state = this.rooms.get(name); if (!state) return [];
1552
1563
  return this.db.query<RoomRefusal, [string]>("SELECT digest,reason,at FROM refusals WHERE room=? ORDER BY rowid").all(state.current.room);
1553
1564
  }
1565
+ /** Metadata-only diagnostics. No durable clock write, sealed frame or plaintext read. */
1566
+ health(name: string): RoomHealth {
1567
+ const now = this.now(); named(name); const state = this.rooms.get(name);
1568
+ if (!state) fail("untrusted");
1569
+ const refused: RoomHealth["refused"] = { invalid_presence: 0, wrong_epoch: 0, trust_changed: 0, stale: 0, invalid_frame: 0 };
1570
+ for (const row of this.db.query<{ reason: RoomRefusalReason; count: number }, [string]>("SELECT reason,COUNT(*) AS count FROM refusals WHERE room=? GROUP BY reason").all(state.current.room)) refused[row.reason] = row.count;
1571
+ const pending = this.db.query<{ depth: number; dated: number; oldest: number | null }, [string, number]>(`SELECT COUNT(*) AS depth, COUNT(COALESCE(r.created_at,a.created_at)) AS dated, MIN(COALESCE(r.created_at,a.created_at)) AS oldest
1572
+ FROM outbox o LEFT JOIN application_attempts t ON t.room=o.room AND t.digest=o.digest
1573
+ LEFT JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id
1574
+ LEFT JOIN receipt_outbox r ON r.room=o.room AND r.receipt_digest=o.digest
1575
+ WHERE o.room=? AND (t.digest IS NULL OR a.state IN ('dispatching','accepted'))
1576
+ AND (r.receipt_digest IS NULL OR r.state='dispatching' AND r.expires_at>?)`).get(state.current.room, now)!;
1577
+ const receipt = this.db.query<{ at: number | null }, [string]>(`SELECT MAX(i.received_at) AS at FROM inbox i
1578
+ JOIN application_outbox a ON a.room=i.room AND a.receipt_digest=i.digest WHERE i.room=?`).get(state.current.room)?.at;
1579
+ return { refused, outboxDepth: pending.depth,
1580
+ oldestOutboxAgeMs: pending.depth > 0 && pending.dated === pending.depth && pending.oldest !== null ? now - pending.oldest : null,
1581
+ lastReceiptAgeMs: receipt != null ? now - receipt : null };
1582
+ }
1554
1583
  /** Read-only physical-cap hint for a validated host payload; no body is loaded and no replay/clock row is written. */
1555
1584
  ordinaryInboxAvailable(name: string, bytes: number): boolean {
1556
1585
  this.now(); named(name);