@staix/agent-hub 0.32.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 (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/docs/design/bus.md +68 -2
  4. package/docs/dogfood.md +1 -0
  5. package/docs/memory.md +9 -3
  6. package/docs/operations.md +47 -14
  7. package/docs/quickstart.md +12 -2
  8. package/docs/security.md +28 -9
  9. package/docs/specs/2026-09-19-agent-hub-design.md +31 -4
  10. package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
  11. package/docs/verification/2026-11-06-0.33.0.md +222 -0
  12. package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
  13. package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
  14. package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
  15. package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
  16. package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
  17. package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
  18. package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
  19. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  20. package/docs/verified.json +10 -10
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +528 -206
  24. package/src/adapters/compat.ts +11 -11
  25. package/src/broker/protocol.ts +5 -0
  26. package/src/broker/server.ts +54 -4
  27. package/src/broker/store.ts +6 -3
  28. package/src/cli/output.ts +5 -1
  29. package/src/cli/room.ts +17 -5
  30. package/src/cli/status-lines.ts +8 -1
  31. package/src/cli/terminal-recovery.ts +37 -15
  32. package/src/hub/bus.ts +7 -0
  33. package/src/hub/daemon.ts +13 -4
  34. package/src/rooms/application.ts +15 -0
  35. package/src/rooms/commands.ts +76 -16
  36. package/src/rooms/health.ts +32 -0
  37. package/src/rooms/link.ts +267 -43
  38. package/src/rooms/remote-peer.ts +18 -3
  39. package/src/rooms/service.ts +27 -12
  40. package/src/rooms/status.ts +57 -1
  41. package/src/rooms/store.ts +20 -1
  42. package/src/rooms/wire.ts +10 -8
  43. package/src/ui/index.html +1 -1
@@ -7,6 +7,7 @@ import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInb
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
+ import { roomHealthCause, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
10
11
  import { projectRoomStatus, type RoomStatusView } from "./status.ts";
11
12
 
12
13
  export interface RoomCommandResult { kind?: string; artifact?: string; name?: string; room?: string; owner?: string; member?: string }
@@ -50,26 +51,64 @@ export class RoomCommands {
50
51
  private pending?: Promise<RoomStore>;
51
52
  private store?: RoomStore;
52
53
  private stopped = false;
54
+ private openFailure?: RoomHealthCause;
55
+ private openRetry?: ReturnType<typeof setTimeout>;
56
+ private openAttempts = 0;
53
57
  private stopWork?: Promise<void>;
54
58
  private readonly links = new Map<string, RoomLink>();
55
59
  private readonly policy: RoomPolicy;
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); }
60
+ constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; refreshMs?: number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat; openStore?: typeof RoomStore.open; retryMs?: number; changed?: () => void }) { this.policy = new RoomPolicy(options.cwd); }
61
+ private changedAvailability(): void {
62
+ try { this.options.changed?.(); } catch { /* Diagnostics cannot change store admission. */ }
63
+ }
64
+ private retryOpen(): void {
65
+ if (this.stopped || this.openRetry) return;
66
+ const delay = Math.min(30_000, (this.options.retryMs ?? 1000) * 2 ** Math.min(this.openAttempts++, 5));
67
+ this.openRetry = setTimeout(() => {
68
+ this.openRetry = undefined;
69
+ if (!this.stopped) void this.start().catch(() => undefined);
70
+ }, delay);
71
+ this.openRetry.unref?.();
72
+ }
57
73
  private async open(): Promise<RoomStore> {
58
74
  if (this.stopped) refuse("stopped");
59
- this.pending ??= RoomStore.open({ stateDir: join(this.options.stateDir, "rooms"), ...(this.options.clock ? { clock: this.options.clock } : {}) }).then(store => {
75
+ if (this.store) return this.store;
76
+ if (this.openRetry) throw new Error(`rooms unavailable (${this.openFailure ?? "unavailable"}); ${roomHealthNextStep(this.openFailure ?? "unavailable")}`);
77
+ this.pending ??= (this.options.openStore ?? RoomStore.open)({ stateDir: join(this.options.stateDir, "rooms"), ...(this.options.clock ? { clock: this.options.clock } : {}) }).then(store => {
60
78
  if (this.stopped) { store.close(); return refuse("stopped"); }
61
- this.store = store; return store;
79
+ this.store = store; this.openFailure = undefined; this.openAttempts = 0;
80
+ this.changedAvailability(); return store;
81
+ }).catch(error => {
82
+ this.pending = undefined;
83
+ if (!this.stopped) {
84
+ const cause = roomHealthCause(error), changed = this.openFailure !== cause;
85
+ this.openFailure = cause; this.retryOpen();
86
+ if (changed) this.changedAvailability();
87
+ }
88
+ throw error;
62
89
  });
63
90
  return this.pending;
64
91
  }
65
92
  async start(): Promise<void> {
66
- if (!existsSync(join(this.options.stateDir, "rooms"))) return;
67
- const store = await this.open();
68
- if (!this.stopped) for (const state of store.list()) if (!state.left) this.link(state.name);
93
+ if (this.stopped || !existsSync(join(this.options.stateDir, "rooms"))) return;
94
+ try {
95
+ const store = await this.open();
96
+ if (!this.stopped) for (const state of store.list()) if (!state.left) this.link(state.name);
97
+ const recovered = this.openFailure !== undefined;
98
+ this.openFailure = undefined; this.openAttempts = 0;
99
+ if (recovered) this.changedAvailability();
100
+ } catch (error) {
101
+ if (!this.stopped && !this.openRetry) {
102
+ const cause = roomHealthCause(error), changed = this.openFailure !== cause;
103
+ this.openFailure = cause; this.retryOpen();
104
+ if (changed) this.changedAvailability();
105
+ }
106
+ throw error;
107
+ }
69
108
  }
70
109
  stop(): Promise<void> {
71
110
  if (this.stopWork) return this.stopWork;
72
- this.stopped = true; clearTimeout(this.presenceTimer); this.options.chat?.stop();
111
+ this.stopped = true; clearTimeout(this.presenceTimer); clearTimeout(this.openRetry); this.options.chat?.stop();
73
112
  const stops = [...this.links.values()].map(link => link.stop()); this.links.clear();
74
113
  this.stopWork = Promise.allSettled([...(this.pending ? [this.pending] : []), ...stops]).then(() => { this.store?.close(); });
75
114
  return this.stopWork;
@@ -81,7 +120,7 @@ export class RoomCommands {
81
120
  const store = this.store;
82
121
  const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application, refreshMs: this.options.refreshMs, now: this.options.clock,
83
122
  onPresence: value => this.options.chat?.presence(store, name, created, value),
84
- onState: state => this.options.chat?.linkState(store, name, created, state), presence: () => {
123
+ onState: state => { this.options.chat?.linkState(store, name, created, state); this.changedAvailability(); }, presence: () => {
85
124
  const allowed = new Set(this.policy.get(name));
86
125
  return (this.options.presence?.() ?? []).filter(row => allowed.has(row.id));
87
126
  } });
@@ -166,21 +205,42 @@ export class RoomCommands {
166
205
  async rows(name?: string, details = name !== undefined): Promise<Record<string, unknown>[]> {
167
206
  if (!this.store && !existsSync(join(this.options.stateDir, "rooms"))) return [];
168
207
  const store = await this.open(); if (name !== undefined) roomName(name);
169
- return store.list().filter(row => name === undefined || row.name === name).map(row => ({ name: row.name, room: row.current.room, owner: this.owner(row), member: store.identity.public.fingerprint, epoch: row.current.epoch, state: row.left ? "left" : this.links.get(row.name)?.status().state ?? "offline", ...(details && this.options.chat ? { deliveries: this.options.chat.rows(row.name), remotePeers: this.options.chat.peers(row.name) } : {}) }));
208
+ return store.list().filter(row => name === undefined || row.name === name).map(row => {
209
+ const link = this.links.get(row.name)?.status();
210
+ const result: Record<string, unknown> = { name: row.name, room: row.current.room, owner: this.owner(row), member: store.identity.public.fingerprint, epoch: row.current.epoch,
211
+ state: row.left ? "left" : link?.state ?? "offline" };
212
+ if (details) {
213
+ result.linkCause = null;
214
+ if (!row.left) result.linkCause = link?.cause ?? null;
215
+ result.health = store.health(row.name);
216
+ if (this.options.chat) {
217
+ result.deliveries = this.options.chat.rows(row.name);
218
+ result.remotePeers = this.options.chat.peers(row.name);
219
+ }
220
+ }
221
+ return result;
222
+ });
170
223
  }
171
- /** Polling uses only already-loaded public metadata; it never opens room state or reads message rows. */
172
- statusView(): RoomStatusView[] | null {
173
- if (this.stopped) return null;
174
- if (!this.store) return this.pending ? null : [];
224
+ /** One metadata snapshot binds unavailable diagnostics to the reads that failed. */
225
+ statusSnapshot(): { rooms: RoomStatusView[] | null; roomsOpening?: true; roomsUnavailable?: { code: RoomHealthCause; nextStep: string } } {
226
+ const unavailable = (code: RoomHealthCause) => ({ rooms: null, roomsUnavailable: { code, nextStep: roomHealthNextStep(code) } });
227
+ if (this.stopped) return { rooms: null };
228
+ if (this.openFailure) return unavailable(this.openFailure);
229
+ if (!this.store) return this.pending ? { rooms: null, roomsOpening: true } : { rooms: [] };
175
230
  try {
176
- return projectRoomStatus(this.store.statusMetadata().map(row => ({
231
+ return { rooms: projectRoomStatus(this.store.statusMetadata().map(row => ({
177
232
  name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch,
178
233
  state: row.left ? "left" : this.links.get(row.name)?.connectionState ?? "offline", members: row.members,
234
+ linkCause: row.left ? null : this.links.get(row.name)?.connectionCause ?? null, health: this.store!.health(row.name),
179
235
  remotePeers: this.options.chat?.peersForStatus(row.name) ?? null,
180
236
  relays: this.options.chat?.metrics(row.name, row.room) ?? null,
181
- })));
182
- } catch { return null; }
237
+ }))) };
238
+ } catch (error) { return unavailable(roomHealthCause(error)); }
183
239
  }
240
+ /** Closed cause metadata only, never raw errors; unavailable is distinct from no rooms. */
241
+ unavailable(): { code: RoomHealthCause; nextStep: string } | undefined { return this.statusSnapshot().roomsUnavailable; }
242
+ /** Polling uses loaded public metadata and aggregate counts; never opens storage or loads payloads. */
243
+ statusView(): RoomStatusView[] | null { return this.statusSnapshot().rooms; }
184
244
  async preview(action: unknown, raw: unknown, check: () => boolean): Promise<RoomPreview> {
185
245
  if (typeof action !== "string") refuse("invalid_operation");
186
246
  await assertRoomCryptoReady(); this.current(check);
@@ -0,0 +1,32 @@
1
+ import { RoomStoreError } from "./store.ts";
2
+
3
+ /** Closed diagnostics only; never carry a filesystem error, path or message body. */
4
+ export const ROOM_HEALTH_CAUSES = ["invalid", "busy", "corrupt", "unsupported", "limit", "untrusted", "state_changed", "clock_rollback", "closed", "inbox_list_limit", "unavailable", "connecting", "transport_closed", "backpressure", "broker_refused", "codec_unsupported", "codec_pending"] as const;
5
+ export type RoomHealthCause = typeof ROOM_HEALTH_CAUSES[number];
6
+ export function roomHealthCause(error: unknown): RoomHealthCause {
7
+ return error instanceof RoomStoreError && ROOM_HEALTH_CAUSES.includes(error.code) ? error.code : "unavailable";
8
+ }
9
+ export function roomHealthNextStep(code: RoomHealthCause): string {
10
+ switch (code) {
11
+ case "clock_rollback": return "Correct the system clock to the last recorded room time; room links retry automatically.";
12
+ case "busy": return "Release the competing room store writer; the hub retries automatically.";
13
+ case "corrupt": case "invalid": case "untrusted": return "Inspect local room state and restore a trusted backup before retrying; do not delete replay records.";
14
+ case "unsupported": return "Upgrade agent-hub to a version supporting this room store, then restart the hub.";
15
+ case "limit": case "inbox_list_limit": return "Inspect ahub room status and ahub room inbox; resolve pending work before retrying.";
16
+ case "connecting": return "Wait for the broker connection; inspect ahub room status if it stays connecting.";
17
+ case "backpressure": return "Wait for the broker queue to drain; stored frames retry automatically.";
18
+ case "codec_pending": return "Wait for the member's signed codec advertisement; queued messages keep their original expiry. Inspect ahub room status if waiting continues.";
19
+ case "codec_unsupported": return "Upgrade both hubs and the broker, reconnect the room, then retry after signed codec support is available.";
20
+ case "broker_refused": return "Check broker availability and the signed room membership, then retry.";
21
+ case "transport_closed": return "Check the broker address and network connection; the link retries automatically.";
22
+ case "closed": return "Restart the hub to reopen room storage.";
23
+ case "state_changed": return "Read ahub room status again before retrying the operation.";
24
+ default: return "Check room storage permissions and broker availability; the hub retries store open automatically.";
25
+ }
26
+ }
27
+ export interface RoomHealth {
28
+ refused: Partial<Record<"invalid_presence" | "wrong_epoch" | "trust_changed" | "stale" | "invalid_frame", number>>;
29
+ outboxDepth: number;
30
+ oldestOutboxAgeMs: number | null;
31
+ lastReceiptAgeMs: number | null;
32
+ }