@staix/agent-hub 0.6.3 → 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 +14 -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 +32 -11
- 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/free-text.ts +8 -0
- package/src/cli/main.ts +52 -2
- package/src/cli/status-lines.ts +6 -1
- package/src/cli/terminal-recovery.ts +41 -1
- package/src/cli/upgrade-runtime.ts +8 -8
- package/src/hub/bus.ts +307 -37
- package/src/hub/control-client.ts +5 -3
- package/src/hub/daemon.ts +109 -20
- package/src/hub/delivery-journal.ts +830 -0
- package/src/hub/envelope.ts +8 -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,10 +27,11 @@ 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 { BasePeer, DEFAULT_WATCHDOG_MS } from "./peers.ts";
|
|
33
|
+
import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId } from "./envelope.ts";
|
|
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";
|
|
36
37
|
import { projectChain, recallFor } from "../memory/recall.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 = () => {
|
|
@@ -511,6 +521,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
511
521
|
renameSync(`${file}.${instanceId}.tmp`, file);
|
|
512
522
|
};
|
|
513
523
|
|
|
524
|
+
// A queue change is not a bus event: without this, status.json reports the queue as it was before the last
|
|
525
|
+
// message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
|
|
526
|
+
bus.onQueues = () => { if (!stopping) writeStatus(); };
|
|
527
|
+
|
|
514
528
|
bus.tap((e) => {
|
|
515
529
|
e = redact(e);
|
|
516
530
|
uiEvents.push({ seq: ++uiSequence, event: e });
|
|
@@ -555,7 +569,32 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
555
569
|
return running;
|
|
556
570
|
}
|
|
557
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Hide a replaced adapter's stop from the console (issue #42): its `offline` would flash into status.json and
|
|
574
|
+
* the dashboard between the two adapters. Returns the undo, which the caller runs on every exit: if no
|
|
575
|
+
* replacement was ever added, the hook goes back on and the truth is emitted, or status keeps reporting a dead
|
|
576
|
+
* peer as idle.
|
|
577
|
+
*/
|
|
578
|
+
function muteState(p: PeerAdapter): () => void {
|
|
579
|
+
const hook = p.onState;
|
|
580
|
+
p.onState = undefined;
|
|
581
|
+
return () => {
|
|
582
|
+
if (bus.peers.get(p.id) !== p || p.onState) return; // a replacement took the id: nothing to put back
|
|
583
|
+
p.onState = hook;
|
|
584
|
+
hook?.(p.state);
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
558
588
|
async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }): Promise<Record<string, unknown>> {
|
|
589
|
+
let unmute: (() => void) | undefined;
|
|
590
|
+
try {
|
|
591
|
+
return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
|
|
592
|
+
} finally {
|
|
593
|
+
unmute?.();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
|
|
559
598
|
if (peer === "pi") {
|
|
560
599
|
if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Pi mode" };
|
|
561
600
|
if (args.backend !== undefined && !["auto", "dgx", "mlx"].includes(args.backend)) return { ok: false, error: "invalid Pi backend" };
|
|
@@ -574,11 +613,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
574
613
|
if (!args.sessionId && !args.sessionFile) args = { ...args, ...existing.pendingResume };
|
|
575
614
|
// Revoke the previous launch bridge before issuing another launch. A late
|
|
576
615
|
// process from the abandoned CLI cannot claim the replacement owner.
|
|
616
|
+
// The replacement's start event is the only state change the console should see (issue #42).
|
|
617
|
+
mute(existing);
|
|
577
618
|
await existing.stop();
|
|
578
619
|
} else if (changesOwner) {
|
|
579
620
|
saved = await existing.captureResume();
|
|
580
621
|
if (!saved.sessionId) return { ok: false, error: "Pi session identity is not ready for handover" };
|
|
581
622
|
args = { ...args, backend: args.backend ?? launch.backend as "auto" | "dgx" | "mlx", model: args.model ?? (args.backend === undefined && typeof launch.model === "string" ? launch.model : undefined), sessionId: String(saved.sessionId), sessionFile: typeof saved.sessionFile === "string" ? saved.sessionFile : undefined };
|
|
623
|
+
// Same as the unclaimed handover: no offline flash between the adapters (issue #42).
|
|
624
|
+
mute(existing);
|
|
582
625
|
await existing.stop();
|
|
583
626
|
} else if (existing.state !== "offline") {
|
|
584
627
|
return mode === "tui" ? { ok: false, error: "Pi already owns a native terminal; use that terminal or switch to headless first" } : { ok: true, already: true };
|
|
@@ -756,18 +799,37 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
756
799
|
}
|
|
757
800
|
|
|
758
801
|
function holdPeer(action: "pause" | "resume", id: string) {
|
|
759
|
-
if (!bus.
|
|
802
|
+
if (!bus.knownPeers().includes(id)) return { ok: false, error: `unknown peer: ${id}` };
|
|
760
803
|
if (action === "pause") {
|
|
804
|
+
const next = [...new Set([...manualPaused, id])];
|
|
805
|
+
bus.setManualPaused(next);
|
|
761
806
|
manualPaused.add(id);
|
|
762
807
|
bus.pause(id);
|
|
763
808
|
} else {
|
|
764
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));
|
|
765
811
|
manualPaused.delete(id);
|
|
766
812
|
bus.resume(id);
|
|
767
813
|
}
|
|
768
814
|
return { ok: true, state: bus.stateOf(id) };
|
|
769
815
|
}
|
|
770
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
|
+
|
|
771
833
|
function uiSnapshot(after: number) {
|
|
772
834
|
return {
|
|
773
835
|
ok: true,
|
|
@@ -814,7 +876,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
814
876
|
}
|
|
815
877
|
case "send": {
|
|
816
878
|
if (!text("body", 8000)) return bad;
|
|
817
|
-
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;
|
|
818
880
|
const { body, priority } = parseMarker(a.body as string, "important");
|
|
819
881
|
if (!body) return bad;
|
|
820
882
|
bus.publish(newEnvelope(USER, body, { priority, ...(Array.isArray(a.to) && a.to.length ? { to: a.to as string[] } : {}) }));
|
|
@@ -993,6 +1055,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
993
1055
|
}
|
|
994
1056
|
if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
|
|
995
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
|
+
}
|
|
996
1083
|
case "recovery":
|
|
997
1084
|
if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
|
|
998
1085
|
void recoveryOp(msg).then(reply, () => reply(recoveryError("recovery operation failed")));
|
|
@@ -1003,10 +1090,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1003
1090
|
const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
|
|
1004
1091
|
if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
|
|
1005
1092
|
const to: PeerId[] | undefined = Array.isArray(msg.to) && msg.to.length ? msg.to.map(String) : undefined;
|
|
1006
|
-
const unknown = to?.filter((id) => !bus.
|
|
1093
|
+
const unknown = to?.filter((id) => !bus.knownPeers().includes(id)) ?? [];
|
|
1007
1094
|
if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
|
|
1008
1095
|
const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
|
|
1009
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);
|
|
1010
1098
|
return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi" });
|
|
1011
1099
|
}
|
|
1012
1100
|
case "tail":
|
|
@@ -1148,6 +1236,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1148
1236
|
budget.close();
|
|
1149
1237
|
board.close();
|
|
1150
1238
|
server.stop(true);
|
|
1239
|
+
bus.closeJournal();
|
|
1151
1240
|
try {
|
|
1152
1241
|
const current = JSON.parse(readFileSync(join(opts.stateDir, "status.json"), "utf8"));
|
|
1153
1242
|
if (current.instanceId === instanceId) for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
|