import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { ExtensionConfig, SubagentRunMode } from "../../shared/types.ts"; import { ASYNC_DIR } from "../../shared/types.ts"; export interface ResolvedOutputStoreConfig { enabled: boolean; root: string; retentionDays: number; maxStoreSizeMb: number; saveEvents: boolean; saveRawLogs: boolean; saveFinalReports: boolean; } export interface DurableRunIndexEntry { runId: string; asyncDir: string; cwd: string; mode: SubagentRunMode; agents: string[]; startedAt: number; parentSessionFile?: string; } const DEFAULT_STORE_ROOT = path.join(os.homedir(), ".pi", "agent", "subagent-runs"); const DEFAULT_RETENTION_DAYS = 30; const DEFAULT_MAX_STORE_SIZE_MB = 2048; function expandTildeDefault(value: string): string { return value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value; } export function resolveOutputStoreConfig( config: ExtensionConfig, expandTilde: (value: string) => string = expandTildeDefault, ): ResolvedOutputStoreConfig { const enabled = config.durableOutputStore !== false; const configuredRoot = typeof config.outputStorePath === "string" && config.outputStorePath.trim() ? config.outputStorePath.trim() : DEFAULT_STORE_ROOT; return { enabled, root: enabled ? path.resolve(expandTilde(configuredRoot)) : ASYNC_DIR, retentionDays: Number.isFinite(config.retentionDays) && config.retentionDays! > 0 ? Math.floor(config.retentionDays!) : DEFAULT_RETENTION_DAYS, maxStoreSizeMb: Number.isFinite(config.maxStoreSizeMb) && config.maxStoreSizeMb! > 0 ? Math.floor(config.maxStoreSizeMb!) : DEFAULT_MAX_STORE_SIZE_MB, saveEvents: config.saveEvents === true, saveRawLogs: config.saveRawLogs !== false, saveFinalReports: config.saveFinalReports !== false, }; } export function resolveAsyncDirRoot(config: ExtensionConfig, expandTilde?: (value: string) => string): string { return resolveOutputStoreConfig(config, expandTilde).root; } export function resolveAsyncRunDir(runId: string, store: Pick): string { return path.join(store.root, runId); } export function parentSessionIndexPath(parentSessionFile: string): string { const sessionDir = path.dirname(parentSessionFile); const sessionBase = path.basename(parentSessionFile, ".jsonl"); return path.join(sessionDir, sessionBase, "subagents-index.json"); } export function recordParentSessionRun(entry: DurableRunIndexEntry): void { if (!entry.parentSessionFile) return; const indexPath = parentSessionIndexPath(entry.parentSessionFile); try { fs.mkdirSync(path.dirname(indexPath), { recursive: true }); let existing: DurableRunIndexEntry[] = []; if (fs.existsSync(indexPath)) { const parsed = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as unknown; if (Array.isArray(parsed)) existing = parsed as DurableRunIndexEntry[]; } const next = [entry, ...existing.filter((item) => item?.runId !== entry.runId)].slice(0, 200); fs.writeFileSync(indexPath, JSON.stringify(next, null, 2) + "\n"); } catch (error) { console.error(`Failed to update subagent session index for '${entry.runId}':`, error); } } function dirSizeBytes(dir: string): number { let total = 0; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); try { if (entry.isDirectory()) total += dirSizeBytes(p); else if (entry.isFile()) total += fs.statSync(p).size; } catch { // Best effort only. } } return total; } export function cleanupOutputStore(store: ResolvedOutputStoreConfig, now = Date.now()): void { if (!store.enabled) return; try { fs.mkdirSync(store.root, { recursive: true }); } catch (error) { console.error(`Failed to create subagent output store '${store.root}':`, error); return; } let entries: Array<{ path: string; mtimeMs: number; size: number }> = []; try { entries = fs.readdirSync(store.root, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => { const p = path.join(store.root, entry.name); const stat = fs.statSync(p); return { path: p, mtimeMs: stat.mtimeMs, size: dirSizeBytes(p) }; }); } catch (error) { console.error(`Failed to inspect subagent output store '${store.root}':`, error); return; } const cutoff = now - store.retentionDays * 24 * 60 * 60 * 1000; for (const entry of entries) { if (entry.mtimeMs < cutoff) { try { fs.rmSync(entry.path, { recursive: true, force: true }); } catch {} } } entries = entries.filter((entry) => fs.existsSync(entry.path)).sort((a, b) => a.mtimeMs - b.mtimeMs); let total = entries.reduce((sum, entry) => sum + entry.size, 0); const maxBytes = store.maxStoreSizeMb * 1024 * 1024; for (const entry of entries) { if (total <= maxBytes) break; try { fs.rmSync(entry.path, { recursive: true, force: true }); total -= entry.size; } catch {} } }