@staix/agent-hub 0.31.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.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
5
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
6
6
  "paths": [
7
7
  "package.json",
8
8
  "src/",
@@ -11,14 +11,14 @@
11
11
  ]
12
12
  },
13
13
  "docs/security.md": {
14
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
14
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
21
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
22
22
  "paths": [
23
23
  "package.json",
24
24
  "src/",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "docs/quickstart.md": {
38
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
38
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
39
39
  "paths": [
40
40
  "package.json",
41
41
  "src/",
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "docs/agent-notes/adapters.md": {
46
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
46
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -93,7 +93,7 @@
93
93
  ]
94
94
  },
95
95
  "docs/agent-notes/budget.md": {
96
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
96
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
97
97
  "paths": [
98
98
  "src/hub/budget.ts",
99
99
  "src/cli/statusline-tee.ts",
@@ -117,7 +117,7 @@
117
117
  ]
118
118
  },
119
119
  "docs/agent-notes/bus.md": {
120
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
120
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
121
121
  "paths": [
122
122
  "src/hub/bus.ts",
123
123
  "src/hub/envelope.ts",
@@ -132,7 +132,7 @@
132
132
  ]
133
133
  },
134
134
  "docs/agent-notes/daemon.md": {
135
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
135
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
136
136
  "paths": [
137
137
  "src/hub/daemon.ts",
138
138
  "src/hub/control-client.ts",
@@ -243,7 +243,7 @@
243
243
  ]
244
244
  },
245
245
  "docs/agent-notes/tasks.md": {
246
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
246
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
247
247
  "paths": [
248
248
  "src/hub/tasks.ts",
249
249
  "src/hub/board.ts",
@@ -291,7 +291,7 @@
291
291
  ]
292
292
  },
293
293
  "docs/agent-notes/tests.md": {
294
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
294
+ "verifiedAgainst": "4ad77748c96383bdef8e55a11ff53808a1359500",
295
295
  "paths": [
296
296
  "test/",
297
297
  "scripts/check.sh",
@@ -320,7 +320,7 @@
320
320
  ]
321
321
  },
322
322
  "docs/agent-notes/README.md": {
323
- "verifiedAgainst": "af37f8fd8b44a2c566aa47629ec5b02ee9f91d69",
323
+ "verifiedAgainst": "2f4c372a6ce48dc01acb4a002f6a46e9557cbd17",
324
324
  "paths": [
325
325
  "src/cli/console-state.ts",
326
326
  "src/cli/attention.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -10248,7 +10248,7 @@ var package_default;
10248
10248
  var init_package = __esm(() => {
10249
10249
  package_default = {
10250
10250
  name: "@staix/agent-hub",
10251
- version: "0.31.0",
10251
+ version: "0.32.0",
10252
10252
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
10253
10253
  license: "MIT",
10254
10254
  type: "module",
@@ -1031,7 +1031,7 @@ function roomDetail(row: RoomStatusView): Record<string, unknown> {
1031
1031
  return {
1032
1032
  room: row.name, "room id": row.room, "link state": row.link ?? "unknown", owner: row.owner, "local member": row.member,
1033
1033
  "member count": count(row.memberCount),
1034
- "remote peers": row.remotePeers === null ? "unknown" : row.remotePeers.length ? row.remotePeers.map(peer => `${peer.id} (${peer.state})`) : "0",
1034
+ "remote peers": row.remotePeers === null ? "unknown" : row.remotePeers.length ? row.remotePeers.map(peer => `${peer.id} (${peer.presence ?? peer.state})${peer.liveness === "untracked" ? "; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness" : ""}`) : "0",
1035
1035
  "counter scope": `since daemon start: ${since}`,
1036
1036
  "out handoff": count(relays?.outbound), "in admitted": count(relays?.inbound), withheld: count(relays?.withheld),
1037
1037
  };
package/src/cli/room.ts CHANGED
@@ -2,7 +2,7 @@ import { decodeBase64url } from "../rooms/crypto.ts";
2
2
  import { roomReceiptReasonText, type RoomReceiptReason } from "../rooms/application.ts";
3
3
  import { frame, type Envelope } from "../hub/envelope.ts";
4
4
  import { terminalText } from "./console-state.ts";
5
- import type { RoomPersonInboxMeta, RoomPersonInboxItem } from "../rooms/store.ts";
5
+ import { ROOM_STORE_LIMITS, type RoomPersonInboxMeta, type RoomPersonInboxItem } from "../rooms/store.ts";
6
6
  /** Membership mutations and inbox forms are person-only; main.ts refuses agent/live-test calls before importing this module. */
7
7
  export const ROOM_USAGE = "usage: ahub room create <name> --broker <url> [--label <id>] | invite <name> [--accept <request>] | join <token> [--name <name>] [--label <id>] | join <name> --roster <signed-roster> | leave <name> | revoke <name> <fingerprint> | expose <name> [<peer>...] | resolve <name> --frame <digest> --action accept|discard (membership mutations and resolve require TTY y/N or --confirm <member-fingerprint-prefix>, 16..64 lowercase hex) | list [--json] | status [<name>] [--json] | inbox [<name>] [--json] | inbox show|done|discard <id>";
8
8
 
@@ -17,8 +17,9 @@ export type RoomRequest = (message: Record<string, unknown>) => Promise<any>;
17
17
  export interface RoomConfirmationIO { isTTY(): boolean; answer(prompt: string): Promise<string> }
18
18
 
19
19
  export class RoomCliError extends Error {
20
- constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch") {
21
- const message = { refused: "operation refused", invalid_reply: "invalid control reply", inbox_list_limit: "inbox list exceeds the limit; choose a room name", confirmation_required: "confirmation required; use a TTY or --confirm with the member fingerprint prefix", not_confirmed: "not confirmed; nothing executed", confirmation_mismatch: "confirmation does not match the member fingerprint; nothing executed" };
20
+ constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch" | "epoch_rotated_hold_unavailable") {
21
+ const message = { refused: "operation refused", invalid_reply: "invalid control reply", inbox_list_limit: "inbox list exceeds the limit; choose a room name", confirmation_required: "confirmation required; use a TTY or --confirm with the member fingerprint prefix", not_confirmed: "not confirmed; nothing executed", confirmation_mismatch: "confirmation does not match the member fingerprint; nothing executed",
22
+ epoch_rotated_hold_unavailable: "epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation" };
22
23
  super(code === "usage" ? ROOM_USAGE : `room: ${message[code]}`);
23
24
  this.name = "RoomCliError";
24
25
  }
@@ -141,9 +142,9 @@ async function confirmMember(member: string, explicit: string | undefined, io: R
141
142
  }
142
143
 
143
144
  type DeliveryState = "pending" | "dispatching" | "accepted" | "completed" | "completed (admitted)" | "completed (inbox)" | "failed_safe" | "needs_review" | "disposed" | "disposed (accept, by person)" | "disposed (discard, by person)";
144
- interface DeliveryRow { direction: "inbound" | "outbound"; id: string; application: string; peer: string; state: DeliveryState; disposition?: "accept" | "completed" | "retry" | "discard"; lateReceipt?: "completed (admitted)" | "completed (inbox)" | "failed_safe"; refusalReason?: RoomReceiptReason }
145
+ interface DeliveryRow { direction: "inbound" | "outbound"; id: string; application: string; peer: string; state: DeliveryState; disposition?: "accept" | "completed" | "retry" | "discard"; lateReceipt?: "completed (admitted)" | "completed (inbox)" | "failed_safe"; refusalReason?: RoomReceiptReason; holdReason?: "epoch rotated" }
145
146
  interface DeliverySummary { direction: "inbound"; pending: number; needsReview: number; truncated: boolean }
146
- interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline" }
147
+ interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline"; presence?: "idle" | "busy" | "paused" | "offline" | "unknown"; liveness?: "untracked" }
147
148
  interface RoomRow { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
148
149
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
149
150
  const REMOTE_ID = /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/;
@@ -170,10 +171,11 @@ function deliveryRows(value: unknown): (DeliveryRow | DeliverySummary)[] {
170
171
  if (row.disposition !== undefined && (typeof row.disposition !== "string" || (row.direction === "inbound" ? !["accept", "discard"].includes(row.disposition) : !["completed", "retry", "discard"].includes(row.disposition)))) return fail("invalid_reply");
171
172
  if (row.lateReceipt !== undefined && row.lateReceipt !== "completed (admitted)" && row.lateReceipt !== "completed (inbox)" && row.lateReceipt !== "failed_safe") return fail("invalid_reply");
172
173
  if (row.refusalReason !== undefined && (typeof row.refusalReason !== "string" || !REFUSAL_REASONS.includes(row.refusalReason as RoomReceiptReason))) return fail("invalid_reply");
174
+ if (row.holdReason !== undefined && (row.holdReason !== "epoch rotated" || row.direction !== "outbound" || row.state !== "needs_review")) return fail("invalid_reply");
173
175
  if (row.direction === "outbound" && row.state.startsWith("disposed")) return fail("invalid_reply");
174
176
  const state = row.state === "completed" ? row.refusalReason === "inbox_admitted" ? "completed (inbox)" : "completed (admitted)" : row.state === "disposed" && row.disposition !== undefined ? `disposed (${row.disposition}, by person)` as DeliveryState : row.state as DeliveryState;
175
177
  return { direction: row.direction, id: row.id, application: row.application, peer: row.peer, state,
176
- ...(row.disposition !== undefined ? { disposition: row.disposition as DeliveryRow["disposition"] } : {}), ...(row.lateReceipt !== undefined ? { lateReceipt: row.lateReceipt as DeliveryRow["lateReceipt"] } : {}), ...(row.refusalReason !== undefined ? { refusalReason: row.refusalReason as RoomReceiptReason } : {}) };
178
+ ...(row.disposition !== undefined ? { disposition: row.disposition as DeliveryRow["disposition"] } : {}), ...(row.lateReceipt !== undefined ? { lateReceipt: row.lateReceipt as DeliveryRow["lateReceipt"] } : {}), ...(row.refusalReason !== undefined ? { refusalReason: row.refusalReason as RoomReceiptReason } : {}), ...(row.holdReason === "epoch rotated" ? { holdReason: "epoch rotated" } : {}) };
177
179
  });
178
180
  }
179
181
  function remotePeerRows(value: unknown): RemotePeerRow[] {
@@ -181,7 +183,9 @@ function remotePeerRows(value: unknown): RemotePeerRow[] {
181
183
  return value.map<RemotePeerRow>(item => {
182
184
  const row = record(item);
183
185
  if (typeof row.id !== "string" || !REMOTE_ID.test(row.id) || typeof row.state !== "string" || !["idle", "busy", "paused", "offline"].includes(row.state)) return fail("invalid_reply");
184
- return { id: row.id, state: row.state as RemotePeerRow["state"] };
186
+ if (row.presence !== undefined && (typeof row.presence !== "string" || !["idle", "busy", "paused", "offline", "unknown"].includes(row.presence))) return fail("invalid_reply");
187
+ if (row.liveness !== undefined && row.liveness !== "untracked") return fail("invalid_reply");
188
+ return { id: row.id, state: row.state as RemotePeerRow["state"], ...(row.presence !== undefined ? { presence: row.presence as RemotePeerRow["presence"] } : {}), ...(row.liveness === "untracked" ? { liveness: "untracked" as const } : {}) };
185
189
  });
186
190
  }
187
191
  function record(value: unknown): Record<string, unknown> {
@@ -190,7 +194,7 @@ function record(value: unknown): Record<string, unknown> {
190
194
  }
191
195
  function success(value: unknown): Record<string, unknown> {
192
196
  const row = record(value);
193
- if (row.ok !== true) fail("refused");
197
+ if (row.ok !== true) fail(row.code === "epoch_rotated_hold_unavailable" ? "epoch_rotated_hold_unavailable" : "refused");
194
198
  return row;
195
199
  }
196
200
  function roomRows(value: unknown): RoomRow[] {
@@ -270,10 +274,10 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
270
274
  console.log(`Rooms (${rows.length})`);
271
275
  for (const row of rows) {
272
276
  console.log(`${row.name} ${row.room} epoch ${row.epoch} ${row.state}\n owner ${row.owner}\n member ${row.member}`);
273
- for (const peer of row.remotePeers ?? []) console.log(` peer ${peer.id}: ${peer.state}`);
277
+ for (const peer of row.remotePeers ?? []) console.log(` peer ${peer.id}: ${peer.presence ?? peer.state}${peer.liveness === "untracked" ? "; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness" : ""}`);
274
278
  for (const delivery of row.deliveries ?? []) {
275
279
  if (!("id" in delivery)) console.log(` inbound: pending ${delivery.pending}, needs_review ${delivery.needsReview}${delivery.truncated ? "; truncated" : ""}`);
276
- else console.log(` ${delivery.direction} ${delivery.id}: ${delivery.state}; application ${delivery.application}; peer ${delivery.peer}${delivery.disposition ? `; person disposition ${delivery.disposition}` : ""}${delivery.lateReceipt ? `; late receipt evidence ${delivery.lateReceipt}` : ""}${delivery.refusalReason ? `; refusal ${roomReceiptReasonText(delivery.refusalReason)}` : ""}`);
280
+ else console.log(` ${delivery.direction} ${delivery.id}: ${delivery.state}; application ${delivery.application}; peer ${delivery.peer}${delivery.disposition ? `; person disposition ${delivery.disposition}` : ""}${delivery.lateReceipt ? `; late receipt evidence ${delivery.lateReceipt}` : ""}${delivery.refusalReason ? `; refusal ${roomReceiptReasonText(delivery.refusalReason)}` : ""}${delivery.holdReason ? "; reason epoch rotated; inspect ahub queue show before resolving" : ""}`);
277
281
  }
278
282
  }
279
283
  return;
@@ -283,6 +287,12 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
283
287
  || typeof preview.ticket !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(preview.ticket)) fail("invalid_reply");
284
288
  console.log(`Owner fingerprint: ${preview.owner}`);
285
289
  console.log(`Member fingerprint: ${preview.member}`);
290
+ if (preview.outstanding !== undefined) {
291
+ if (!Array.isArray(preview.outstanding) || preview.outstanding.length > ROOM_STORE_LIMITS.outboxPerRoom
292
+ || preview.outstanding.some(value => typeof value !== "string" || !UUID.test(value)) || new Set(preview.outstanding).size !== preview.outstanding.length) fail("invalid_reply");
293
+ console.log(`Outstanding deliveries on the old epoch (${preview.outstanding.length}):`);
294
+ for (const id of preview.outstanding) console.log(` ${id}`);
295
+ }
286
296
  const confirm = await confirmMember(preview.member, command.confirm, io);
287
297
  const result = success(await request({ t: "room", op: "execute", ticket: preview.ticket, confirm }));
288
298
  if (result.name !== undefined) {
package/src/hub/bus.ts CHANGED
@@ -161,6 +161,7 @@ export const NOTES_KEPT = 10;
161
161
  * it holds an `important` envelope, or `batchMax` envelopes, or its oldest one has waited `batchMs`.
162
162
  */
163
163
  export const ROOM_PROOF_UNAVAILABLE_REASON = "room delivery proof unavailable; inspect ahub queue show";
164
+ export const ROOM_EPOCH_ROTATED_REASON = "room epoch rotated; inspect ahub queue show";
164
165
 
165
166
  export class Bus {
166
167
  readonly peers = new Map<PeerId, PeerAdapter>();
@@ -1367,6 +1368,23 @@ export class Bus {
1367
1368
  return this.holdRemoteDeliveryMetadata(peerId, row, `room recipient proof no longer current after restart; inspect ahub queue show ${deliveryId}`);
1368
1369
  }
1369
1370
 
1371
+ /** Person-authorized epoch change: hold metadata only, without cancellation, replay or completion. */
1372
+ holdRemoteEpochRotated(peerId: string, deliveryId: string, expectedRoom: string, expectedMember: string, currentGuard: () => boolean): boolean {
1373
+ if (!parseRemotePeerId(peerId) || typeof expectedRoom !== "string" || !expectedRoom || !remoteFingerprint(expectedMember)
1374
+ || !this.journal || this.storageError || this.closed
1375
+ || this.remoteQueueRooms.get(peerId) !== expectedRoom || this.remoteQueueMembers.get(peerId) !== expectedMember) return false;
1376
+ const row = this.journal.get(deliveryId), reason = `${ROOM_EPOCH_ROTATED_REASON} ${deliveryId}`;
1377
+ if (!row || row.peer !== peerId || row.originals.length !== 1 || row.originals[0]?.to?.includes(peerId) !== true
1378
+ || this.journal.resolution(deliveryId)) return false;
1379
+ const active = this.activeDeliveries.get(deliveryId), transport = this.remoteTransport.get(deliveryId)?.peer;
1380
+ if (active !== undefined && active !== peerId || transport !== undefined && transport !== peerId) return false;
1381
+ if (!["dispatching", "accepted", "needs_review"].includes(row.state)) return false;
1382
+ if (row.state === "needs_review" && row.reason !== reason && row.reason !== `${ROOM_PROOF_UNAVAILABLE_REASON} ${deliveryId}`) return false;
1383
+ try { if (currentGuard() !== true) return false; } catch { return false; }
1384
+ if (row.state === "needs_review" && row.reason === reason) return true;
1385
+ return this.holdRemoteDeliveryMetadata(peerId, row, reason);
1386
+ }
1387
+
1370
1388
  /** Resume the same delivery after the host revalidates its durable outbox proof. */
1371
1389
  resumeRemotePending(source: PeerAdapter, deliveryId: string, state: "dispatching" | "accepted" | "needs_review", currentGuard: () => boolean): boolean {
1372
1390
  const peer = source.id;
package/src/hub/daemon.ts CHANGED
@@ -4,7 +4,7 @@ import { readSdlcPr } from "./sdlc.ts";
4
4
  import type { ActionMeta } from "@staix/agent-console/contract";
5
5
  import { isReviewObserverExcluded, reviewCaptureExcluded } from "./memory-policy.ts";
6
6
  import { RoomStoreError } from "../rooms/store.ts";
7
- import { RoomCommands, type RoomPreview } from "../rooms/commands.ts";
7
+ import { RoomCommands, RoomRotationHoldError, type RoomPreview } from "../rooms/commands.ts";
8
8
  import { queueBackpressurePolicy, type QueueBackpressurePolicy } from "./queue-backpressure.ts";
9
9
  import { RoomChat, type RoomInvocationIngress } from "../rooms/service.ts";
10
10
  import { RoomInvocations, roomInvocationHash } from "./room-invocations.ts";
@@ -10788,7 +10788,9 @@ export async function startDaemon(opts: DaemonOptions) {
10788
10788
  const personRoom = c.role === "console";
10789
10789
  const roomCurrent = () => !stopping && !recoveryActive() && !bus.isRecoveryHeld
10790
10790
  && sock.data === client && sock.readyState === WebSocket.OPEN && personRoom;
10791
- const roomFailure = () => reply({ ok: false, error: "room operation refused; check room state, authority and arguments" });
10791
+ const roomFailure = (error?: unknown) => error instanceof RoomRotationHoldError
10792
+ ? reply({ ok: false, code: "epoch_rotated_hold_unavailable", error: error.message })
10793
+ : reply({ ok: false, error: "room operation refused; check room state, authority and arguments" });
10792
10794
  if (msg.op === "list" || msg.op === "status") {
10793
10795
  if (msg.input !== undefined && (!msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)
10794
10796
  || Object.keys(msg.input).some(key => key !== "name") || (msg.input.name !== undefined && typeof msg.input.name !== "string"))) return roomFailure();
@@ -10831,7 +10833,7 @@ export async function startDaemon(opts: DaemonOptions) {
10831
10833
  if (!roomCurrent()) return roomFailure();
10832
10834
  const ticket = crypto.randomUUID();
10833
10835
  roomTickets.set(sock, { id: ticket, expires: Date.now() + 30_000, preview });
10834
- reply({ ok: true, owner: preview.owner, member: preview.member, ticket });
10836
+ reply({ ok: true, owner: preview.owner, member: preview.member, ticket, ...(preview.outstanding ? { outstanding: preview.outstanding } : {}) });
10835
10837
  }, roomFailure);
10836
10838
  return;
10837
10839
  }
@@ -11312,7 +11314,7 @@ export async function startDaemon(opts: DaemonOptions) {
11312
11314
  async function stopOnce(): Promise<void> {
11313
11315
  stopping = true;
11314
11316
  controlWriteTelemetry?.stop();
11315
- roomCommands.stop();
11317
+ const roomsStopped = roomCommands.stop().catch(() => { log("room shutdown failed; inspect ahub room status after restart"); });
11316
11318
  tasks.stopQueuePressureRechecks();
11317
11319
  const studiesStopped = tasks.stopStudies();
11318
11320
  const reviewCommentsStopped = tasks.stopReviewComments();
@@ -11346,7 +11348,7 @@ export async function startDaemon(opts: DaemonOptions) {
11346
11348
  await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses, studiesStopped, reviewCommentsStopped]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
11347
11349
  clearTimeout(timer);
11348
11350
  }
11349
- await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
11351
+ await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped, roomsStopped]);
11350
11352
  await terminalLaunches.settled();
11351
11353
  // A peer can still be inside memory recall or its native handshake when kill arrives.
11352
11354
  // Drain those starts before taking the final owned-process snapshot.
@@ -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))) });