@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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -2
- package/docs/design/bus.md +68 -2
- package/docs/dogfood.md +1 -0
- package/docs/memory.md +9 -3
- package/docs/operations.md +47 -14
- package/docs/quickstart.md +12 -2
- package/docs/security.md +28 -9
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -4
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
- package/docs/verification/2026-11-06-0.33.0.md +222 -0
- package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
- package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
- package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
- package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
- package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +10 -10
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +528 -206
- package/src/adapters/compat.ts +11 -11
- package/src/broker/protocol.ts +5 -0
- package/src/broker/server.ts +54 -4
- package/src/broker/store.ts +6 -3
- package/src/cli/output.ts +5 -1
- package/src/cli/room.ts +17 -5
- package/src/cli/status-lines.ts +8 -1
- package/src/cli/terminal-recovery.ts +37 -15
- package/src/hub/bus.ts +7 -0
- package/src/hub/daemon.ts +13 -4
- package/src/rooms/application.ts +15 -0
- package/src/rooms/commands.ts +76 -16
- package/src/rooms/health.ts +32 -0
- package/src/rooms/link.ts +267 -43
- package/src/rooms/remote-peer.ts +18 -3
- package/src/rooms/service.ts +27 -12
- package/src/rooms/status.ts +57 -1
- package/src/rooms/store.ts +20 -1
- package/src/rooms/wire.ts +10 -8
- package/src/ui/index.html +1 -1
package/src/rooms/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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
|
-
|
|
68
|
-
|
|
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 =>
|
|
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
|
-
/**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (
|
|
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
|
|
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
|
+
}
|