@staix/agent-hub 0.20.0 → 0.21.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 (175) hide show
  1. package/CHANGELOG.md +59 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +2 -2
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +37 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +565 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +14 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  37. package/docs/verification/claude-permission-transport-native.py +71 -14
  38. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  39. package/docs/verified.json +15 -15
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +1016 -280
  43. package/src/adapters/acp.ts +79 -3
  44. package/src/adapters/channel-peer.ts +4 -0
  45. package/src/adapters/claude-channel.ts +13 -6
  46. package/src/adapters/claude-implementer.ts +60 -7
  47. package/src/adapters/codex-appserver.ts +77 -4
  48. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  49. package/src/adapters/conformance/kit.ts +3 -9
  50. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  51. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  52. package/src/adapters/definitions/native-config-policy.ts +3 -2
  53. package/src/adapters/definitions/native-context.ts +12 -0
  54. package/src/adapters/external-metadata.ts +48 -0
  55. package/src/adapters/external-modules.ts +63 -3
  56. package/src/adapters/jcode.ts +6 -2
  57. package/src/adapters/local-worker.ts +40 -25
  58. package/src/adapters/native-proof-ports.ts +10 -0
  59. package/src/adapters/registry-types.ts +4 -0
  60. package/src/broker/protocol.ts +203 -0
  61. package/src/broker/server.ts +314 -0
  62. package/src/broker/store.ts +548 -0
  63. package/src/cli/bench.ts +3 -2
  64. package/src/cli/broker.ts +58 -0
  65. package/src/cli/conductor-recovery.ts +142 -0
  66. package/src/cli/configured-acp-entry.ts +26 -6
  67. package/src/cli/console-state.ts +59 -17
  68. package/src/cli/console.ts +17 -2
  69. package/src/cli/facts-hook.ts +3 -1
  70. package/src/cli/help.ts +28 -16
  71. package/src/cli/identity.ts +23 -10
  72. package/src/cli/init.ts +34 -7
  73. package/src/cli/intake-output.ts +33 -0
  74. package/src/cli/main.ts +314 -71
  75. package/src/cli/output.ts +41 -1
  76. package/src/cli/recovery-package.ts +18 -1
  77. package/src/cli/role-holders.ts +35 -0
  78. package/src/cli/room.ts +304 -0
  79. package/src/cli/say-input.ts +16 -0
  80. package/src/cli/schedule-output.ts +49 -0
  81. package/src/cli/sdlc-output.ts +44 -6
  82. package/src/cli/smoke-pty-bridge.ts +33 -0
  83. package/src/cli/smoke-pty.ts +145 -0
  84. package/src/cli/smoke-run.ts +139 -0
  85. package/src/cli/smoke-target.ts +130 -0
  86. package/src/cli/source-output.ts +29 -0
  87. package/src/cli/task-output.ts +3 -2
  88. package/src/cli/upgrade-runtime.ts +98 -16
  89. package/src/cli/upgrade.ts +66 -7
  90. package/src/hub/ask.ts +76 -12
  91. package/src/hub/board.ts +132 -22
  92. package/src/hub/bus.ts +399 -56
  93. package/src/hub/child-process.ts +2 -0
  94. package/src/hub/ci-watch.ts +42 -20
  95. package/src/hub/claude-failure-stage.ts +85 -0
  96. package/src/hub/claude-shell-wrapper.ts +11 -5
  97. package/src/hub/conductor.ts +17 -4
  98. package/src/hub/config-apply.ts +32 -5
  99. package/src/hub/config-reload.ts +7 -5
  100. package/src/hub/config-trust.ts +7 -1
  101. package/src/hub/context-window.ts +33 -3
  102. package/src/hub/control-client.ts +14 -2
  103. package/src/hub/corpus-config.ts +34 -0
  104. package/src/hub/corpus-evidence.ts +184 -0
  105. package/src/hub/corpus.ts +373 -0
  106. package/src/hub/cost-governance.ts +26 -0
  107. package/src/hub/daemon.ts +1150 -171
  108. package/src/hub/deliverable-files.ts +8 -2
  109. package/src/hub/delivery-journal.ts +123 -17
  110. package/src/hub/dogfood-profile.ts +32 -5
  111. package/src/hub/dogfood-read.ts +6 -2
  112. package/src/hub/envelope.ts +11 -0
  113. package/src/hub/eval-gate.ts +131 -0
  114. package/src/hub/events.ts +1 -1
  115. package/src/hub/external-instance.ts +69 -0
  116. package/src/hub/facts.ts +15 -5
  117. package/src/hub/folder-trigger.ts +223 -0
  118. package/src/hub/hub-tools.ts +2 -1
  119. package/src/hub/inference.ts +20 -5
  120. package/src/hub/instance-config.ts +69 -3
  121. package/src/hub/instance-execution.ts +6 -2
  122. package/src/hub/instances.ts +32 -4
  123. package/src/hub/intake.ts +125 -0
  124. package/src/hub/learning.ts +85 -0
  125. package/src/hub/lifecycle.ts +7 -3
  126. package/src/hub/limits.ts +12 -5
  127. package/src/hub/native-permission-request.ts +77 -0
  128. package/src/hub/operational-authority.ts +63 -5
  129. package/src/hub/operational-settings.ts +76 -0
  130. package/src/hub/operational-view.ts +1 -1
  131. package/src/hub/packs.ts +132 -0
  132. package/src/hub/path-policy.ts +7 -0
  133. package/src/hub/peer-doing.ts +130 -0
  134. package/src/hub/peers.ts +12 -0
  135. package/src/hub/queue-backpressure.ts +58 -0
  136. package/src/hub/read-tools.ts +2 -0
  137. package/src/hub/recovery-authority.ts +151 -0
  138. package/src/hub/recovery-policy.ts +220 -0
  139. package/src/hub/research.ts +24 -14
  140. package/src/hub/restart.ts +7 -0
  141. package/src/hub/routing.ts +54 -11
  142. package/src/hub/schedules.ts +266 -0
  143. package/src/hub/sdlc-merge.ts +38 -14
  144. package/src/hub/sdlc-metrics.ts +32 -10
  145. package/src/hub/sdlc-queue.ts +7 -1
  146. package/src/hub/sdlc-release.ts +1 -1
  147. package/src/hub/sdlc.ts +37 -7
  148. package/src/hub/security-baseline.ts +38 -0
  149. package/src/hub/settings.ts +6 -1
  150. package/src/hub/shipped-packs.ts +8 -0
  151. package/src/hub/snapshots.ts +72 -6
  152. package/src/hub/source-read.ts +160 -0
  153. package/src/hub/sources.ts +311 -0
  154. package/src/hub/tasks.ts +910 -110
  155. package/src/hub/workflow.ts +1 -1
  156. package/src/local/doc-read.ts +69 -0
  157. package/src/local/proxy.ts +4 -13
  158. package/src/local/tools.ts +37 -3
  159. package/src/local/web-fetch-config.ts +43 -0
  160. package/src/local/web-fetch.ts +183 -0
  161. package/src/memory/brief.ts +85 -13
  162. package/src/memory/capture.ts +1 -1
  163. package/src/rooms/application.ts +114 -0
  164. package/src/rooms/boundary.ts +103 -0
  165. package/src/rooms/commands.ts +294 -0
  166. package/src/rooms/crypto.ts +371 -0
  167. package/src/rooms/link.ts +584 -0
  168. package/src/rooms/policy.ts +82 -0
  169. package/src/rooms/remote-peer.ts +80 -0
  170. package/src/rooms/service.ts +426 -0
  171. package/src/rooms/status.ts +146 -0
  172. package/src/rooms/store.ts +1210 -0
  173. package/src/rooms/wire.ts +465 -0
  174. package/src/ui/index.html +91 -4
  175. package/templates/packs/example.pack.json +13 -0
@@ -0,0 +1,82 @@
1
+ /** Person-owned exposure only; committed/shared config cannot enroll or expose a hub. */
2
+ import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { configRefusal } from "../hub/config-trust.ts";
5
+
6
+ const ID = /^[a-z][a-z0-9-]{0,31}$/;
7
+ export function roomName(value: unknown): string {
8
+ if (typeof value !== "string" || !ID.test(value)) throw new Error("room: invalid_name");
9
+ return value;
10
+ }
11
+ export function exposedPeers(value: unknown): string[] {
12
+ if (!Array.isArray(value) || value.length > 64 || value.some(x => typeof x !== "string" || !ID.test(x) || ["hub", "digest"].includes(x))) throw new Error("room: invalid_exposure");
13
+ return [...new Set(value as string[])].sort();
14
+ }
15
+ export class RoomPolicy {
16
+ constructor(private readonly cwd: string) {}
17
+ private read(): Record<string, any> {
18
+ const dir = join(this.cwd, ".agenthub"), file = join(dir, "config.local.json");
19
+ if (configRefusal(this.cwd, "config.local.json")) throw new Error("room: untrusted_config");
20
+ if (existsSync(dir) && (lstatSync(dir).isSymbolicLink() || !lstatSync(dir).isDirectory())) throw new Error("room: untrusted_config");
21
+ if (!existsSync(file)) return {};
22
+ const stat = lstatSync(file);
23
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 1_048_576) throw new Error("room: untrusted_config");
24
+ const raw: unknown = JSON.parse(readFileSync(file, "utf8"));
25
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("room: invalid_config");
26
+ const config = raw as Record<string, any>;
27
+ if (config.rooms !== undefined) {
28
+ if (!config.rooms || typeof config.rooms !== "object" || Array.isArray(config.rooms) || Object.keys(config.rooms).filter(key => key !== "ack_timeout_s" && key !== "queue_ttl_h").length > 64) throw new Error("room: invalid_config");
29
+ for (const [name, row] of Object.entries(config.rooms)) {
30
+ if (name === "ack_timeout_s" || name === "queue_ttl_h") {
31
+ if (!Number.isSafeInteger(row) || (row as number) < 1 || (row as number) > (name === "ack_timeout_s" ? 86400 : 168)) throw new Error("room: invalid_config");
32
+ continue;
33
+ }
34
+ roomName(name);
35
+ if (!row || typeof row !== "object" || Array.isArray(row) || Object.keys(row).some(key => key !== "expose")) throw new Error("room: invalid_config");
36
+ exposedPeers((row as {expose?: unknown}).expose ?? []);
37
+ }
38
+ }
39
+ return config;
40
+ }
41
+ // ponytail: each policy read repeats hardened Git/file probes (up to 64 rooms);
42
+ // proof/effect fences can read more than once per publication. Profile before
43
+ // replacing these reads with per-turn/stat-keyed snapshots that keep effect fences fresh.
44
+ get(name: string): string[] {
45
+ roomName(name);
46
+ try { return exposedPeers(this.read().rooms?.[name]?.expose ?? []); }
47
+ catch { return []; } // Loss of trust revokes exposure, never widens it.
48
+ }
49
+ timing(): { ackTimeoutMs: number; ttlMs: number } {
50
+ const rooms = this.read().rooms ?? {};
51
+ return { ackTimeoutMs: (rooms.ack_timeout_s ?? 600) * 1000, ttlMs: (rooms.queue_ttl_h ?? 24) * 3_600_000 };
52
+ }
53
+ private prepare(name: string, peers: string[]): { config: Record<string, any>; bytes: string } {
54
+ roomName(name); const expose = exposedPeers(peers), config = this.read();
55
+ const rooms = { ...config.rooms, [name]: { expose } };
56
+ if (Object.keys(rooms).filter(key => key !== "ack_timeout_s" && key !== "queue_ttl_h").length > 64) throw new Error("room: config_full");
57
+ const bytes = JSON.stringify({ ...config, rooms }, null, 2) + "\n";
58
+ if (Buffer.byteLength(bytes) > 1_048_576) throw new Error("room: config_full");
59
+ return { config, bytes };
60
+ }
61
+ assertWritable(name: string, peers: string[], current: () => boolean): void {
62
+ this.prepare(name, peers);
63
+ if (!current()) throw new Error("room: state_changed");
64
+ }
65
+ set(name: string, peers: string[], current: () => boolean): void {
66
+ const { config, bytes } = this.prepare(name, peers);
67
+ const dir = join(this.cwd, ".agenthub"), file = join(dir, "config.local.json");
68
+ if (!current()) throw new Error("room: state_changed");
69
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
70
+ const temporary = join(dir, `.room-${crypto.randomUUID()}.tmp`);
71
+ let fd: number | undefined;
72
+ try {
73
+ fd = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
74
+ writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined;
75
+ if (!current() || configRefusal(this.cwd, "config.local.json") || JSON.stringify(this.read()) !== JSON.stringify(config)) throw new Error("room: state_changed");
76
+ renameSync(temporary, file);
77
+ } finally {
78
+ if (fd !== undefined) closeSync(fd);
79
+ try { unlinkSync(temporary); } catch { /* renamed or not created */ }
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,80 @@
1
+ /** A host-owned room transport, never a task owner, reviewer, tool caller or native runtime. */
2
+ import { BasePeer, type DeliveryReceipt } from "../hub/peers.ts";
3
+ import type { Envelope, PeerState } from "../hub/envelope.ts";
4
+ import { parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
5
+ import { readRoomOutbound, roomBoundary, type RoomBoundaryContext } from "./boundary.ts";
6
+
7
+ export interface RemotePeerPorts {
8
+ member: string;
9
+ context: (env: Envelope) => RoomBoundaryContext;
10
+ send: (env: RoomChatEnvelope, deliveryId: string, expiresAt: number, current: () => boolean, withheld: boolean) => Promise<void>;
11
+ now?: () => number;
12
+ /** Record post-publication trust loss locally before any late signed outcome can settle it. */
13
+ quarantine?: (deliveryId: string) => void;
14
+ }
15
+
16
+ export class RemotePeer extends BasePeer {
17
+ readonly hubNative = true;
18
+ readonly deliveryOnly = true;
19
+ readonly remoteMember: string;
20
+ private generation = 0;
21
+ private running = false;
22
+ private online = false;
23
+ private observed: PeerState = "offline";
24
+ private readonly pending = new Set<string>();
25
+ private readonly deadlines = new Map<string, number>();
26
+ constructor(id: string, readonly remoteRoom: string, readonly remoteQueueTtlMs: number, private readonly ports: RemotePeerPorts) {
27
+ super(id);
28
+ this.remoteMember = ports.member;
29
+ if (!parseRemotePeerId(id) || !remoteRoom || !Number.isSafeInteger(remoteQueueTtlMs) || remoteQueueTtlMs < 1 || remoteQueueTtlMs > 604_800_000) throw new Error("room: invalid_message");
30
+ if (!/^[0-9a-f]{64}$/.test(this.remoteMember)) throw new Error("room: invalid_message");
31
+ }
32
+ // Room ack/TTL deadlines, not the native inactivity watchdog, own transport settlement.
33
+ protected override touch(): void { this.clearWatchdog(); }
34
+ private update(): void { this.setState(this.pending.size ? "busy" : this.running && this.online ? this.observed : "offline"); }
35
+ async start(): Promise<void> { this.running = true; this.generation++; this.update(); }
36
+ async stop(): Promise<void> {
37
+ this.running = false; this.generation++;
38
+ for (const id of [...this.pending]) { this.quarantine(id); this.receipt({ id, state: "needs_review", reason: "room transport stopped" }); }
39
+ this.deadlines.clear(); this.update();
40
+ }
41
+ private quarantine(id: string): void { try { this.ports.quarantine?.(id); } catch { /* the Bus still retains uncertainty if room storage is unavailable */ } }
42
+ presence(state: PeerState, online: boolean): void { this.observed = state; this.online = online; this.update(); }
43
+ setRemoteDeadline(envelopeId: string, expiresAt: number): void {
44
+ if (Number.isSafeInteger(expiresAt) && expiresAt > 0 && this.deadlines.size < 200) this.deadlines.set(envelopeId, expiresAt);
45
+ }
46
+ receipt(receipt: DeliveryReceipt): boolean {
47
+ if (!this.pending.has(receipt.id)) return false;
48
+ if (receipt.state !== "accepted") this.pending.delete(receipt.id);
49
+ this.delivery(receipt); // Journal settlement precedes a state transition that may drain the next item.
50
+ this.update();
51
+ return true;
52
+ }
53
+ async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
54
+ if (!this.running || !this.online || !deliveryId || envs.length !== 1) throw new Error("room: unavailable");
55
+ const env = envs[0]!, generation = this.generation;
56
+ const expiresAt = this.deadlines.get(env.id);
57
+ this.deadlines.delete(env.id);
58
+ const context = this.ports.context(env);
59
+ const current = () => this.running && this.online && this.generation === generation && context.current();
60
+ const result = await roomBoundary(env, { ...context, current });
61
+ if (!current()) throw new Error("room: state_changed");
62
+ if (result.status === "refused") {
63
+ this.delivery({ id: deliveryId, state: "failed_safe", reason: `room: ${result.code}` });
64
+ return;
65
+ }
66
+ const view = readRoomOutbound(result.proof);
67
+ if (!view || expiresAt === undefined || expiresAt <= (this.ports.now?.() ?? Date.now())) {
68
+ this.delivery({ id: deliveryId, state: "needs_review", reason: "room queued deadline unavailable or expired" });
69
+ return;
70
+ }
71
+ this.pending.add(deliveryId); this.update();
72
+ try {
73
+ await this.ports.send(view, deliveryId, expiresAt, () => current() && readRoomOutbound(result.proof) !== undefined, result.withheld);
74
+ if (!current()) { this.quarantine(deliveryId); this.receipt({ id: deliveryId, state: "needs_review", reason: "room trust changed during publication" }); }
75
+ } catch {
76
+ this.quarantine(deliveryId);
77
+ this.receipt({ id: deliveryId, state: "needs_review", reason: "room publication outcome is uncertain" });
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,426 @@
1
+ /** Host integration for X4. Remote identities carry chat, never local control authority. */
2
+ import type { Bus } from "../hub/bus.ts";
3
+ import { USER, MAX_HOP, type Envelope, type PeerState } from "../hub/envelope.ts";
4
+ import { Limiter, PROJECT_LIMITS, type LimitsConfig } from "../hub/limits.ts";
5
+ import type { PiiVerdict } from "../hub/inference.ts";
6
+ import type { Routing } from "../hub/routing.ts";
7
+ import { roomMembers } from "./wire.ts";
8
+ import { RoomStore, RoomStoreError, RoomApplicationLimitError, type RoomOutgoing } from "./store.ts";
9
+ import { RoomPolicy } from "./policy.ts";
10
+ import { RemotePeer } from "./remote-peer.ts";
11
+ import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason } from "./application.ts";
12
+ import { RoomApplicationDeferredError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
13
+
14
+ export interface RoomChatOptions {
15
+ bus: Bus; cwd: string; now?: () => number;
16
+ routing: () => Routing; piiPaths: () => readonly string[];
17
+ senderPrivate: (peer: string) => boolean | undefined;
18
+ taskPrivate: (id: number) => boolean | undefined;
19
+ modelScreenOn: () => boolean; screen: (text: string) => Promise<PiiVerdict>;
20
+ policyVersion: () => string | number;
21
+ roster?: (ids: readonly string[]) => void;
22
+ changed?: () => void;
23
+ limits?: () => LimitsConfig;
24
+ notice?: (value: { code: "application_capacity"; room: string; count: number } | { code: "sequence_gap"; room: string; sender: string; count: string }) => void;
25
+ }
26
+ export interface RoomMetrics { since: number; outbound: number | null; inbound: number | null; withheld: number | null }
27
+ interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; online: boolean; states: Map<string, PeerState> }
28
+ interface Target { binding: Binding; member: string; peer: string }
29
+ const ACTIVE = new Set(["dispatching", "accepted", "needs_review"]);
30
+ function completedText(reason?: RoomReceiptReason): string {
31
+ return reason === "inbox_admitted" ? "completed (inbox)" : "completed (admitted)";
32
+ }
33
+ function lateReceiptText(receipt: RoomOutgoing["lateReceipt"]): string | undefined {
34
+ if (!receipt) return undefined;
35
+ return receipt.outcome === "completed" ? completedText(receipt.reason) : "failed_safe";
36
+ }
37
+ function refusalReason(row: Pick<RoomOutgoing, "reason" | "lateReceipt">): RoomReceiptReason | undefined {
38
+ if (row.reason && row.reason !== "admitted" && row.reason !== "inbox_admitted") return row.reason;
39
+ const late = row.lateReceipt?.reason;
40
+ return late && late !== "admitted" && late !== "inbox_admitted" ? late : undefined;
41
+ }
42
+
43
+ export class RoomChat {
44
+ private readonly policy: RoomPolicy;
45
+ private readonly metricsSince: number | null;
46
+ // ponytail: at most 64 room-name session counters, never cross-run totals.
47
+ // If cross-run totals are required, add durable versioned counters at the storage boundary.
48
+ private readonly counts = new Map<string, { room: string; outbound: number | null; inbound: number | null; withheld: number | null }>();
49
+ private readonly bindings = new Map<string, Binding>();
50
+ private readonly targets = new Map<string, Target>();
51
+ private statusTargets = new Map<string, string[] | null>();
52
+ private readonly remotes = new Map<string, RemotePeer>();
53
+ private readonly outgoing = new Map<string, { binding: Binding; row: RoomOutgoing }>();
54
+ private evidenceCache?: Map<string, { peer: string; roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string }>;
55
+ private timer?: ReturnType<typeof setTimeout>;
56
+ private stopped = false;
57
+ private capacityRefreshPending = false;
58
+ private limitKey = "";
59
+ private peerLimiter = new Limiter(PROJECT_LIMITS);
60
+ readonly application: RoomLinkApplication;
61
+ constructor(private readonly options: RoomChatOptions) {
62
+ this.policy = new RoomPolicy(options.cwd);
63
+ let since: number | null = null;
64
+ try { const value = this.now(); if (Number.isSafeInteger(value) && value >= 0) since = value; } catch { /* Observation failure never disables transport construction. */ }
65
+ this.metricsSince = since;
66
+ this.application = {
67
+ members: context => { if (context.current()) this.bind(context.store, context.name, context.link); },
68
+ receive: (context, input) => this.receive(context, input),
69
+ online: async context => { if (context.current()) { this.bind(context.store, context.name, context.link); this.bindings.get(context.name)!.online = true; this.refreshTargets(); this.refreshOutgoing(this.bindings.get(context.name)!); this.queueChanged(); } },
70
+ stored: (context, input) => {
71
+ if (!context.current()) return;
72
+ const row = context.store.markApplicationStored(context.name, input.digest, input.seq);
73
+ if (row) this.outcome(this.bindings.get(context.name)!, row);
74
+ },
75
+ capacityChanged: context => { if (context.current()) this.capacityChanged(); },
76
+ gap: (context, input) => {
77
+ if (context.current()) this.options.notice?.({ code: "sequence_gap", room: context.store.get(context.name)!.current.room, sender: input.sender, count: input.count });
78
+ },
79
+ };
80
+ }
81
+ private now(): number { return this.options.now?.() ?? Date.now(); }
82
+ metrics(name: string, room?: string): RoomMetrics | null {
83
+ const binding = this.bindings.get(name);
84
+ if (this.stopped || !binding || this.metricsSince === null || room !== undefined && binding.room !== room) return null;
85
+ const row = this.counts.get(name);
86
+ return { since: this.metricsSince, outbound: row?.room === binding.room ? row.outbound : null,
87
+ inbound: row?.room === binding.room ? row.inbound : null, withheld: row?.room === binding.room ? row.withheld : null };
88
+ }
89
+ private count(binding: Binding, direction: "outbound" | "inbound", withheld = false): void {
90
+ const row = this.counts.get(binding.name);
91
+ if (!row || row.room !== binding.room) return;
92
+ const increment = (key: "outbound" | "inbound" | "withheld") => {
93
+ const value = row[key]; row[key] = value !== null && value < Number.MAX_SAFE_INTEGER ? value + 1 : null;
94
+ };
95
+ increment(direction); if (direction === "outbound" && withheld) increment("withheld");
96
+ try { this.options.changed?.(); } catch { /* Metrics observation changes no send/admission outcome. */ }
97
+ }
98
+ private valid(binding: Binding): boolean {
99
+ return !this.stopped && this.bindings.get(binding.name) === binding && binding.store.get(binding.name)?.left === false;
100
+ }
101
+ bind(store: RoomStore, name: string, link: RoomLink): void {
102
+ if (this.stopped) return;
103
+ const state = store.get(name), digest = state?.current.digest; if (!digest || !state) return;
104
+ const room = state.current.room, counted = this.counts.get(name);
105
+ if (counted?.room !== room && (counted || this.counts.size < 64)) this.counts.set(name, { room, outbound: 0, inbound: 0, withheld: 0 });
106
+ const saved = this.bindings.get(name);
107
+ const binding = saved?.store === store && saved.link === link && saved.room === room ? saved : { store, name, room, link, digest, online: false, states: new Map<string, PeerState>() };
108
+ if (binding.digest !== digest) { binding.states.clear(); binding.online = false; binding.digest = digest; }
109
+ this.bindings.set(name, binding);
110
+ this.refreshTargets(); this.refreshOutgoing(binding); this.queueChanged();
111
+ }
112
+ private refreshTargets(): void {
113
+ const candidates = new Map<string, Target[]>();
114
+ for (const binding of this.bindings.values()) {
115
+ const state = binding.store.get(binding.name);
116
+ if (!this.valid(binding) || !state) continue;
117
+ for (const member of roomMembers(state.current)) {
118
+ if (member.fingerprint === binding.store.identity.public.fingerprint) continue;
119
+ for (const peer of new Set([...member.exposedPeers, USER])) {
120
+ // USER routes only to the remote hub's signed unsupported-inbox refusal, never a human queue.
121
+ const id = `${peer}@${member.label}`;
122
+ if (!parseRemotePeerId(id)) continue;
123
+ const rows = candidates.get(id) ?? []; rows.push({ binding, member: member.fingerprint, peer }); candidates.set(id, rows);
124
+ }
125
+ }
126
+ }
127
+ this.targets.clear();
128
+ for (const [id, rows] of candidates) {
129
+ // A global peer@label cannot select between rooms. Ambiguous ids stay inert.
130
+ if (rows.length !== 1) continue;
131
+ const target = rows[0]!, room = target.binding.store.get(target.binding.name)!.current.room;
132
+ let remote = this.remotes.get(id);
133
+ if (remote && (remote.remoteRoom !== room || remote.remoteMember !== target.member)) {
134
+ const pending = this.options.bus.queueSummary(id);
135
+ if (this.options.bus.pendingEnvelopes(id).length || pending.needsReview || pending.liveAccepted || remote.state === "busy") continue;
136
+ void remote.stop(); this.remotes.delete(id); remote = undefined;
137
+ }
138
+ if (!remote) {
139
+ let timing: { ackTimeoutMs: number; ttlMs: number }; try { timing = this.policy.timing(); } catch { continue; }
140
+ remote = new RemotePeer(id, room, timing.ttlMs, {
141
+ member: target.member,
142
+ context: env => {
143
+ const selected = this.targets.get(id);
144
+ if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) throw new Error("room: state_changed");
145
+ const binding = selected.binding, state = binding.store.get(binding.name), version = this.options.policyVersion();
146
+ const digest = state?.current.digest;
147
+ const current = () => this.valid(binding) && this.targets.get(id)?.binding === binding && this.targets.get(id)?.member === target.member && this.options.policyVersion() === version
148
+ && binding.store.get(binding.name)?.current.digest === digest;
149
+ return { cwd: this.options.cwd, target: id, current,
150
+ senderExposed: peer => this.exposed(binding, peer), senderPrivate: this.options.senderPrivate,
151
+ taskPrivate: this.options.taskPrivate, routing: this.options.routing, piiPaths: this.options.piiPaths,
152
+ modelScreenOn: this.options.modelScreenOn, screen: this.options.screen };
153
+ },
154
+ send: async (env, deliveryId, expiresAt, current, withheld) => {
155
+ const selected = this.targets.get(id);
156
+ if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) throw new Error("room: state_changed");
157
+ const binding = selected.binding;
158
+ if (!current()) throw new Error("room: state_changed");
159
+ const timing = this.policy.timing(), remaining = expiresAt - this.now();
160
+ if (remaining <= 0) throw new Error("room: expired");
161
+ try {
162
+ await binding.link.publishApplication({ v: 1, type: "chat", envelope: env }, target.member, {
163
+ deliveryId, application: env.id, remotePeer: id, recipient: target.member, peer: target.peer,
164
+ ackTimeoutMs: timing.ackTimeoutMs, ttlMs: Math.min(remaining, timing.ttlMs),
165
+ }, current);
166
+ this.count(binding, "outbound", withheld === true);
167
+ } catch (error) { binding.store.quarantineOutgoing(binding.name, deliveryId); throw error; }
168
+ finally { this.refreshOutgoing(binding); this.queueChanged(); }
169
+ }, quarantine: deliveryId => {
170
+ const selected = this.targets.get(id);
171
+ const cached = [...this.outgoing.values()].filter(value => value.row.deliveryId === deliveryId && value.row.remotePeer === id && value.row.recipient === target.member).map(value => value.binding);
172
+ const bindings = [...new Set([...(selected?.member === target.member && selected.peer === target.peer ? [selected.binding] : []), ...cached])];
173
+ for (const binding of bindings) try {
174
+ if (binding.store.get(binding.name)?.current.room !== room) continue;
175
+ const row = binding.store.quarantineOutgoing(binding.name, deliveryId);
176
+ if (row && row.remotePeer === id && row.recipient === target.member) { this.outcome(binding, row); return; }
177
+ } catch { /* A retained old binding may already be closed after an explicit rejoin. */ }
178
+ }, now: () => this.now(),
179
+ });
180
+ try { this.options.bus.add(remote); } catch { continue; }
181
+ this.remotes.set(id, remote); void remote.start();
182
+ }
183
+ this.targets.set(id, target);
184
+ }
185
+ // ponytail: status shows complete peer lists only within 32 per room / 256 total.
186
+ // Larger lists stay unknown here; the explicit room status command remains the detail path.
187
+ const statusTargets = new Map<string, string[] | null>([...this.bindings.keys()].map(name => [name, []]));
188
+ // An exposed candidate that cannot route uniquely is unknown, never an empty exposure list.
189
+ for (const [id, rows] of candidates) if (rows.some(row => row.peer !== USER) && (rows.length !== 1 || !this.targets.has(id))) {
190
+ for (const row of rows) statusTargets.set(row.binding.name, null);
191
+ }
192
+ let shown = 0;
193
+ for (const [id, target] of this.targets) {
194
+ if (target.peer === USER) continue;
195
+ const ids = statusTargets.get(target.binding.name);
196
+ if (!ids) continue;
197
+ if (ids.length >= 32 || shown >= 256) { shown -= ids.length; statusTargets.set(target.binding.name, null); }
198
+ else { ids.push(id); shown++; }
199
+ }
200
+ this.statusTargets = statusTargets;
201
+ for (const [id, remote] of this.remotes) {
202
+ const target = this.targets.get(id);
203
+ remote.presence(target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline", target?.binding.online === true);
204
+ }
205
+ this.options.roster?.([...this.targets.keys()]);
206
+ }
207
+ private exposed(binding: Binding, peer: string): boolean {
208
+ const state = binding.store.get(binding.name);
209
+ return this.valid(binding) && !!state && this.options.bus.peers.has(peer) && this.options.bus.isRostered(peer) && !peer.includes("@")
210
+ && this.policy.get(binding.name).includes(peer)
211
+ && roomMembers(state.current).some(member => member.fingerprint === binding.store.identity.public.fingerprint && member.exposedPeers.includes(peer));
212
+ }
213
+ presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[] }): void {
214
+ const binding = this.bindings.get(name), state = store.get(name);
215
+ if (!binding || binding.store !== store || binding.link !== link || !state || !this.valid(binding)) return;
216
+ const member = roomMembers(state.current).find(row => row.fingerprint === value.member);
217
+ if (!member) return;
218
+ for (const peer of member.exposedPeers) binding.states.delete(`${peer}@${member.label}`);
219
+ for (const peer of value.peers) if (member.exposedPeers.includes(peer.id)) binding.states.set(`${peer.id}@${member.label}`, peer.state);
220
+ this.refreshTargets();
221
+ }
222
+ linkState(store: RoomStore, name: string, link: RoomLink, state: RoomLinkState): void {
223
+ const binding = this.bindings.get(name);
224
+ if (!binding || binding.store !== store || binding.link !== link) return;
225
+ binding.online = state === "online";
226
+ // A transport blip does not revoke the last authenticated signed state.
227
+ // A new roster digest still clears it in bind(), before target reconstruction.
228
+ if (!this.valid(binding)) {
229
+ try { for (const row of binding.store.applicationOutbox(name, true)) if (row.state === "dispatching" || row.state === "accepted") {
230
+ const unknown = binding.store.quarantineOutgoing(name, row.deliveryId); if (unknown) this.outcome(binding, unknown);
231
+ } } catch { for (const [key, row] of this.outgoing) if (row.binding === binding) this.outgoing.delete(key); }
232
+ }
233
+ this.refreshTargets();
234
+ this.queueChanged();
235
+ }
236
+ private refreshOutgoing(binding: Binding): void {
237
+ this.evidenceCache = undefined;
238
+ for (const [key, value] of this.outgoing) if (value.binding.name === binding.name) this.outgoing.delete(key);
239
+ for (const row of binding.store.applicationOutbox(binding.name, true)) this.outgoing.set(`${binding.name}:${row.digest}`, { binding, row });
240
+ }
241
+ pendingIds(room: string): string[] {
242
+ 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))];
243
+ }
244
+ private outcome(binding: Binding, row: RoomOutgoing): void {
245
+ const remote = this.remotes.get(row.remotePeer);
246
+ if (row.lateReceipt) this.options.bus.noteLateRemoteReceipt(row.remotePeer, row.deliveryId, row.lateReceipt.outcome);
247
+ else if (!row.disposition) remote?.receipt({ id: row.deliveryId, state: row.state === "dispatching" ? "needs_review" : row.state, reason: row.state === "completed" ? `room ${completedText(row.reason)}` : row.state === "failed_safe" ? roomReceiptReasonText(row.reason ?? "invalid_message") : row.state === "accepted" ? "room broker accepted ciphertext" : "room acknowledgement expired or outcome uncertain" });
248
+ this.refreshOutgoing(binding); this.queueChanged(); this.options.changed?.();
249
+ }
250
+ private rate(from: string, member: string, target: string, body: string): boolean {
251
+ const config = this.options.limits?.() ?? PROJECT_LIMITS, key = JSON.stringify(config);
252
+ if (key !== this.limitKey) { this.limitKey = key; this.peerLimiter = new Limiter(config, () => this.now()); }
253
+ return !this.peerLimiter.admit(from, [target], "status", body, "", member);
254
+ }
255
+ private sourceUnambiguous(binding: Binding, member: string, label: string): boolean {
256
+ const owners = new Set<string>();
257
+ for (const room of this.bindings.values()) {
258
+ const state = room.store.get(room.name); if (!this.valid(room) || !state) continue;
259
+ for (const row of roomMembers(state.current)) if (row.fingerprint !== room.store.identity.public.fingerprint && row.label === label) owners.add(row.fingerprint);
260
+ }
261
+ return this.valid(binding) && owners.size === 1 && owners.has(member);
262
+ }
263
+ private async receive(context: RoomLinkContext, input: { digest: string; sender: string; message?: RoomMessage }): Promise<void> {
264
+ if (!context.current() || this.stopped) throw new Error("room: state_changed");
265
+ const binding = this.bindings.get(context.name);
266
+ if (!binding || binding.store !== context.store || binding.link !== context.link) throw new Error("room: unavailable");
267
+ const saved = context.store.applicationReceipt(context.name, input.digest);
268
+ if (saved) { await context.link.publishApplication(saved, input.sender, undefined, context.current); return; }
269
+ if (input.message?.type === "receipt") {
270
+ const row = context.store.applyApplicationReceipt(context.name, input.digest, context.current);
271
+ if (row) this.outcome(binding, row);
272
+ context.store.settleInbox(context.name, input.digest); this.capacityChanged(); return;
273
+ }
274
+ if (!input.message) return; // Exact unknown/stale digest never earns a fabricated application receipt.
275
+ let incoming: Awaited<ReturnType<RoomStore["applicationIncoming"]>>;
276
+ try { incoming = await context.store.applicationIncoming(context.name, input.digest, context.current); }
277
+ catch (error) {
278
+ if (error instanceof RoomApplicationLimitError) try { this.options.notice?.({ code: "application_capacity", room: error.room, count: error.count }); } catch { /* fixed metadata observation only */ }
279
+ if (error instanceof RoomStoreError && error.code === "invalid") { context.store.settleInbox(context.name, input.digest); this.capacityChanged(); return; }
280
+ if (error instanceof RoomApplicationLimitError) throw new RoomApplicationDeferredError("capacity");
281
+ if (error instanceof RoomStoreError && error.code === "limit") throw new RoomApplicationDeferredError("admission_limit");
282
+ throw error;
283
+ }
284
+ if (!context.current() || !this.valid(binding)) throw new Error("room: state_changed");
285
+ this.capacityChanged(); // Registration may have settled a duplicate's retained plaintext.
286
+ const duplicate = context.store.applicationReceipt(context.name, input.digest);
287
+ if (duplicate) { await context.link.publishApplication(duplicate, input.sender, undefined, context.current); return; }
288
+ if (!incoming.primary || incoming.state !== "pending") return;
289
+ const state = context.store.get(context.name)!, member = roomMembers(state.current).find(row => row.fingerprint === incoming.sender), chat = incoming.chat;
290
+ if (chat.to !== USER && !this.options.bus.deliveryJournalAvailable()) throw new RoomApplicationDeferredError("journal_unavailable");
291
+ const from = member ? `${chat.from}@${member.label}` : "";
292
+ const env: Envelope = { ...chat, from, to: [chat.to], priority: chat.priority === "fyi" ? "fyi" : "status" };
293
+ let refusal: RoomReceiptReason | undefined;
294
+ if (!member || !parseRemotePeerId(from) || !this.sourceUnambiguous(binding, incoming.sender, member.label)) refusal = "not_exposed";
295
+ else if (chat.from !== USER && !member.exposedPeers.includes(chat.from)) refusal = "not_exposed";
296
+ else if (chat.to !== USER && !this.exposed(binding, chat.to)) refusal = "not_exposed";
297
+ else if (chat.priority === "fyi") refusal = "boundary_refused";
298
+ else if (chat.hop > MAX_HOP || chat.hop < (context.store.applicationTraceHop(chat.trace) ?? 0)) refusal = "hop";
299
+ else if (chat.to !== USER && this.options.bus.hushed(env).some(row => row.peer === chat.to)) refusal = "boundary_refused";
300
+ else if (!this.rate(from, incoming.sender, chat.to, chat.body)) refusal = "rate_limited";
301
+ if (refusal) context.store.finishApplication(context.name, input.digest, "failed_safe", refusal, context.current);
302
+ else if (chat.to === USER) {
303
+ if (!context.current() || !this.valid(binding)) throw new Error("room: state_changed");
304
+ const admission = context.store.admitPersonInbox(context.name, input.digest, context.current);
305
+ if (admission.state === "admitted") this.count(binding, "inbound");
306
+ } else if (context.store.claimApplication(context.name, input.digest, context.current)) {
307
+ try {
308
+ if (!context.current() || !this.exposed(binding, chat.to)) throw new Error("room: state_changed");
309
+ const result = this.options.bus.publishResult(env);
310
+ if (!result.targets.length) context.store.finishApplication(context.name, input.digest, "failed_safe", "boundary_refused", context.current);
311
+ else {
312
+ if (result.targets.length !== 1 || result.targets[0] !== chat.to || result.dropped) throw new Error("room: admission unavailable");
313
+ context.store.finishApplication(context.name, input.digest, "completed", "admitted", context.current);
314
+ this.count(binding, "inbound");
315
+ }
316
+ } catch (error) {
317
+ // Revocation removes business authority, but still permits recording uncertainty.
318
+ context.store.quarantineApplication(context.name, input.digest);
319
+ this.capacityChanged();
320
+ this.options.changed?.(); throw error;
321
+ }
322
+ }
323
+ this.capacityChanged();
324
+ const receipt = context.store.applicationReceipt(context.name, input.digest);
325
+ if (receipt) await context.link.publishApplication(receipt, input.sender, undefined, context.current);
326
+ this.options.changed?.();
327
+ }
328
+ queueChanged(): void {
329
+ if (this.timer) clearTimeout(this.timer);
330
+ this.timer = undefined; if (this.stopped || !this.bindings.size) return;
331
+ let next = this.options.bus.nextRemoteExpiry();
332
+ for (const { row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
333
+ const due = Math.min(row.expiresAt, row.ackDeadline ?? Infinity); next = next === undefined ? due : Math.min(next, due);
334
+ }
335
+ if (next === undefined) return;
336
+ this.timer = setTimeout(() => { this.timer = undefined; try { this.tick(); } catch { this.stop(); } }, Math.max(1, Math.min(2_147_483_647, next - this.now())));
337
+ this.timer.unref?.();
338
+ }
339
+ tick(): void {
340
+ if (this.stopped) return;
341
+ this.options.bus.expireRemoteQueues(this.now());
342
+ for (const binding of this.bindings.values()) if (this.valid(binding)) {
343
+ try {
344
+ for (const row of binding.store.expireApplications(binding.name)) this.outcome(binding, row);
345
+ this.reconcileDispositions(binding);
346
+ }
347
+ catch { binding.online = false; for (const [key, entry] of this.outgoing) if (entry.binding === binding) this.outgoing.delete(key); }
348
+ }
349
+ void this.retryPending(); this.queueChanged();
350
+ }
351
+ /** Global byte capacity can be freed by another room. This is event-coalesced, never polled. */
352
+ private capacityChanged(): void {
353
+ if (this.stopped || this.capacityRefreshPending) return;
354
+ this.capacityRefreshPending = true;
355
+ queueMicrotask(() => {
356
+ this.capacityRefreshPending = false;
357
+ if (this.stopped) return;
358
+ for (const binding of this.bindings.values()) try {
359
+ if (binding.online && this.valid(binding)) void binding.link.refreshPresenceIfCapacityFree().catch(() => undefined);
360
+ } catch { /* Retain the numeric pending refresh flag until another actual capacity event. */ }
361
+ });
362
+ }
363
+ /** Explicit disposition/online opportunities only; blocked plaintext is never polled. */
364
+ async retryPending(name?: string): Promise<void> {
365
+ if (this.stopped) return;
366
+ const retries: Promise<void>[] = [];
367
+ for (const binding of this.bindings.values()) try {
368
+ if ((name === undefined || name === binding.name) && this.valid(binding) && binding.online) retries.push(Promise.resolve().then(() => binding.link.retryPendingApplications()));
369
+ } catch { /* A completed person disposition must not fail because another room is unavailable. */ }
370
+ await Promise.allSettled(retries); this.capacityChanged();
371
+ }
372
+ private reconcileDispositions(binding: Binding): void {
373
+ for (const row of binding.store.applicationOutbox(binding.name, true)) if (row.state === "needs_review" && !row.disposition) {
374
+ const action = this.options.bus.remoteDisposition(row.deliveryId);
375
+ if (action) binding.store.disposeApplication(binding.name, row.deliveryId, action, () => !this.stopped);
376
+ }
377
+ this.refreshOutgoing(binding);
378
+ }
379
+ disposition(peer: string, deliveryId: string, action: "completed" | "retry" | "discard"): void {
380
+ if (this.options.bus.remoteDisposition(deliveryId) !== action) return;
381
+ for (const binding of this.bindings.values()) {
382
+ const row = binding.store.applicationOutbox(binding.name, true).find(row => row.deliveryId === deliveryId && row.remotePeer === peer);
383
+ if (!row) continue;
384
+ binding.store.disposeApplication(binding.name, deliveryId, action, () => !this.stopped);
385
+ this.refreshOutgoing(binding); break;
386
+ }
387
+ this.refreshTargets(); this.queueChanged(); void this.retryPending();
388
+ }
389
+ evidence(peer: string, deliveryId: string): { roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string } | undefined {
390
+ if (!peer.includes("@")) return undefined;
391
+ if (!this.evidenceCache) {
392
+ const cache = new Map<string, { peer: string; roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string }>();
393
+ for (const binding of this.bindings.values()) for (const row of binding.store.applicationOutbox(binding.name)) cache.set(row.deliveryId, {
394
+ peer: row.remotePeer, roomState: row.state === "completed" ? completedText(row.reason) : row.state,
395
+ ...(row.disposition ? { disposition: row.disposition } : {}),
396
+ ...(row.lateReceipt ? { lateReceipt: lateReceiptText(row.lateReceipt) } : {}),
397
+ ...(refusalReason(row) ? { refusalReason: roomReceiptReasonText(refusalReason(row)!) } : {}),
398
+ });
399
+ this.evidenceCache = cache;
400
+ queueMicrotask(() => { if (this.evidenceCache === cache) this.evidenceCache = undefined; });
401
+ }
402
+ const row = this.evidenceCache.get(deliveryId); if (!row || row.peer !== peer) return undefined;
403
+ const { peer: _peer, ...view } = row; return view;
404
+ }
405
+ rows(name: string): Record<string, unknown>[] {
406
+ const binding = this.bindings.get(name); if (!binding) return [];
407
+ const incoming = binding.store.applicationStatus(name);
408
+ 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) } : {}) })),
409
+ ...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 } : {}) })),
410
+ { direction: "inbound", pending: incoming.pending, needsReview: incoming.needsReview, truncated: incoming.truncated }];
411
+ }
412
+ peers(name: string): { id: string; state: PeerState }[] {
413
+ 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));
414
+ }
415
+ /** A bounded status list, separate from the explicit detail query above. */
416
+ peersForStatus(name: string): { id: string; state: PeerState }[] | null {
417
+ const ids = this.statusTargets.get(name);
418
+ return ids ? ids.map(id => ({ id, state: this.remotes.get(id)?.state ?? "offline" })) : null;
419
+ }
420
+ stop(): void {
421
+ if (this.stopped) return; this.stopped = true;
422
+ if (this.timer) clearTimeout(this.timer); this.timer = undefined;
423
+ for (const remote of this.remotes.values()) void remote.stop().catch(() => undefined);
424
+ this.options.roster?.([]);
425
+ }
426
+ }