/** * Durable notice delivery: the notice is written to disk before any send * attempt and deleted only after delivery is confirmed accepted. A crash * between write and confirm leaves the file for the next session_start replay. * Pattern: pi-subagents result files (write -> deliver -> delete on accept), * minus the watcher/index machinery: our files are written by this process * only, so a startup scan is enough. */ import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; export interface DurableNotice { /** Job id, also the file stem. */ id: string; /** The formatted notice text, resent verbatim on replay. */ content: string; createdAt: number; } export class DurableStore { private readonly dir: string; constructor(dir: string) { this.dir = dir; } /** Atomic write (tmp + rename): a crash mid-write leaves no corrupt .json. */ write(notice: DurableNotice): void { mkdirSync(this.dir, { recursive: true }); const tmp = join(this.dir, `.${notice.id}.tmp`); writeFileSync(tmp, JSON.stringify(notice), "utf8"); renameSync(tmp, join(this.dir, `${notice.id}.json`)); } /** Leftover notices; corrupt files are skipped, not deleted (could be transient). */ list(): DurableNotice[] { let names: string[]; try { names = readdirSync(this.dir); } catch { return []; // dir never created } const notices: DurableNotice[] = []; for (const name of names) { if (!name.endsWith(".json")) continue; try { notices.push(JSON.parse(readFileSync(join(this.dir, name), "utf8")) as DurableNotice); } catch { // skip unreadable/corrupt file; leave it on disk } } return notices; } /** Confirmed delivery: remove the record. Missing file is fine (already gone). */ remove(id: string): void { try { unlinkSync(join(this.dir, `${id}.json`)); } catch { // already removed } } } /** Send replayable content; returns whether delivery was accepted. */ export type SendNotice = (content: string, wake: boolean) => boolean; /** * Replay leftover notices at session_start: steer without triggerTurn (never * yank the session open on startup), delete only what was accepted. * Returns the number of notices still pending. */ export function replayNotices(store: DurableStore, send: SendNotice): number { let remaining = 0; for (const notice of store.list()) { const accepted = send(notice.content, false); if (accepted) store.remove(notice.id); else remaining += 1; } return remaining; }