/// import * as path from 'node:path' import type { ExtensionAPI } from '@earendil-works/pi-coding-agent' import { normalizeTimestampMs } from '@firstpick/pi-utils' import { isToolCallEventType } from '@earendil-works/pi-coding-agent' import { adoptUntrackedDiskEdits, applyTreeFileCheckout, findOutsideDeletions, findUntrackedDiskEdits, plannedTreeFileChanges, loadState, pruneStalePins, readTextFile, rebuildStackFromHistory, resolveToolPath, saveState, type SessionEntryLike, type SessionUndoState, type UndoOperation } from './reverse-last-state.ts' import { captureTurnLeafId, currentSessionLeafId, ensureLegacyHistory, recordCapturedOperation, snapshotChangedFiles, undoRecentOperations, undoSelectedOperations } from './reverse-last-capture.ts' import { registerReverseLastCommands } from './reverse-last-commands.ts' const EXTENSION_ID = 'reverse-last' function toolCallIdFromEvent(event: { toolCallId?: unknown }): string | undefined { return typeof event.toolCallId === 'string' ? event.toolCallId : undefined } function reverseOperations(operations: UndoOperation[]): UndoOperation[] { const reversed: UndoOperation[] = [] for (let index = operations.length - 1; index >= 0; index--) { const operation = operations[index] if (operation) { reversed.push(operation) } } return reversed } export default function reverseLastExtension(pi: ExtensionAPI) { const pending = new Map() let activeSessionId = '' let turnLeafId: string | undefined let state: SessionUndoState = { stack: [], history: [] } const reloadForSession = ( sessionId: string, sessionManager?: { getBranch?: () => SessionEntryLike[]; getEntries(): SessionEntryLike[] } ) => { activeSessionId = sessionId pending.clear() turnLeafId = undefined state = loadState(sessionId) if (sessionManager) { rebuildStackFromHistory(state, sessionManager) } pruneStalePins(state) } pi.on('session_start', async (_event, ctx) => { reloadForSession(ctx.sessionManager.getSessionId(), ctx.sessionManager) }) pi.on('turn_end', async () => { turnLeafId = undefined }) pi.on('session_tree', async (event, ctx) => { const sessionId = ctx.sessionManager.getSessionId() const sameSession = Boolean(activeSessionId) && sessionId === activeSessionId const previousStack = sameSession ? [...state.stack] : [] if (sameSession) { rebuildStackFromHistory(state, ctx.sessionManager) pruneStalePins(state) } else { reloadForSession(sessionId, ctx.sessionManager) } if (sameSession && pending.size === 0) { const deletionPaths = findOutsideDeletions(previousStack, state.stack, state.pins) let allowOutsideDeletions = false if (deletionPaths.length > 0) { const preview = deletionPaths .slice(0, 8) .map((absPath) => path.relative(ctx.cwd, absPath) || absPath) const extra = deletionPaths.length > 8 ? `\n...and ${deletionPaths.length - 8} more` : '' const recreate = await ctx.ui.confirm( 'Files deleted outside pi', `These captured files were deleted outside pi (shell/editor). Recreate them for this jump?\n${preview.join('\n')}${extra}\n\nAnswer No to leave them deleted; pi will not ask again until they change.` ) if (recreate) { allowOutsideDeletions = true } else { const pins = state.pins ?? [] state.pins = [...pins, ...deletionPaths.filter((absPath) => !pins.includes(absPath))] ctx.ui.notify( `Left ${deletionPaths.length} file${deletionPaths.length === 1 ? '' : 's'} deleted. pi will not ask about ${deletionPaths.length === 1 ? 'it' : 'them'} again until ${deletionPaths.length === 1 ? 'it' : 'they'} change.\n${preview.join('\n')}${extra}`, 'info' ) } } const dirty = findUntrackedDiskEdits(previousStack, state.stack, state.pins) let skipUntracked = true if (dirty.length > 0) { const preview = dirty .slice(0, 8) .map((absPath) => path.relative(ctx.cwd, absPath) || absPath) const extra = dirty.length > 8 ? `\n...and ${dirty.length - 8} more` : '' const overwrite = await ctx.ui.confirm( 'Untracked file edits', `These files changed outside captured write/edit snapshots. Overwrite them for /tree?\n${preview.join('\n')}${extra}` ) skipUntracked = !overwrite if (skipUntracked) { adoptUntrackedDiskEdits(state.stack, dirty) adoptUntrackedDiskEdits( state.history.flatMap((record) => record.type === 'capture' ? [record.operation] : [] ), dirty ) ctx.ui.notify( `Kept current files and updated snapshots for ${dirty.length} file${dirty.length === 1 ? '' : 's'}. /tree will not ask about them again unless they change.\n${preview.join('\n')}${extra}`, 'info' ) } } const planned = plannedTreeFileChanges(previousStack, state.stack, { skipUntracked, pins: state.pins, allowOutsideDeletions }) if (planned.length > 0) { const preview = planned.slice(0, 8).map((action) => { const label = path.relative(ctx.cwd, action.absPath) || action.absPath if (action.kind === 'delete') { return `${label} (delete)` } return readTextFile(action.absPath) === undefined ? `${label} (recreate)` : label }) const extra = planned.length > 8 ? `\n...and ${planned.length - 8} more` : '' const restoreFiles = await ctx.ui.confirm( 'Restore files?', `Restore captured files to this /tree node, or keep the current files and only move the conversation?\n${preview.join('\n')}${extra}` ) if (!restoreFiles) { ctx.ui.notify('Jumped in the conversation only. Files were left unchanged.', 'info') if (activeSessionId) { saveState(activeSessionId, state) } return } } const restored = applyTreeFileCheckout(previousStack, state.stack, ctx.cwd, { skipUntracked, pins: state.pins, allowOutsideDeletions }) if (restored.length > 0) { const suffix = restored.length > 12 ? `\n...and ${restored.length - 12} more` : '' ctx.ui.notify( `Restored ${restored.length} file${restored.length === 1 ? '' : 's'} for /tree.\n${restored.slice(0, 12).join('\n')}${suffix}`, 'info' ) pi.appendEntry(EXTENSION_ID, { type: 'tree-restore', at: Date.now(), oldLeafId: event.oldLeafId, newLeafId: event.newLeafId, count: restored.length, files: restored }) } } else if (sameSession && pending.size > 0) { ctx.ui.notify( 'Skipped file restore on /tree because a file change is still being captured', 'warning' ) } if (activeSessionId) { saveState(activeSessionId, state) } }) pi.on('session_shutdown', async () => { if (!activeSessionId) { return } saveState(activeSessionId, state) }) pi.on('tool_call', async (event, ctx) => { const sessionId = ctx.sessionManager.getSessionId() if (!activeSessionId) { reloadForSession(sessionId, ctx.sessionManager) } if (sessionId !== activeSessionId) { reloadForSession(sessionId, ctx.sessionManager) } if (isToolCallEventType('write', event)) { const absPath = resolveToolPath(ctx.cwd, String(event.input.path)) const before = readTextFile(absPath) pending.set(event.toolCallId, { id: event.toolCallId, toolName: 'write', timestamp: Date.now(), files: [ { absPath, previousExists: before !== undefined, previousContent: before } ] }) return } if (isToolCallEventType('edit', event)) { const absPath = resolveToolPath(ctx.cwd, String(event.input.path)) const before = readTextFile(absPath) pending.set(event.toolCallId, { id: event.toolCallId, toolName: 'edit', timestamp: Date.now(), files: [ { absPath, previousExists: before !== undefined, previousContent: before } ] }) } }) pi.on('tool_result', async (event, ctx) => { const toolCallId = toolCallIdFromEvent(event) if (!toolCallId) { return } const op = pending.get(toolCallId) if (!op) { return } pending.delete(toolCallId) if (event.isError) { return } const sessionId = ctx.sessionManager.getSessionId() if (!activeSessionId || sessionId !== activeSessionId) { reloadForSession(sessionId, ctx.sessionManager) } const changedFiles = snapshotChangedFiles(op.files) if (changedFiles.length === 0) { return } ensureLegacyHistory(state) turnLeafId = captureTurnLeafId(ctx.sessionManager, turnLeafId) const operation = recordCapturedOperation( state, { ...op, files: changedFiles, timestamp: Date.now() }, turnLeafId ) pi.appendEntry(EXTENSION_ID, { type: 'capture', at: Date.now(), operationId: operation.id, toolName: op.toolName, count: changedFiles.length, files: changedFiles.map((f) => path.relative(ctx.cwd, f.absPath) || f.absPath) }) if (activeSessionId) { saveState(activeSessionId, state) } }) const ensureCurrentSessionState = (ctx: { sessionManager: { getSessionId(): string getBranch?: () => SessionEntryLike[] getEntries(): SessionEntryLike[] } }) => { const sessionId = ctx.sessionManager.getSessionId() if (!activeSessionId || sessionId !== activeSessionId) { reloadForSession(sessionId, ctx.sessionManager) } else { rebuildStackFromHistory(state, ctx.sessionManager) } } const appendUndoEntry = (operationIds: string[], files: string[]) => { pi.appendEntry(EXTENSION_ID, { type: 'undo', at: Date.now(), operationIds, count: files.length, files }) } const lastUndoOperationIds = (): string[] => { const last = state.history.at(-1) return last?.type === 'undo' ? last.operationIds : [] } const undoChanges = ( ctx: { cwd: string sessionManager: { getSessionId(): string getLeafId(): string | null getBranch?: () => SessionEntryLike[] getEntries(): SessionEntryLike[] } }, count: number ) => { ensureCurrentSessionState(ctx) const result = undoRecentOperations( state, ctx.cwd, count, currentSessionLeafId(ctx.sessionManager) ) if (result.steps > 0) { appendUndoEntry(lastUndoOperationIds(), result.restored) } if (activeSessionId) { saveState(activeSessionId, state) } return result } const lastUserMessageTimestamp = (ctx: { sessionManager: { getEntries(): SessionEntryLike[]; getBranch?: () => SessionEntryLike[] } }): number | null => { const entries = ctx.sessionManager.getBranch ? ctx.sessionManager.getBranch() : ctx.sessionManager.getEntries() for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] if (entry?.type !== 'message') { continue } if (entry?.message?.role !== 'user') { continue } const ts = entry?.message?.timestamp if (typeof ts === 'number' && Number.isFinite(ts)) { return normalizeTimestampMs(ts) } const parsed = Date.parse(String(entry?.timestamp ?? '')) if (Number.isFinite(parsed)) { return parsed } } return null } const getSelectionCandidates = (ctx: { sessionManager: { getEntries(): SessionEntryLike[] getSessionId(): string getBranch?: () => SessionEntryLike[] } }) => { ensureCurrentSessionState(ctx) if (state.stack.length === 0) { return [] as UndoOperation[] } const lastUserTs = lastUserMessageTimestamp(ctx) if (lastUserTs === null) { return reverseOperations(state.stack) } const filtered = state.stack.filter((op) => op.timestamp >= lastUserTs) if (filtered.length === 0) { return reverseOperations(state.stack) } return reverseOperations(filtered) } const undoSelectedChanges = ( ctx: { cwd: string sessionManager: { getSessionId(): string getLeafId(): string | null getBranch?: () => SessionEntryLike[] getEntries(): SessionEntryLike[] } }, selectedIds: Set ) => { ensureCurrentSessionState(ctx) const result = undoSelectedOperations( state, ctx.cwd, selectedIds, currentSessionLeafId(ctx.sessionManager) ) if (result.steps > 0) { appendUndoEntry(lastUndoOperationIds(), result.restored) } if (activeSessionId) { saveState(activeSessionId, state) } return result } registerReverseLastCommands(pi, { pending, getSelectionCandidates, undoChanges, undoSelectedChanges }) }