import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import { z } from "zod"; import type { Logger } from "pino"; import { AgentStatusSchema } from "../messages.js"; import { toStoredAgentRecord } from "./agent-projections.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { AgentSessionConfig } from "./agent-sdk-types.js"; const SERIALIZABLE_CONFIG_SCHEMA = z .object({ title: z.string().nullable().optional(), modeId: z.string().nullable().optional(), model: z.string().nullable().optional(), thinkingOptionId: z.string().nullable().optional(), extra: z.record(z.any()).nullable().optional(), systemPrompt: z.string().nullable().optional(), mcpServers: z.record(z.any()).nullable().optional(), }) .nullable() .optional(); const PERSISTENCE_HANDLE_SCHEMA = z .object({ provider: z.string(), sessionId: z.string(), nativeHandle: z.any().optional(), metadata: z.record(z.any()).optional(), }) .nullable() .optional(); const STORED_AGENT_SCHEMA = z.object({ id: z.string(), provider: z.string(), cwd: z.string(), createdAt: z.string(), updatedAt: z.string(), lastActivityAt: z.string().optional(), lastUserMessageAt: z.string().nullable().optional(), title: z.string().nullable().optional(), labels: z.record(z.string()).default({}), lastStatus: AgentStatusSchema.default("closed"), lastModeId: z.string().nullable().optional(), config: SERIALIZABLE_CONFIG_SCHEMA, runtimeInfo: z .object({ provider: z.string(), sessionId: z.string().nullable(), model: z.string().nullable().optional(), thinkingOptionId: z.string().nullable().optional(), modeId: z.string().nullable().optional(), extra: z.record(z.unknown()).optional(), }) .optional(), persistence: PERSISTENCE_HANDLE_SCHEMA, requiresAttention: z.boolean().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionTimestamp: z.string().nullable().optional(), internal: z.boolean().optional(), archivedAt: z.string().nullable().optional(), }); export type SerializableAgentConfig = Pick< AgentSessionConfig, | "title" | "modeId" | "model" | "thinkingOptionId" | "extra" | "systemPrompt" | "mcpServers" >; export type StoredAgentRecord = z.infer; export class AgentStorage { private cache: Map = new Map(); private pathById: Map = new Map(); private pendingWrites: Map> = new Map(); private deleting: Set = new Set(); private loaded = false; private baseDir: string; private loadPromise: Promise | null = null; private logger: Logger; constructor(baseDir: string, logger: Logger) { this.baseDir = baseDir; this.logger = logger.child({ module: "agent", component: "agent-storage" }); } async list(): Promise { await this.load(); return Array.from(this.cache.values()); } async get(agentId: string): Promise { await this.load(); const cached = this.cache.get(agentId); if (cached) { return cached; } const discoveredPath = await this.findAgentPathById(agentId); if (!discoveredPath) { return null; } const record = await this.readRecordFile(discoveredPath); if (!record) { return null; } this.cache.set(record.id, record); this.pathById.set(record.id, discoveredPath); return record; } async upsert(record: StoredAgentRecord): Promise { await this.load(); const agentId = record.id; const prev = this.pendingWrites.get(agentId) ?? Promise.resolve(); const next = prev.then(async () => { if (this.deleting.has(agentId)) { return; } const nextPath = this.buildRecordPath(record); const previousPath = this.pathById.get(agentId); await fs.mkdir(path.dirname(nextPath), { recursive: true }); await writeFileAtomically(nextPath, JSON.stringify(record, null, 2)); if (previousPath && previousPath !== nextPath) { try { await fs.unlink(previousPath); } catch { // ignore cleanup errors } } this.cache.set(agentId, record); this.pathById.set(agentId, nextPath); }); this.pendingWrites.set( agentId, next.finally(() => { if (this.pendingWrites.get(agentId) === next) { this.pendingWrites.delete(agentId); } }) ); await next; } beginDelete(agentId: string): void { this.deleting.add(agentId); } async remove(agentId: string): Promise { await this.load(); this.beginDelete(agentId); await (this.pendingWrites.get(agentId) ?? Promise.resolve()); const candidates = new Set(); const existingPath = this.pathById.get(agentId) ?? (await this.findAgentPathById(agentId)); if (existingPath) { candidates.add(existingPath); } // Remove any stray duplicate record files across project directories. // This can happen across storage layout migrations or when a record path changes. try { const projects = await fs.readdir(this.baseDir, { withFileTypes: true }); for (const project of projects) { if (!project.isDirectory()) { continue; } const projectDir = path.join(this.baseDir, project.name); candidates.add(path.join(projectDir, `${agentId}.json`)); // Support one more nesting layer (e.g. provider/version subfolders). let entries: Array = []; try { entries = await fs.readdir(projectDir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory()) { continue; } candidates.add(path.join(projectDir, entry.name, `${agentId}.json`)); } } } catch { // ignore scan errors } // Support legacy flat layouts: baseDir/.json candidates.add(path.join(this.baseDir, `${agentId}.json`)); for (const filePath of candidates) { try { await fs.unlink(filePath); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code && code !== "ENOENT") { this.logger.warn( { err: error, agentId, filePath }, "Failed to remove agent record file" ); } } } this.cache.delete(agentId); this.pathById.delete(agentId); } async applySnapshot( agent: ManagedAgent, options?: { title?: string | null; internal?: boolean } ): Promise { await this.load(); const existing = (await this.get(agent.id)) ?? null; const hasTitleOverride = options !== undefined && Object.prototype.hasOwnProperty.call(options, "title"); const hasInternalOverride = options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal"); const record = toStoredAgentRecord(agent, { title: hasTitleOverride ? options?.title ?? null : existing?.title ?? null, createdAt: existing?.createdAt, internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal), }); // Preserve soft-delete/archive status across snapshot flushes. // `archivedAt` is not part of the ManagedAgent snapshot, so a naive projection // would wipe it during normal persistence (including on daemon restart). if (existing && existing.archivedAt !== undefined) { record.archivedAt = existing.archivedAt; } await this.upsert(record); } async setTitle(agentId: string, title: string): Promise { await this.load(); const record = await this.get(agentId); if (!record) { throw new Error(`Agent ${agentId} not found`); } await this.upsert({ ...record, title }); } async flush(): Promise { await this.load().catch(() => undefined); const writes = Array.from(this.pendingWrites.values()); await Promise.allSettled(writes); } private async load(): Promise { if (this.loaded) { return Array.from(this.cache.values()); } if (!this.loadPromise) { this.loadPromise = this.doLoad(); } return this.loadPromise; } private async doLoad(): Promise { this.cache.clear(); this.pathById.clear(); try { const records = await this.scanDisk(); this.loaded = true; return records; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { this.loaded = true; return []; } this.logger.error({ err: error }, "Failed to load agents"); this.loaded = true; return []; } } private async scanDisk(): Promise { const records: StoredAgentRecord[] = []; let entries: Array = []; try { entries = await fs.readdir(this.baseDir, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return []; } throw error; } for (const entry of entries) { if (!entry.isDirectory()) { continue; } const projectDir = path.join(this.baseDir, entry.name); let files: Array = []; try { files = await fs.readdir(projectDir, { withFileTypes: true }); } catch { continue; } for (const file of files) { if (!file.isFile() || !file.name.endsWith(".json")) { continue; } const filePath = path.join(projectDir, file.name); const record = await this.readRecordFile(filePath); if (!record) { continue; } records.push(record); this.cache.set(record.id, record); this.pathById.set(record.id, filePath); } } return records; } private async readRecordFile(filePath: string): Promise { try { const content = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(content); return STORED_AGENT_SCHEMA.parse(parsed); } catch (error) { this.logger.error({ err: error, filePath }, "Skipping invalid agent record"); return null; } } private buildRecordPath(record: StoredAgentRecord): string { const projectDir = projectDirNameFromCwd(record.cwd); return path.join(this.baseDir, projectDir, `${record.id}.json`); } private async findAgentPathById(agentId: string): Promise { let projects: Array = []; try { projects = await fs.readdir(this.baseDir, { withFileTypes: true }); } catch { return null; } for (const project of projects) { if (!project.isDirectory()) { continue; } const candidate = path.join(this.baseDir, project.name, `${agentId}.json`); try { await fs.access(candidate); return candidate; } catch { // not here } } return null; } } function projectDirNameFromCwd(cwd: string): string { const trimmed = cwd.replace(/^[\\/]+/, "").replace(/[\\/]+$/, ""); if (!trimmed) { return "root"; } return trimmed.replace(/[\\/]+/g, "-"); } async function writeFileAtomically(targetPath: string, payload: string) { const directory = path.dirname(targetPath); const tempPath = path.join( directory, `.agent.tmp-${process.pid}-${Date.now()}-${randomUUID()}` ); await fs.writeFile(tempPath, payload, "utf8"); await fs.rename(tempPath, targetPath); }