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.

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.
/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.
Select a step to see only its files, or all steps for the whole turn.
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 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:
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 see | What it means | What 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 #14 | Something 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 #14 | The file was committed in between, so its versions can't be compared. | Revert anyway. |
| The history … was trimmed | The 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 alone | The 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 copy | No 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.
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:
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.
| Setting | Default | What it does |
|---|---|---|
enabled | on | Turn Changes on or off. |
band | on | The line above the prompt after a turn that changed code. |
autoOpen | off | Open 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. |
tellClaude | on | The hidden note to Claude after a revert. |
trackShell | on | In a git repository, find the files Bash and PowerShell commands changed. |
keepHistoryDays | 7 | How 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.
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.
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.git status, and the files it lists, before and after each command.~/.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)./diffClaude Code's built-in /diff shows the working tree's git diff. Changes is per turn and can undo:
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.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.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.hooks/register.tsx 1177 lines1import 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}
1177hooks/core/model.ts 107 lines1// 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}
107hooks/core/noise.ts 34 lines1// 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}
34hooks/core/paths.ts 17 lines1// 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}
17hooks/core/sweep.ts 23 lines1// 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}
23hooks/core/revert-plan.ts 123 lines1// 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}
123hooks/core/timeline.ts 406 lines1// 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}
406hooks/io/blobs.ts 104 lines1// 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}
104hooks/io/diffs.ts 64 lines1// 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}
64hooks/io/history.ts 136 lines1// 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}
136hooks/io/git.ts 134 lines1// 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}
134hooks/io/ports.ts 19 lines1// 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