feat(hub): persist agent list to disk for restart recovery

Store agent records in ~/.super-multica/agents/agents.json.
Hub restores agents on startup and updates the file on create/delete.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yushen 2026-01-30 13:56:19 +08:00
parent 36e9acb35f
commit c6d0476679
2 changed files with 67 additions and 1 deletions

47
src/hub/agent-store.ts Normal file
View file

@ -0,0 +1,47 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { DATA_DIR } from "../shared/index.js";
export interface AgentRecord {
id: string;
createdAt: number;
}
const AGENTS_DIR = join(DATA_DIR, "agents");
const AGENTS_FILE = join(AGENTS_DIR, "agents.json");
function ensureDir(): void {
if (!existsSync(AGENTS_DIR)) {
mkdirSync(AGENTS_DIR, { recursive: true });
}
}
export function loadAgentRecords(): AgentRecord[] {
if (!existsSync(AGENTS_FILE)) return [];
try {
const content = readFileSync(AGENTS_FILE, "utf-8");
return JSON.parse(content) as AgentRecord[];
} catch {
return [];
}
}
export function saveAgentRecords(records: AgentRecord[]): void {
ensureDir();
writeFileSync(AGENTS_FILE, JSON.stringify(records, null, 2), "utf-8");
}
export function addAgentRecord(record: AgentRecord): void {
const records = loadAgentRecords();
if (records.some((r) => r.id === record.id)) return;
records.push(record);
saveAgentRecords(records);
}
export function removeAgentRecord(id: string): void {
const records = loadAgentRecords();
const filtered = records.filter((r) => r.id !== id);
if (filtered.length !== records.length) {
saveAgentRecords(filtered);
}
}

View file

@ -3,6 +3,7 @@ import type { ConnectionState } from "../shared/gateway-sdk/types.js";
import { AsyncAgent } from "../agent/async-agent.js";
import { getDeviceId } from "./device.js";
import { GatewayClient } from "../shared/gateway-sdk/client.js";
import { loadAgentRecords, addAgentRecord, removeAgentRecord } from "./agent-store.js";
export class Hub {
private readonly agents = new Map<string, AsyncAgent>();
@ -23,6 +24,18 @@ export class Hub {
this.deviceId = getDeviceId();
this.client = this.createClient(this.url);
this.client.connect();
this.restoreAgents();
}
/** Restore agents from persistent storage */
private restoreAgents(): void {
const records = loadAgentRecords();
for (const record of records) {
this.createAgent(record.id, { persist: false });
}
if (records.length > 0) {
console.log(`[Hub] Restored ${records.length} agent(s)`);
}
}
private createClient(url: string): GatewayClient {
@ -82,7 +95,7 @@ export class Hub {
}
/** Create new Agent, or rebuild with existing ID */
createAgent(id?: string): AsyncAgent {
createAgent(id?: string, options?: { persist?: boolean }): AsyncAgent {
if (id) {
const existing = this.agents.get(id);
if (existing && !existing.closed) {
@ -93,6 +106,11 @@ export class Hub {
const agent = new AsyncAgent({ sessionId: id });
this.agents.set(agent.sessionId, agent);
// Persist to agent store (skip during restore to avoid duplicates)
if (options?.persist !== false) {
addAgentRecord({ id: agent.sessionId, createdAt: Date.now() });
}
// Internally consume messages produced by agent
void this.consumeAgent(agent);
@ -130,6 +148,7 @@ export class Hub {
agent.close();
this.agents.delete(id);
this.agentSenders.delete(id);
removeAgentRecord(id);
return true;
}