/// import * as fs from 'node:fs' import * as path from 'node:path' import { getAgentDir, resolvePathFromAgentDir } from '@firstpick/pi-utils' export type FileSnapshot = { absPath: string previousExists: boolean previousContent?: string afterExists?: boolean afterContent?: string } export type FileCheckoutAction = | { kind: 'write'; absPath: string; content: string } | { kind: 'delete'; absPath: string } type PlannedFileState = { exists: true; content: string } | { exists: false } | { unknown: true } export type UndoOperation = { id: string toolName: 'write' | 'edit' timestamp: number files: FileSnapshot[] sessionEntryId?: string } export type UndoHistoryRecord = | { type: 'capture' entryId?: string operation: UndoOperation } | { type: 'undo' entryId?: string operationIds: string[] } export type SessionUndoState = { stack: UndoOperation[] history: UndoHistoryRecord[] /** Files the user chose to leave deleted after an outside (non-pi) deletion. */ pins?: string[] } export type SessionEntryLike = { id?: string type?: string timestamp?: string | number message?: { role?: string; timestamp?: number } } export const MAX_STACK = 100 const MAX_HISTORY = MAX_STACK * 4 const STATE_DIR_NAME = 'rewind-unwind' const LEGACY_STATE_DIR_NAME = 'reverse-last' function defaultStateDir(): string { return path.join(getAgentDir(), 'state', STATE_DIR_NAME) } function legacyStateDir(): string { return path.join(getAgentDir(), 'state', LEGACY_STATE_DIR_NAME) } function getStateDir(): string { const configured = ( process.env.PI_REWIND_UNWIND_STATE_DIR ?? process.env.PI_REVERSE_LAST_STATE_DIR )?.trim() if (configured) { return path.isAbsolute(configured) ? path.normalize(configured) : resolvePathFromAgentDir(configured) } return defaultStateDir() } function getStatePath(sessionId: string): string { return path.join(getStateDir(), `${sessionId}.json`) } function getLegacyStatePath(sessionId: string): string { return path.join(legacyStateDir(), `${sessionId}.json`) } function parseState(raw: string): SessionUndoState { let parsed: SessionUndoState try { parsed = JSON.parse(raw) as SessionUndoState } catch { return { stack: [], history: [] } } if (!parsed || !Array.isArray(parsed.stack)) { return { stack: [], history: [] } } const stack = parsed.stack.filter((item) => item && Array.isArray(item.files)).slice(-MAX_STACK) const history = Array.isArray(parsed.history) ? parsed.history .filter( (item) => item && ((item.type === 'capture' && item.operation && Array.isArray(item.operation.files)) || (item.type === 'undo' && Array.isArray(item.operationIds))) ) .slice(-MAX_HISTORY) : [] const pins = Array.isArray(parsed.pins) ? parsed.pins.filter((item): item is string => typeof item === 'string') : [] return { stack, history, pins } } export function ensureDir(dir: string): void { fs.mkdirSync(dir, { recursive: true }) } export function readTextFile(absPath: string): string | undefined { try { return fs.readFileSync(absPath, 'utf8') } catch { return undefined } } export function resolveToolPath(cwd: string, inputPath: string): string { return path.isAbsolute(inputPath) ? path.normalize(inputPath) : path.resolve(cwd, inputPath) } export function loadState(sessionId: string): SessionUndoState { try { return parseState(fs.readFileSync(getStatePath(sessionId), 'utf8')) } catch { try { return parseState(fs.readFileSync(getLegacyStatePath(sessionId), 'utf8')) } catch { return { stack: [], history: [] } } } } export function saveState(sessionId: string, state: SessionUndoState): void { ensureDir(getStateDir()) const statePath = getStatePath(sessionId) const tempPath = `${statePath}.tmp` fs.writeFileSync( tempPath, JSON.stringify({ stack: state.stack.slice(-MAX_STACK), history: state.history.slice(-MAX_HISTORY), pins: state.pins ?? [] }), 'utf8' ) try { fs.renameSync(tempPath, statePath) } catch { fs.rmSync(statePath, { force: true }) fs.renameSync(tempPath, statePath) } } function getBranchEntryIds(sessionManager: { getBranch?: () => SessionEntryLike[] getEntries(): SessionEntryLike[] }): Set { const entries = sessionManager.getBranch ? sessionManager.getBranch() : sessionManager.getEntries() return new Set( entries.map((entry) => entry.id).filter((id): id is string => typeof id === 'string') ) } export function rebuildStackFromHistory( state: SessionUndoState, sessionManager: { getBranch?: () => SessionEntryLike[]; getEntries(): SessionEntryLike[] } ): void { if (state.history.length === 0) { return } const branchEntryIds = getBranchEntryIds(sessionManager) const stack: UndoOperation[] = [] for (const record of state.history) { const recordEntryId = record.entryId ?? (record.type === 'capture' ? record.operation.sessionEntryId : undefined) if (recordEntryId && !branchEntryIds.has(recordEntryId)) { continue } if (record.type === 'capture') { stack.push(record.operation) continue } const undone = new Set(record.operationIds) for (let index = stack.length - 1; index >= 0; index--) { if (undone.has(stack[index]?.id)) { stack.splice(index, 1) } } } state.stack = stack.slice(-MAX_STACK) } function operationsTouchingPath(stack: UndoOperation[], absPath: string): UndoOperation[] { return stack.filter((operation) => operation.files.some((file) => file.absPath === absPath)) } function previousFileState(file: FileSnapshot): PlannedFileState { if (file.previousExists) { return { exists: true, content: file.previousContent ?? '' } } return { exists: false } } function afterFileState(file: FileSnapshot): PlannedFileState { if (file.afterExists === undefined && file.afterContent === undefined) { return { unknown: true } } if (file.afterExists === false) { return { exists: false } } return { exists: true, content: file.afterContent ?? '' } } function desiredFileState( previousStack: UndoOperation[], nextStack: UndoOperation[], absPath: string ): PlannedFileState | undefined { const nextOps = operationsTouchingPath(nextStack, absPath) if (nextOps.length > 0) { let state: PlannedFileState | undefined for (const operation of nextOps) { const file = operation.files.find((item) => item.absPath === absPath) if (!file) { continue } if (!state) { state = previousFileState(file) } const after = afterFileState(file) if ('unknown' in after) { return { unknown: true } } state = after } return state } const previousOps = operationsTouchingPath(previousStack, absPath) const first = previousOps[0]?.files.find((item) => item.absPath === absPath) return first ? previousFileState(first) : undefined } export function planTreeFileCheckout( previousStack: UndoOperation[], nextStack: UndoOperation[] ): FileCheckoutAction[] { const paths = new Set() for (const operation of [...previousStack, ...nextStack]) { for (const file of operation.files) { paths.add(file.absPath) } } const actions: FileCheckoutAction[] = [] for (const absPath of paths) { const desired = desiredFileState(previousStack, nextStack, absPath) if (!desired || 'unknown' in desired) { continue } if (desired.exists) { actions.push({ kind: 'write', absPath, content: desired.content }) } else { actions.push({ kind: 'delete', absPath }) } } return actions } function fileMatchesState(absPath: string, state: PlannedFileState): boolean { const current = readTextFile(absPath) if ('unknown' in state) { return true } if (state.exists) { return current === state.content } return current === undefined } function knownFileStates(file: FileSnapshot): PlannedFileState[] { const states: PlannedFileState[] = [previousFileState(file)] const after = afterFileState(file) if (!('unknown' in after)) { states.push(after) } return states } export function isUntrackedDiskEdit( absPath: string, previousStack: UndoOperation[], nextStack: UndoOperation[] ): boolean { const known = new Map() for (const operation of [...previousStack, ...nextStack]) { const file = operation.files.find((item) => item.absPath === absPath) if (!file) { continue } for (const state of knownFileStates(file)) { const key = 'exists' in state && state.exists ? `write:${state.content}` : 'delete' known.set(key, state) } } if (known.size === 0) { return false } return ![...known.values()].some((state) => fileMatchesState(absPath, state)) } /** A pin is honored only while the file is still absent from disk. */ export function isPinnedDeleted(pins: string[] | undefined, absPath: string): boolean { return (pins ?? []).includes(absPath) && readTextFile(absPath) === undefined } /** * The file is gone from disk, but a capture still on either branch claims it * should exist (afterExists). The plugin itself only deletes files while popping * the operation that created them, so this means something outside pi deleted it. */ export function isOutsideDeletion( absPath: string, previousStack: UndoOperation[], nextStack: UndoOperation[], pins?: string[] ): boolean { if (isPinnedDeleted(pins, absPath)) { return false } if (readTextFile(absPath) !== undefined) { return false } return [...previousStack, ...nextStack].some((operation) => operation.files.some((file) => file.absPath === absPath && file.afterExists === true) ) } /** Files deleted outside pi that this jump's plan would silently recreate. */ export function findOutsideDeletions( previousStack: UndoOperation[], nextStack: UndoOperation[], pins?: string[] ): string[] { const deletions: string[] = [] for (const action of planTreeFileCheckout(previousStack, nextStack)) { if (action.kind !== 'write') { continue } if (isOutsideDeletion(action.absPath, previousStack, nextStack, pins)) { deletions.push(action.absPath) } } return deletions } /** Drop pins for files that reappeared on disk (the deletion was undone). */ export function pruneStalePins(state: SessionUndoState): void { if (!state.pins) { return } state.pins = state.pins.filter((absPath) => readTextFile(absPath) === undefined) } export function findUntrackedDiskEdits( previousStack: UndoOperation[], nextStack: UndoOperation[], pins?: string[] ): string[] { const paths = new Set() for (const operation of [...previousStack, ...nextStack]) { for (const file of operation.files) { paths.add(file.absPath) } } return [...paths].filter( (absPath) => !isPinnedDeleted(pins, absPath) && !isOutsideDeletion(absPath, previousStack, nextStack, pins) && isUntrackedDiskEdit(absPath, previousStack, nextStack) ) } function adoptDiskIntoSnapshot(file: FileSnapshot, current: string | undefined): void { if (current === undefined) { file.previousExists = false file.previousContent = undefined file.afterExists = false file.afterContent = undefined return } file.previousExists = true file.previousContent = current file.afterExists = true file.afterContent = current } export function adoptUntrackedDiskEdits(operations: UndoOperation[], absPaths: string[]): number { const targets = new Set(absPaths) let updated = 0 for (const operation of operations) { for (const file of operation.files) { if (!targets.has(file.absPath)) { continue } adoptDiskIntoSnapshot(file, readTextFile(file.absPath)) updated += 1 } } return updated } export function plannedTreeFileChanges( previousStack: UndoOperation[], nextStack: UndoOperation[], options?: { skipUntracked?: boolean; pins?: string[]; allowOutsideDeletions?: boolean } ): FileCheckoutAction[] { const skipUntracked = options?.skipUntracked ?? true const allowOutsideDeletions = options?.allowOutsideDeletions ?? false return planTreeFileCheckout(previousStack, nextStack).filter((action) => { if (isPinnedDeleted(options?.pins, action.absPath)) { return false } const outside = isOutsideDeletion(action.absPath, previousStack, nextStack, options?.pins) if (outside && !allowOutsideDeletions) { return false } if (skipUntracked && !outside && isUntrackedDiskEdit(action.absPath, previousStack, nextStack)) { return false } if (action.kind === 'write') { return readTextFile(action.absPath) !== action.content } return fs.existsSync(action.absPath) }) } export function applyTreeFileCheckout( previousStack: UndoOperation[], nextStack: UndoOperation[], cwd: string, options?: { skipUntracked?: boolean; pins?: string[]; allowOutsideDeletions?: boolean } ): string[] { const restored: string[] = [] for (const action of plannedTreeFileChanges(previousStack, nextStack, options)) { const label = path.relative(cwd, action.absPath) || action.absPath if (action.kind === 'write') { const current = readTextFile(action.absPath) if (current === action.content) { continue } ensureDir(path.dirname(action.absPath)) fs.writeFileSync(action.absPath, action.content, 'utf8') restored.push(label) continue } if (!fs.existsSync(action.absPath)) { continue } fs.unlinkSync(action.absPath) restored.push(`${label} (deleted)`) } return restored }