The agent changed five files, and the result is wrong. You want the files back as they were before that turn, and you do not want a reset in your own git repository. snapshots() saves the files of the workspace at each model step, in a git repository of its own. /undo puts the files back and removes the turn from the conversation.
import { homedir } from 'node:os'
import { join } from 'node:path'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { permissions } from '@tanstack/ai-harness/plugins'
import { snapshots, workspaceTools } from '@tanstack/ai-harness/plugins/coding'
import { openaiText } from '@tanstack/ai-openai'
import { memoryPersistence } from '@tanstack/ai-persistence'
const root = process.cwd()
const dataDir = join(homedir(), '.acme-coder', 'snapshots')
const history = snapshots({ root, dataDir })
const coder = defineHarness({
name: 'acme/coder',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [permissions({ root }), workspaceTools({ root }), history],
})
const persistence = memoryPersistence()
const { log } = persistence.stores
const host = createHarnessHost({ persistence })
const session = await host.open(coder, { threadId: 'fix-login' })In a terminal app, the user types /undo and /redo. See Build a coding agent.
When someone else edits the same files:
In your own UI, add expose: { commands: ['undo', 'redo'] } to defineHarness. A client can run only the commands that the harness exposes. Then call client.runCommand({ name: 'undo' }). The result text arrives as a harness.command.result event.
Each model step that changed files leaves a step: { from, to, files, toolCallIds }. from and to are the ids of two snapshots. files lists the changed paths, and toolCallIds lists the tool calls of the step. history.diff(from, to) gives the changed files and a unified diff.
The steps are tanstack/snapshots:step records in the session log:
import { logIdOf } from '@tanstack/ai-harness'
await session.send('Fix the login form.')
for (const { record } of await log.read({ logId: logIdOf({ logId: 'fix-login' }) })) {
if (record.type !== 'tanstack/snapshots:step') continue
if (typeof record.from !== 'string' || typeof record.to !== 'string') continue
const { files, patch } = await history.diff(record.from, record.to)
console.log(files.join(', '))
console.log(patch)
}The file tools send a FileChanged event for each file that they change, with its diff. A bash command sends none. So at the end of each model step, snapshots() sends FileChanged for each file of the step that no tool reported. Listen for all of them in a view:
import { FileChanged } from '@tanstack/ai-harness/plugins/coding'
import { createSessionView } from '@tanstack/ai-harness/view'
const view = createSessionView(session)
view.on(FileChanged, (file) => console.log(`${file.change} ${file.path}`))When the model runs pnpm format with bash, the handler prints one line for each file that the command changed, for example updated /work/repo/src/app.ts. A tool like bash does not report the files that it changes. If the step has one call of such a tool, each event has the toolCallId of that call. See also the known limits of these events.
/undo, /redo, a revert, and unrevert() also send FileChanged for each file that they change, with no toolCallId. So a diff pane, or a copy of the files that uses these events, stays correct.
/undo goes back one turn. To go back more turns, revert the session to a message of the transcript:
const messages = await session.transcript()
const firstAnswer = messages.find((message) => message.role === 'assistant')
if (firstAnswer?.id) await session.revert({ messageId: firstAnswer.id })
// Bring back the files and the messages.
await session.unrevert()In your own UI, call client.revert({ messageId }) and client.unrevert(). The client needs 'undo' in expose.commands. More about reverts: Go back to an earlier message.
When the coding tools run in a sandbox, give snapshots() the same backend:
import { snapshots } from '@tanstack/ai-harness/plugins/coding'
import { sandboxWorkspaceBackend } from '@tanstack/ai-sandbox/harness'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
const sandbox = await dockerSandbox({ image: 'node:22' }).create({})
export const sandboxHistory = snapshots({
root: '/workspace',
dataDir: '/tmp/snapshots',
backend: sandboxWorkspaceBackend(sandbox),
})Git then runs in the sandbox, and dataDir is a path in the sandbox. The sandbox image needs git.
The undo point and the steps are in the session log. So when another server takes the thread, /undo, /redo, and a revert work there too, if that server reaches the same snapshot repository:
host.sessions.delete removes the snapshot repository of the thread for you. This needs two things:
In other cases, call purgeSnapshots with the options of the plugin and the thread:
import { purgeSnapshots } from '@tanstack/ai-harness/plugins/coding'
await purgeSnapshots({ root, dataDir }, { tenantId: 'acme', threadId: 'fix-login' })For the FileChanged events of the plugin: