@staix/agent-hub 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/bus.md +3 -1
  4. package/docs/agent-notes/daemon.md +1 -0
  5. package/docs/agent-notes/tests.md +2 -2
  6. package/docs/design/bus.md +109 -4
  7. package/docs/dogfood.md +2 -0
  8. package/docs/memory.md +9 -3
  9. package/docs/operations.md +98 -17
  10. package/docs/quickstart.md +18 -2
  11. package/docs/security.md +52 -9
  12. package/docs/specs/2026-09-19-agent-hub-design.md +75 -5
  13. package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
  14. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +55 -1
  15. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  16. package/docs/verification/2026-11-06-0.33.0.md +222 -0
  17. package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
  18. package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
  19. package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
  20. package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
  21. package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
  22. package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
  23. package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
  24. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  25. package/docs/verified.json +11 -11
  26. package/package.json +2 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +528 -206
  29. package/src/adapters/compat.ts +11 -11
  30. package/src/broker/protocol.ts +5 -0
  31. package/src/broker/server.ts +54 -4
  32. package/src/broker/store.ts +6 -3
  33. package/src/cli/console-state.ts +1 -1
  34. package/src/cli/output.ts +5 -1
  35. package/src/cli/room.ts +34 -12
  36. package/src/cli/status-lines.ts +8 -1
  37. package/src/cli/terminal-recovery.ts +37 -15
  38. package/src/hub/bus.ts +25 -0
  39. package/src/hub/daemon.ts +20 -9
  40. package/src/rooms/application.ts +15 -0
  41. package/src/rooms/commands.ts +135 -30
  42. package/src/rooms/health.ts +32 -0
  43. package/src/rooms/link.ts +394 -56
  44. package/src/rooms/liveness.ts +11 -0
  45. package/src/rooms/remote-peer.ts +18 -3
  46. package/src/rooms/service.ts +167 -28
  47. package/src/rooms/status.ts +65 -2
  48. package/src/rooms/store.ts +30 -1
  49. package/src/rooms/wire.ts +10 -8
  50. package/src/ui/index.html +5 -3
@@ -34,21 +34,21 @@ export const COMPAT = {
34
34
  parse: out => plain(out.trim()),
35
35
  trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0 and 1.5.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
36
36
  receipt: "docs/verification/2026-11-03-pi-150-compat.md" },
37
- // Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; 2.1.318: docs/verification/2026-11-05-claude-implementer-2.1.318.md; host-wrapper receipt below repeats with them.
38
- "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316", "2.1.318"], gate: "contract", probe: ["claude", "--version"],
37
+ // Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; 2.1.318: docs/verification/2026-11-05-claude-implementer-2.1.318.md; 2.1.319: docs/verification/2026-11-06-claude-implementer-2.1.319.md; host-wrapper receipt below repeats with them.
38
+ "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316", "2.1.318", "2.1.319"], gate: "contract", probe: ["claude", "--version"],
39
39
  parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
40
40
  trustNote: "The native prompt-tool transport and host-wrapper rewrite, environment isolation and replay contract have isolated receipts. No dontAsk/bypass fallback exists; another version's permission or rewrite behavior is unverified.",
41
- receipt: "docs/verification/2026-11-05-claude-implementer-2.1.318.md" },
42
- // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md; 0.166.0: docs/verification/2026-10-31-codex-0166-reviewer-isolation.md
41
+ receipt: "docs/verification/2026-11-06-claude-implementer-2.1.319.md" },
42
+ // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md; 0.166.0: docs/verification/2026-10-31-codex-0166-reviewer-isolation.md; 0.167.0 model-free only, not verified until its model-backed run: docs/verification/2026-11-06-codex-0167-reviewer-isolation.md
43
43
  "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0", "0.166.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
44
44
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
45
45
  trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md).",
46
- receipt: "docs/verification/2026-10-31-codex-0166-reviewer-isolation.md" },
47
- // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md; Kimi 2.3.2: docs/verification/2026-10-26-kimi-232-reviewer-compat.md; Kimi 2.4.1: docs/verification/2026-10-31-kimi-241-reviewer-compat.md
48
- "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1", "2.3.2", "2.4.1"], gate: "isolation", probe: ["kimi", "--version"],
46
+ receipt: "docs/verification/2026-11-06-codex-0167-reviewer-isolation.md" },
47
+ // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md; Kimi 2.3.2: docs/verification/2026-10-26-kimi-232-reviewer-compat.md; Kimi 2.4.1: docs/verification/2026-10-31-kimi-241-reviewer-compat.md; Kimi 2.5.1: docs/verification/2026-11-06-kimi-251-reviewer-compat.md
48
+ "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1", "2.3.2", "2.4.1", "2.5.1"], gate: "isolation", probe: ["kimi", "--version"],
49
49
  parse: out => plain(out.trim()),
50
50
  trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven.",
51
- receipt: "docs/verification/2026-10-31-kimi-241-reviewer-compat.md" },
51
+ receipt: "docs/verification/2026-11-06-kimi-251-reviewer-compat.md" },
52
52
  jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0", "0.99.1"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
53
53
  parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
54
54
  trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
@@ -57,10 +57,10 @@ export const COMPAT = {
57
57
  parse: out => plain(out.trim()),
58
58
  trustNote: "Only 1.4.2, 1.4.7 and 1.4.12 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
59
59
  receipt: "docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md" },
60
- qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
60
+ qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5", "0.26.8"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
61
61
  parse: out => plain(out.trim()),
62
- trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4 and 0.26.5; real model turns remain unverified. Another version may change those contracts.",
63
- receipt: "docs/verification/2026-11-03-qwen-0265-acp.md" },
62
+ trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4, 0.26.5 and 0.26.8 (macOS arm64 and Linux x86_64); real model turns remain unverified. Another version may change those contracts.",
63
+ receipt: "docs/verification/2026-11-06-qwen-0268-acp.md" },
64
64
  // Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
65
65
  // the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
66
66
  "qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
@@ -8,6 +8,8 @@ import {
8
8
  type OwnerPin, type VerifiedPublicRoster,
9
9
  } from "../rooms/wire.ts";
10
10
 
11
+ /** Optional codec header token; the existing ephemeral opt-in remains byte-for-byte unchanged. */
12
+ export const BROKER_CODEC_FEATURES_OPT_IN = "1";
11
13
  export const BROKER_CONTROL_LIMITS = Object.freeze({ control: 1_048_576, challenge: 8192, nonceBytes: 32 });
12
14
  export type BrokerProtocolCode = "invalid_wire" | "limit" | "untrusted" | "expired" | "authentication_failed";
13
15
  export class BrokerProtocolError extends Error {
@@ -35,6 +37,9 @@ export interface BrokerAuth {
35
37
  roster: string | null;
36
38
  signature: string;
37
39
  }
40
+ /** Broker-to-client transport evidence; never accepted as client control or authority. */
41
+ export interface BrokerCodecPeer { member: string; session: string | null; supported: boolean }
42
+ export interface BrokerCodecPeers { v: 1; type: "codec_peers"; digest: string; peers: BrokerCodecPeer[] }
38
43
  export interface BrokerRosterReady { v: 1; type: "roster_ready"; digest: string }
39
44
  export interface BrokerReceived { v: 1; type: "received"; digest: string }
40
45
  export type BrokerControl = BrokerAuth | BrokerRosterReady | BrokerReceived;
@@ -2,7 +2,7 @@
2
2
  import type { ServerWebSocket } from "bun";
3
3
  import { assertRoomCryptoReady, decodeBase64url, encodeBase64url, randomBytes } from "../rooms/crypto.ts";
4
4
  import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, publicRosterWire, verifyPublicFrame, verifyPublicRoster, type VerifiedPublicRoster } from "../rooms/wire.ts";
5
- import { BROKER_CONTROL_LIMITS, decodeBrokerControl, encodeBrokerChallenge, verifyBrokerAuth, type BrokerChallenge } from "./protocol.ts";
5
+ import { BROKER_CODEC_FEATURES_OPT_IN, BROKER_CONTROL_LIMITS, decodeBrokerControl, encodeBrokerChallenge, verifyBrokerAuth, type BrokerChallenge } from "./protocol.ts";
6
6
  import { BrokerStore, BrokerStoreError, type BrokerLimits } from "./store.ts";
7
7
 
8
8
  export const BROKER_SOCKET_LIMITS = Object.freeze({ connections: 128, verifications: 16, authMs: 10_000, rosterAckMs: 30_000, backpressureBytes: 1_048_576, queuedMessages: 8, queuedBytes: 2_097_152, globalQueuedBytes: 33_554_432 });
@@ -26,6 +26,10 @@ interface Client {
26
26
  ws?: ServerWebSocket<Client>;
27
27
  room: string;
28
28
  ephemeral: boolean;
29
+ codecFeatures: boolean;
30
+ codecSession: string;
31
+ pendingCodecMembers: Set<string>;
32
+ codecFlushQueued: boolean;
29
33
  challenge: BrokerChallenge;
30
34
  phase: "challenge" | "authenticating" | "roster" | "ready" | "closed";
31
35
  busy: boolean;
@@ -108,11 +112,13 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
108
112
  function close(client: Client, code: string): void {
109
113
  if (client.phase === "closed") return;
110
114
  client.phase = "closed";
115
+ client.pendingCodecMembers.clear(); client.codecFlushQueued = false;
111
116
  client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined;
112
117
  queuedBytes -= client.inboxBytes; client.inboxBytes = 0; client.inbox.length = 0;
113
118
  if (client.member && members.get(memberKey(client)) === client) {
114
119
  members.delete(memberKey(client));
115
120
  try { store.disconnect(client.room, client.member); } catch { emit("refused", client, "state_unavailable"); }
121
+ notifyCodecPeers(client.room, client.member);
116
122
  }
117
123
  if (client.ws) { try { client.ws.close(4403, code); } catch { /* already closed */ } }
118
124
  else clients.delete(client);
@@ -137,15 +143,55 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
137
143
  if (!roster || !member || identityKey(member) !== client.key) throw new BrokerServerError("untrusted");
138
144
  return roster;
139
145
  }
146
+ /** Connection evidence only: the sender must still echo its session in signed presence. */
147
+ function sendCodecPeers(recipient: Client, memberIds: readonly string[]): void {
148
+ if (!recipient.codecFeatures || recipient.phase !== "ready") return;
149
+ try {
150
+ const roster = active(recipient);
151
+ const peers = memberIds.filter(member => member !== recipient.member && roster.members.some(row => row.fingerprint === member)).map(member => {
152
+ const source = members.get(`${recipient.room}|${member}`);
153
+ const current = source?.phase === "ready" && active(source).digest === roster.digest ? source : undefined;
154
+ return { member, session: current?.codecSession ?? null, supported: current?.codecFeatures === true };
155
+ });
156
+ if (peers.length) control(recipient, { v: 1, type: "codec_peers", digest: roster.digest, peers });
157
+ } catch { close(recipient, "state_changed"); }
158
+ }
159
+ function flushCodecPeers(client: Client): void {
160
+ const pending = [...client.pendingCodecMembers]; client.pendingCodecMembers.clear();
161
+ if (pending.length) sendCodecPeers(client, pending);
162
+ }
163
+ function notifyCodecPeers(room: string, member: string): void {
164
+ for (const recipient of clients) if (recipient.room === room && recipient.codecFeatures && recipient.phase === "ready") {
165
+ try {
166
+ const current = new Set(active(recipient).members.map(row => row.fingerprint));
167
+ // At most the signed roster's members can wait; churn replaces evidence
168
+ // rather than growing a control queue beyond the existing packet ceiling.
169
+ for (const pending of recipient.pendingCodecMembers) if (!current.has(pending)) recipient.pendingCodecMembers.delete(pending);
170
+ if (member === recipient.member || !current.has(member)) continue;
171
+ recipient.pendingCodecMembers.add(member);
172
+ if (!recipient.codecFlushQueued) {
173
+ recipient.codecFlushQueued = true;
174
+ queueMicrotask(() => { recipient.codecFlushQueued = false; flushCodecPeers(recipient); });
175
+ }
176
+ } catch { close(recipient, "state_changed"); }
177
+ }
178
+ }
179
+ function codecInventory(client: Client): void {
180
+ if (!client.codecFeatures || client.phase !== "ready") return;
181
+ client.pendingCodecMembers.clear();
182
+ sendCodecPeers(client, active(client).members.map(member => member.fingerprint));
183
+ }
140
184
  function sendRoster(client: Client): void {
141
185
  const roster = active(client);
142
186
  client.phase = "roster"; client.rosterDigest = roster.digest;
143
187
  client.rosterDeadline = now() + BROKER_SOCKET_LIMITS.rosterAckMs;
144
188
  // Legacy hubs have a closed ready grammar; advertise only after HTTP opt-in.
145
- if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest, ...(client.ephemeral ? { ephemeral: true } : {}) });
189
+ if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest, ...(client.ephemeral ? { ephemeral: true } : {}), ...(client.codecFeatures ? { codecFeatures: true, codecSession: client.codecSession } : {}) });
146
190
  }
147
191
  function pump(client: Client): void {
148
192
  if (stopping || client.phase === "closed" || !client.member) return;
193
+ flushCodecPeers(client); // Source-session invalidation always precedes ciphertext handoff.
194
+ if (members.get(memberKey(client)) !== client) return;
149
195
  const roster = active(client);
150
196
  const next = store.next(client.room, client.member);
151
197
  if (client.inFlightEphemeral && client.inFlightEpoch !== roster.epoch) {
@@ -192,6 +238,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
192
238
  if (previous && previous !== client) close(previous, "replaced");
193
239
  members.set(memberKey(client), client);
194
240
  client.phase = "roster";
241
+ notifyCodecPeers(client.room, client.member);
195
242
  sendRoster(client);
196
243
  emit("connected", client);
197
244
  }
@@ -218,7 +265,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
218
265
  const proof = await (ports.verifyFrame ?? verifyPublicFrame)({ wire: bytes, roster: selected });
219
266
  const onlineRecipients = proof.recipients.filter(recipient => {
220
267
  const target = members.get(`${client.room}|${recipient}`);
221
- return target?.phase === "ready" && (!proof.ephemeral || target.ephemeral);
268
+ return target?.phase === "ready" && (!proof.ephemeral || target.ephemeral) && (!proof.codecFeatures || target.codecFeatures);
222
269
  });
223
270
  try {
224
271
  active(client);
@@ -241,6 +288,8 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
241
288
  const latest = active(client);
242
289
  if (packet.digest !== client.rosterDigest || packet.digest !== latest.digest) { sendRoster(client); return; }
243
290
  client.phase = "ready"; client.rosterDeadline = undefined;
291
+ codecInventory(client);
292
+ if (client.member) notifyCodecPeers(client.room, client.member);
244
293
  safePump(client); return;
245
294
  }
246
295
  if (packet.digest !== client.inFlight) { refused(client, "unexpected_ack", false); return; }
@@ -290,7 +339,8 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
290
339
  const room = roomId(url.pathname.slice("/v1/rooms/".length));
291
340
  if (clients.size >= BROKER_SOCKET_LIMITS.connections) return new Response("busy", { status: 503 });
292
341
  const current = store.current(room)?.current;
293
- const client: Client = { room, ephemeral: req.headers.get("x-agent-hub-ephemeral") === "1", challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
342
+ const transportOptIn = req.headers.get("x-agent-hub-ephemeral");
343
+ const client: Client = { room, ephemeral: transportOptIn === "1", codecFeatures: transportOptIn === "1" && req.headers.get("x-agent-hub-codecs") === BROKER_CODEC_FEATURES_OPT_IN, codecSession: encodeBase64url(randomBytes(32)), pendingCodecMembers: new Set(), codecFlushQueued: false, challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
294
344
  clients.add(client);
295
345
  if (host.upgrade(req, { data: client })) return;
296
346
  clients.delete(client); return new Response("websocket required", { status: 400 });
@@ -408,7 +408,9 @@ export class BrokerStore {
408
408
  // ponytail: while A awaits C, A's broadcast reply to B may replace C's request;
409
409
  // C waits for A's next state change/reconnect. Task #736 idle refresh or
410
410
  // per-(sender,recipient) coalescing closes this latest-per-sender ceiling.
411
- this.removeEphemeral(proof.room, proof.sender);
411
+ // Codec advertisements must not evict a legacy snapshot that a mixed
412
+ // member has not received yet. Both groups share the existing queue caps.
413
+ this.removeEphemeral(proof.room, proof.sender, undefined, proof.codecFeatures === true);
412
414
  const connected = new Set(onlineRecipients);
413
415
  recipients = recipients.filter(recipient => connected.has(recipient));
414
416
  }
@@ -438,12 +440,13 @@ export class BrokerStore {
438
440
  this.transaction(() => { this.removeEphemeral(room, undefined, recipient); });
439
441
  }
440
442
 
441
- private removeEphemeral(room?: string, sender?: string, recipient?: string): void {
443
+ private removeEphemeral(room?: string, sender?: string, recipient?: string, codecFeatures?: boolean): void {
442
444
  const rows = this.db.query<{ rowid: number; wire: Uint8Array; recipients: string }, [string | null, string | null, string | null, string | null]>(
443
445
  "SELECT rowid,wire,recipients FROM queued_frames WHERE (? IS NULL OR room=?) AND (? IS NULL OR sender=?)",
444
446
  ).all(room ?? null, room ?? null, sender ?? null, sender ?? null);
445
447
  for (const row of rows) {
446
- if (!decodeFrame(row.wire).ephemeral) continue;
448
+ const frame = decodeFrame(row.wire);
449
+ if (!frame.ephemeral || codecFeatures !== undefined && (frame.codecFeatures === true) !== codecFeatures) continue;
447
450
  const remaining = recipient === undefined ? [] : parseRecipients(row.recipients).filter(id => id !== recipient);
448
451
  if (remaining.length) this.db.query("UPDATE queued_frames SET recipients=? WHERE rowid=?").run(JSON.stringify(remaining), row.rowid);
449
452
  else this.db.query("DELETE FROM queued_frames WHERE rowid=?").run(row.rowid);
@@ -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/output.ts CHANGED
@@ -5,7 +5,7 @@ import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
5
5
  import { peerStyle } from "../ui/peer-palette.ts";
6
6
  import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
7
7
  import { taskProgress } from "../ui/task-progress.ts";
8
- import { backendLabel, memorySyncLine, reviewInputHealthLine } from "./status-lines.ts";
8
+ import { backendLabel, memorySyncLine, reviewInputHealthLine, roomsUnavailableLine } from "./status-lines.ts";
9
9
  import { ownerIsolationText } from "./role-holders.ts";
10
10
  import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
11
11
  import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
@@ -14,6 +14,8 @@ import type { SpecsStatus } from "../hub/specs-task.ts";
14
14
 
15
15
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
16
16
  export interface StatusOutput {
17
+ roomsUnavailable?: unknown;
18
+ roomsOpening?: boolean;
17
19
  controlWriteTiming?: { active: number; slowRequests: number; blockedLoopSamples: number; droppedActive: number; droppedRecent: number; droppedLogs: number; droppedSpans?: number; droppedMetadata?: number; expiredRequests?: number };
18
20
  specs?: SpecsStatus & { missingBlockHint?: boolean };
19
21
  queueBackpressure?: QueueBackpressureStatus;
@@ -263,6 +265,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
263
265
  const text = ownerIsolationText(isolated, data.ownerIsolationNotes?.[id]);
264
266
  if (text) out.push(...detail(`${id} owner isolation`, text, columns, isolated ? undefined : "attention"));
265
267
  }
268
+ if (data.roomsOpening === true) out.push(...lines("rooms: opening; wait for local room storage initialization", columns, "muted"));
269
+ if (data.roomsUnavailable !== undefined) out.push(...lines(roomsUnavailableLine(data.roomsUnavailable), columns, "failure"));
266
270
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
267
271
  for (const [peer, count] of Object.entries(data.deliveryFailures ?? {})) {
268
272
  if (Number.isSafeInteger(count) && count > 0) out.push(...detail(`${peer} failed deliveries`, `${count}; next: ahub queue discard --peer ${peer} --state failed`, columns, "attention"));
package/src/cli/room.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { projectRoomUnavailable, projectRoomDiagnostics, type RoomStatusDiagnostics } from "../rooms/status.ts";
1
2
  import { decodeBase64url } from "../rooms/crypto.ts";
2
3
  import { roomReceiptReasonText, type RoomReceiptReason } from "../rooms/application.ts";
3
4
  import { frame, type Envelope } from "../hub/envelope.ts";
4
5
  import { terminalText } from "./console-state.ts";
5
- import type { RoomPersonInboxMeta, RoomPersonInboxItem } from "../rooms/store.ts";
6
+ import { ROOM_STORE_LIMITS, type RoomPersonInboxMeta, type RoomPersonInboxItem } from "../rooms/store.ts";
6
7
  /** Membership mutations and inbox forms are person-only; main.ts refuses agent/live-test calls before importing this module. */
7
8
  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
9
 
@@ -17,8 +18,10 @@ export type RoomRequest = (message: Record<string, unknown>) => Promise<any>;
17
18
  export interface RoomConfirmationIO { isTTY(): boolean; answer(prompt: string): Promise<string> }
18
19
 
19
20
  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" };
21
+ constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch" | "epoch_rotated_hold_unavailable" | "rooms_unavailable", availability?: unknown) {
22
+ const unavailable = projectRoomUnavailable(availability);
23
+ const message = { rooms_unavailable: `unavailable (${unavailable?.code ?? "unknown"}); ${unavailable?.nextStep ?? "Run ahub status to inspect room storage before retrying."}`, 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",
24
+ epoch_rotated_hold_unavailable: "epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation" };
22
25
  super(code === "usage" ? ROOM_USAGE : `room: ${message[code]}`);
23
26
  this.name = "RoomCliError";
24
27
  }
@@ -141,10 +144,10 @@ async function confirmMember(member: string, explicit: string | undefined, io: R
141
144
  }
142
145
 
143
146
  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 }
147
+ 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
148
  interface DeliverySummary { direction: "inbound"; pending: number; needsReview: number; truncated: boolean }
146
- interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline" }
147
- interface RoomRow { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
149
+ interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline"; presence?: "idle" | "busy" | "paused" | "offline" | "unknown"; liveness?: "untracked" }
150
+ interface RoomRow extends RoomStatusDiagnostics { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
148
151
  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
152
  const REMOTE_ID = /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/;
150
153
  const REFUSAL_REASONS: readonly RoomReceiptReason[] = ["admitted", "not_exposed", "boundary_refused", "invalid_message", "rate_limited", "hop", "person_inbox_unsupported", "inbox_admitted", "inbox_full", "inbox_oversize", "inbox_expired", "inbox_age_unavailable"];
@@ -170,10 +173,11 @@ function deliveryRows(value: unknown): (DeliveryRow | DeliverySummary)[] {
170
173
  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
174
  if (row.lateReceipt !== undefined && row.lateReceipt !== "completed (admitted)" && row.lateReceipt !== "completed (inbox)" && row.lateReceipt !== "failed_safe") return fail("invalid_reply");
172
175
  if (row.refusalReason !== undefined && (typeof row.refusalReason !== "string" || !REFUSAL_REASONS.includes(row.refusalReason as RoomReceiptReason))) return fail("invalid_reply");
176
+ if (row.holdReason !== undefined && (row.holdReason !== "epoch rotated" || row.direction !== "outbound" || row.state !== "needs_review")) return fail("invalid_reply");
173
177
  if (row.direction === "outbound" && row.state.startsWith("disposed")) return fail("invalid_reply");
174
178
  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
179
  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 } : {}) };
180
+ ...(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
181
  });
178
182
  }
179
183
  function remotePeerRows(value: unknown): RemotePeerRow[] {
@@ -181,7 +185,9 @@ function remotePeerRows(value: unknown): RemotePeerRow[] {
181
185
  return value.map<RemotePeerRow>(item => {
182
186
  const row = record(item);
183
187
  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"] };
188
+ if (row.presence !== undefined && (typeof row.presence !== "string" || !["idle", "busy", "paused", "offline", "unknown"].includes(row.presence))) return fail("invalid_reply");
189
+ if (row.liveness !== undefined && row.liveness !== "untracked") return fail("invalid_reply");
190
+ 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
191
  });
186
192
  }
187
193
  function record(value: unknown): Record<string, unknown> {
@@ -190,7 +196,10 @@ function record(value: unknown): Record<string, unknown> {
190
196
  }
191
197
  function success(value: unknown): Record<string, unknown> {
192
198
  const row = record(value);
193
- if (row.ok !== true) fail("refused");
199
+ if (row.ok !== true) {
200
+ if (row.code === "rooms_unavailable") throw new RoomCliError("rooms_unavailable", row.roomsUnavailable);
201
+ fail(row.code === "epoch_rotated_hold_unavailable" ? "epoch_rotated_hold_unavailable" : "refused");
202
+ }
194
203
  return row;
195
204
  }
196
205
  function roomRows(value: unknown): RoomRow[] {
@@ -202,7 +211,7 @@ function roomRows(value: unknown): RoomRow[] {
202
211
  || typeof row.epoch !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(row.epoch)
203
212
  || typeof row.state !== "string" || !/^[a-z][a-z_-]{0,31}$/.test(row.state)) return fail("invalid_reply");
204
213
  // Ignore all other fields, even from a newer daemon: read output never grows into room bodies or secrets.
205
- return { name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch, state: row.state,
214
+ return { name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch, state: row.state, ...projectRoomDiagnostics(row),
206
215
  ...(row.deliveries !== undefined ? { deliveries: deliveryRows(row.deliveries) } : {}), ...(row.remotePeers !== undefined ? { remotePeers: remotePeerRows(row.remotePeers) } : {}) };
207
216
  });
208
217
  }
@@ -270,10 +279,17 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
270
279
  console.log(`Rooms (${rows.length})`);
271
280
  for (const row of rows) {
272
281
  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}`);
282
+ if (command.op === "status") {
283
+ const health = row.health, age = (value: number | null | undefined) => typeof value === "number" ? `${value}ms` : "unknown";
284
+ if (row.state === "online" && (row.linkCause === null || row.linkCause === undefined)) console.log(" link online");
285
+ else console.log(` link cause ${row.linkCause ?? "unknown"}; next: ${row.linkNextStep ?? "Check broker availability, then read ahub room status again."}`);
286
+ console.log(` outbox ${health?.outboxDepth ?? "unknown"}; oldest ${age(health?.oldestOutboxAgeMs)}; last receipt ${age(health?.lastReceiptAgeMs)}`);
287
+ console.log(` refused: ${health?.refused ? Object.entries(health.refused).map(([reason, count]) => `${reason} ${count}`).join(", ") : "unknown"}`);
288
+ }
289
+ 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
290
  for (const delivery of row.deliveries ?? []) {
275
291
  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)}` : ""}`);
292
+ 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
293
  }
278
294
  }
279
295
  return;
@@ -283,6 +299,12 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
283
299
  || typeof preview.ticket !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(preview.ticket)) fail("invalid_reply");
284
300
  console.log(`Owner fingerprint: ${preview.owner}`);
285
301
  console.log(`Member fingerprint: ${preview.member}`);
302
+ if (preview.outstanding !== undefined) {
303
+ if (!Array.isArray(preview.outstanding) || preview.outstanding.length > ROOM_STORE_LIMITS.outboxPerRoom
304
+ || preview.outstanding.some(value => typeof value !== "string" || !UUID.test(value)) || new Set(preview.outstanding).size !== preview.outstanding.length) fail("invalid_reply");
305
+ console.log(`Outstanding deliveries on the old epoch (${preview.outstanding.length}):`);
306
+ for (const id of preview.outstanding) console.log(` ${id}`);
307
+ }
286
308
  const confirm = await confirmMember(preview.member, command.confirm, io);
287
309
  const result = success(await request({ t: "room", op: "execute", ticket: preview.ticket, confirm }));
288
310
  if (result.name !== undefined) {
@@ -1,3 +1,4 @@
1
+ import { projectRoomUnavailable } from "../rooms/status.ts";
1
2
  import type { ContextView } from "../hub/context-window.ts";
2
3
  import { memorySyncNextStep, type MemorySyncCause } from "../hub/memory-sync-state.ts";
3
4
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
@@ -35,7 +36,7 @@ export function memorySyncLine(sync: MemorySyncRow, time = (at: number) => new D
35
36
  const cause = (causes.includes(sync.cause ?? "") ? sync.cause : "unknown") as MemorySyncCause;
36
37
  if (cause === "declined" && sync.placementActive === false) {
37
38
  const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? String(sync.declinedCount) : "unknown";
38
- return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store (agent-mem has no activation command yet, agent-mem#281). Inspect with `ahub memory declined`.";
39
+ return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store. Activation is person-only: preview with `amem migrate --sync-placement --dry-run` and declare every writer via --writer-root. The hub does not yet supply the fresh placementAttestation required for active-placement sync (#1173). Inspect with `ahub memory declined`.";
39
40
  }
40
41
  const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? `, ${sync.declinedCount} declined` : "";
41
42
  const ids = cause === "declined" && sync.declinedIdsKnown === false ? "; declined ID set unknown" : "";
@@ -55,3 +56,9 @@ export function reviewInputHealthLine(health: ReviewInputHealthRow): string | un
55
56
  const state = health.state === "incomplete" || health.state === "corrupt" ? health.state : "unknown";
56
57
  return `${state} (${cause}); next: ${next}`;
57
58
  }
59
+
60
+ /** Room failures carry a closed cause and locally generated next step only. */
61
+ export function roomsUnavailableLine(value: unknown): string {
62
+ const unavailable = projectRoomUnavailable(value);
63
+ return `rooms: unavailable (${unavailable?.code ?? "unknown"}); next: ${unavailable?.nextStep ?? "Run ahub room status to inspect room storage and broker availability."}`;
64
+ }
@@ -206,17 +206,32 @@ function decoded(result: CommandResult): unknown {
206
206
  }
207
207
  }
208
208
 
209
+ /** Orca's structured error, from the JSON it prints on stdout or stderr. */
210
+ function orcaError(result: CommandResult): Record<string, unknown> {
211
+ const fromStdout = object(object(decoded(result)).error);
212
+ if (fromStdout.code !== undefined) return fromStdout;
213
+ try { return object(object(JSON.parse(bytes(result.stderr))).error); } catch { return {}; }
214
+ }
215
+
216
+ function errorCode(result: CommandResult): unknown {
217
+ return orcaError(result).code;
218
+ }
219
+
220
+ /** #1233: Orca prints its error as JSON on stdout with an empty stderr, so its code comes before the exit-status fallback. */
209
221
  function errorText(result: CommandResult): string {
222
+ const { code, message } = orcaError(result);
223
+ if (typeof code === "string") return typeof message === "string" && message !== code ? `${code}: ${message.slice(0, 200)}` : code;
210
224
  const stderr = bytes(result.stderr).trim();
211
225
  return stderr || `exit status ${commandStatus(result)}`;
212
226
  }
213
227
 
214
- /** Orca's structured error code, from the JSON it prints on stdout or stderr. */
215
- function errorCode(result: CommandResult): unknown {
216
- const fromStdout = object(object(decoded(result)).error).code;
217
- if (fromStdout !== undefined) return fromStdout;
218
- try { return object(object(JSON.parse(bytes(result.stderr))).error).code; } catch { return undefined; }
219
- }
228
+ /**
229
+ * Orca 1.4.250 answers a terminal it can no longer reach with one of these codes, and its own CLI groups all four as
230
+ * gone (docs/verification/2026-11-06-orca-1.4.250-cli-contract.md). A wait on a pane whose process ended throws
231
+ * terminal_exited, so restoration must read it as gone and check the launcher, not stop on a command error (#1233).
232
+ */
233
+ const GONE_CODES = new Set(["terminal_handle_stale", "terminal_exited", "terminal_gone", "no_connected_pty"]);
234
+ const GONE_STATES: Record<string, string> = { terminal_handle_stale: "no longer exists", terminal_exited: "exited (terminal_exited)" };
220
235
 
221
236
  function runnerFailure(argv: readonly string[], result: CommandResult): RecoveryBlocker {
222
237
  return { code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} failed: ${errorText(result)}` };
@@ -226,9 +241,11 @@ async function run(runner: CommandRunner, argv: readonly string[]): Promise<unkn
226
241
  const result = await runner(argv);
227
242
  if (commandStatus(result) !== 0) {
228
243
  // Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
229
- if (errorCode(result) === "terminal_handle_stale") {
244
+ const code = errorCode(result);
245
+ if (typeof code === "string" && GONE_CODES.has(code)) {
230
246
  const handle = argv.includes("--terminal") ? argv[argv.indexOf("--terminal") + 1] : undefined;
231
- throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} no longer exists` });
247
+ const state = GONE_STATES[code] ?? `is gone (${code})`;
248
+ throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} ${state}` });
232
249
  }
233
250
  throw new OrcaCommandError(runnerFailure(argv, result));
234
251
  }
@@ -236,7 +253,9 @@ async function run(runner: CommandRunner, argv: readonly string[]): Promise<unkn
236
253
  const root = object(value);
237
254
  const payload = responseResult(value);
238
255
  if (root.ok === false || payload.ok === false) {
239
- throw new OrcaCommandError({ code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} returned ok:false${typeof root.error === "string" ? `: ${root.error}` : ""}` });
256
+ const error = root.error ?? payload.error;
257
+ const detail = typeof error === "string" ? error : object(error).code;
258
+ throw new OrcaCommandError({ code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} returned ok:false${typeof detail === "string" && detail ? `: ${detail}` : ""}` });
240
259
  }
241
260
  return value;
242
261
  }
@@ -816,14 +835,17 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
816
835
 
817
836
  /**
818
837
  * One bounded Orca wait. Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
819
- * that is "not satisfied", not a failure. Any other failure still throws.
838
+ * that is "not satisfied", not a failure. Any other failure still throws. 1.4.250 also answers ok with `satisfied: false`
839
+ * and a `blockedReason` (seen: `agent-update-prompt`) when the TUI waits on a prompt; the reason is passed on (#1233).
820
840
  */
821
- async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<boolean> {
841
+ async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<{ satisfied: boolean; blockedReason?: string }> {
822
842
  const argv = ["terminal", "wait", "--terminal", handle, "--for", "tui-idle", "--timeout-ms", String(timeoutMs), "--json"];
823
843
  const result = await runner(argv);
824
- if (commandStatus(result) !== 0 && errorCode(result) === "timeout") return false;
844
+ if (commandStatus(result) !== 0 && errorCode(result) === "timeout") return { satisfied: false };
825
845
  const root = responseResult(await run(async () => result, argv));
826
- return root.satisfied === true || object(root.wait).satisfied === true;
846
+ if (root.satisfied === true || object(root.wait).satisfied === true) return { satisfied: true };
847
+ const reason = root.blockedReason ?? object(root.wait).blockedReason;
848
+ return typeof reason === "string" ? { satisfied: false, blockedReason: reason.slice(0, 100) } : { satisfied: false };
827
849
  }
828
850
 
829
851
  /** Wait only on the captured terminal and only for Orca's supported TUI-idle condition. */
@@ -832,8 +854,8 @@ export async function waitForIdle(binding: TerminalBinding, timeoutMs: number, o
832
854
  const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Math.min(Math.floor(timeoutMs), MAX_WAIT_MS) : 0;
833
855
  try {
834
856
  await showBinding(binding, config);
835
- const satisfied = await waitSatisfied(config.runner, binding.handle, timeout);
836
- const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait`, binding.peer, binding.handle)];
857
+ const { satisfied, blockedReason } = await waitSatisfied(config.runner, binding.handle, timeout);
858
+ const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait${blockedReason ? ` (Orca: blocked by ${blockedReason})` : ""}`, binding.peer, binding.handle)];
837
859
  return { satisfied, manualRequired: !satisfied, blockers };
838
860
  } catch (error) {
839
861
  const item = error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, binding.handle);
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>();
@@ -265,6 +266,8 @@ export class Bus {
265
266
  private condensing = 0;
266
267
  private closed = false;
267
268
  storageError?: string;
269
+ /** Verified durable-write recovery edge; observation never changes settlement. */
270
+ onStorageRecovered?: () => void;
268
271
 
269
272
  constructor(opts: Partial<BusOptions> = {}) {
270
273
  this.opts = { ...DEFAULT_BUS, ...opts };
@@ -422,6 +425,7 @@ export class Bus {
422
425
  private persist(): void {
423
426
  if (!this.journal) return;
424
427
  const tracking = this.trackingQueuePressure();
428
+ const wasUnavailable = this.storageError !== undefined;
425
429
  try {
426
430
  this.journal.transaction(() => {
427
431
  for (const outcome of this.pendingOutcomes) this.journal!.createDelivery(outcome);
@@ -429,6 +433,10 @@ export class Bus {
429
433
  });
430
434
  this.pendingOutcomes.length = 0;
431
435
  this.storageError = undefined;
436
+ if (wasUnavailable) queueMicrotask(() => {
437
+ if (this.closed || this.storageError) return;
438
+ try { this.onStorageRecovered?.(); } catch { /* Recovery observers cannot undo the durable write. */ }
439
+ });
432
440
  }
433
441
  catch { this.storageError = "delivery journal unavailable"; throw new Error("delivery journal unavailable"); }
434
442
  }
@@ -1367,6 +1375,23 @@ export class Bus {
1367
1375
  return this.holdRemoteDeliveryMetadata(peerId, row, `room recipient proof no longer current after restart; inspect ahub queue show ${deliveryId}`);
1368
1376
  }
1369
1377
 
1378
+ /** Person-authorized epoch change: hold metadata only, without cancellation, replay or completion. */
1379
+ holdRemoteEpochRotated(peerId: string, deliveryId: string, expectedRoom: string, expectedMember: string, currentGuard: () => boolean): boolean {
1380
+ if (!parseRemotePeerId(peerId) || typeof expectedRoom !== "string" || !expectedRoom || !remoteFingerprint(expectedMember)
1381
+ || !this.journal || this.storageError || this.closed
1382
+ || this.remoteQueueRooms.get(peerId) !== expectedRoom || this.remoteQueueMembers.get(peerId) !== expectedMember) return false;
1383
+ const row = this.journal.get(deliveryId), reason = `${ROOM_EPOCH_ROTATED_REASON} ${deliveryId}`;
1384
+ if (!row || row.peer !== peerId || row.originals.length !== 1 || row.originals[0]?.to?.includes(peerId) !== true
1385
+ || this.journal.resolution(deliveryId)) return false;
1386
+ const active = this.activeDeliveries.get(deliveryId), transport = this.remoteTransport.get(deliveryId)?.peer;
1387
+ if (active !== undefined && active !== peerId || transport !== undefined && transport !== peerId) return false;
1388
+ if (!["dispatching", "accepted", "needs_review"].includes(row.state)) return false;
1389
+ if (row.state === "needs_review" && row.reason !== reason && row.reason !== `${ROOM_PROOF_UNAVAILABLE_REASON} ${deliveryId}`) return false;
1390
+ try { if (currentGuard() !== true) return false; } catch { return false; }
1391
+ if (row.state === "needs_review" && row.reason === reason) return true;
1392
+ return this.holdRemoteDeliveryMetadata(peerId, row, reason);
1393
+ }
1394
+
1370
1395
  /** Resume the same delivery after the host revalidates its durable outbox proof. */
1371
1396
  resumeRemotePending(source: PeerAdapter, deliveryId: string, state: "dispatching" | "accepted" | "needs_review", currentGuard: () => boolean): boolean {
1372
1397
  const peer = source.id;