@staix/agent-hub 0.30.0 → 0.32.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 (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +11 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +178 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +23 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +355 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +24 -5
  21. package/docs/security.md +178 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  30. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  31. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  32. package/docs/verified.json +14 -14
  33. package/package.json +3 -4
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +4393 -3609
  36. package/src/adapters/acp.ts +5 -4
  37. package/src/adapters/channel-peer.ts +72 -3
  38. package/src/adapters/claude-channel-evidence.ts +52 -0
  39. package/src/adapters/claude-channel.ts +61 -18
  40. package/src/adapters/claude-implementer.ts +29 -7
  41. package/src/adapters/claude-print-child.ts +12 -2
  42. package/src/adapters/codex-appserver.ts +13 -12
  43. package/src/adapters/compat.ts +3 -3
  44. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  45. package/src/adapters/definitions/qwen.ts +1 -0
  46. package/src/adapters/local-worker.ts +61 -18
  47. package/src/adapters/pi.ts +48 -6
  48. package/src/adapters/registry-types.ts +12 -0
  49. package/src/adapters/registry.ts +2 -0
  50. package/src/adapters/reviewer-print.ts +50 -11
  51. package/src/broker/server.ts +44 -13
  52. package/src/broker/store.ts +38 -8
  53. package/src/cli/answer-args.ts +50 -0
  54. package/src/cli/attention.ts +17 -2
  55. package/src/cli/backlog-return-args.ts +37 -0
  56. package/src/cli/console-question.ts +110 -0
  57. package/src/cli/console-state.ts +78 -17
  58. package/src/cli/console.ts +54 -1
  59. package/src/cli/handoff-args.ts +15 -5
  60. package/src/cli/handoff-output.ts +15 -2
  61. package/src/cli/help.ts +8 -5
  62. package/src/cli/identity.ts +13 -3
  63. package/src/cli/main.ts +128 -16
  64. package/src/cli/memory.ts +107 -3
  65. package/src/cli/output.ts +29 -6
  66. package/src/cli/pet.ts +4 -4
  67. package/src/cli/results-args.ts +30 -0
  68. package/src/cli/room.ts +20 -10
  69. package/src/cli/runtime-version-warning.ts +50 -0
  70. package/src/cli/status-lines.ts +41 -1
  71. package/src/cli/tail-render.ts +1 -0
  72. package/src/cli/upgrade-interactive.ts +14 -2
  73. package/src/hub/action-class.ts +9 -1
  74. package/src/hub/board.ts +369 -18
  75. package/src/hub/bus.ts +511 -53
  76. package/src/hub/completion-detail.ts +50 -0
  77. package/src/hub/completions-store.ts +168 -0
  78. package/src/hub/completions.ts +120 -0
  79. package/src/hub/config-apply.ts +7 -5
  80. package/src/hub/config-reload.ts +10 -1
  81. package/src/hub/control-client.ts +40 -4
  82. package/src/hub/daemon.ts +1576 -139
  83. package/src/hub/delivery-failure.ts +14 -0
  84. package/src/hub/delivery-hints.ts +14 -4
  85. package/src/hub/delivery-journal.ts +751 -42
  86. package/src/hub/delivery-superseded.ts +4 -1
  87. package/src/hub/envelope.ts +3 -3
  88. package/src/hub/events.ts +6 -0
  89. package/src/hub/handoff.ts +145 -6
  90. package/src/hub/hub-tools.ts +56 -7
  91. package/src/hub/manager.ts +59 -3
  92. package/src/hub/memory-policy.ts +44 -1
  93. package/src/hub/memory-sync-policy.ts +109 -0
  94. package/src/hub/memory-sync-state.ts +145 -0
  95. package/src/hub/no-writes.ts +1 -1
  96. package/src/hub/operational-settings.ts +11 -2
  97. package/src/hub/operational-view.ts +12 -1
  98. package/src/hub/peers.ts +25 -2
  99. package/src/hub/planning.ts +6 -1
  100. package/src/hub/question-desk.ts +201 -0
  101. package/src/hub/question-detach.ts +6 -0
  102. package/src/hub/questions.ts +260 -0
  103. package/src/hub/research-evidence.ts +106 -0
  104. package/src/hub/restart.ts +14 -0
  105. package/src/hub/review-anchor.ts +181 -0
  106. package/src/hub/review-brief.ts +13 -0
  107. package/src/hub/review-comments.ts +115 -0
  108. package/src/hub/review-findings.ts +7 -2
  109. package/src/hub/review-material.ts +76 -0
  110. package/src/hub/routing.ts +3 -1
  111. package/src/hub/task-sweep.ts +2 -0
  112. package/src/hub/tasks.ts +577 -63
  113. package/src/memory/capture.ts +5 -2
  114. package/src/pet/config.ts +25 -13
  115. package/src/rooms/commands.ts +61 -16
  116. package/src/rooms/link.ts +338 -36
  117. package/src/rooms/liveness.ts +11 -0
  118. package/src/rooms/remote-peer.ts +186 -19
  119. package/src/rooms/service.ts +475 -35
  120. package/src/rooms/status.ts +8 -1
  121. package/src/rooms/store.ts +447 -65
  122. package/src/rooms/wire.ts +24 -6
  123. package/src/ui/index.html +142 -9
  124. package/src/ui/needs.ts +10 -4
  125. package/src/ui/plugin-questions.ts +56 -0
  126. package/src/ui/plugin-results.ts +81 -0
  127. package/src/ui/plugin.ts +207 -27
  128. package/src/pet/window.html +0 -114
  129. package/src/pet/window.jxa.js +0 -122
  130. package/src/pet/window.ts +0 -154
@@ -1,6 +1,7 @@
1
+ import { reviewCaptureExcluded } from "../hub/memory-policy.ts";
1
2
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
3
  import { defaultCapturePeer } from "../adapters/definitions/native-identity-defaults.ts";
3
- import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, HANDOFF_TOOLS, STUDY_TOOLS } from "../hub/hub-tools.ts";
4
+ import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, HANDOFF_TOOLS, QUESTION_TOOLS, STUDY_TOOLS } from "../hub/hub-tools.ts";
4
5
  import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
5
6
  import { isDenied } from "../local/deny.ts";
6
7
  import type { MemoryClient } from "./client.ts";
@@ -38,7 +39,9 @@ export class Capture {
38
39
  }
39
40
 
40
41
  observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
41
- if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_set_title" || this.opts.skip?.includes(call.tool)) return;
42
+ if (!this.sessionId || reviewCaptureExcluded(call.tool, call.args) || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_set_title" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_evidence_record" || this.opts.skip?.includes(call.tool)) return;
43
+ // #1053: a question and its answer are the person's and the peer's words: never memory evidence, whatever the call's paths say.
44
+ if (QUESTION_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) return;
42
45
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
43
46
  if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
44
47
  // ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
package/src/pet/config.ts CHANGED
@@ -8,7 +8,14 @@ const PALETTES: Record<string, PetPalette> = { a: "blue", b: "teal", c: "violet"
8
8
  export function petPalette(value: unknown): PetPalette | undefined {
9
9
  return typeof value === "string" ? PALETTES[value.toLowerCase()] : undefined;
10
10
  }
11
- export interface PetConfig { enabled: boolean; blink: boolean; bell: boolean; character?: CharacterId; size?: PetSize; glyphs?: PetGlyphs; palette?: PetPalette; notice?: string; window?: boolean }
11
+ export const PET_WINDOW_RETIRED_NOTICE = "The floating pet window moved to acon Mini; run acon ui and open Mini.";
12
+ /** Source compatibility only. Removed window data is never a terminal option or a settings capability. */
13
+ export function stripLegacyPetWindow(value: unknown): unknown {
14
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "window")) return value;
15
+ const { window: _retired, ...terminal } = value as Record<string, unknown>;
16
+ return terminal;
17
+ }
18
+ export interface PetConfig { enabled: boolean; blink: boolean; bell: boolean; character?: CharacterId; size?: PetSize; glyphs?: PetGlyphs; palette?: PetPalette; notice?: string }
12
19
  /** Console-local presentation only; it merges pet.palette into config.local.json and grants no authority. */
13
20
  export function savePetPalette(cwd: string, value: string): string {
14
21
  const palette = petPalette(value);
@@ -35,34 +42,39 @@ export function savePetPalette(cwd: string, value: string): string {
35
42
  /** Console-local presentation only; this reader never writes config or grants authority. */
36
43
  export function loadPetConfig(cwd: string): PetConfig {
37
44
  let value: unknown;
38
- let enabled = true;
45
+ let enabled = true, retiredWindow = false;
39
46
  for (const name of ["config.json", "config.local.json"]) {
40
47
  try {
41
48
  const file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
42
- // A window-only local opt-in does not undo inherited pet:false presentation.
43
- if (file.pet === false) enabled = false;
44
- else if (file.pet !== undefined && !(file.pet && typeof file.pet === "object" && !Array.isArray(file.pet) && Object.keys(file.pet).length === 1 && Object.hasOwn(file.pet, "window"))) enabled = true;
45
- if (file.pet !== undefined) value = value && typeof value === "object" && file.pet && typeof file.pet === "object" ? { ...value, ...file.pet } : file.pet;
49
+ const pet = stripLegacyPetWindow(file.pet);
50
+ if (pet !== file.pet) {
51
+ retiredWindow = true;
52
+ // A retired-only block contributes no terminal override, including no enablement over inherited pet:false.
53
+ if (pet && typeof pet === "object" && !Object.keys(pet).length) continue;
54
+ }
55
+ if (pet === false) enabled = false;
56
+ else if (pet !== undefined) enabled = true;
57
+ if (pet !== undefined) value = value && typeof value === "object" && pet && typeof pet === "object" ? { ...value, ...pet } : pet;
46
58
  } catch (error) {
47
59
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
48
60
  }
49
61
  }
50
- if (value === false) return { enabled: false, blink: true, bell: false };
62
+ const notice = (other?: string) => [retiredWindow ? PET_WINDOW_RETIRED_NOTICE : undefined, other].filter(Boolean).join(" ");
63
+ if (value === false) return { enabled: false, blink: true, bell: false, ...(retiredWindow ? { notice: notice() } : {}) };
51
64
  const defaults = { enabled: true, blink: true, bell: false };
52
- if (value === undefined) return defaults;
53
- if (!value || typeof value !== "object" || Array.isArray(value)) return { ...defaults, notice: "Unknown pet configuration; using stai line defaults." };
65
+ if (value === undefined) return { ...defaults, ...(retiredWindow ? { notice: notice() } : {}) };
66
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { ...defaults, notice: notice("Unknown pet configuration; using stai line defaults.") };
54
67
  const row = value as Record<string, unknown>;
55
- const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell", "palette", "window"].includes(key)) ||
68
+ const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell", "palette"].includes(key)) ||
56
69
  row.character !== undefined && !characterId(row.character) || row.size !== undefined && !["line", "mini", "full"].includes(row.size as string) ||
57
70
  row.glyphs !== undefined && (typeof row.glyphs !== "string" || !["auto", "ascii", "blocks"].includes(row.glyphs)) ||
58
71
  row.palette !== undefined && !petPalette(row.palette) ||
59
- row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean" || row.window !== undefined && typeof row.window !== "boolean";
72
+ row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean";
60
73
  const palette = petPalette(row.palette);
61
74
  return { enabled, blink: typeof row.blink === "boolean" ? row.blink : true, bell: typeof row.bell === "boolean" ? row.bell : false,
62
- ...(typeof row.window === "boolean" ? { window: row.window } : {}),
63
75
  ...(characterId(row.character) && row.character !== "stai" ? { character: row.character } : {}),
64
76
  ...(row.size === "mini" || row.size === "full" ? { size: row.size } : {}),
65
77
  ...(row.glyphs === "ascii" || row.glyphs === "blocks" ? { glyphs: row.glyphs } : {}),
66
78
  ...(palette && palette !== "teal" ? { palette } : {}),
67
- ...(invalid ? { notice: "Unknown pet option; using stai line defaults for that option." } : {}) };
79
+ ...(invalid || retiredWindow ? { notice: notice(invalid ? "Unknown pet option; using stai line defaults for that option." : undefined) } : {}) };
68
80
  }
@@ -3,14 +3,17 @@ import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { assertRoomCryptoReady, decodeBase64url, encodeBase64url, signBytes, verifyBytes } from "./crypto.ts";
5
5
  import { acceptRoomEpoch, brokerUrl, canonicalBytes, createInvite, createJoin, createRoomEpoch, decodeInvite, decodeJoin, decodeRoster, roomMembers } from "./wire.ts";
6
- import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInboxItem } from "./store.ts";
6
+ import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInboxItem, type RoomOutgoing } from "./store.ts";
7
7
  import { RoomLink } from "./link.ts";
8
8
  import { exposedPeers, roomName, RoomPolicy } from "./policy.ts";
9
9
  import type { RoomChat } from "./service.ts";
10
10
  import { projectRoomStatus, type RoomStatusView } from "./status.ts";
11
11
 
12
12
  export interface RoomCommandResult { kind?: string; artifact?: string; name?: string; room?: string; owner?: string; member?: string }
13
- export interface RoomPreview { owner: string; member: string; commit: () => Promise<RoomCommandResult> }
13
+ export interface RoomPreview { owner: string; member: string; outstanding?: readonly string[]; commit: () => Promise<RoomCommandResult> }
14
+ export class RoomRotationHoldError extends Error {
15
+ constructor() { super("room epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation"); this.name = "RoomRotationHoldError"; }
16
+ }
14
17
  const FP = /^[0-9a-f]{64}$/;
15
18
  function refuse(code: string): never { throw new Error(`room: ${code}`); }
16
19
  function fields(input: unknown, keys: string[], optional: string[] = []): Record<string, any> {
@@ -47,9 +50,10 @@ export class RoomCommands {
47
50
  private pending?: Promise<RoomStore>;
48
51
  private store?: RoomStore;
49
52
  private stopped = false;
53
+ private stopWork?: Promise<void>;
50
54
  private readonly links = new Map<string, RoomLink>();
51
55
  private readonly policy: RoomPolicy;
52
- constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat }) { this.policy = new RoomPolicy(options.cwd); }
56
+ constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; refreshMs?: number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat }) { this.policy = new RoomPolicy(options.cwd); }
53
57
  private async open(): Promise<RoomStore> {
54
58
  if (this.stopped) refuse("stopped");
55
59
  this.pending ??= RoomStore.open({ stateDir: join(this.options.stateDir, "rooms"), ...(this.options.clock ? { clock: this.options.clock } : {}) }).then(store => {
@@ -63,13 +67,19 @@ export class RoomCommands {
63
67
  const store = await this.open();
64
68
  if (!this.stopped) for (const state of store.list()) if (!state.left) this.link(state.name);
65
69
  }
66
- stop(): void { this.stopped = true; clearTimeout(this.presenceTimer); this.options.chat?.stop(); for (const link of this.links.values()) link.stop(); this.links.clear(); this.store?.close(); }
70
+ stop(): Promise<void> {
71
+ if (this.stopWork) return this.stopWork;
72
+ this.stopped = true; clearTimeout(this.presenceTimer); this.options.chat?.stop();
73
+ const stops = [...this.links.values()].map(link => link.stop()); this.links.clear();
74
+ this.stopWork = Promise.allSettled([...(this.pending ? [this.pending] : []), ...stops]).then(() => { this.store?.close(); });
75
+ return this.stopWork;
76
+ }
67
77
  private link(name: string): void {
68
78
  if (this.stopped || !this.store || this.store.get(name)?.left) return;
69
79
  let link = this.links.get(name);
70
80
  if (!link) {
71
81
  const store = this.store;
72
- const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application,
82
+ const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application, refreshMs: this.options.refreshMs, now: this.options.clock,
73
83
  onPresence: value => this.options.chat?.presence(store, name, created, value),
74
84
  onState: state => this.options.chat?.linkState(store, name, created, state), presence: () => {
75
85
  const allowed = new Set(this.policy.get(name));
@@ -78,7 +88,7 @@ export class RoomCommands {
78
88
  this.links.set(name, created); link = created;
79
89
  this.options.chat?.bind(store, name, created);
80
90
  void created.start().catch(() => undefined);
81
- } else { this.options.chat?.bind(this.store, name, link); link.refresh(); }
91
+ } else { this.options.chat?.bind(this.store, name, link); link.refresh(); void link.start().catch(() => undefined); }
82
92
  }
83
93
  private presenceTimer?: ReturnType<typeof setTimeout>;
84
94
  changed(): void {
@@ -91,6 +101,26 @@ export class RoomCommands {
91
101
  this.presenceTimer.unref?.();
92
102
  }
93
103
  private current(check: () => boolean): void { if (this.stopped || !check()) refuse("state_changed"); }
104
+ private async rotate(store: RoomStore, name: string, digest: string, expected: readonly RoomOutgoing[], check: () => boolean,
105
+ operation: (current: () => boolean) => Promise<RoomState>, after?: () => void): Promise<RoomState> {
106
+ const ids = expected.map(row => row.deliveryId).sort(), release = await this.links.get(name)?.quiesceApplications();
107
+ let next: RoomState | undefined, returned = false;
108
+ const current = () => !this.stopped && check() && store.get(name)?.current.digest === digest
109
+ && JSON.stringify(store.rotationPending(name).map(row => row.deliveryId).sort()) === JSON.stringify(ids);
110
+ try {
111
+ if (!current()) refuse("state_changed");
112
+ next = await operation(current);
113
+ const held = next.current.digest === digest || this.options.chat?.epochRotated(store, name, digest, expected) !== false;
114
+ this.current(check); after?.();
115
+ if (!held) throw new RoomRotationHoldError();
116
+ returned = true; return next;
117
+ } finally {
118
+ release?.();
119
+ // A committed epoch still needs publication if its journal label or local
120
+ // policy update failed; its old sealed intents were already fenced atomically.
121
+ if (next && !returned && next.current.digest !== digest) this.link(name);
122
+ }
123
+ }
94
124
  private active(store: RoomStore, name: string): RoomState {
95
125
  const state = store.get(name); if (!state || state.left) refuse("unavailable"); return state;
96
126
  }
@@ -196,9 +226,12 @@ export class RoomCommands {
196
226
  ? { room: existing.current.room, owner, minEpoch: existing.current.epoch, digest: existing.current.digest }
197
227
  : { room: invitation!.room, owner, minEpoch: "0" } });
198
228
  this.current(check);
199
- return { owner, member: own, commit: async () => {
229
+ const outstanding = existing ? store.rotationPending(name) : [];
230
+ return { owner, member: own, ...(existing ? { outstanding: outstanding.map(row => row.deliveryId).sort() } : {}), commit: async () => {
200
231
  this.current(check);
201
- const state = await store.install({ name, broker, wire, expectedDigest: existing?.current.digest ?? null, current: check, reactivate: true });
232
+ const state = existing ? await this.rotate(store, name, existing.current.digest, outstanding, check, current =>
233
+ store.install({ name, broker, wire, expectedDigest: existing.current.digest, current, reactivate: true }))
234
+ : await store.install({ name, broker, wire, expectedDigest: null, current: check, reactivate: true });
202
235
  return completed(state);
203
236
  } };
204
237
  }
@@ -235,7 +268,11 @@ export class RoomCommands {
235
268
  const state = this.active(store, name), owner = this.owner(state), digest = state.current.digest;
236
269
  if (action === "leave") return { owner, member: own, commit: async () => {
237
270
  this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
238
- store.leave(name); this.links.get(name)?.stop(); this.links.delete(name); return { name, room: state.current.room, owner, member: own };
271
+ await this.links.get(name)?.stop();
272
+ try {
273
+ this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
274
+ store.leave(name); this.links.delete(name); return { name, room: state.current.room, owner, member: own };
275
+ } catch (error) { this.link(name); throw error; }
239
276
  } };
240
277
  if (action === "invite") {
241
278
  if (owner !== own) refuse("owner_only");
@@ -254,9 +291,12 @@ export class RoomCommands {
254
291
  if (!sender || exposure.room !== state.current.room || exposure.owner !== owner || exposure.digest !== digest || !await verifyBytes({ fingerprint: sender.fingerprint, signingKey: sender.signingKey, agreementKey: sender.agreementKey }, exposureBytes(body), decodeBase64url(signature, 64))) refuse("untrusted");
255
292
  }
256
293
  this.current(check);
257
- return { owner, member, commit: async () => {
294
+ const outstanding = store.rotationPending(name);
295
+ return { owner, member, outstanding: outstanding.map(row => row.deliveryId).sort(), commit: async () => {
258
296
  this.current(check);
259
- const next = exposure ? await store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === member ? { ...m, exposedPeers: exposedPeers(exposure.peers) } : m), digest, check) : await store.enroll(name, request, check);
297
+ const next = await this.rotate(store, name, digest, outstanding, check, current => exposure
298
+ ? store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === member ? { ...m, exposedPeers: exposedPeers(exposure.peers) } : m), digest, current)
299
+ : store.enroll(name, request, current));
260
300
  this.current(check); completed(next);
261
301
  return { name, owner, member, kind: "roster", artifact: encodeBase64url(next.wire) };
262
302
  } };
@@ -264,19 +304,24 @@ export class RoomCommands {
264
304
  if (action === "revoke") {
265
305
  if (owner !== own) refuse("owner_only");
266
306
  if (typeof row.member !== "string" || !FP.test(row.member) || row.member === own || !roomMembers(state.current).some(m => m.fingerprint === row.member)) refuse("invalid_member");
267
- return { owner, member: row.member, commit: async () => {
268
- this.current(check); const next = await store.rotate(name, roomMembers(state.current).filter(m => m.fingerprint !== row.member), digest, check); return completed(next);
307
+ const outstanding = store.rotationPending(name);
308
+ return { owner, member: row.member, outstanding: outstanding.map(row => row.deliveryId).sort(), commit: async () => {
309
+ this.current(check); const next = await this.rotate(store, name, digest, outstanding, check, current =>
310
+ store.rotate(name, roomMembers(state.current).filter(m => m.fingerprint !== row.member), digest, current)); return completed(next);
269
311
  } };
270
312
  }
271
313
  if (action === "expose") {
272
314
  const peers = exposedPeers(row.peers ?? []);
273
315
  this.policy.assertWritable(name, peers, check);
274
- return { owner, member: own, commit: async () => {
316
+ const outstanding = owner === own ? store.rotationPending(name) : [];
317
+ return { owner, member: own, ...(owner === own ? { outstanding: outstanding.map(row => row.deliveryId).sort() } : {}), commit: async () => {
275
318
  this.current(check); this.policy.assertWritable(name, peers, check);
276
319
  if (store.get(name)?.current.digest !== digest) refuse("state_changed");
277
320
  if (owner === own) {
278
- const next = await store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === own ? { ...m, exposedPeers: peers } : m), digest, check);
279
- this.current(check); this.policy.set(name, peers, check); return completed(next);
321
+ const next = await this.rotate(store, name, digest, outstanding, check, current =>
322
+ store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === own ? { ...m, exposedPeers: peers } : m), digest, current),
323
+ () => this.policy.set(name, peers, check));
324
+ return completed(next);
280
325
  }
281
326
  const body: Omit<ExposureRequest, "signature"> = { v: 1, type: "exposure", room: state.current.room, owner, member: own, digest, peers };
282
327
  const request = canonicalBytes({ ...body, signature: encodeBase64url(await signBytes(store.identity, exposureBytes(body))) });