SLOPSHOPPER

changes

See what Claude changed, turn by turn. Keep it or send it back. A per-turn timeline with each file's diff and keep/revert.

newpanebandspinnerguardcommand
v0.2.0MITupdated 2026-10-15candygoogy/notary/plugins/changes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · changes
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ This conversation is also open in another │ ┃ Claude Code window. Its history shows here, ● changes: changes: save failed: undefined is not an object (evaluati │ ┃ but changes made here are not saved to it. ⏺ Read(src/auth.ts) │ ┃ ── Earlier · Jan 1, 00:00 ── ⎿ Read 6 lines │ ┃ ▾ background agent main · 3 files ⏺ Update(src/auth.ts) │ ┃ ● all steps ⎿ Added 2 lines, removed 1 line │ ┃ ○ Edit · 1 file ⏺ Bash(bun test) │ ┃ ○ Write · 1 file ⎿ 3 pass, 1 fail │ ┃ ○ Write · 1 file │ ┃ • src/auth.ts [ Keep ] [ Revert ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ loading diff… │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ • src/audit.ts │ ┃ no net change › /changes │ ┃ loading diff… ⎿ changes: Changes pane opened. │ ┃ │ ┃ • src/cache.ts │ ┃ no net change │ ┃ loading diff… │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
This conversation is also open in another Claude Code window. Its history shows here, but changes made here are not saved to it. ── Earlier · Jan 1, 00:00 ── ▾ background agent main · 3 files ● all steps ○ Edit · 1 file ○ Write · 1 file ○ Write · 1 file • src/auth.ts [ Keep ] [ Revert ] loading diff… • src/audit.ts no net change loading diff… • src/cache.ts no net change loading diff…
README

Changes

See what Claude changed, turn by turn. Keep it or send it back.

A Claude Code mod with one pane, Changes. It shows what Claude changed in each turn of the session, including through subagents and shell commands, with a diff per file and Keep / Revert per file. This page is the reference. For the suite, see the Notary README.

/plugin install changes --marketplace candygoogy/notary

Needs Claude Code 2.1.287 or later; on an older version, Changes shows one notice and stays off. Git is optional. Inside a git repository, Changes also finds files that shell commands changed.

The Changes pane

/changes opens the pane. In the fullscreen layout it docks beside the transcript; otherwise it opens inline. If the window is too narrow for it, the pane waits, and /changes or the band's Open says so and why, once.

  • Timeline, newest turn first. Each row is a turn: its number, its prompt and how many files it changed. Selecting a turn expands its steps:
  • the tool calls that changed files;
  • the subagents it started, with their own calls;
  • changed during this turn (shell or you) for changes no tool call explains.

Select a step to see only its files, or all steps for the whole turn.

  • Diffs, one per file, with the lines added and removed, and Keep and Revert buttons. Notes appear where they apply:
  • line endings only;
  • binary file;
  • too large to show;
  • edited by two agents at once;
  • changed outside Claude between edits; reverting this turn also undoes that.

Edits made between turns are yours. They aren't listed, and the pane doesn't undo them.

After a turn that changed code, a line above the prompt says Turn #12 changed 3 files [Open] [Dismiss]. Open shows only while the pane is shut. The line goes away at your next prompt, or right away with Dismiss, and comes back after the next turn that changes code. Turns that only touched docs, notes, logs, lockfiles or build folders don't show it. Those changes are still listed in the pane.

Keep and Revert

Keep marks a file's change as reviewed and folds it away. Undo keep brings it back. Nothing on disk changes.

Revert puts the file back the way it was before that turn. It takes two presses:

  1. The first press checks the file as it is now and writes nothing.
  2. If the revert can go ahead, the button becomes Confirm revert (#12) for 5 seconds. It names every turn the revert covers. Press it to revert. A confirmation pressed too late does nothing and says so.

Changes only writes when the file on disk is exactly what Claude left. After writing, it reads the file back to make sure. When it can't be sure, it refuses and says why:

You seeWhat it meansWhat you can do
was also changed by #14. Revert those turns together, or none.A later (or, through a subagent, earlier) turn changed the same file.Revert with #14 reverts both turns' changes to the file.
was changed outside Claude between #12 and #14Something else changed the file between Claude's edits, maybe you.Revert anyway also undoes that change.
Can't confirm nothing else changed it between #12 and #14The file was committed in between, so its versions can't be compared.Revert anyway.
The history … was trimmedThe session is long, and older edits to the file are no longer kept.Revert anyway also undoes what's no longer listed.
changed since Claude's last edit, so it was left aloneThe file on disk isn't what Claude left.Nothing to override: look at the file and decide yourself.
is too large to restore / There is no saved copyNo snapshot from before that turn (over 4 MiB, or unreadable).Restore it with git or by hand.

After a revert, Claude gets a short hidden note, for example "The user reverted src/a.ts to its state before turn #12 using the Changes pane. Re-read the file before editing it." Without it, Claude's next edit to that file would fail on text that's no longer there. The tellClaude setting turns the note off.

A revert is recorded too, as a row in the timeline. A later revert of the same file builds on it, so the history stays honest.

Revert a whole turn

Under the selected turn, Revert all (#12) reverts every file that turn changed and you haven't reverted or kept. It takes two presses too:

  1. The first press checks every file as it is now and writes nothing. If a file can't be reverted, the pane says which one and why, with the same words as the table above.
  2. The button becomes Confirm revert all (#12): write 3, skip 1 for 5 seconds. Press it to revert the files that can be.

A file that can't be reverted is skipped and never forced: Revert all has no "anyway". Where the table above offers a way on (Revert with #14, Revert anyway), that button appears on the file's own row afterwards, for that file alone. Kept files are left alone and listed as kept.

Each file is written and checked as a single revert is, and recorded before the next one. If a write can't be confirmed (a full or locked disk, say), Revert all stops there and leaves the remaining files untouched, saying which file to check. The files already reverted stay reverted and recorded.

Claude gets one hidden note listing every file reverted, not one per file.

Settings

SettingDefaultWhat it does
enabledonTurn Changes on or off.
bandonThe line above the prompt after a turn that changed code.
autoOpenoffOpen the pane by itself, once, when the first turn with code changes ends: only in the fullscreen layout, where it docks beside the transcript, and on a terminal at least 144 columns wide.
tellClaudeonThe hidden note to Claude after a revert.
trackShellonIn a git repository, find the files Bash and PowerShell commands changed.
keepHistoryDays7How many days a conversation's copies and history are kept after you last used it, so a resumed conversation can still revert. 0 deletes them at the end of each session.

Change them in the /config menu.

Resumed conversations

Resume a conversation (claude --resume, claude --continue or /resume) and the pane shows its earlier turns again, under a line Earlier · Oct 12, 14:05. Turn numbers carry on from where the conversation was, and Keep and Revert work as before.

A file that changed while no session was watching (you edited it, another conversation's Claude did, you switched branches) is not reverted: Changes only writes when the file is exactly what Claude left, the same check as always. A copy that is no longer on disk is refused before anything is written.

/clear starts a new conversation, so the pane starts empty. The old conversation's history is kept and comes back if you resume it.

If the same conversation is open in two Claude Code windows, the second shows the history but doesn't save to it, and says so. A window that crashed gives up its claim after 10 minutes.

How it works

  • Claude's own edits (Edit, Write, NotebookEdit, in the main conversation or any subagent): Changes reads the file right before and right after each one, and saves a copy.
  • Subagents belong to the turn that started them, even when they keep working after that turn ends.
  • Shell commands (inside a git repository only): Changes compares git status, and the files it lists, before and after each command.
  • Copies of files are stored once per content, under ~/.claude/plugin-data/notary-changes/<conversation>/, with the conversation's history in index.json beside them. They're kept for keepHistoryDays after the conversation was last used (see Privacy).

How it differs from /diff

Claude Code's built-in /diff shows the working tree's git diff. Changes is per turn and can undo:

  • It has Revert, with the checks above.
  • It shows the contents of new files, not just their names.
  • It sees subagent edits, and attributes them to the turn that started the subagent.
  • Outside git, it still tracks Claude's own edits. Only shell tracking needs git.

Limits

  • History is kept for keepHistoryDays. A new session and /clear start an empty pane. A resumed conversation brings its earlier turns back (see Resumed conversations). After keepHistoryDays (7 by default), or sooner if all kept conversations pass 1 GB, a conversation's history is deleted and its turns can't be reverted from the pane. Your files are untouched.
  • Per file, not per change. Revert puts the whole file back to before the turn. You can't keep one hunk and revert another.
  • Shell changes only in git. Outside a git repository, and in gitignored files, changes made by shell commands aren't seen.
  • A rename shows as a delete plus a new file.
  • A large file (over 1 MB) rewritten with the same size and timestamp can be missed.
  • Tracking a command never adds more than about 2 seconds to it. A command that changes more than 200 files, a checkout, reset, pull or commit, or a repository with more than 200 uncommitted files is shown as not tracked, with the reason, instead of being slowed down.
  • If git is too slow to answer in time, shell tracking turns off for the rest of the session, and the pane says so.
  • Reverts are yours, not Claude's. A revert from the pane happens outside Claude's tool calls, so other mods that watch Claude's edits (Receipts, for one) don't count it as an edit. Claude is told about it with a hidden note.
  • Your edits during a turn. If you edit a file in your editor while Claude is working, the change can appear as changed during this turn (shell or you).
  • No merging. If a file changed after Claude's edit, Revert refuses rather than guess.
  • Large files. Files over 4 MiB can only be reverted if they were clean in git before the turn.
  • Folders. Reverting a new file deletes the file, not the folders Claude created for it.
  • Long sessions. The timeline keeps the last 200 turns and 5,000 edits. Older turns can't be reverted.
  • Big diffs. A diff shows 400 lines, then Show all. A file whose diff is over 1 MB isn't drawn. Up to 12 files are drawn at once; select a step to narrow the list.
  • Desktop app. The first click on an unfocused pane may only focus it. Selecting a turn or a step works on that first click, but Keep, Revert and Confirm always need a real click, so a stray focus never changes a file.

Privacy

Changes runs entirely on your machine. It makes no network requests, collects no telemetry, and makes no model calls. Nothing it keeps leaves your machine.

What it keeps, and where. A copy of each file Claude changed, from before and after each change, and the conversation's history of those changes, in ~/.claude/plugin-data/notary-changes/<conversation>/ (under CLAUDE_CONFIG_DIR if you set it). The copies are of whatever Claude changed, so they can include secrets, such as a .env file Claude edited.

For how long. 7 days after you last used the conversation, so that a resumed conversation (claude --resume, --continue, /resume) can still show and revert its changes. Older ones are deleted the next time Changes starts, and all kept conversations together are held under 1 GB, oldest deleted first.

How to keep less.

  • /changes forget deletes the current conversation's copies and history now.
  • Set keepHistoryDays to 0 in /config to delete them at the end of every session, as Changes did before 0.2. Copies left by a crash are then deleted after 7 days.
Source 25 files
hooks/register.tsx 1177 lines
1import type { EngineInterface as Engine, Hook, Register, Timer } from 'claude-code'
2import { update } from 'claude-code'
3
4import { emptyIndex, statusKey, type Content, type Index } from './core/model.ts'
5import { isCodePath } from './core/noise.ts'
6import { relativeTo, shownPath } from './core/paths.ts'
7import { unexplained } from './core/sweep.ts'
8import type { RevertPlan, RevertRef } from './core/revert-plan.ts'
9import {
10  addAction,
11  addEdit,
12  addUntracked,
13  appendedSize,
14  closeAgent,
15  closeTurn,
16  fitToSize,
17  linkAgent,
18  openTurn,
19  recordRevert,
20  setStatus,
21  STATE_BUDGET_CHARS,
22  timeline,
23  turnFiles,
24  type FileChange,
25} from './core/timeline.ts'
26import { capture, keyOf, storeDirOf, type StoreBudget } from './io/blobs.ts'
27import { diffOf, type DiffView } from './io/diffs.ts'
28import { holdsLease, LEASE_RENEW_MS, loadHistory, releaseLease, renewLease, saveHistory, takeLease } from './io/history.ts'
29import { cleanBlobOf, gitBudget, repoRootOf } from './io/git.ts'
30import { platformOf, type Ports } from './io/ports.ts'
31import { planFile, planTurn, revertFile, revertTurn, type RevertOutcome, type TurnFile } from './io/restore.ts'
32import { waitingNote } from './shared/pane.ts'
33import { shellAfter, shellBefore, type ShellToken, type SnapshotCache } from './io/shell.ts'
34import { DAY_MS, overTotalCap, removeTree, staleSessions, STALE_MS, STORE_CAP_BYTES, storeUsage, touch } from './io/store.ts'
35import { bandView } from './ui/band.tsx'
36import { revertAllNotice, revertAllPlanNotice, revertNotice } from './ui/notice.ts'
37import { paneView, type RevertAsk } from './ui/pane.tsx'
38
39// Wiring only (spec §5): events in, core and io calls, state out. Observers
40// always hand back what `next` resolved to; a failure here means "this change
41// is not tracked", never a blocked or altered tool call.
42
43const MIN_VERSION = [2, 1, 287] as const
44const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit']
45const SHELL_TOOLS: readonly string[] = ['Bash', 'PowerShell']
46const AGENT_TOOLS: readonly string[] = ['Agent', 'Task']
47const TOUCH_EVERY_MS = 60_000
48// autoOpen opens the pane only as a sidebar, and only this wide (as /diff does).
49const AUTO_OPEN_COLUMNS = 144
50
51// State references: `plugin` and `key` must be literals in a const of this file.
52const INDEX = { plugin: 'changes', key: 'index' } as const
53const SESSION = { plugin: 'changes', key: 'session' } as const
54const BAND = { plugin: 'changes', key: 'band' } as const
55const VIEW = { plugin: 'changes', key: 'view' } as const
56const PAUSED = { plugin: 'changes', key: 'paused' } as const
57const INACTIVE = { plugin: 'changes', key: 'inactive' } as const
58const STORE_FULL = { plugin: 'changes', key: 'storeFull' } as const
59const AUTO_OPENED = { plugin: 'changes', key: 'autoOpened' } as const
60const SHELL_OFF = { plugin: 'changes', key: 'shellOff' } as const
61const HISTORY_NOTE = { plugin: 'changes', key: 'historyNote' } as const
62
63const PANE = { id: 'changes', title: 'Changes' } as const
64
65type Options = { enabled: boolean; band: boolean; autoOpen: boolean; tellClaude: boolean; trackShell: boolean; keepHistoryDays: number }
66// `token`: this process's claim on the conversation's lease; `writer`: whether
67// it holds the lease, so saves the history; `launchedAt`: turns that started
68// before it came from an earlier launch.
69type Session = {
70  sessionId: string
71  store: string
72  base: string
73  platform: 'windows' | 'posix'
74  root: string | null
75  cwd: string
76  token: string
77  writer: boolean
78  launchedAt: number
79}
80type Render = Parameters<Hook<'ui.render'>>
81
82// Per module copy, rebuilt after a hot reload: the turn sweep's baseline (a
83// lost one skips that turn's sweep), large-file snapshots by mtime, the last
84// touch of the store, and whether the pane opened by itself yet.
85let sweepToken: ShellToken | null = null
86const snapshotCache: SnapshotCache = new Map()
87let lastTouch = 0
88// The layout as the last drawing saw it (a render hook can't write state);
89// unknown until something draws, and then nothing opens by itself.
90let layout: { columns: number; isFullscreen?: boolean } | null = null
91// Room left for this session's snapshots, re-measured at each turn.start and
92// at the first capture after a load.
93const storeBudget: StoreBudget = { used: 0, cap: STORE_CAP_BYTES, full: false }
94let storeMeasured = false
95// A revert running from the pane: a second press while it runs is refused. A
96// reload resets it, and the confirm was disarmed before the revert began.
97let reverting = false
98// The index's JSON size as of the last full measure (-1: unknown since a
99// load), and the saves since; appends are added on, a full measure redone on
100// any other change and every FULL_MEASURE_EVERY saves.
101let indexSize = -1
102let savesSinceMeasure = 0
103const FULL_MEASURE_EVERY = 100
104const SHELL_OFF_NOTE = "git is slow here; shell changes aren't tracked this session"
105// Renews the lease while this process holds it; stopped at the session's end.
106let heartbeat: Timer | null = null
107// keepHistoryDays as this module was registered with (register sets it).
108let keepHistoryDays = 7
109const READ_ONLY_NOTE = 'This conversation is also open in another Claude Code window. Its history shows here, but changes made here are not saved to it.'
110const UNREADABLE_NOTE = 'The history saved for this conversation could not be read, so it starts empty here.'
111// Every kept history together, oldest dropped first past it.
112const TOTAL_CAP_BYTES = 1024 * 1024 * 1024
113
114export const register: Register = (on, options) => {
115  const opts: Options = {
116    enabled: options.enabled !== false,
117    band: options.band !== false,
118    autoOpen: options.autoOpen === true,
119    tellClaude: options.tellClaude !== false,
120    trackShell: options.trackShell !== false,
121    keepHistoryDays: typeof options.keepHistoryDays === 'number' && options.keepHistoryDays >= 0 ? Math.floor(options.keepHistoryDays) : 7,
122  }
123  keepHistoryDays = opts.keepHistoryDays
124
125  on('session.start', async ($, e, next) => {
126    const r = await next(e)
127    try {
128      await $.command.register({
129        name: 'changes',
130        description: 'Open the Changes pane: what Claude changed, turn by turn, with keep and revert. /changes forget deletes this conversation\'s kept history.',
131        argumentHint: '[forget]',
132      })
133    } catch (error) {
134      debug($, 'session.start', error)
135    }
136    if (!opts.enabled) return r
137    try {
138      const { base, version } = await $.session.version()
139      if (isOlder(base ?? version, MIN_VERSION)) {
140        await $.state.set(INACTIVE, true)
141        $.ui.toast(`Changes needs Claude Code ${MIN_VERSION.join('.')} or later; it is off for this session.`)
142        return r
143      }
144      await startSession($, opts)
145    } catch (error) {
146      debug($, 'session.start', error)
147    }
148    return r
149  })
150
151  on('turn.start', async ($, e, next) => {
152    const r = await next(e)
153    const session = await activeSession($, opts)
154    if (session === null) return r
155    try {
156      const at = await $.clock.now()
157      await saveIndex($, index => openTurn(index, { turnId: e.turnId, prompt: e.text, at }))
158      await $.state.set(BAND, null)
159      await touch(portsOf($), session.store, at)
160      lastTouch = at
161      storeBudget.used = await storeUsage(portsOf($), session.store)
162      storeMeasured = true
163      sweepToken = null
164      if (await shellTracked($, opts, session)) {
165        const token = await shellBefore(portsOf($), shellContext(session), snapshotCache)
166        if (token !== null && 'slow' in token) await stopShellTracking($)
167        else sweepToken = token
168      }
169    } catch (error) {
170      debug($, 'turn.start', error)
171    }
172    return r
173  })
174
175  on('tool.call', async ($, e, next) => {
176    const session = await activeSession($, opts)
177    if (session === null) return next(e)
178    if (EDIT_TOOLS.includes(e.tool)) return recordEditCall($, session, e, next)
179    if (SHELL_TOOLS.includes(e.tool) && (await shellTracked($, opts, session))) return recordShellCall($, session, e, next)
180    const r = await next(e)
181    if (AGENT_TOOLS.includes(e.tool)) {
182      try {
183        await linkSpawn($, e, r)
184      } catch (error) {
185        debug($, 'tool.call', error)
186      }
187    }
188    return r
189  }).catch(($, e, next) => next(e))
190
191  on('turn.complete', async ($, e, next) => {
192    const r = await next(e)
193    const session = await activeSession($, opts)
194    if (session === null) return r
195    try {
196      const at = await $.clock.now()
197      if (e.agentId !== undefined) {
198        const agentId = e.agentId
199        await saveIndex($, index => closeAgent(index, agentId, at))
200        return r
201      }
202      await saveIndex($, index => closeTurn(index, { turnId: e.turnId, at, reason: e.reason }))
203      await sweepTurn($, session, e.turnId)
204      await showBand($, opts, session, e.turnId)
205      await persist($)
206    } catch (error) {
207      debug($, 'turn.complete', error)
208    }
209    return r
210  })
211
212  on('prompt.submit', async ($, e, next) => {
213    const r = await next(e)
214    try {
215      await $.state.set(BAND, null)
216    } catch (error) {
217      debug($, 'prompt.submit', error)
218    }
219    return r
220  }).catch(($, e, next) => next(e))
221
222  on('session.end', async ($, e, next) => {
223    const r = await next(e)
224    // The history is kept on disk for a resume (keepHistoryDays): the end only
225    // gives the lease up and marks the folder used, within the end's short
226    // bound. With keepHistoryDays 0 the copies go, as before. A /clear or
227    // resume goes on in the same process, so the next hook starts the session
228    // it continues with (no session.start follows a /clear).
229    stopHeartbeat()
230    try {
231      const { value: session = null } = await $.state.get(SESSION)
232      if (session !== null && opts.keepHistoryDays === 0) await removeTree(portsOf($), session.platform, session.store)
233      else if (session !== null) {
234        if (session.writer) await releaseLease(portsOf($), session.store, session.token)
235        await touch(portsOf($), session.store, await $.clock.now())
236      }
237      if (e.reason === 'clear' || e.reason === 'resume') await resetHistory($)
238    } catch (error) {
239      debug($, 'session.end', error)
240    }
241    return r
242  })
243
244  // `/changes` opens the pane: its answer says when the pane waits undrawn,
245  // and why. `/changes forget` deletes this conversation's kept history.
246  on('command.run', { command: 'changes' }, async ($, e) => {
247    if (e.args.trim().toLowerCase() === 'forget') return { text: await forget($) }
248    return { text: (await openPane($)) ?? 'Changes pane opened.' }
249  })
250
251  // The band's [Open] shows only while the pane is shut: closing it (the
252  // person's close mark or key) draws the band again.
253  on('ui.close', { id: 'changes' }, async ($, e, next) => {
254    const r = await next(e)
255    $.ui.invalidate('ui.render')
256    return r
257  }).catch(($, e, next) => next(e))
258
259  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => renderBand($, opts, e, next))
260
261  // The prompt hint draws on every surface: it tells autoOpen the layout.
262  on('ui.render', { component: 'PromptHint' }, (_$, e, next) => {
263    noteLayout(e.viewport)
264    return next(e)
265  })
266
267  on('ui.render', { component: 'Pane', requestId: 'changes' }, ($, e, next) => renderPane($, opts, e, next))
268
269  on('ui.focus', { requestId: 'changes' }, async ($, e, next) => {
270    const r = await next(e)
271    try {
272      if (e.origin.kind === 'person' && e.element !== undefined) await focusSelects($, e.element)
273    } catch (error) {
274      debug($, 'ui.focus', error)
275    }
276    return r
277  }).catch(($, e, next) => next(e))
278}
279
280// The loader follows `$` only within this file, so io/ gets closures over it.
281function portsOf($: Engine): Ports {
282  return {
283    fs: {
284      read: ((path: string, options?: { as?: 'text' | 'bytes' }) => $.fs.read(path, options as never)) as Ports['fs']['read'],
285      write: (path, text) => $.fs.write(path, text),
286      exists: path => $.fs.exists(path),
287      stat: (path, options) => $.fs.stat(path, options),
288      list: path => $.fs.list(path),
289    },
290    process: { run: (argv, init) => $.process.run(argv, init) },
291    clock: { now: () => $.clock.now() },
292  }
293}
294
295// Where snapshots go and which repository the session runs in; old session
296// folders a crash left behind are removed.
297async function startSession($: Engine, opts: Options): Promise<void> {
298  const io = portsOf($)
299  // A session already set up (a hot reload) goes on while its snapshots do.
300  // One whose folder is gone, or none at all, starts a fresh history: an
301  // index is never kept pointing at snapshots that no longer exist.
302  const { value: existing = null } = await $.state.get(SESSION)
303  if (existing !== null && (await io.fs.exists(existing.store))) {
304    const now = await $.clock.now()
305    await touch(io, existing.store, now)
306    lastTouch = now
307    storeBudget.used = await storeUsage(io, existing.store)
308    storeMeasured = true
309    startHeartbeat($, opts)
310    return
311  }
312  const sessionId = (await $.session.id()).trim()
313  // No id, no folder of our own: nothing is tracked (and nothing deleted).
314  if (sessionId === '') return
315  await resetHistory($)
316  const env = {
317    CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
318    USERPROFILE: await $.env.get('USERPROFILE'),
319    HOME: await $.env.get('HOME'),
320  }
321  const store = storeDirOf(env, sessionId)
322  const base = store.slice(0, store.lastIndexOf('/'))
323  const platform = platformOf(await $.env.get('OS'))
324  const cwd = (await $.session.root()).replace(/\\/g, '/')
325  const root = opts.trackShell ? await repoRootOf(io, cwd) : null
326  const now = await $.clock.now()
327  const token = `${sessionId}:${now}:${Math.random().toString(36).slice(2)}`
328  // A conversation resumed (same id) finds its history where it was kept. One
329  // process writes it: the lease holder; another shows it read-only.
330  let writer = false
331  let note: string | null = null
332  if (opts.keepHistoryDays > 0) {
333    writer = await takeLease(io, store, token, now)
334    const loaded = await loadHistory(io, store, sessionId)
335    if (loaded !== null && 'index' in loaded) await saveIndex($, () => loaded.index)
336    else if (loaded !== null) note = UNREADABLE_NOTE
337    if (!writer) note = note === null ? READ_ONLY_NOTE : `${note} ${READ_ONLY_NOTE}`
338  }
339  await $.state.set(SESSION, { sessionId, store, base, platform, root, cwd, token, writer, launchedAt: now })
340  await $.state.set(HISTORY_NOTE, note)
341  await touch(io, store, now)
342  lastTouch = now
343  storeBudget.used = await storeUsage(io, store)
344  storeMeasured = true
345  // Histories unused for keepHistoryDays (crash leftovers: a week) go, then
346  // the oldest while all of them together pass the total cap.
347  const keepFor = opts.keepHistoryDays > 0 ? opts.keepHistoryDays * DAY_MS : STALE_MS
348  for (const dir of await staleSessions(io, base, store, now, keepFor)) await removeTree(io, platform, dir)
349  for (const dir of await overTotalCap(io, base, store, TOTAL_CAP_BYTES)) await removeTree(io, platform, dir)
350  startHeartbeat($, opts)
351}
352
353// Saves the history for a resume, while this process holds the lease. A
354// lease taken over meanwhile (this process was idle past LEASE_STALE_MS and
355// another took it) is seen here, before anything is written: from then on
356// this process shows the history read-only.
357async function persist($: Engine): Promise<void> {
358  if (keepHistoryDays === 0) return
359  try {
360    const { value: session = null } = await $.state.get(SESSION)
361    if (session === null || !session.writer) return
362    const io = portsOf($)
363    if (!(await holdsLease(io, session.store, session.token))) {
364      await loseLease($, session)
365      return
366    }
367    const { value: index = emptyIndex() } = await $.state.get(INDEX)
368    if (!(await saveHistory(io, session.platform, session.store, session.sessionId, index as Index, await $.clock.now()))) {
369      debug($, 'history', new Error('the history could not be saved for a resume'))
370    }
371  } catch (error) {
372    debug($, 'history', error)
373  }
374}
375
376async function loseLease($: Engine, session: Session): Promise<void> {
377  await $.state.set(SESSION, { ...session, writer: false })
378  await $.state.set(HISTORY_NOTE, READ_ONLY_NOTE)
379}
380
381// The heartbeat keeps a live holder's lease fresh, so an idle window keeps it
382// and only a crashed one's goes stale.
383function startHeartbeat($: Engine, opts: Options): void {
384  if (heartbeat !== null || opts.keepHistoryDays === 0) return
385  heartbeat = $.clock.every(LEASE_RENEW_MS, () => void beat($))
386}
387
388async function beat($: Engine): Promise<void> {
389  try {
390    const { value: session = null } = await $.state.get(SESSION)
391    if (session === null || !session.writer) return
392    if (!(await renewLease(portsOf($), session.store, session.token, await $.clock.now()))) await loseLease($, session)
393  } catch (error) {
394    debug($, 'lease', error)
395  }
396}
397
398function stopHeartbeat(): void {
399  heartbeat?.cancel()
400  heartbeat = null
401}
402
403// /changes forget: this conversation's kept history and copies are deleted
404// now; the next hook starts it again, empty. Refused while another window
405// holds the lease: it would be deleting under that window.
406async function forget($: Engine): Promise<string> {
407  const { value: session = null } = await $.state.get(SESSION)
408  if (session === null) return 'There is no history to forget.'
409  if (!session.writer) return 'This conversation is open in another Claude Code window. Forget it there.'
410  stopHeartbeat()
411  const removed = await removeTree(portsOf($), session.platform, session.store)
412  await resetHistory($)
413  return removed ? "Changes deleted this conversation's history and its saved copies." : 'Changes could not delete the saved copies. Try again, or delete the folder by hand.'
414}
415
416// Shell writes are tracked in a repository, with the setting on, until git
417// proves slow here.
418async function shellTracked($: Engine, opts: Options, session: Session): Promise<boolean> {
419  if (!opts.trackShell || session.root === null) return false
420  const { value: off = false } = await $.state.get(SHELL_OFF)
421  return !off
422}
423
424// A git call was killed at its timeout: stop shell tracking for the session
425// rather than slow every Bash call; the pane says so.
426async function stopShellTracking($: Engine): Promise<void> {
427  const { value: off = false } = await $.state.get(SHELL_OFF)
428  if (!off) await $.state.set(SHELL_OFF, true)
429}
430
431// After a hot reload the snapshot budget is measured again before the first
432// capture counts against it.
433async function measureStore($: Engine, session: Session): Promise<void> {
434  if (storeMeasured) return
435  storeBudget.used = await storeUsage(portsOf($), session.store)
436  storeMeasured = true
437}
438
439async function activeSession($: Engine, opts: Options): Promise<Session | null> {
440  if (!opts.enabled) return null
441  const { value: inactive = false } = await $.state.get(INACTIVE)
442  if (inactive) return null
443  const { value: session = null } = await $.state.get(SESSION)
444  if (session !== null) return session
445  // No session: after a /clear or a resume (no session.start follows), or
446  // state the host reset. The first hook sets up a fresh one.
447  await startSession($, opts)
448  const { value: started = null } = await $.state.get(SESSION)
449  return started
450}
451
452// An empty history: no index, view or band, no session folder, nothing
453// cached. The next hook starts a session again.
454async function resetHistory($: Engine): Promise<void> {
455  await $.state.set(INDEX, emptyIndex())
456  await $.state.set(VIEW, NO_VIEW)
457  await $.state.set(BAND, null)
458  await $.state.set(SESSION, null)
459  await $.state.set(PAUSED, null)
460  await $.state.set(STORE_FULL, false)
461  await $.state.set(HISTORY_NOTE, null)
462  indexSize = -1
463  savesSinceMeasure = 0
464  sweepToken = null
465  snapshotCache.clear()
466  diffCache.clear()
467  storeBudget.used = 0
468  storeBudget.full = false
469  storeMeasured = false
470}
471
472// Writes the index through `update` (parallel subagents' calls retry on a
473// version miss), trimmed to fit `$.state`. A change that can't fit is not
474// written and tracking shows as paused; the pause lifts once a write fits.
475// Answers whether the change was saved.
476async function saveIndex($: Engine, change: (index: Index) => Index): Promise<boolean> {
477  let fits = true
478  let size: number | null = null
479  let quick = false
480  try {
481    await update($, INDEX, (index = emptyIndex()) => {
482      const prev = index as Index
483      const next = change(prev)
484      // Appends are added onto the size known; anything else, an unknown
485      // size, a size near the budget, or every FULL_MEASURE_EVERY saves, is
486      // measured in full and trimmed to fit.
487      const added = indexSize >= 0 && savesSinceMeasure < FULL_MEASURE_EVERY ? appendedSize(prev, next) : null
488      if (added !== null && indexSize + added <= STATE_BUDGET_CHARS * 0.9) {
489        fits = true
490        quick = true
491        size = indexSize + added
492        return next
493      }
494      const fitted = fitToSize(next)
495      fits = fitted.fits
496      quick = false
497      const kept = fitted.fits ? fitted.index : prev
498      size = JSON.stringify(kept).length
499      return kept
500    })
501    if (size !== null) {
502      indexSize = size
503      savesSinceMeasure = quick ? savesSinceMeasure + 1 : 0
504    }
505  } catch (error) {
506    fits = false
507    indexSize = -1
508    debug($, 'save', error)
509  }
510  const { value: paused = null } = await $.state.get(PAUSED)
511  const wanted = fits ? null : 'Tracking is paused: the history is too large to save.'
512  if (paused !== wanted) await $.state.set(PAUSED, wanted)
513  return fits
514}
515
516function shellContext(session: Session) {
517  return { root: session.root!, store: session.store, budget: storeBudget }
518}
519
520// Once the snapshot budget is spent, the pane says so.
521async function noteStoreFull($: Engine): Promise<void> {
522  if (!storeBudget.full) return
523  const { value: full = false } = await $.state.get(STORE_FULL)
524  if (!full) await $.state.set(STORE_FULL, true)
525}
526
527type ToolEvent = Parameters<Hook<'tool.call'>>[1]
528type ToolNext = Parameters<Hook<'tool.call'>>[2]
529
530// Edit, Write, NotebookEdit: the file before and after, around the call.
531async function recordEditCall($: Engine, session: Session, e: ToolEvent, next: ToolNext) {
532  const input = e as unknown as { file_path?: string; notebook_path?: string; agentId?: string; tool_use_id: string; tool: string }
533  const path = input.file_path ?? input.notebook_path
534  if (path === undefined) return next(e)
535  const io = portsOf($)
536  let key = path
537  let before: Content = { unreadable: 'io' }
538  const startedAt = await $.clock.now()
539  try {
540    await measureStore($, session)
541    key = await keyOf(io, path, session.platform)
542    before = await capture(io, session.store, path, { budget: storeBudget })
543    before = await gitBeforeIfLarge(io, session, path, before)
544  } catch (error) {
545    debug($, 'tool.call', error)
546  }
547  const r = await next(e)
548  try {
549    const result = (r as { result?: { staged?: boolean } }).result
550    if ('deny' in r && r.deny !== undefined) return r
551    if ((r as { isError?: boolean }).isError === true || result?.staged === true) return r
552    const after = await capture(io, session.store, path, { budget: storeBudget })
553    const at = await $.clock.now()
554    await saveIndex($, index =>
555      addEdit(index, { toolUseId: input.tool_use_id, tool: input.tool, via: 'tool', path, key, before, after, startedAt, at, ...agentOf(input) }),
556    )
557    await noteStoreFull($)
558    await touchStore($, session, at)
559  } catch (error) {
560    debug($, 'tool.call', error)
561  }
562  return r
563}
564
565// Bash, PowerShell: what git says the command changed.
566async function recordShellCall($: Engine, session: Session, e: ToolEvent, next: ToolNext) {
567  const input = e as unknown as { agentId?: string; tool_use_id: string; tool: string }
568  const io = portsOf($)
569  const ctx = shellContext(session)
570  const startedAt = await $.clock.now()
571  let token: ShellToken | null = null
572  try {
573    await measureStore($, session)
574    token = await shellBefore(io, ctx, snapshotCache)
575  } catch (error) {
576    debug($, 'tool.call', error)
577  }
578  const r = await next(e)
579  if (token === null || ('deny' in r && r.deny !== undefined)) return r
580  try {
581    const result = await shellAfter(io, ctx, token)
582    const at = await $.clock.now()
583    if ('untracked' in result) {
584      // Too big, a HEAD move, or git too slow: said on the turn, nothing
585      // recorded; a slow git stops shell tracking for the session.
586      if (result.slow) await stopShellTracking($)
587      const reason = result.slow ? SHELL_OFF_NOTE : result.untracked
588      await saveIndex($, index => addUntracked(index, { toolUseId: input.tool_use_id, tool: input.tool, reason, at, ...agentOf(input) }))
589      return r
590    }
591    if (result.changes.length === 0) return r
592    const keyed = await Promise.all(result.changes.map(async c => ({ ...c, key: await keyOf(io, c.path, session.platform) })))
593    await saveIndex($, index =>
594      keyed.reduce(
595        (acc, c) => addEdit(acc, { toolUseId: input.tool_use_id, tool: input.tool, via: 'shell', path: c.path, key: c.key, before: c.before, after: c.after, startedAt, at, ...agentOf(input) }),
596        index,
597      ),
598    )
599    await noteStoreFull($)
600    await touchStore($, session, at)
601  } catch (error) {
602    debug($, 'tool.call', error)
603  }
604  return r
605}
606
607// An Agent call launched a subagent: it belongs to the spawning turn (spec §5.1).
608async function linkSpawn($: Engine, e: ToolEvent, r: unknown): Promise<void> {
609  const result = (r as { result?: { agentId?: unknown } }).result
610  if (typeof result?.agentId !== 'string') return
611  const agentId = result.agentId
612  const input = e as unknown as { tool_use_id: string; agentId?: string; description?: string }
613  await saveIndex($, index =>
614    linkAgent(index, {
615      agentId,
616      parentToolUseId: input.tool_use_id,
617      ...(input.agentId !== undefined ? { spawnedBy: input.agentId } : {}),
618      ...(typeof input.description === 'string' ? { label: input.description } : {}),
619    }),
620  )
621}
622
623// Changes during the turn that no recorded call explains (spec §5.3 step 4).
624async function sweepTurn($: Engine, session: Session, turnId: string): Promise<void> {
625  const token = sweepToken
626  sweepToken = null
627  if (token === null || session.root === null) return
628  const io = portsOf($)
629  const result = await shellAfter(io, shellContext(session), token)
630  const at = await $.clock.now()
631  const { value: current = emptyIndex() } = await $.state.get(INDEX)
632  if ('untracked' in result) {
633    if (result.slow) await stopShellTracking($)
634    // A call of the turn already said why (a commit, a big checkout): the
635    // sweep adds nothing more to say.
636    if (!current.untracked.some(u => u.turnId === turnId)) {
637      const reason = result.slow ? SHELL_OFF_NOTE : result.untracked
638      await saveIndex($, index => addUntracked(index, { turnId, toolUseId: `sweep:${turnId}`, tool: 'turn sweep', reason, at }))
639    }
640    return
641  }
642  if (result.changes.length === 0) return
643  const keyed = await Promise.all(result.changes.map(async c => ({ ...c, key: await keyOf(io, c.path, session.platform) })))
644  const left = unexplained(current as Index, keyed)
645  if (left.length === 0) return
646  await saveIndex($, index =>
647    left.reduce(
648      (acc, c) => addEdit(acc, { turnId, toolUseId: `sweep:${turnId}`, tool: 'unknown', via: 'unattributed', path: c.path, key: c.key, before: c.before, after: c.after, startedAt: at, at }),
649      index,
650    ),
651  )
652}
653
654// The band speaks only for a turn whose recorded calls changed code: docs,
655// notes and changes only the turn sweep found stay quiet.
656async function showBand($: Engine, opts: Options, session: Session, turnId: string): Promise<void> {
657  const { value: index = emptyIndex() } = await $.state.get(INDEX)
658  const bases = session.root === null ? [session.cwd] : [session.root, session.cwd]
659  const files = turnFiles(index as Index, turnId).filter(
660    f => !f.isNoop && f.edits.some(e => e.via !== 'unattributed') && isCodePath(f.path, bases, session.platform === 'windows'),
661  )
662  const turn = index.turns.find(t => t.turnId === turnId)
663  if (files.length === 0 || turn === undefined) return
664  if (opts.band) await $.state.set(BAND, { turnId, ordinal: turn.ordinal, files: files.length })
665  // Once per session (in state, so a hot reload doesn't open it again), and
666  // only where the pane docks beside the transcript in a wide enough terminal.
667  if (opts.autoOpen && layout?.isFullscreen === true && layout.columns >= AUTO_OPEN_COLUMNS) {
668    const { value: opened = false } = await $.state.get(AUTO_OPENED)
669    if (!opened) {
670      await $.state.set(AUTO_OPENED, true)
671      await openPane($)
672    }
673  }
674}
675
676// A file `$.fs` can't read (over 4 MiB) that was clean in git: git holds
677// its before, so a revert can still come from there (spec §5.2).
678async function gitBeforeIfLarge(io: Ports, session: Session, path: string, before: Content): Promise<Content> {
679  if (!('unreadable' in before) || before.unreadable !== 'too-large' || session.root === null) return before
680  const rel = relativeTo(session.root, path, session.platform === 'windows')
681  if (rel === undefined) return before
682  const blob = await cleanBlobOf(io, session.root, rel)
683  return blob === null ? before : { git: blob }
684}
685
686async function touchStore($: Engine, session: Session, now: number): Promise<void> {
687  if (now - lastTouch < TOUCH_EVERY_MS) return
688  lastTouch = now
689  await touch(portsOf($), session.store, now)
690}
691
692// Keep, undo keep and revert, as the pane's buttons call them.
693async function keepFile($: Engine, turnId: string, key: string, path: string, kept: boolean): Promise<void> {
694  const at = await $.clock.now()
695  await saveIndex($, index => addAction(setStatus(index, [{ turnId, key }], kept ? 'kept' : 'pending'), { kind: kept ? 'keep' : 'undo-keep', turnIds: [turnId], key, path, at }))
696  await persist($)
697}
698
699async function revertFromPane(
700  $: Engine,
701  opts: Pick<Options, 'tellClaude'>,
702  ref: { turnId: string; key: string },
703  mode: 'turn' | 'turnAndLater',
704  acceptOutsideChanges: boolean,
705): Promise<{ outcome: RevertOutcome; saved: boolean } | null> {
706  const { value: session = null } = await $.state.get(SESSION)
707  if (session === null) return null
708  const { value: stored = emptyIndex() } = await $.state.get(INDEX)
709  const index = stored as Index
710  const last = [...index.edits].reverse().find(e => e.key === ref.key)
711  if (last === undefined) return null
712  const rel = session.root === null ? undefined : relativeTo(session.root, last.path, session.platform === 'windows')
713  const ctx = { platform: session.platform, store: session.store, ...(session.root !== null ? { root: session.root } : {}) }
714  const outcome = await revertFile(portsOf($), ctx, index, ref, mode, last.path, rel, { acceptOutsideChanges })
715  if (!outcome.ok) return { outcome, saved: true }
716  // The file changed back: whatever happens to the history, Claude is told.
717  const at = await $.clock.now()
718  const saved = await saveIndex($, current => recordRevert(current, { key: ref.key, path: last.path, before: outcome.before, after: outcome.after, refs: outcome.refs, at }))
719  if (opts.tellClaude) await tellReverted($, index, [last.path], outcome.refs)
720  await persist($)
721  return { outcome, saved }
722}
723
724// One hidden note to Claude for the files a revert changed back, however many.
725async function tellReverted($: Engine, index: Index, paths: string[], refs: RevertRef[]): Promise<void> {
726  try {
727    // The history starts fresh at every /clear, so its turn numbers are
728    // the conversation's own; a background agent's bucket has none.
729    const turnIds = [...new Set(refs.map(r => r.turnId))]
730    const turns = turnIds
731      .map(id => index.turns.find(t => t.turnId === id))
732      .filter(t => t !== undefined && t.kind === 'turn')
733      .map(t => `#${t!.ordinal}`)
734    const before = turns.length === turnIds.length && turns.length > 0 ? `turn ${turns.join(', ')}` : 'an earlier turn'
735    const text =
736      paths.length === 1
737        ? `The user reverted ${paths[0]} to its state before ${before} using the Changes pane. Re-read the file before editing it.`
738        : `The user reverted these files to their state before ${before} using the Changes pane: ${paths.join(', ')}. Re-read them before editing them.`
739    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
740  } catch (error) {
741    debug($, 'revert note', error)
742  }
743}
744
745function noteLayout(viewport: { columns: number; isFullscreen?: boolean } | undefined): void {
746  if (viewport !== undefined) layout = { columns: viewport.columns, ...(viewport.isFullscreen !== undefined ? { isFullscreen: viewport.isFullscreen } : {}) }
747}
748
749async function renderBand($: Engine, opts: Options, e: Render[1], next: Render[2]) {
750  noteLayout(e.viewport)
751  if (!opts.band || e.component !== 'AbovePrompt' || e.props.hasSurvey) return next(e)
752  const { value: band = null } = await $.state.get(BAND)
753  if (band === null) return next(e)
754  const elements = $.ui.resolve(e)
755  const mine = bandView(
756    elements,
757    band,
758    (await paneIsUp($)) ? null : () => void openOnTurn($, band.turnId),
759    () => void $.state.set(BAND, null),
760  )
761  // Other mods' bands draw beneath ours, never hidden by it, while there is
762  // room for both (Receipts composes the same way).
763  if (e.props.maxRows < 2) return mine
764  const { Box } = elements
765  const below = await next(e)
766  return (
767    <Box key="changes-above-prompt" flexDirection="column">
768      {mine}
769      {below}
770    </Box>
771  )
772}
773
774// The band's [Open]: the pane opens first, straight from the press, then
775// selects the turn. A pane that waits undrawn says why, once, in a toast.
776async function openOnTurn($: Engine, turnId: string): Promise<void> {
777  const note = await openPane($)
778  if (note !== null) $.ui.toast(note)
779  await update($, VIEW, (v = NO_VIEW) => ({ ...v, turnId, node: null, confirm: null, offer: null, confirmAll: null }))
780}
781
782// Opens the pane, then draws the band again without its [Open]. Answers what
783// to tell the person when the pane waits undrawn (waitingNote), else null.
784async function openPane($: Engine): Promise<string | null> {
785  const opened = await $.ui.open({ id: PANE.id, title: PANE.title })
786  $.ui.invalidate('ui.render')
787  return waitingNote(PANE.title, opened)
788}
789
790// Whether the pane is open and drawn, by the engine's record: one waiting
791// undrawn (a narrow terminal) still needs the band's [Open]. A tab behind
792// another pane counts as up: no event says when tabs switch.
793async function paneIsUp($: Engine): Promise<boolean> {
794  try {
795    return (await $.ui.panes()).some(pane => pane.id === PANE.id && pane.isPlaced)
796  } catch (error) {
797    debug($, 'panes', error)
798    return false
799  }
800}
801
802async function renderPane($: Engine, opts: Options, e: Render[1], next: Render[2]) {
803  if (e.component !== 'Pane' || e.requestId !== PANE.id) return next(e)
804  const { value: stored = emptyIndex() } = await $.state.get(INDEX)
805  const { value: session = null } = await $.state.get(SESSION)
806  const { value: view = NO_VIEW } = await $.state.get(VIEW)
807  const { value: paused = null } = await $.state.get(PAUSED)
808  const { value: storeFull = false } = await $.state.get(STORE_FULL)
809  const index = stored as Index
810  const rows = timeline(index)
811  const selected = view.turnId ?? rows.find(r => r.kind === 'turn' && r.files.length > 0)?.turn.turnId ?? null
812  const files = selected === null ? [] : turnFiles(index, selected, view.node ?? undefined)
813  // A draw only reads diffs already computed: the rest show as loading, and
814  // one fetch runs off the draw, then draws again.
815  const { diffs, missing } = cachedDiffs(files, view.expanded)
816  // Revert all is offered while the selected turn has a file still pending.
817  const pending = selected === null ? 0 : pendingFiles(index, selected).length
818  if (session !== null && missing.length > 0 && !fetchingDiffs) {
819    fetchingDiffs = true
820    $.clock.after(0, () => void fetchDiffs($, session, missing))
821  }
822  const { value: shellOff = false } = await $.state.get(SHELL_OFF)
823  const { value: historyNote = null } = await $.state.get(HISTORY_NOTE)
824  const notes = [
825    paused,
826    storeFull ? 'Snapshots are paused: this session saved 500 MB of copies. New changes are listed but cannot be reverted.' : null,
827    shellOff ? `Shell commands: ${SHELL_OFF_NOTE}.` : null,
828    historyNote,
829  ].filter((n): n is string => n !== null)
830  return paneView($.ui.resolve(e), {
831    rows,
832    selected,
833    node: view.node,
834    files,
835    diffs,
836    expanded: view.expanded,
837    confirm: view.confirm,
838    offer: view.offer,
839    offers: view.offers,
840    confirmAll: view.confirmAll,
841    canRevertAll: pending > 0,
842    notice: view.notice,
843    paused: notes.length > 0 ? notes.join(' ') : null,
844    cwd: session?.cwd ?? '',
845    launchedAt: session?.launchedAt ?? 0,
846    isWindows: session?.platform === 'windows',
847    onSelect: turnId => void selectTurn($, turnId),
848    onNode: node => void selectNode($, node),
849    onKeep: file => void keepFile($, file.turnId, file.key, file.path, file.status !== 'kept'),
850    onRevert: file => void armRevert($, { turnId: file.turnId, key: file.key, path: file.path }, 'turn', false),
851    onConfirm: file => void confirmRevert($, opts, file),
852    onOffer: () => void armOffer($),
853    onOfferFile: ask => void armFileOffer($, ask),
854    onRevertAll: turnId => void armRevertAll($, turnId),
855    onConfirmAll: turnId => void confirmRevertAll($, opts, turnId),
856    onShowAll: file => void update($, VIEW, (v = NO_VIEW) => ({ ...v, expanded: [...new Set([...v.expanded, statusKey(file.turnId, file.key)])] })),
857  })
858}
859
860const NO_VIEW = { turnId: null, node: null, confirm: null, offer: null, notice: null, expanded: [] as string[], confirmAll: null, offers: [] as RevertAsk[] }
861const MAX_DIFF_FILES = 12
862const MAX_DIFF_CHARS = 80_000
863const CONFIRM_MS = 5_000
864
865// Diffs by (before, after, all rows?), across draws; dropped on reload.
866const diffCache = new Map<string, DiffView>()
867// One diff fetch at a time, each within FETCH_BUDGET_MS of git.
868let fetchingDiffs = false
869const FETCH_BUDGET_MS = 5_000
870const LOADING: DiffView = { kind: 'note', text: 'loading diff…' }
871
872type DiffWanted = { id: string; file: FileChange; all: boolean }
873
874function diffId(file: FileChange, all: boolean): string {
875  return `${contentId(file.before)}|${contentId(file.after)}|${all ? 'all' : 'cut'}`
876}
877
878// What a draw can show now, from the cache alone, and what is still missing.
879function cachedDiffs(files: FileChange[], expanded: readonly string[]): { diffs: Record<string, DiffView>; missing: DiffWanted[] } {
880  const diffs: Record<string, DiffView> = {}
881  const missing: DiffWanted[] = []
882  let chars = 0
883  for (const [i, file] of files.entries()) {
884    if (i >= MAX_DIFF_FILES || chars > MAX_DIFF_CHARS) {
885      diffs[file.key] = { kind: 'note', text: 'diff not drawn here: select a step above to narrow the list' }
886      continue
887    }
888    const all = expanded.includes(statusKey(file.turnId, file.key))
889    const id = diffId(file, all)
890    const view = diffCache.get(id)
891    if (view === undefined) {
892      missing.push({ id, file, all })
893      diffs[file.key] = LOADING
894      continue
895    }
896    diffs[file.key] = view
897    if (view.kind === 'diff') chars += view.hunks.length
898  }
899  return { diffs, missing }
900}
901
902// Off the draw: compute what was missing, within one git budget, then draw
903// again. A file whose git was too slow is cached as such, never retried in a
904// loop; one cut short by the budget is fetched on the next round.
905async function fetchDiffs($: Engine, session: Session, wanted: DiffWanted[]): Promise<void> {
906  try {
907    const io = portsOf($)
908    const ctx = { store: session.store, root: session.root, platform: session.platform }
909    const budget = gitBudget(await $.clock.now(), FETCH_BUDGET_MS)
910    for (const want of wanted) {
911      if (budget.over) break
912      const view = await diffOf(io, ctx, want.file, want.all, budget)
913      if (budget.over && !budget.timedOut) break
914      if (diffCache.size > 200) diffCache.clear()
915      diffCache.set(want.id, budget.timedOut && view.kind === 'note' ? { kind: 'note', text: 'diff unavailable: git is slow here' } : view)
916      if (budget.timedOut) break
917    }
918  } catch (error) {
919    debug($, 'diff', error)
920  } finally {
921    fetchingDiffs = false
922    $.ui.invalidate('ui.render')
923  }
924}
925
926function contentId(content: Content): string {
927  if ('hash' in content) return content.hash
928  if ('git' in content) return `git:${content.git.oid}`
929  return 'absent' in content ? 'absent' : `unreadable:${content.unreadable}`
930}
931
932async function selectTurn($: Engine, turnId: string): Promise<void> {
933  await update($, VIEW, (v = NO_VIEW) => (v.turnId === turnId ? v : { ...v, turnId, node: null, confirm: null, offer: null, confirmAll: null }))
934}
935
936async function selectNode($: Engine, node: string | null): Promise<void> {
937  await update($, VIEW, (v = NO_VIEW) => (v.node === node ? v : { ...v, node, confirm: null }))
938}
939
940// The first press of Revert: plan it against the file as it is now. A plan
941// that would land arms the confirmation, naming every turn it reverts; a
942// refusal says why and, where one exists, offers the way on.
943async function armRevert($: Engine, ask: { turnId: string; key: string; path: string }, mode: 'turn' | 'turnAndLater', accept: boolean): Promise<void> {
944  const { value: session = null } = await $.state.get(SESSION)
945  if (session === null) return
946  const { value: stored = emptyIndex() } = await $.state.get(INDEX)
947  const index = stored as Index
948  const current = await capture(portsOf($), session.store, ask.path, { store: false })
949  const plan = await planFile(portsOf($), turnContext(session), index, { turnId: ask.turnId, key: ask.key }, mode, current, { acceptOutsideChanges: accept })
950  const name = turnNamer(index)
951  const shown = shownPath(session.cwd, ask.path, session.platform === 'windows')
952  const now = await $.clock.now()
953  const { value: view = NO_VIEW } = await $.state.get(VIEW)
954  if (plan.ok) {
955    // With a step selected the pane shows that step's diff, but a revert
956    // undoes the whole turn's change to the file: the button says so.
957    const turns = plan.refs.map(r => name(r.turnId)).join(', ')
958    const label = view.node !== null ? `Confirm revert of the whole turn (${turns})` : `Confirm revert (${turns})`
959    const notice = plan.widenedTo ? `This also reverts ${plan.widenedTo.map(name).join(', ')}: an agent it started edited ${shown} later.` : null
960    await update($, VIEW, (v = NO_VIEW) => ({ ...v, confirm: { ...ask, mode, accept, label, until: now + CONFIRM_MS }, offer: null, notice: notice ?? v.notice }))
961    return
962  }
963  const notice = revertNotice(plan, shown, name)
964  const offer = offerFor(plan, ask, mode, accept, name)
965  await update($, VIEW, (v = NO_VIEW) => ({ ...v, confirm: null, offer, notice }))
966}
967
968// The way on that a refusal offers, if any: the later turns together, or, past
969// a link that can't be confirmed, anyway. Always one file at a time.
970function offerFor(
971  plan: Exclude<RevertPlan, { ok: true }>,
972  ask: { turnId: string; key: string; path: string },
973  mode: 'turn' | 'turnAndLater',
974  accept: boolean,
975  name: (turnId: string) => string,
976): RevertAsk | null {
977  const file = { turnId: ask.turnId, key: ask.key, path: ask.path }
978  if (plan.reason === 'changed-since') return { ...file, mode: 'turnAndLater', accept, label: `Revert with ${plan.otherTurns.map(name).join(', ')}` }
979  const isLink = plan.reason === 'user-changed-between' || plan.reason === 'unverifiable-link' || plan.reason === 'history-trimmed'
980  return isLink ? { ...file, mode, accept: true, label: 'Revert anyway' } : null
981}
982
983// A file's own offer after Revert all, pressed: plan it again and arm its confirm.
984async function armFileOffer($: Engine, ask: RevertAsk): Promise<void> {
985  await update($, VIEW, (v = NO_VIEW) => ({ ...v, offers: v.offers.filter(o => o.turnId !== ask.turnId || o.key !== ask.key) }))
986  await armRevert($, ask, ask.mode, ask.accept)
987}
988
989// The turn's files Revert all acts on: changed, not reverted, not kept.
990function pendingFiles(index: Index, turnId: string): FileChange[] {
991  return turnFiles(index, turnId).filter(f => !f.isNoop && f.status === 'pending')
992}
993
994function keptFiles(index: Index, turnId: string): FileChange[] {
995  return turnFiles(index, turnId).filter(f => !f.isNoop && f.status === 'kept')
996}
997
998function turnFilesOf(session: Session, files: FileChange[]): TurnFile[] {
999  return files.map(f => {
1000    const rel = session.root === null ? undefined : relativeTo(session.root, f.path, session.platform === 'windows')
1001    return { turnId: f.turnId, key: f.key, path: f.path, ...(rel !== undefined ? { rel } : {}) }
1002  })
1003}
1004
1005function turnContext(session: Session) {
1006  return { platform: session.platform, store: session.store, ...(session.root !== null ? { root: session.root } : {}) }
1007}
1008
1009// The first press of Revert all: plan every pending file against the disk as
1010// it is now, write nothing. The confirm says how many files it writes and
1011// skips; the notice says why each skipped one is.
1012async function armRevertAll($: Engine, turnId: string): Promise<void> {
1013  const { value: session = null } = await $.state.get(SESSION)
1014  if (session === null) return
1015  const { value: stored = emptyIndex() } = await $.state.get(INDEX)
1016  const index = stored as Index
1017  const name = turnNamer(index)
1018  const shown = (p: string) => shownPath(session.cwd, p, session.platform === 'windows')
1019  const plans = await planTurn(portsOf($), turnContext(session), index, turnFilesOf(session, pendingFiles(index, turnId)))
1020  const writes = plans.filter(p => p.plan.ok).length
1021  const skipped = [
1022    ...plans.flatMap(p => (p.plan.ok ? [] : [{ path: shown(p.file.path), why: p.plan as RevertOutcome }])),
1023    ...keptFiles(index, turnId).map(f => ({ path: shown(f.path), why: 'kept' as const })),
1024  ]
1025  const notice = revertAllPlanNotice({ turn: name(turnId), skipped, writes }, name)
1026  const now = await $.clock.now()
1027  const label = `Confirm revert all (${name(turnId)}): write ${writes}, skip ${skipped.length}`
1028  await update($, VIEW, (v = NO_VIEW) => ({
1029    ...v,
1030    confirm: null,
1031    offer: null,
1032    confirmAll: writes > 0 ? { turnId, label, until: now + CONFIRM_MS } : null,
1033    notice: notice === '' ? null : notice,
1034  }))
1035}
1036
1037// The second press: the revertible files are written one by one, each saved
1038// as a single revert is (recordRevert) before the next; a refusal skips its
1039// file and offers its own way on, on its row; a write that did not land stops
1040// the run (io/restore.ts revertTurn). Claude gets one note for all the files.
1041async function confirmRevertAll($: Engine, opts: Options, turnId: string): Promise<void> {
1042  const { value: view = NO_VIEW } = await $.state.get(VIEW)
1043  const ask = view.confirmAll
1044  if (ask === null || ask.turnId !== turnId || reverting) return
1045  reverting = true
1046  let notice: string
1047  let offers: RevertAsk[] = []
1048  try {
1049    const isLate = (await $.clock.now()) > ask.until
1050    await update($, VIEW, (v = NO_VIEW) => ({ ...v, confirm: null, offer: null, confirmAll: null, offers: [], notice: isLate ? v.notice : 'Reverting…' }))
1051    const { value: session = null } = await $.state.get(SESSION)
1052    if (isLate) notice = 'The confirmation timed out: press Revert all again.'
1053    else if (session === null) notice = 'Nothing to revert: this session keeps no history.'
1054    else ({ notice, offers } = await revertAllFromPane($, opts, session, turnId))
1055  } catch (error) {
1056    debug($, 'revert all', error)
1057    notice = 'Revert all stopped with an error: check the files before going on.'
1058  } finally {
1059    reverting = false
1060  }
1061  await update($, VIEW, (v = NO_VIEW) => ({ ...v, confirmAll: null, notice, offers }))
1062}
1063
1064async function revertAllFromPane($: Engine, opts: Pick<Options, 'tellClaude'>, session: Session, turnId: string): Promise<{ notice: string; offers: RevertAsk[] }> {
1065  const { value: stored = emptyIndex() } = await $.state.get(INDEX)
1066  const index = stored as Index
1067  const name = turnNamer(index)
1068  const shown = (p: string) => shownPath(session.cwd, p, session.platform === 'windows')
1069  let saved = true
1070  const result = await revertTurn(portsOf($), turnContext(session), index, turnFilesOf(session, pendingFiles(index, turnId)), async (file, outcome) => {
1071    const at = await $.clock.now()
1072    const ok = await saveIndex($, current => recordRevert(current, { key: file.key, path: file.path, before: outcome.before, after: outcome.after, refs: outcome.refs, at }))
1073    saved = saved && ok
1074    const { value: now = emptyIndex() } = await $.state.get(INDEX)
1075    return now as Index
1076  })
1077  let notice = revertAllNotice(
1078    {
1079      turn: name(turnId),
1080      reverted: result.reverted.map(r => shown(r.file.path)),
1081      skipped: [
1082        ...result.skipped.map(r => ({ path: shown(r.file.path), why: r.outcome as RevertOutcome })),
1083        ...keptFiles(index, turnId).map(f => ({ path: shown(f.path), why: 'kept' as const })),
1084      ],
1085      stopped: result.stopped === null ? null : shown(result.stopped.path),
1086      notTried: result.notTried.length,
1087    },
1088    name,
1089  )
1090  if (result.reverted.length > 0 && !saved) notice += " Some of that history couldn't be saved."
1091  const offers = result.skipped.flatMap(r => {
1092    if (r.outcome.reason === 'io') return []
1093    const offer = offerFor(r.outcome, r.file, 'turn', false, name)
1094    return offer === null ? [] : [offer]
1095  })
1096  if (opts.tellClaude && result.reverted.length > 0) {
1097    await tellReverted($, index, result.reverted.map(r => r.file.path), result.reverted.flatMap(r => r.outcome.refs))
1098  }
1099  await persist($)
1100  return { notice, offers }
1101}
1102
1103// A refusal's offer pressed: plan again with its mode, then confirm as usual.
1104async function armOffer($: Engine): Promise<void> {
1105  const { value: view = NO_VIEW } = await $.state.get(VIEW)
1106  if (view.offer === null) return
1107  await armRevert($, view.offer, view.offer.mode, view.offer.accept)
1108}
1109
1110// The second press: the armed revert runs, within its few seconds, for the
1111// file its button belongs to, and one at a time. The confirmation is disarmed
1112// before the revert starts, so nothing stays armed if the module reloads
1113// mid-revert, and the outcome replaces the "Reverting…" line after.
1114async function confirmRevert($: Engine, opts: Options, file: { turnId: string; key: string }): Promise<void> {
1115  const { value: view = NO_VIEW } = await $.state.get(VIEW)
1116  const ask = view.confirm
1117  if (ask === null || ask.turnId !== file.turnId || ask.key !== file.key) return
1118  if (reverting) return
1119  reverting = true
1120  let notice: string
1121  try {
1122    const isLate = (await $.clock.now()) > ask.until
1123    await update($, VIEW, (v = NO_VIEW) => ({ ...v, confirm: null, offer: null, notice: isLate ? v.notice : 'Reverting…' }))
1124    if (isLate) {
1125      notice = 'The confirmation timed out: press Revert again.'
1126    } else {
1127      const done = await revertFromPane($, opts, { turnId: ask.turnId, key: ask.key }, ask.mode, ask.accept)
1128      const { value: session = null } = await $.state.get(SESSION)
1129      const { value: stored = emptyIndex() } = await $.state.get(INDEX)
1130      const shown = session === null ? ask.path : shownPath(session.cwd, ask.path, session.platform === 'windows')
1131      if (done === null) notice = `Nothing to revert for ${shown}.`
1132      else if (done.outcome.ok && !done.saved) notice = `Reverted ${shown}, but its history couldn't be saved.`
1133      else notice = revertNotice(done.outcome, shown, turnNamer(stored as Index))
1134    }
1135  } catch (error) {
1136    debug($, 'revert', error)
1137    notice = 'The revert stopped with an error: check the file before going on.'
1138  } finally {
1139    reverting = false
1140  }
1141  await update($, VIEW, (v = NO_VIEW) => ({ ...v, confirm: null, offer: null, notice }))
1142}
1143
1144function turnNamer(index: Index): (turnId: string) => string {
1145  return turnId => {
1146    const turn = index.turns.find(t => t.turnId === turnId)
1147    return turn === undefined ? 'an earlier turn' : turn.kind === 'background' ? 'a background agent' : `#${turn.ordinal}`
1148  }
1149}
1150
1151// Desktop's first click on an unfocused pane fires only ui.focus: for the
1152// turn rows and steps, which only select, focus does what the press would.
1153// Keep, Revert and Confirm always need a real press.
1154async function focusSelects($: Engine, element: string): Promise<void> {
1155  if (element.startsWith('select-')) await selectTurn($, element.slice('select-'.length))
1156  else if (element === 'node-all') await selectNode($, null)
1157  else if (element.startsWith('node-')) await selectNode($, element.slice('node-'.length))
1158}
1159
1160function agentOf(input: { agentId?: string }): { agentId?: string } {
1161  return input.agentId === undefined ? {} : { agentId: input.agentId }
1162}
1163
1164function isOlder(version: string, min: readonly number[]): boolean {
1165  const parts = version.split(/[.-]/).map(Number)
1166  for (let i = 0; i < min.length; i++) {
1167    const part = parts[i] ?? 0
1168    if (Number.isNaN(part)) return false
1169    if (part !== min[i]) return part < min[i]
1170  }
1171  return false
1172}
1173
1174function debug($: Engine, where: string, error: unknown): void {
1175  $.ui.log(`changes: ${where} failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
1176}
1177
hooks/core/model.ts 107 lines
1// The Changes engine's data model (spec §3.1). Plain JSON data: the index
2// lives in `$.state`, the blobs it names live on disk.
3
4import type { Eol } from './diff.ts'
5
6export type Content =
7  | { hash: string; size: number; kind: 'text' | 'binary'; eol: Eol; bom: boolean }
8  // Clean in git before the change: the blob in `commit` at the record's path.
9  // Read with `git cat-file --filters` (so autocrlf applies), restored with
10  // `git restore --source=<commit>`, verified with `git hash-object`.
11  | { git: { oid: string; commit: string } }
12  | { absent: true }
13  | { unreadable: 'too-large' | 'io' }
14
15// 'revert' is a revert the person ran from the pane: the disk went from its
16// before to its after. It belongs to no turn (turnId REVERT_TURN) and keeps
17// each file's history one unbroken chain of transitions.
18export type Via = 'tool' | 'shell' | 'unattributed' | 'revert'
19
20export const REVERT_TURN = 'revert'
21
22export type EditRecord = {
23  seq: number
24  turnId: string
25  toolUseId: string
26  tool: string
27  via: Via
28  agentId?: string
29  path: string
30  key: string
31  before: Content
32  after: Content
33  // When the before capture started and the after capture ended.
34  startedAt: number
35  at: number
36  interleaved?: true
37}
38
39export type AgentNode = {
40  agentId: string
41  turnId: string
42  seq: number
43  parentToolUseId?: string
44  parentAgentId?: string
45  label?: string
46  // Set when the agent's run completed (its turn.complete); until then its
47  // turn is never trimmed, since its edits still arrive.
48  endedAt?: number
49}
50
51export type TurnRecord = {
52  turnId: string
53  // Orders turns, background buckets and user actions on one timeline.
54  seq: number
55  // 'background' is the bucket of an agent whose spawning turn is unknown.
56  kind: 'turn' | 'background'
57  ordinal: number
58  prompt: string
59  startedAt: number
60  endedAt?: number
61  reason?: string
62}
63
64export type FileStatus = 'pending' | 'kept' | 'reverted'
65
66// A tool call whose changes were not recorded, and why ("many files
67// changed (5123)", "HEAD moved", "took too long"): shown on its turn so the
68// timeline never claims more than it knows.
69export type UntrackedRecord = { seq: number; turnId: string; toolUseId: string; tool: string; reason: string; at: number }
70
71// Something the person did in the pane, kept in the timeline.
72export type UserAction = { seq: number; kind: 'keep' | 'undo-keep' | 'revert'; turnIds: string[]; key: string; path: string; at: number }
73
74export type Index = {
75  turns: TurnRecord[]
76  edits: EditRecord[]
77  agents: AgentNode[]
78  actions: UserAction[]
79  untracked: UntrackedRecord[]
80  // Keyed by statusKey(turnId, key).
81  status: Record<string, FileStatus>
82  openTurnId?: string
83  nextSeq: number
84  nextOrdinal: number
85  // Per file key, the highest seq of an edit trimmed away: a chain break
86  // after it is Claude's dropped history, not the person's change.
87  trimmed: Record<string, number>
88}
89
90export function emptyIndex(): Index {
91  return { turns: [], edits: [], agents: [], actions: [], untracked: [], status: {}, nextSeq: 1, nextOrdinal: 1, trimmed: {} }
92}
93
94export function statusKey(turnId: string, key: string): string {
95  return turnId + '\u0000' + key
96}
97
98// Whether two captures hold the same content: true, false, or null when it
99// can't be told (unreadable, or a git blob id against a SHA-256 hash).
100export function sameContent(a: Content, b: Content): boolean | null {
101  if ('unreadable' in a || 'unreadable' in b) return null
102  if ('absent' in a || 'absent' in b) return 'absent' in a && 'absent' in b
103  if ('hash' in a && 'hash' in b) return a.hash === b.hash
104  if ('git' in a && 'git' in b) return a.git.oid === b.git.oid
105  return null
106}
107
hooks/core/noise.ts 34 lines
1// Which changed files are worth a line above the prompt. Everything is
2// tracked and shown in the pane; the band speaks only for code, so a turn
3// that only touched notes, docs, logs, data or lockfiles stays quiet.
4
5import { relativeTo } from './paths.ts'
6
7const PROSE = /\.(md|mdx|markdown|rst|adoc|org)$/i
8// Text files that are code or build input, not notes.
9const TEXT_CODE = /^(cmakelists\.txt|(requirements|constraints)([-_.][\w.-]*)?\.txt|robots\.txt)$/i
10const QUIET_EXT = /\.(txt|log|out|csv|tsv|lock)$/i
11const LOCKFILES = new Set([
12  'package-lock.json',
13  'npm-shrinkwrap.json',
14  'pnpm-lock.yaml',
15  'bun.lockb',
16  'go.sum',
17  'packages.lock.json',
18])
19// Matched only below the repository root or the session's folder, never on
20// the absolute path (a project living under C:/Work/Build/ is still code).
21const QUIET_FOLDERS = /(^|\/)(docs?|notes|\.claude|node_modules|dist|build|out|coverage|\.next|target)\//i
22
23export function isCodePath(path: string, bases: readonly string[] = [], isWindows = false): boolean {
24  const normal = path.replace(/\\/g, '/')
25  const name = normal.slice(normal.lastIndexOf('/') + 1).toLowerCase()
26  if (TEXT_CODE.test(name)) return true
27  if (PROSE.test(name) || QUIET_EXT.test(name) || LOCKFILES.has(name)) return false
28  for (const base of bases) {
29    const rel = relativeTo(base, normal, isWindows)
30    if (rel !== undefined) return !QUIET_FOLDERS.test(rel)
31  }
32  return true
33}
34
hooks/core/paths.ts 17 lines
1// Paths as git and the pane want them. Pure: forward slashes, and on Windows
2// a case-insensitive prefix test, since its file system ignores case.
3
4export function relativeTo(root: string, path: string, isWindows: boolean): string | undefined {
5  const base = root.replace(/\\/g, '/').replace(/\/+$/, '')
6  const full = path.replace(/\\/g, '/')
7  const fold = (s: string) => (isWindows ? s.toLowerCase() : s)
8  if (!fold(full).startsWith(fold(base) + '/')) return undefined
9  const rest = full.slice(base.length + 1)
10  return rest === '' ? undefined : rest
11}
12
13// For display: relative to the session's folder when under it, else as given.
14export function shownPath(cwd: string, path: string, isWindows: boolean): string {
15  return relativeTo(cwd, path, isWindows) ?? path.replace(/\\/g, '/')
16}
17
hooks/core/sweep.ts 23 lines
1// The turn sweep (spec §5.3 step 4): git status at turn.start and at
2// turn.complete catches changes no tool call recorded (a background command,
3// a tool we don't know, the person's editor during the turn).
4
5import { sameContent, type Content, type Index } from './model.ts'
6
7// The changes the index does not account for: a change is accounted for when
8// the file's last recorded transition, in any turn, left exactly this content
9// (an async agent files its edits under the turn that spawned it).
10export function unexplained<T extends { key: string; after: Content }>(index: Index, changes: readonly T[]): T[] {
11  return changes.filter(change => {
12    let last: Content | undefined
13    let lastSeq = -1
14    for (const e of index.edits) {
15      if (e.key === change.key && e.seq > lastSeq) {
16        last = e.after
17        lastSeq = e.seq
18      }
19    }
20    return last === undefined || sameContent(last, change.after) !== true
21  })
22}
23
hooks/core/revert-plan.ts 123 lines
1// What a revert must do, decided from the index and the file as it is on
2// disk now (spec §5.4). Pure: the io layer executes the plan and verifies.
3//
4// A file's history is its chain of transitions in edit order (seq), across
5// turns: Claude's edits and the reverts already run from the pane. A revert
6// restores the before of the first transition it undoes, so it is only safe
7// when every transition from there on links to the next (nothing outside
8// that chain changed the file) and the disk holds the last one's after.
9
10import { REVERT_TURN, sameContent, type Content, type EditRecord, type Index } from './model.ts'
11import { statusOf } from './timeline.ts'
12
13export type Restore =
14  | { kind: 'write-text'; hash: string }
15  | { kind: 'write-bytes'; hash: string }
16  | { kind: 'delete' }
17  | { kind: 'git-restore'; commit: string; oid: string }
18  // The disk already holds the target; only the statuses change.
19  | { kind: 'none' }
20
21export type RevertRef = { turnId: string; key: string }
22
23export type RevertPlan =
24  // `widenedTo`: turns that started before this one and were pulled in by
25  // 'turnAndLater' (an async agent's earlier edits); the confirmation names
26  // every turn in `refs`.
27  | { ok: true; target: Content; restore: Restore; refs: RevertRef[]; widenedTo?: string[] }
28  // Other turns changed the file inside this change's span, before or after
29  // it in time; 'turnAndLater' reverts them together.
30  | { ok: false; reason: 'changed-since'; otherTurns: string[] }
31  // Two transitions in the span don't link, so reverting would also undo
32  // whatever happened between them; `acceptOutsideChanges` goes ahead anyway.
33  // Why, as far as the index can tell:
34  //   history-trimmed      Claude's edits between them were trimmed away;
35  //   unverifiable-link    the two sides can't be compared (a git blob
36  //                        against a hash, after a commit; an unreadable capture);
37  //   user-changed-between something outside Claude changed the file.
38  // `between` names the two sides' turns; REVERT_TURN is an earlier revert.
39  | { ok: false; reason: 'user-changed-between' | 'unverifiable-link' | 'history-trimmed'; between: [string, string] }
40  | {
41      ok: false
42      reason: 'changed-externally' | 'too-large' | 'no-snapshot' | 'io' | 'already-reverted' | 'nothing-to-revert'
43    }
44
45export type RevertOptions = {
46  // The person confirmed that changes outside the chain may be undone too.
47  acceptOutsideChanges?: boolean
48}
49
50// mode 'turn' undoes this turn's change to the file alone, and only when no
51// other turn's live edit to it falls inside the span; 'turnAndLater' undoes
52// everything from the change's first edit on, pulling in every turn with a
53// live edit in that span (an async agent's turn can start before it).
54export function planRevert(
55  index: Index,
56  ref: RevertRef,
57  mode: 'turn' | 'turnAndLater',
58  current: Content,
59  options: RevertOptions = {},
60): RevertPlan {
61  const history = index.edits.filter(e => e.key === ref.key).sort((a, b) => a.seq - b.seq)
62  const own = history.filter(e => e.turnId === ref.turnId && e.via !== 'revert')
63  if (own.length === 0) return { ok: false, reason: 'nothing-to-revert' }
64  if (statusOf(index, ref.turnId, ref.key) === 'reverted') return { ok: false, reason: 'already-reverted' }
65  if ('unreadable' in current) return { ok: false, reason: current.unreadable === 'too-large' ? 'too-large' : 'io' }
66
67  const isLive = (e: EditRecord) => e.via !== 'revert' && statusOf(index, e.turnId, e.key) !== 'reverted'
68  const firstLiveSeq = (turnId: string) => Math.min(...history.filter(e => e.turnId === turnId && isLive(e)).map(e => e.seq))
69
70  let start = own[0].seq
71  const involved = new Set([ref.turnId])
72  if (mode === 'turnAndLater') {
73    // Widen to the first live edit of every turn with a live edit in the span,
74    // until no turn reaches further back.
75    for (let widened = true; widened; ) {
76      widened = false
77      for (const e of history) {
78        if (e.seq < start || !isLive(e) || involved.has(e.turnId)) continue
79        involved.add(e.turnId)
80        const first = firstLiveSeq(e.turnId)
81        if (first < start) {
82          start = first
83          widened = true
84        }
85      }
86    }
87  }
88
89  const span = history.filter(e => e.seq >= start)
90  const others = [...new Set(span.filter(e => isLive(e) && !involved.has(e.turnId)).map(e => e.turnId))]
91  if (others.length > 0) return { ok: false, reason: 'changed-since', otherTurns: others }
92
93  if (!options.acceptOutsideChanges) {
94    for (let i = 1; i < span.length; i++) {
95      const link = sameContent(span[i - 1].after, span[i].before)
96      if (link === true) continue
97      const between: [string, string] = [span[i - 1].turnId, span[i].turnId]
98      if ((index.trimmed[ref.key] ?? 0) > span[i - 1].seq) return { ok: false, reason: 'history-trimmed', between }
99      return { ok: false, reason: link === null ? 'unverifiable-link' : 'user-changed-between', between }
100    }
101  }
102
103  if (sameContent(current, span[span.length - 1].after) !== true) return { ok: false, reason: 'changed-externally' }
104
105  const target = span[0].before
106  if (mode === 'turn' && span.length === own.length && sameContent(target, current) === true) {
107    return { ok: false, reason: 'nothing-to-revert' }
108  }
109  const refs = [...involved]
110    .filter(turnId => turnId !== REVERT_TURN && span.some(e => e.turnId === turnId && isLive(e)))
111    .sort((a, b) => firstLiveSeq(a) - firstLiveSeq(b))
112    .map(turnId => ({ turnId, key: ref.key }))
113  const widenedTo = refs.map(r => r.turnId).filter(turnId => firstLiveSeq(turnId) < own[0].seq)
114  const plan = (restore: Restore): RevertPlan =>
115    widenedTo.length > 0 ? { ok: true, target, restore, refs, widenedTo } : { ok: true, target, restore, refs }
116
117  if (sameContent(current, target) === true) return plan({ kind: 'none' })
118  if ('absent' in target) return plan({ kind: 'delete' })
119  if ('git' in target) return plan({ kind: 'git-restore', commit: target.git.commit, oid: target.git.oid })
120  if ('hash' in target) return plan({ kind: target.kind === 'text' ? 'write-text' : 'write-bytes', hash: target.hash })
121  return { ok: false, reason: target.unreadable === 'too-large' ? 'too-large' : 'no-snapshot' }
122}
123
hooks/core/timeline.ts 406 lines
1// Pure updates and views over the Changes index (spec §3, §5.1, §5.2).
2// Every update returns a new index and leaves the one it was given alone;
3// no field is ever set to undefined, so the index survives `$.state` as is.
4
5import {
6  REVERT_TURN,
7  sameContent,
8  statusKey,
9  type AgentNode,
10  type Content,
11  type EditRecord,
12  type FileStatus,
13  type Index,
14  type TurnRecord,
15  type UntrackedRecord,
16  type UserAction,
17  type Via,
18} from './model.ts'
19
20export const MAX_TURNS = 200
21// About 450 characters each in $.state, so 5,000 stay near 2.3 MB.
22export const MAX_EDITS = 5000
23// Stored prompts are cut to this: the pane shows a line of them, and a long
24// pasted prompt must not fill $.state.
25export const PROMPT_CHARS = 200
26export const MAX_UNTRACKED = 200
27
28// An edit as the wiring reports it; the index assigns `seq` and, unless the
29// caller names one (the turn sweep), the turn.
30export type EditInput = Omit<EditRecord, 'seq' | 'turnId' | 'interleaved'> & { turnId?: string }
31
32// One file in one turn: from the first edit's before to the last edit's after.
33export type FileChange = {
34  turnId: string
35  key: string
36  path: string
37  before: Content
38  after: Content
39  edits: EditRecord[]
40  agentIds: string[]
41  status: FileStatus
42  interleaved: boolean
43  // Consecutive edits don't chain: something else changed the file between them.
44  chainBroken: boolean
45  isNoop: boolean
46}
47
48export type CallNode = { kind: 'call'; toolUseId: string; tool: string; via: Via; agentId?: string; seq: number; keys: string[] }
49export type AgentTreeNode = {
50  kind: 'agent'
51  agentId: string
52  label?: string
53  parentToolUseId?: string
54  seq: number
55  keys: string[]
56  children: TimelineNode[]
57}
58export type TimelineNode = CallNode | AgentTreeNode
59
60export type TimelineRow =
61  | { kind: 'turn'; turn: TurnRecord; nodes: TimelineNode[]; files: FileChange[]; untracked: UntrackedRecord[] }
62  | { kind: 'action'; action: UserAction }
63
64export function openTurn(index: Index, input: { turnId: string; prompt: string; at: number }): Index {
65  const turn: TurnRecord = {
66    turnId: input.turnId,
67    seq: index.nextSeq,
68    kind: 'turn',
69    ordinal: index.nextOrdinal,
70    prompt: input.prompt.slice(0, PROMPT_CHARS),
71    startedAt: input.at,
72  }
73  return trim({
74    ...index,
75    turns: [...index.turns, turn],
76    openTurnId: input.turnId,
77    nextSeq: index.nextSeq + 1,
78    nextOrdinal: index.nextOrdinal + 1,
79  })
80}
81
82export function closeTurn(index: Index, input: { turnId: string; at: number; reason: string }): Index {
83  const turns = index.turns.map(t => (t.turnId === input.turnId ? { ...t, endedAt: input.at, reason: input.reason } : t))
84  if (index.openTurnId !== input.turnId) return { ...index, turns }
85  const { openTurnId: _closed, ...rest } = index
86  return { ...rest, turns }
87}
88
89// Records the agent an `Agent` tool call launched: it belongs to the turn of
90// whoever spawned it (the open main turn, or the spawning agent's turn).
91export function linkAgent(index: Index, input: { agentId: string; parentToolUseId?: string; spawnedBy?: string; label?: string }): Index {
92  // The agent's first edit can land before its spawn's result does: it was
93  // recorded then with no parent, so fill in what the spawn knows. Its turn
94  // stays, since its edits are already filed under it.
95  const known = index.agents.find(a => a.agentId === input.agentId)
96  if (known) {
97    const filled: AgentNode = { ...known }
98    if (filled.parentToolUseId === undefined && input.parentToolUseId !== undefined) filled.parentToolUseId = input.parentToolUseId
99    if (filled.parentAgentId === undefined && input.spawnedBy !== undefined) filled.parentAgentId = input.spawnedBy
100    if (filled.label === undefined && input.label !== undefined) filled.label = input.label
101    return { ...index, agents: index.agents.map(a => (a === known ? filled : a)) }
102  }
103  let next = index
104  let turnId: string
105  if (input.spawnedBy !== undefined) {
106    ;({ index: next, turnId } = turnOfAgent(next, input.spawnedBy))
107  } else {
108    ;({ index: next, turnId } = turnForMain(next, input.agentId))
109  }
110  const agent: AgentNode = { agentId: input.agentId, turnId, seq: next.nextSeq }
111  if (input.parentToolUseId !== undefined) agent.parentToolUseId = input.parentToolUseId
112  if (input.spawnedBy !== undefined) agent.parentAgentId = input.spawnedBy
113  if (input.label !== undefined) agent.label = input.label
114  return { ...next, agents: [...next.agents, agent], nextSeq: next.nextSeq + 1 }
115}
116
117export function addEdit(index: Index, input: EditInput): Index {
118  let next = index
119  let turnId: string
120  if (input.turnId !== undefined) turnId = input.turnId
121  else if (input.agentId !== undefined) ({ index: next, turnId } = turnOfAgent(next, input.agentId))
122  else ({ index: next, turnId } = turnForMain(next, 'main'))
123
124  const record: EditRecord = { ...input, turnId, seq: next.nextSeq }
125  let edits = next.edits
126
127  // Interleaving: the last edit of this file finished after this one began,
128  // and this one's before is not what that one left.
129  const previous = lastEditOf(edits, input.key)
130  if (previous && previous.at > input.startedAt && sameContent(previous.after, input.before) === false) {
131    record.interleaved = true
132    edits = edits.map(e => (e === previous ? { ...e, interleaved: true } : e))
133  }
134  return trim({ ...next, edits: [...edits, record], nextSeq: next.nextSeq + 1 })
135}
136
137// A call whose changes were not recorded: filed like an edit (its agent's
138// turn, else the open turn), kept to the newest MAX_UNTRACKED.
139export function addUntracked(index: Index, input: Omit<UntrackedRecord, 'seq' | 'turnId'> & { agentId?: string; turnId?: string }): Index {
140  let next = index
141  let turnId: string
142  if (input.turnId !== undefined) turnId = input.turnId
143  else if (input.agentId !== undefined) ({ index: next, turnId } = turnOfAgent(next, input.agentId))
144  else ({ index: next, turnId } = turnForMain(next, 'main'))
145  const record: UntrackedRecord = { seq: next.nextSeq, turnId, toolUseId: input.toolUseId, tool: input.tool, reason: input.reason, at: input.at }
146  return { ...next, untracked: [...next.untracked, record].slice(-MAX_UNTRACKED), nextSeq: next.nextSeq + 1 }
147}
148
149export function addAction(index: Index, action: Omit<UserAction, 'seq'>): Index {
150  return { ...index, actions: [...index.actions, { ...action, seq: index.nextSeq }], nextSeq: index.nextSeq + 1 }
151}
152
153export function setStatus(index: Index, refs: { turnId: string; key: string }[], status: FileStatus): Index {
154  const next = { ...index.status }
155  for (const ref of refs) {
156    if (status === 'pending') delete next[statusKey(ref.turnId, ref.key)]
157    else next[statusKey(ref.turnId, ref.key)] = status
158  }
159  return { ...index, status: next }
160}
161
162export function statusOf(index: Index, turnId: string, key: string): FileStatus {
163  return index.status[statusKey(turnId, key)] ?? 'pending'
164}
165
166// The turn's files in the order first changed; `node` narrows to one tool
167// call (its toolUseId) or one agent and the agents it spawned.
168export function turnFiles(index: Index, turnId: string, node?: string): FileChange[] {
169  let edits = index.edits.filter(e => e.turnId === turnId)
170  if (node !== undefined) {
171    const agents = agentAndDescendants(index, node)
172    edits = edits.filter(e => e.toolUseId === node || (e.agentId !== undefined && agents.has(e.agentId)))
173  }
174  const byKey = new Map<string, EditRecord[]>()
175  for (const e of edits) byKey.set(e.key, [...(byKey.get(e.key) ?? []), e])
176  return [...byKey.entries()].map(([key, list]) => {
177    const first = list[0]
178    const last = list[list.length - 1]
179    const interleaved = list.some(e => e.interleaved === true)
180    const chainBroken = !interleaved && list.some((e, i) => i > 0 && sameContent(list[i - 1].after, e.before) === false)
181    return {
182      turnId,
183      key,
184      path: last.path,
185      before: first.before,
186      after: last.after,
187      edits: list,
188      agentIds: [...new Set(list.flatMap(e => (e.agentId === undefined ? [] : [e.agentId])))],
189      status: statusOf(index, turnId, key),
190      interleaved,
191      chainBroken,
192      isNoop: sameContent(first.before, last.after) === true,
193    }
194  })
195}
196
197// Newest first: each turn (or background bucket) with its tree of calls and
198// agents, and the person's actions between them.
199export function timeline(index: Index): TimelineRow[] {
200  const rows: (TimelineRow & { seq: number })[] = [
201    ...index.turns.map(turn => ({
202      kind: 'turn' as const,
203      seq: turn.seq,
204      turn,
205      nodes: treeOf(index, turn.turnId),
206      files: turnFiles(index, turn.turnId),
207      untracked: index.untracked.filter(u => u.turnId === turn.turnId),
208    })),
209    ...index.actions.map(action => ({ kind: 'action' as const, seq: action.seq, action })),
210  ]
211  return rows.sort((a, b) => b.seq - a.seq).map(({ seq: _seq, ...row }) => row as TimelineRow)
212}
213
214function treeOf(index: Index, turnId: string): TimelineNode[] {
215  const edits = index.edits.filter(e => e.turnId === turnId)
216  const agents = index.agents.filter(a => a.turnId === turnId)
217  const agentIds = new Set(agents.map(a => a.agentId))
218
219  const callsOf = (agentId: string | undefined): CallNode[] => {
220    const calls = new Map<string, CallNode>()
221    for (const e of edits) {
222      if (e.agentId !== agentId) continue
223      const call = calls.get(e.toolUseId)
224      if (call) {
225        if (!call.keys.includes(e.key)) call.keys.push(e.key)
226        continue
227      }
228      const node: CallNode = { kind: 'call', toolUseId: e.toolUseId, tool: e.tool, via: e.via, seq: e.seq, keys: [e.key] }
229      if (e.agentId !== undefined) node.agentId = e.agentId
230      calls.set(e.toolUseId, node)
231    }
232    return [...calls.values()]
233  }
234
235  const agentNode = (agent: AgentNode): AgentTreeNode => {
236    const children = [
237      ...callsOf(agent.agentId),
238      ...agents.filter(a => a.parentAgentId === agent.agentId).map(agentNode),
239    ].sort((a, b) => a.seq - b.seq)
240    const node: AgentTreeNode = {
241      kind: 'agent',
242      agentId: agent.agentId,
243      seq: agent.seq,
244      keys: [...new Set(children.flatMap(c => c.keys))],
245      children,
246    }
247    if (agent.label !== undefined) node.label = agent.label
248    if (agent.parentToolUseId !== undefined) node.parentToolUseId = agent.parentToolUseId
249    return node
250  }
251
252  const roots = agents.filter(a => a.parentAgentId === undefined || !agentIds.has(a.parentAgentId))
253  return [...callsOf(undefined), ...roots.map(agentNode)].sort((a, b) => a.seq - b.seq)
254}
255
256function agentAndDescendants(index: Index, agentId: string): Set<string> {
257  const found = new Set<string>()
258  if (!index.agents.some(a => a.agentId === agentId)) return found
259  const queue = [agentId]
260  while (queue.length > 0) {
261    const id = queue.shift()!
262    if (found.has(id)) continue
263    found.add(id)
264    for (const a of index.agents) if (a.parentAgentId === id) queue.push(a.agentId)
265  }
266  return found
267}
268
269function lastEditOf(edits: EditRecord[], key: string): EditRecord | undefined {
270  for (let i = edits.length - 1; i >= 0; i--) if (edits[i].key === key) return edits[i]
271  return undefined
272}
273
274// The turn an agent's work belongs to; an agent with no recorded spawn is
275// recorded now against the open turn, or a background bucket of its own.
276function turnOfAgent(index: Index, agentId: string): { index: Index; turnId: string } {
277  const known = index.agents.find(a => a.agentId === agentId)
278  if (known) return { index, turnId: known.turnId }
279  const { index: next, turnId } = turnForMain(index, agentId)
280  const agent: AgentNode = { agentId, turnId, seq: next.nextSeq }
281  return { index: { ...next, agents: [...next.agents, agent], nextSeq: next.nextSeq + 1 }, turnId }
282}
283
284// The open main turn, else the background bucket named for `owner`.
285function turnForMain(index: Index, owner: string): { index: Index; turnId: string } {
286  if (index.openTurnId !== undefined) return { index, turnId: index.openTurnId }
287  const turnId = `background:${owner}`
288  if (index.turns.some(t => t.turnId === turnId)) return { index, turnId }
289  const bucket: TurnRecord = { turnId, seq: index.nextSeq, kind: 'background', ordinal: 0, prompt: '', startedAt: 0 }
290  return { index: trim({ ...index, turns: [...index.turns, bucket], nextSeq: index.nextSeq + 1 }), turnId }
291}
292
293// Keeps at most MAX_TURNS turns and MAX_EDITS edits, dropping the oldest
294// turns whole. A turn that is open, or whose agents are still running, is
295// never dropped: their edits still arrive. A dropped turn's changes stop being
296// revertible, and a later turn whose chain ran through them is refused by
297// planRevert's link check, never restored wrongly.
298function trim(index: Index): Index {
299  let next = index
300  while (next.turns.length > MAX_TURNS || next.edits.length > MAX_EDITS) {
301    const dropped = dropOldest(next)
302    if (dropped === null) break
303    next = dropped
304  }
305  return next
306}
307
308// The index with its oldest droppable turn removed, or null when none is.
309function dropOldest(index: Index): Index | null {
310  const running = new Set(index.agents.filter(a => a.endedAt === undefined).map(a => a.turnId))
311  const victim = [...index.turns]
312    .sort((a, b) => a.seq - b.seq)
313    .find(t => t.turnId !== index.openTurnId && !running.has(t.turnId))
314  if (!victim) return null
315  const gone = victim.turnId
316  const turns = index.turns.filter(t => t.turnId !== gone)
317  // Reverts older than every remaining turn have nothing left to chain to.
318  const oldest = Math.min(...turns.map(t => t.seq), index.nextSeq)
319  const status: Record<string, FileStatus> = {}
320  for (const [k, v] of Object.entries(index.status)) if (k.slice(0, k.indexOf('\u0000')) !== gone) status[k] = v
321  const keep = (e: EditRecord) => (e.via === 'revert' ? e.seq > oldest : e.turnId !== gone)
322  const trimmed = { ...index.trimmed }
323  for (const e of index.edits) if (!keep(e)) trimmed[e.key] = Math.max(trimmed[e.key] ?? 0, e.seq)
324  return {
325    ...index,
326    turns,
327    trimmed,
328    edits: index.edits.filter(keep),
329    agents: index.agents.filter(a => a.turnId !== gone),
330    untracked: index.untracked.filter(u => u.turnId !== gone),
331    actions: index.actions
332      .map(a => ({ ...a, turnIds: a.turnIds.filter(id => id !== gone) }))
333      .filter(a => a.turnIds.length > 0),
334    status,
335  }
336}
337
338// What `$.state` holds per value is 4,194,304 characters; this leaves room.
339export const STATE_BUDGET_CHARS = 4_000_000
340
341// The index trimmed until its JSON fits `budget`; `fits` false when nothing
342// droppable is left and it still doesn't (the caller then skips the write).
343// Measured on the JSON itself: a count of records says nothing about a long
344// path or prompt.
345export function fitToSize(index: Index, budget = STATE_BUDGET_CHARS): { index: Index; fits: boolean } {
346  let next = index
347  while (JSON.stringify(next).length > budget) {
348    const dropped = dropOldest(next)
349    if (dropped === null) return { index: next, fits: false }
350    next = dropped
351  }
352  return { index: next, fits: true }
353}
354
355// The JSON characters `next` adds to `prev` when it only appended records
356// (and changed statuses or counters), so a save can skip measuring the whole
357// index; null when anything else changed (a trim, a record replaced at either
358// end) and only a full measure can tell. A record replaced in the middle (an
359// `interleaved` flag) is missed by a few characters: the caller re-measures
360// in full every so often.
361export function appendedSize(prev: Index, next: Index): number | null {
362  if (prev.trimmed !== next.trimmed) return null
363  let size = 64
364  for (const list of ['turns', 'edits', 'agents', 'actions', 'untracked'] as const) {
365    const before: readonly unknown[] = prev[list]
366    const after: readonly unknown[] = next[list]
367    if (before === after) continue
368    if (after.length < before.length) return null
369    if (before.length > 0 && (after[0] !== before[0] || after[before.length - 1] !== before[before.length - 1])) return null
370    for (let i = before.length; i < after.length; i++) size += JSON.stringify(after[i]).length + 1
371  }
372  if (prev.status !== next.status) size += Math.max(0, JSON.stringify(next.status).length - JSON.stringify(prev.status).length)
373  return size
374}
375
376// The agent's run completed (its turn.complete carried its agentId).
377export function closeAgent(index: Index, agentId: string, at: number): Index {
378  if (!index.agents.some(a => a.agentId === agentId && a.endedAt === undefined)) return index
379  return trim({ ...index, agents: index.agents.map(a => (a.agentId === agentId ? { ...a, endedAt: at } : a)) })
380}
381
382// A revert that landed: the disk transition joins the file's chain, the
383// reverted changes are marked, and the action goes on the timeline.
384export function recordRevert(
385  index: Index,
386  input: { key: string; path: string; before: Content; after: Content; refs: { turnId: string; key: string }[]; at: number },
387): Index {
388  const transition: EditRecord = {
389    seq: index.nextSeq,
390    turnId: REVERT_TURN,
391    toolUseId: `revert:${index.nextSeq}`,
392    tool: 'Changes',
393    via: 'revert',
394    path: input.path,
395    key: input.key,
396    before: input.before,
397    after: input.after,
398    startedAt: input.at,
399    at: input.at,
400  }
401  let next: Index = { ...index, edits: [...index.edits, transition], nextSeq: index.nextSeq + 1 }
402  next = setStatus(next, input.refs, 'reverted')
403  next = addAction(next, { kind: 'revert', turnIds: [...new Set(input.refs.map(r => r.turnId))], key: input.key, path: input.path, at: input.at })
404  return trim(next)
405}
406
hooks/io/blobs.ts 104 lines
1// The snapshot store (spec §5.2, §5.6): content-addressed blobs under
2// `<config>/plugin-data/notary-changes/<sessionId>/blobs/`, `<sha256>.txt`
3// for text a write gives back byte for byte, `<sha256>.b64` for the rest.
4
5import { base64FromBytes, bytesFromBase64, describe } from '../core/content.ts'
6import type { Content } from '../core/model.ts'
7import type { Platform, Ports } from './ports.ts'
8
9// `$.fs` refuses reads over this.
10export const MAX_READ_BYTES = 4 * 1024 * 1024
11
12const slashes = (path: string) => path.replace(/\\/g, '/')
13
14// The variables as register read them with `$.env.get` (names must be literals).
15export type StoreEnv = { CLAUDE_CONFIG_DIR?: string; USERPROFILE?: string; HOME?: string }
16
17export function storeDirOf(env: StoreEnv, sessionId: string): string {
18  const home = env.USERPROFILE ?? env.HOME ?? '.'
19  const config = env.CLAUDE_CONFIG_DIR ?? `${slashes(home).replace(/\/$/, '')}/.claude`
20  return `${slashes(config).replace(/\/$/, '')}/plugin-data/notary-changes/${sessionId}`
21}
22
23export async function sha256Hex(bytes: Uint8Array): Promise<string> {
24  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
25  return Array.from(digest, b => b.toString(16).padStart(2, '0')).join('')
26}
27
28// The bytes a session's new blobs may still take; `full` once one was refused.
29export type StoreBudget = { used: number; cap: number; full: boolean }
30
31// What the file holds now: absent, too large, unreadable, or its hash and
32// kind, with the blob written unless `store` is false (a check, not a
33// snapshot). A blob the budget has no room for is not written, and the
34// capture is `{ unreadable: 'io' }`: never a hash that names no saved copy.
35export async function capture(io: Ports, store: string, path: string, options: { store?: boolean; budget?: StoreBudget } = {}): Promise<Content> {
36  try {
37    if (!(await io.fs.exists(path))) return { absent: true }
38    const stat = await io.fs.stat(path)
39    if (stat.kind !== 'file') return { unreadable: 'io' }
40    if (stat.size > MAX_READ_BYTES) return { unreadable: 'too-large' }
41    const read = await io.fs.read(path, { as: 'bytes' })
42    const bytes = bytesFromBase64(typeof read === 'string' ? read : read.base64)
43    const meta = describe(bytes)
44    const hash = await sha256Hex(bytes)
45    if (options.store !== false) {
46      const blob = `${store}/blobs/${hash}.${meta.kind === 'text' ? 'txt' : 'b64'}`
47      if (!(await io.fs.exists(blob))) {
48        const budget = options.budget
49        if (budget !== undefined && budget.used + bytes.length > budget.cap) {
50          budget.full = true
51          return { unreadable: 'io' }
52        }
53        await io.fs.write(blob, meta.kind === 'text' ? meta.text : base64FromBytes(bytes))
54        if (budget !== undefined) budget.used += bytes.length
55      }
56    }
57    return meta.kind === 'text'
58      ? { hash, size: meta.size, kind: 'text', eol: meta.eol, bom: meta.bom }
59      : { hash, size: meta.size, kind: 'binary', eol: 'none', bom: false }
60  } catch {
61    return { unreadable: 'io' }
62  }
63}
64
65export async function loadText(io: Ports, store: string, hash: string): Promise<string | null> {
66  try {
67    return (await io.fs.read(`${store}/blobs/${hash}.txt`)) as string
68  } catch {
69    return null
70  }
71}
72
73export async function loadBase64(io: Ports, store: string, hash: string): Promise<string | null> {
74  try {
75    return (await io.fs.read(`${store}/blobs/${hash}.b64`)) as string
76  } catch {
77    return null
78  }
79}
80
81// A file's identity across spellings: where the path really lands (links and
82// `..` resolved; for a file not there yet, its folder's real path plus the
83// name), lowercased on Windows, whose file system ignores case.
84export async function keyOf(io: Ports, path: string, platform: Platform): Promise<string> {
85  const normal = slashes(path)
86  let real = normal
87  try {
88    const own = await io.fs.stat(path, { resolve: true })
89    real = own.realPath ?? normal
90  } catch {
91    const cut = normal.lastIndexOf('/')
92    if (cut > 0) {
93      try {
94        const folder = await io.fs.stat(normal.slice(0, cut), { resolve: true })
95        if (folder.realPath) real = `${slashes(folder.realPath).replace(/\/$/, '')}${normal.slice(cut)}`
96      } catch {
97        // The folder is missing too: keep the spelling.
98      }
99    }
100  }
101  real = slashes(real)
102  return platform === 'windows' ? real.toLowerCase() : real
103}
104
hooks/io/diffs.ts 64 lines
1// A file's diff for the pane: each side's text from the blob store, git (its
2// filters applied, so line endings match the working copy) or nothing, then
3// core/diff.ts. Thin: what can't be drawn comes back as a note.
4
5import { diffText, limitHunks } from '../core/diff.ts'
6import type { Content } from '../core/model.ts'
7import { relativeTo } from '../core/paths.ts'
8import { loadText } from './blobs.ts'
9import { filteredText, type GitBudget } from './git.ts'
10import type { Platform, Ports } from './ports.ts'
11
12// As `/diff` draws them: 400 rows per file, then "show all"; no diff of a
13// side over 1 MB.
14export const MAX_DIFF_ROWS = 400
15export const MAX_DIFF_BYTES = 1_000_000
16
17export type DiffView =
18  | { kind: 'diff'; hunks: string; added: number; removed: number; shownRows: number; totalRows: number; eolOnly?: true; bomChanged?: true; capped?: true }
19  | { kind: 'note'; text: string }
20
21export type DiffContext = { store: string; root: string | null; platform: Platform }
22
23type Side = { text: string } | { note: string }
24
25export async function sideText(io: Ports, ctx: DiffContext, content: Content, path: string, budget?: GitBudget): Promise<Side> {
26  if ('absent' in content) return { text: '' }
27  if ('unreadable' in content) return { note: content.unreadable === 'too-large' ? 'too large to show' : 'no saved copy to compare' }
28  if ('git' in content) {
29    const rel = ctx.root === null ? undefined : relativeTo(ctx.root, path, ctx.platform === 'windows')
30    if (rel === undefined) return { note: 'no saved copy to compare' }
31    const text = await filteredText(io, ctx.root!, content.git.commit, rel, budget)
32    if (text === null) return { note: 'no saved copy to compare' }
33    return text.length > MAX_DIFF_BYTES ? { note: 'too large to show' } : { text }
34  }
35  if (content.kind === 'binary') return { note: 'binary file' }
36  if (content.size > MAX_DIFF_BYTES) return { note: 'too large to show' }
37  const text = await loadText(io, ctx.store, content.hash)
38  return text === null ? { note: 'no saved copy to compare' } : { text }
39}
40
41// `all` draws every row; otherwise the first MAX_DIFF_ROWS.
42// `budget` bounds the git reads of a fetch that diffs several files.
43export async function diffOf(io: Ports, ctx: DiffContext, file: { before: Content; after: Content; path: string }, all: boolean, budget?: GitBudget): Promise<DiffView> {
44  const before = await sideText(io, ctx, file.before, file.path, budget)
45  const after = await sideText(io, ctx, file.after, file.path, budget)
46  if ('note' in before) return { kind: 'note', text: before.note }
47  if ('note' in after) return { kind: 'note', text: after.note }
48  const diff = diffText(before.text, after.text)
49  const limited = all ? { hunks: diff.hunks, shownRows: 0, totalRows: 0 } : limitHunks(diff.hunks, MAX_DIFF_ROWS)
50  const rows = all ? diff.hunks.split('\n').filter(r => r !== '' && !r.startsWith('@@')).length : limited.shownRows
51  const view: DiffView = {
52    kind: 'diff',
53    hunks: limited.hunks,
54    added: diff.added,
55    removed: diff.removed,
56    shownRows: rows,
57    totalRows: all ? rows : limited.totalRows,
58  }
59  if (diff.eolOnly) view.eolOnly = true
60  if (diff.bomChanged) view.bomChanged = true
61  if (diff.capped) view.capped = true
62  return view
63}
64
hooks/io/history.ts 136 lines
1// A conversation's history kept on disk, so a resumed conversation
2// (`claude --resume`, `--continue`, `/resume`) shows its turns again
3// (docs/notes/changes-history-persist.md). It lives next to the copies, in
4// `<config>/plugin-data/notary-changes/<sessionId>/index.json`.
5//
6// One process writes it at a time: the holder of `lease.json`. A second
7// process on the same conversation shows the history but never writes it.
8
9import type { Index } from '../core/model.ts'
10import { MAX_READ_BYTES } from './blobs.ts'
11import type { Platform, Ports } from './ports.ts'
12import { moveInto, TMP_SUFFIX } from './restore.ts'
13
14export const HISTORY_FORMAT = 1
15// A lease not renewed for this long belongs to a process that is gone (a
16// crash, a killed terminal): a live holder renews it every LEASE_RENEW_MS.
17export const LEASE_STALE_MS = 10 * 60 * 1000
18export const LEASE_RENEW_MS = 60 * 1000
19
20const historyFile = (store: string) => `${store}/index.json`
21const leaseFile = (store: string) => `${store}/lease.json`
22
23export type Loaded = { index: Index } | { unreadable: 'corrupt' | 'format' | 'other-session' } | null
24
25// Writes the history as one file in one rename: the text goes to a temp file,
26// which then replaces index.json, so a crash leaves the old file or the new,
27// never half of one. Refused (false) when it would be too large to read back.
28export async function saveHistory(io: Ports, platform: Platform, store: string, sessionId: string, index: Index, now: number): Promise<boolean> {
29  const text = JSON.stringify({ format: HISTORY_FORMAT, sessionId, savedAt: now, index })
30  if (new TextEncoder().encode(text).length > MAX_READ_BYTES) return false
31  const tmp = `${historyFile(store)}${TMP_SUFFIX}`
32  try {
33    await io.fs.write(tmp, text)
34    return await moveInto(io, platform, tmp, historyFile(store))
35  } catch {
36    return false
37  }
38}
39
40// The history saved for this conversation: null when there is none; a file
41// that does not parse, is of another format or another conversation, or does
42// not hold an index of the right shape, is refused whole, never half-loaded.
43export async function loadHistory(io: Ports, store: string, sessionId: string): Promise<Loaded> {
44  let text: string
45  try {
46    if (!(await io.fs.exists(historyFile(store)))) return null
47    text = String(await io.fs.read(historyFile(store)))
48  } catch {
49    return { unreadable: 'corrupt' }
50  }
51  let saved: unknown
52  try {
53    saved = JSON.parse(text)
54  } catch {
55    return { unreadable: 'corrupt' }
56  }
57  if (!isRecord(saved)) return { unreadable: 'corrupt' }
58  if (saved.format !== HISTORY_FORMAT) return { unreadable: 'format' }
59  if (saved.sessionId !== sessionId) return { unreadable: 'other-session' }
60  return isIndex(saved.index) ? { index: saved.index } : { unreadable: 'corrupt' }
61}
62
63export type Lease = { token: string; at: number }
64
65// Takes the lease, or answers that another live process holds it. A lease
66// that is missing, released (at 0), our own, or not renewed within
67// LEASE_STALE_MS is taken. The write is read back: of two processes taking
68// it at once, the one that reads another token is not the holder. A holder
69// checks the lease again before each save (holdsLease), so a takeover is
70// seen before anything is written.
71export async function takeLease(io: Ports, store: string, token: string, now: number): Promise<boolean> {
72  const current = await readLease(io, store)
73  if (current !== null && current.token !== token && current.at > 0 && now - current.at < LEASE_STALE_MS) return false
74  try {
75    await io.fs.write(leaseFile(store), JSON.stringify({ token, at: now }))
76  } catch {
77    return false
78  }
79  return (await readLease(io, store))?.token === token
80}
81
82export async function holdsLease(io: Ports, store: string, token: string): Promise<boolean> {
83  return (await readLease(io, store))?.token === token
84}
85
86// Renews the lease (a heartbeat), only while it is still ours.
87export async function renewLease(io: Ports, store: string, token: string, now: number): Promise<boolean> {
88  if (!(await holdsLease(io, store, token))) return false
89  try {
90    await io.fs.write(leaseFile(store), JSON.stringify({ token, at: now }))
91    return true
92  } catch {
93    return false
94  }
95}
96
97// Gives the lease up at the session's end (one small write), so the next
98// process takes it at once.
99export async function releaseLease(io: Ports, store: string, token: string): Promise<void> {
100  try {
101    if (await holdsLease(io, store, token)) await io.fs.write(leaseFile(store), JSON.stringify({ token, at: 0 }))
102  } catch {
103    // Best effort: an unreleased lease goes stale on its own.
104  }
105}
106
107async function readLease(io: Ports, store: string): Promise<Lease | null> {
108  try {
109    if (!(await io.fs.exists(leaseFile(store)))) return null
110    const lease: unknown = JSON.parse(String(await io.fs.read(leaseFile(store))))
111    return isRecord(lease) && typeof lease.token === 'string' && typeof lease.at === 'number' ? { token: lease.token, at: lease.at } : null
112  } catch {
113    return null
114  }
115}
116
117function isRecord(value: unknown): value is Record<string, unknown> {
118  return typeof value === 'object' && value !== null && !Array.isArray(value)
119}
120
121// The shape the pane and the planner read; anything else is refused.
122function isIndex(value: unknown): value is Index {
123  if (!isRecord(value)) return false
124  const arrays = ['turns', 'edits', 'agents', 'actions', 'untracked'] as const
125  if (!arrays.every(key => Array.isArray(value[key]))) return false
126  if (!isRecord(value.status) || !isRecord(value.trimmed)) return false
127  if (typeof value.nextSeq !== 'number' || typeof value.nextOrdinal !== 'number') return false
128  const turns = value.turns as unknown[]
129  const edits = value.edits as unknown[]
130  const isTurn = (t: unknown) => isRecord(t) && typeof t.turnId === 'string' && typeof t.ordinal === 'number' && typeof t.seq === 'number' && typeof t.prompt === 'string'
131  const isContent = (c: unknown) => isRecord(c) && ('hash' in c || 'git' in c || 'absent' in c || 'unreadable' in c)
132  const isEdit = (e: unknown) =>
133    isRecord(e) && typeof e.seq === 'number' && typeof e.turnId === 'string' && typeof e.key === 'string' && typeof e.path === 'string' && isContent(e.before) && isContent(e.after)
134  return turns.every(isTurn) && edits.every(isEdit)
135}
136
hooks/io/git.ts 134 lines
1// Git, by argv. Thin: each call answers its value or null and never throws;
2// what the answers mean is decided in core/git-status.ts and core/revert-plan.ts.
3
4import type { ProcessRunResult } from 'claude-code'
5
6import { parseStatus, type StatusEntry } from '../core/git-status.ts'
7import type { Ports } from './ports.ts'
8
9export const GIT_TIMEOUT_MS = 5000
10
11// Global options on every call: never take the index lock from a git the
12// person is running, read paths literally (no globs), print paths unquoted.
13const LEAD = ['--no-optional-locks', '--literal-pathspecs', '-c', 'core.quotePath=false']
14
15// A deadline several git calls share (the shell budget, a diff fetch): each
16// call's timeout is the time left, at most GIT_TIMEOUT_MS, and none starts
17// once it has passed (`over`). A call killed at its timeout sets `timedOut`:
18// git is slow here.
19export type GitBudget = { deadline: number; over: boolean; timedOut: boolean }
20
21export function gitBudget(now: number, ms: number): GitBudget {
22  return { deadline: now + ms, over: false, timedOut: false }
23}
24
25export async function runGit(io: Ports, cwd: string, args: readonly string[], stdin?: string, budget?: GitBudget): Promise<ProcessRunResult | null> {
26  let timeoutMs = GIT_TIMEOUT_MS
27  if (budget !== undefined) {
28    const left = budget.deadline - (await io.clock.now())
29    if (left <= 0) {
30      budget.over = true
31      return null
32    }
33    timeoutMs = Math.min(timeoutMs, Math.ceil(left))
34  }
35  const startedAt = budget === undefined ? 0 : await io.clock.now()
36  try {
37    return await io.process.run(['git', ...LEAD, ...args], {
38      cwd,
39      env: { LC_ALL: 'C', LANGUAGE: '' },
40      timeoutMs,
41      ...(stdin === undefined ? {} : { stdin }),
42    })
43  } catch {
44    if (budget !== undefined && (await io.clock.now()) - startedAt >= timeoutMs) budget.timedOut = true
45    return null
46  }
47}
48
49async function line(io: Ports, cwd: string, args: readonly string[], budget?: GitBudget): Promise<string | null> {
50  const r = await runGit(io, cwd, args, undefined, budget)
51  if (!r || r.exitCode !== 0) return null
52  const out = r.stdout.trim()
53  return out === '' ? null : out
54}
55
56export async function repoRootOf(io: Ports, cwd: string): Promise<string | null> {
57  return line(io, cwd, ['rev-parse', '--show-toplevel'])
58}
59
60// null on an unborn HEAD (a repository with no commit yet).
61export async function headOf(io: Ports, root: string, budget?: GitBudget): Promise<string | null> {
62  return line(io, root, ['rev-parse', '--verify', '--quiet', 'HEAD'], budget)
63}
64
65// null when git failed or its answer was cut: a partial status would make
66// files look clean that are not.
67export async function statusOf(io: Ports, root: string, budget?: GitBudget): Promise<Map<string, StatusEntry> | null> {
68  const r = await runGit(io, root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], undefined, budget)
69  if (!r || r.exitCode !== 0 || r.isStdoutTruncated) return null
70  return parseStatus(r.stdout)
71}
72
73// The blob at `rel` in `commit`, or null when the commit has no such path.
74export async function blobAt(io: Ports, root: string, commit: string, rel: string): Promise<string | null> {
75  return line(io, root, ['rev-parse', '--verify', '--quiet', `${commit}:${rel}`])
76}
77
78// The blob at each `rel` in `commit` (null where the commit has none), from
79// one git process. null when git fails.
80export async function blobsAt(io: Ports, root: string, commit: string, rels: readonly string[], budget?: GitBudget): Promise<Map<string, string | null> | null> {
81  if (rels.length === 0) return new Map()
82  const r = await runGit(io, root, ['cat-file', '--batch-check'], rels.map(rel => `${commit}:${rel}`).join('\n') + '\n', budget)
83  if (!r || r.exitCode !== 0 || r.isStdoutTruncated) return null
84  const lines = r.stdout.split('\n').filter(l => l !== '')
85  if (lines.length !== rels.length) return null
86  return new Map(rels.map((rel, i) => [rel, / blob \d+$/.test(lines[i]) ? lines[i].split(' ')[0] : null]))
87}
88
89// The blob ids the working files would have (clean filters applied), from
90// one git process; every path must exist. null when git fails.
91export async function hashObjects(io: Ports, root: string, rels: readonly string[], budget?: GitBudget): Promise<Map<string, string> | null> {
92  if (rels.length === 0) return new Map()
93  const r = await runGit(io, root, ['hash-object', '--stdin-paths'], rels.join('\n') + '\n', budget)
94  if (!r || r.exitCode !== 0 || r.isStdoutTruncated) return null
95  const lines = r.stdout.split('\n').filter(l => l !== '')
96  if (lines.length !== rels.length) return null
97  return new Map(rels.map((rel, i) => [rel, lines[i].trim()]))
98}
99
100// The blob id the working file would have, clean filters (autocrlf) applied.
101export async function hashObject(io: Ports, root: string, rel: string): Promise<string | null> {
102  return line(io, root, ['hash-object', '--', rel])
103}
104
105// The blob's text as a checkout would write it (smudge filters applied).
106export async function filteredText(io: Ports, root: string, commit: string, rel: string, budget?: GitBudget): Promise<string | null> {
107  const r = await runGit(io, root, ['cat-file', '--filters', `${commit}:${rel}`], undefined, budget)
108  if (!r || r.exitCode !== 0 || r.isStdoutTruncated) return null
109  return r.stdout
110}
111
112
113// For a file `$.fs` can't read (over 4 MiB): its blob in HEAD when the
114// working file is clean, so a revert can still come from git (spec §5.2).
115export async function cleanBlobOf(io: Ports, root: string, rel: string): Promise<{ oid: string; commit: string } | null> {
116  const head = await headOf(io, root)
117  if (head === null) return null
118  const r = await runGit(io, root, ['status', '--porcelain=v1', '-z', '--', rel])
119  if (!r || r.exitCode !== 0 || r.stdout !== '') return null
120  const oid = await blobAt(io, root, head, rel)
121  return oid === null ? null : { oid, commit: head }
122}
123
124export async function namesBetween(io: Ports, root: string, from: string, to: string): Promise<string[] | null> {
125  const r = await runGit(io, root, ['diff', '--name-only', '-z', '--no-renames', from, to])
126  if (!r || r.exitCode !== 0 || r.isStdoutTruncated) return null
127  return r.stdout.split('\0').filter(p => p !== '')
128}
129
130export async function restoreFrom(io: Ports, root: string, commit: string, rel: string): Promise<boolean> {
131  const r = await runGit(io, root, ['restore', `--source=${commit}`, '--worktree', '--', rel])
132  return r !== null && r.exitCode === 0
133}
134
hooks/io/ports.ts 19 lines
1// What io/ may do to the machine, handed in by register.tsx. The loader
2// follows `$` only into functions of the file that uses it, never across an
3// import, so register builds these closures over `$` and io/ never sees `$`.
4
5import type { EngineInterface } from 'claude-code'
6
7export type Ports = {
8  fs: Pick<EngineInterface['fs'], 'read' | 'write' | 'exists' | 'stat' | 'list'>
9  process: Pick<EngineInterface['process'], 'run'>
10  clock: Pick<EngineInterface['clock'], 'now'>
11}
12
13export type Platform = 'windows' | 'posix'
14
15// From `$.env.get('OS')`, read in register (env names must be literals).
16export function platformOf(os: string | undefined): Platform {
17  return os === 'Windows_NT' ? 'windows' : 'posix'
18}
19