import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; /** * On-disk home for this extension's runtime artifacts. Deliberately distinct * from @quintinshaw/pi-dynamic-workflows' ~/.pi/workflows so the two can be * A/B-tested without clobbering each other's runs. */ export function workflowHome(): string { const dir = path.join(os.homedir(), ".pi", "better-workflows"); fs.mkdirSync(path.join(dir, "scripts"), { recursive: true }); fs.mkdirSync(path.join(dir, "runs"), { recursive: true }); return dir; } /** Monotonic-ish run id without Date.now()/random in the workflow realm (this is host code, so it's fine). */ let counter = 0; export function generateRunId(): string { counter = (counter + 1) % 0xffff; return `run-${Date.now().toString(36)}-${counter.toString(36)}`; } /** * Persist the script to disk and return its path — faithful to CC, which writes * every invocation's script under the session dir and returns the path so the * model can edit it and re-invoke with {scriptPath}. */ export function persistScript(name: string, runId: string, script: string): string { const safe = name.replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 50) || "workflow"; const file = path.join(workflowHome(), "scripts", `${safe}-${runId}.js`); fs.writeFileSync(file, script, "utf8"); return file; } export interface JournalEntry { index: number; hash: string; result: unknown; /** * The subagent's full tool-use/tool-result chain in call order. Optional so * resume journals written before this field landed still load cleanly. * Typed loosely here to keep persistence free of an agent.ts import cycle * — see ToolCallRecord in agent.ts for the canonical shape. */ toolCalls?: Array<{ id: string; name: string; arguments: Record; result?: string; resultTruncated?: boolean; isError?: boolean; }>; } export interface PersistedRun { runId: string; name: string; scriptPath?: string; status: "running" | "complete" | "error" | "stopped"; args?: unknown; journal: JournalEntry[]; result?: unknown; error?: string; startedAt: number; finishedAt?: number; } function runFile(runId: string): string { return path.join(workflowHome(), "runs", `${runId}.json`); } export function saveRun(state: PersistedRun): void { try { fs.writeFileSync(runFile(state.runId), JSON.stringify(state, null, 2), "utf8"); } catch { // persistence is best-effort; a failed write must never kill a run } } export function loadRun(runId: string): PersistedRun | undefined { try { return JSON.parse(fs.readFileSync(runFile(runId), "utf8")) as PersistedRun; } catch { return undefined; } } /** Build a resume lookup (callIndex -> {hash, result}) from a persisted run's journal. */ export function journalMap(run: PersistedRun | undefined): Map { const map = new Map(); if (!run) return map; for (const e of run.journal) map.set(e.index, { hash: e.hash, result: e.result }); return map; } /** Deterministic, dependency-free string hash (djb2) for resume cache keys. */ export function hashString(s: string): string { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36); }