@staix/agent-hub 0.6.4 → 0.7.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 +7 -0
- package/README.md +16 -15
- package/docs/operations.md +217 -0
- package/docs/quickstart.md +121 -0
- package/docs/security.md +57 -0
- package/docs/smoke.md +525 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +675 -0
- package/docs/specs/2026-09-19-multi-project-design.md +95 -0
- package/docs/specs/2026-09-20-durable-delivery-design.md +67 -0
- package/docs/specs/2026-09-20-pi-local-models-design.md +40 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +151 -0
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +31 -10
- package/src/adapters/acp.ts +40 -7
- package/src/adapters/claude-channel.ts +22 -5
- package/src/adapters/codex-appserver.ts +50 -7
- package/src/adapters/local-worker.ts +20 -3
- package/src/adapters/pi.ts +33 -7
- package/src/cli/main.ts +49 -0
- package/src/cli/status-lines.ts +4 -1
- package/src/cli/terminal-recovery.ts +41 -1
- package/src/cli/upgrade-runtime.ts +8 -8
- package/src/hub/bus.ts +290 -38
- package/src/hub/control-client.ts +5 -3
- package/src/hub/daemon.ts +75 -19
- package/src/hub/delivery-journal.ts +830 -0
- package/src/hub/manager.ts +6 -4
- package/src/hub/peers.ts +16 -3
- package/src/hub/restart.ts +2 -0
- package/src/ui/index.html +7 -3
package/src/hub/daemon.ts
CHANGED
|
@@ -27,9 +27,10 @@ import { DEFAULT_INFERENCE, DIGEST, Inference, type InferenceConfig } from "./in
|
|
|
27
27
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
28
28
|
import { currentRouting, detectSignals } from "./routing.ts";
|
|
29
29
|
import { Bus } from "./bus.ts";
|
|
30
|
+
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
30
31
|
import { startDashboard } from "./ui.ts";
|
|
31
32
|
import { PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
32
|
-
import { newEnvelope, parseMarker, replyParent, USER, type Envelope, type PeerId } from "./envelope.ts";
|
|
33
|
+
import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId } from "./envelope.ts";
|
|
33
34
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
34
35
|
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
35
36
|
import { VERSION } from "../version.ts";
|
|
@@ -147,10 +148,11 @@ class WsPeer extends BasePeer {
|
|
|
147
148
|
this.sock = undefined;
|
|
148
149
|
this.setState("offline");
|
|
149
150
|
}
|
|
150
|
-
async deliver(envs: Envelope[]): Promise<void> {
|
|
151
|
+
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
151
152
|
if (!this.sock) throw new Error(`${this.id} is offline`);
|
|
152
|
-
this.sock.send(JSON.stringify({ t: "deliver", envs }));
|
|
153
|
+
this.sock.send(JSON.stringify({ t: "deliver", envs, ...(deliveryId ? { deliveryId } : {}) }));
|
|
153
154
|
}
|
|
155
|
+
owns(sock: Sock): boolean { return this.sock === sock; }
|
|
154
156
|
async start(): Promise<void> {}
|
|
155
157
|
async stop(): Promise<void> {
|
|
156
158
|
this.sock?.close();
|
|
@@ -186,10 +188,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
186
188
|
},
|
|
187
189
|
websocket: {
|
|
188
190
|
message(sock, data) {
|
|
191
|
+
let parsed: any;
|
|
189
192
|
try {
|
|
190
|
-
|
|
193
|
+
parsed = JSON.parse(String(data));
|
|
194
|
+
onMessage(sock, parsed);
|
|
191
195
|
} catch (e) {
|
|
192
|
-
log(
|
|
196
|
+
log("control operation failed; inspect delivery storage and recovery status");
|
|
197
|
+
if (sock.data.authed && Number.isSafeInteger(parsed?.rid)) sock.send(JSON.stringify({ rid: parsed.rid, ok: false, error: "operation failed; inspect delivery storage and recovery status before retrying" }));
|
|
193
198
|
}
|
|
194
199
|
},
|
|
195
200
|
close(sock) {
|
|
@@ -203,25 +208,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
203
208
|
startupCleanup.push(() => server.stop(true));
|
|
204
209
|
|
|
205
210
|
|
|
211
|
+
const recoveryOperation = process.env.AGENTHUB_RECOVERY_OPERATION;
|
|
212
|
+
const restartFilePresent = existsSync(restartPath(opts.stateDir));
|
|
213
|
+
const restored = recoveryOperation
|
|
214
|
+
? readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId: recoveryOperation })
|
|
215
|
+
: undefined;
|
|
216
|
+
if ((restartFilePresent && !restored) || (recoveryOperation && !restored)) throw new Error("restart state is unreadable, missing, or does not match this project and recovery operation");
|
|
217
|
+
|
|
206
218
|
// The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
|
|
207
219
|
let inference: Inference | undefined;
|
|
208
|
-
const
|
|
209
|
-
const
|
|
220
|
+
const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation });
|
|
221
|
+
const bus = new Bus({ journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs) });
|
|
222
|
+
startupCleanup.push(() => bus.closeJournal());
|
|
223
|
+
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
210
224
|
let recoveryOperationId: string | undefined;
|
|
211
225
|
let recoveryPhase: RecoveryPhase | undefined;
|
|
212
226
|
let recoveryCommitted = false;
|
|
213
227
|
let recoveryPeerSnapshot: RestartPeerSnapshot[] | undefined;
|
|
214
228
|
let recoveryLeaseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
215
229
|
const recoveryActive = () => !!recoveryOperationId && recoveryPhase !== "released";
|
|
216
|
-
const recoveryOperation = process.env.AGENTHUB_RECOVERY_OPERATION;
|
|
217
|
-
const restartFilePresent = existsSync(restartPath(opts.stateDir));
|
|
218
|
-
const restored = recoveryOperation
|
|
219
|
-
? readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId: recoveryOperation })
|
|
220
|
-
: undefined;
|
|
221
|
-
if ((restartFilePresent && !restored) || (recoveryOperation && !restored)) throw new Error("restart state is unreadable, missing, or does not match this project and recovery operation");
|
|
222
230
|
if (restored) {
|
|
223
|
-
bus.restore(restored.bus);
|
|
231
|
+
bus.restore(restored.bus, restored.operationId);
|
|
224
232
|
for (const peer of restored.manualPaused) { manualPaused.add(peer); bus.pause(peer); }
|
|
233
|
+
bus.setManualPaused([...manualPaused]);
|
|
225
234
|
recoveryOperationId = restored.operationId;
|
|
226
235
|
recoveryPhase = "restored";
|
|
227
236
|
recoveryPeerSnapshot = restored.peers;
|
|
@@ -442,7 +451,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
442
451
|
|
|
443
452
|
const pausedNote = (id: PeerId) => {
|
|
444
453
|
const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
|
|
445
|
-
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : {};
|
|
454
|
+
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
|
|
446
455
|
};
|
|
447
456
|
const recoveryReady = () => {
|
|
448
457
|
if (!recoveryActive() || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
@@ -498,11 +507,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
498
507
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
499
508
|
codexProxyPort: opts.codexProxyPort,
|
|
500
509
|
peers: Object.fromEntries(
|
|
501
|
-
|
|
510
|
+
bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); return [id, { state: bus.stateOf(id), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
|
|
502
511
|
),
|
|
503
512
|
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
504
513
|
...(modelRelay ? { models: modelRelay.status() } : {}),
|
|
505
514
|
tasks: board.counts(),
|
|
515
|
+
...(bus.storageError ? { deliveryError: bus.storageError } : {}),
|
|
506
516
|
...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
|
|
507
517
|
});
|
|
508
518
|
const writeStatus = () => {
|
|
@@ -789,18 +799,37 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
789
799
|
}
|
|
790
800
|
|
|
791
801
|
function holdPeer(action: "pause" | "resume", id: string) {
|
|
792
|
-
if (!bus.
|
|
802
|
+
if (!bus.knownPeers().includes(id)) return { ok: false, error: `unknown peer: ${id}` };
|
|
793
803
|
if (action === "pause") {
|
|
804
|
+
const next = [...new Set([...manualPaused, id])];
|
|
805
|
+
bus.setManualPaused(next);
|
|
794
806
|
manualPaused.add(id);
|
|
795
807
|
bus.pause(id);
|
|
796
808
|
} else {
|
|
797
809
|
if (budget.record(id)) return { ok: false, error: `${id} is paused by the budget coordinator until its window resets (ahub budget); to override: ahub budget resume ${id}` };
|
|
810
|
+
bus.setManualPaused([...manualPaused].filter((peer) => peer !== id));
|
|
798
811
|
manualPaused.delete(id);
|
|
799
812
|
bus.resume(id);
|
|
800
813
|
}
|
|
801
814
|
return { ok: true, state: bus.stateOf(id) };
|
|
802
815
|
}
|
|
803
816
|
|
|
817
|
+
// Queue diagnostics are public metadata by default. Private bodies stay behind the existing task console.
|
|
818
|
+
function queueView(row: ReturnType<Bus["queueShow"]>, detail = false) {
|
|
819
|
+
if (!row) return undefined;
|
|
820
|
+
const originals = row.originals;
|
|
821
|
+
const privateDelivery = originals.some((env) => env.private);
|
|
822
|
+
return {
|
|
823
|
+
id: row.id, peer: row.peer, state: row.state, revision: row.revision,
|
|
824
|
+
createdAt: row.createdAt, updatedAt: row.updatedAt,
|
|
825
|
+
envelopeIds: originals.map((env) => env.id),
|
|
826
|
+
important: originals.some((env) => env.priority === "important"),
|
|
827
|
+
...(detail ? { messages: originals.map((env) => ({ id: env.id, from: env.from, priority: env.priority, kind: env.kind,
|
|
828
|
+
body: privateDelivery ? "[private: inspect the associated task with ahub task show]" : sanitize(env.body),
|
|
829
|
+
})) } : {}),
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
804
833
|
function uiSnapshot(after: number) {
|
|
805
834
|
return {
|
|
806
835
|
ok: true,
|
|
@@ -847,7 +876,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
847
876
|
}
|
|
848
877
|
case "send": {
|
|
849
878
|
if (!text("body", 8000)) return bad;
|
|
850
|
-
if (a.to !== undefined && (!Array.isArray(a.to) || a.to.length > 32 || a.to.some((id) => typeof id !== "string" || !PEER_ID.test(id) || !bus.
|
|
879
|
+
if (a.to !== undefined && (!Array.isArray(a.to) || a.to.length > 32 || a.to.some((id) => typeof id !== "string" || !PEER_ID.test(id) || !bus.knownPeers().includes(id)))) return bad;
|
|
851
880
|
const { body, priority } = parseMarker(a.body as string, "important");
|
|
852
881
|
if (!body) return bad;
|
|
853
882
|
bus.publish(newEnvelope(USER, body, { priority, ...(Array.isArray(a.to) && a.to.length ? { to: a.to as string[] } : {}) }));
|
|
@@ -1026,6 +1055,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1026
1055
|
}
|
|
1027
1056
|
if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
|
|
1028
1057
|
switch (msg.t) {
|
|
1058
|
+
case "queue": {
|
|
1059
|
+
if (c.role !== "console") return void reply({ ok: false, error: "queue inspection and resolution are console-only" });
|
|
1060
|
+
if (msg.op === "list") {
|
|
1061
|
+
if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
|
|
1062
|
+
return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row)) });
|
|
1063
|
+
}
|
|
1064
|
+
if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
|
|
1065
|
+
if (msg.op === "show") {
|
|
1066
|
+
const row = bus.queueShow(msg.id);
|
|
1067
|
+
return void reply(row ? { ok: true, delivery: queueView(row, true) } : { ok: false, error: "delivery not found" });
|
|
1068
|
+
}
|
|
1069
|
+
if (msg.op !== "resolve" || !["completed", "retry", "discard"].includes(msg.action) || !Number.isSafeInteger(msg.revision) || msg.revision < 0 || typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "resolution needs an action, observed revision and reason" });
|
|
1070
|
+
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
|
|
1071
|
+
try {
|
|
1072
|
+
bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
|
|
1073
|
+
writeStatus();
|
|
1074
|
+
return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
|
|
1075
|
+
} catch { return void reply({ ok: false, error: "resolution refused: refresh the delivery revision and inspect active or already resolved work" }); }
|
|
1076
|
+
}
|
|
1077
|
+
case "delivery_receipt": {
|
|
1078
|
+
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
1079
|
+
if (c.role !== "peer" || !(peer instanceof WsPeer) || !peer.owns(sock) || typeof msg.deliveryId !== "string" || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
|
|
1080
|
+
bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state === "needs_review" ? { reason: "Claude bridge could not confirm notification delivery" } : {}) });
|
|
1081
|
+
return void reply({ ok: true });
|
|
1082
|
+
}
|
|
1029
1083
|
case "recovery":
|
|
1030
1084
|
if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
|
|
1031
1085
|
void recoveryOp(msg).then(reply, () => reply(recoveryError("recovery operation failed")));
|
|
@@ -1036,10 +1090,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1036
1090
|
const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
|
|
1037
1091
|
if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
|
|
1038
1092
|
const to: PeerId[] | undefined = Array.isArray(msg.to) && msg.to.length ? msg.to.map(String) : undefined;
|
|
1039
|
-
const unknown = to?.filter((id) => !bus.
|
|
1093
|
+
const unknown = to?.filter((id) => !bus.knownPeers().includes(id)) ?? [];
|
|
1040
1094
|
if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
|
|
1041
1095
|
const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
|
|
1042
1096
|
const targets = bus.publish(newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) }));
|
|
1097
|
+
if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id);
|
|
1043
1098
|
return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi" });
|
|
1044
1099
|
}
|
|
1045
1100
|
case "tail":
|
|
@@ -1181,6 +1236,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1181
1236
|
budget.close();
|
|
1182
1237
|
board.close();
|
|
1183
1238
|
server.stop(true);
|
|
1239
|
+
bus.closeJournal();
|
|
1184
1240
|
try {
|
|
1185
1241
|
const current = JSON.parse(readFileSync(join(opts.stateDir, "status.json"), "utf8"));
|
|
1186
1242
|
if (current.instanceId === instanceId) for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
|