A live graph of what Claude Code knows, read and changed

<img src=".github/assets/header.svg" alt="cc-graph: see what Claude knows" width="100%">
<samp>install · the pane · privacy · roadmap · changelog</samp>
<samp>After a long turn, nobody can say without scrolling back what Claude actually has in context. cc-graph is a Claude Code mod that draws it in a live pane beside the conversation: every file Claude read, searched or changed, how those files import each other, and what compaction took away.</samp>
<a name="install"></a> <img src=".github/assets/h-install.svg" alt="install" width="100%">
/plugin install cc-graph --marketplace kuehn-lars/cc-graph
<samp>Then type /graph. Needs Claude Code v2.1.287 or later, in the terminal.</samp>
<a name="the-pane"></a> <img src=".github/assets/h-the-pane.svg" alt="the pane" width="100%">
<img src=".github/assets/showcase.svg" alt="The cc-graph pane in the terminal, annotated: the context window meter, partly read and compacted files, import edges, folder frames, size bars, the focus, search-only files, an edit and a new file, the now line and the legend" width="100%">
| <samp>control</samp> | <samp>what it does</samp> |
|---|---|
| <kbd>g</kbd> | <samp>graph view</samp> |
| <kbd>l</kbd> | <samp>list view</samp> |
| <kbd>1</kbd> … <kbd>9</kbd> | <samp>pick a row in the list</samp> |
maxNodes = 60 | <samp>most files in the graph, in /config</samp> |
importEdges = on | <samp>show file imports, in /config</samp> |
blindSpots = on | <samp>look for blind spots with git, in /config</samp> |
captureThinking = on | <samp>keep thinking excerpts, in /config</samp> |
<samp>Files a shell command created, deleted or changed show too, named with the command that did it, and so do files Claude read with cat, head or sed -n. Files that import a change but were never opened are flagged as blind spots while the turn is still running.</samp>
<samp>The list also shows Claude's newest thinking: a short excerpt per step, with its turn and the files that step read or changed. Claude Code passes thinking text to mods only when "showThinkingSummaries": true is in your settings (or with --thinking-display summarized). This holds for every 5.5 model: Opus, Sonnet and Haiku. Without it, the footer says thinking is hidden.</samp>
<a name="privacy"></a> <img src=".github/assets/h-privacy.svg" alt="privacy" width="100%">
<samp>cc-graph only watches. Its hooks pass every tool result on untouched and record what happened in session memory.</samp>
captureThinking is on. All of it is gone when the conversation ends or on /clear.</samp><a name="roadmap"></a> <img src=".github/assets/h-roadmap.svg" alt="roadmap" width="100%">
<img src=".github/assets/roadmap.svg" alt="Roadmap: M0 Foundations, v0.1 What Claude knows, v0.2 The graph and v0.3 Blind spots shipped; next v0.4 Why it did that, then v0.5 Desktop graph and pointer, v0.6 Decisions and timeline, v1.0 Hardening and listing" width="100%">
<samp>Features, exit gates and spikes per milestone are in ROADMAP.md.</samp>
<samp>The project is spec-driven: specs/ is the source of truth, and each feature goes from spec to failing tests to code. Instructions for Claude Code working on this repository are in CLAUDE.md.</samp>
claude --plugin-dir . # run with the mod loaded (use a copy of fixtures/sample-app)
npx tsc -p . && claude plugin test && node scripts/check-calls.mjs
node scripts/trace.mjs --check # every approved acceptance criterion has a test
<samp><a href="LICENSE">MIT</a> © 2026 Lars Kühn · built with and for <a href="https://claude.com/claude-code">Claude Code</a></samp>
hooks/register.tsx 485 lines1import { read, update, type CoreEngineInterface, type Register, type StateDollar } from 'claude-code'
2import { layout, reshaped } from './layout'
3import { emptyGraph, reduce, type Action, type FileAction } from './model'
4import type { Commanded } from './model/commanded'
5import { together } from './model/batch'
6import { evict } from './model/evict'
7import { loaded, refreshed, sized } from './model/memory'
8import { withCapNote, withHiddenNote } from './model/notes'
9import { EMPTY_BLOCK, addChunk, thinkingNote, type Block } from './model/thinking'
10import { editAction, notebookEditAction } from './parse/fileEdited'
11import { externalAction, reattachedAction } from './parse/attachment'
12import { PATHSPECS, importPattern, importersOf } from './parse/grep'
13import { readAction } from './parse/fileRead'
14import { writeAction } from './parse/fileWritten'
15import { mentionAction } from './parse/mention'
16import { listed, memoryNote, nestedSize } from './parse/memory'
17import { normalize, worktreeOf } from './parse/path'
18import { searchAction } from './parse/search'
19import { shellReads } from './parse/shellReads'
20import { moves, namedPaths, removals, stashes, writes } from './parse/shell'
21import { linesOf, nameStatusOf, outsideChanges, shellChanges, statusOf, type ShellChange } from './parse/status'
22import { MIN_ROWS, TOO_SHORT, frame, graphColumns, graphRows, renderGraph } from './render/graph'
23import { renderList } from './render/list'
24import { summarize } from './render/summary'
25
26const GRAPH = { plugin: 'cc-graph', key: 'graph' } as const
27const PANE = 'cc-graph'
28const TURN = { plugin: 'cc-graph', key: 'turn' } as const
29const VIEW = { plugin: 'cc-graph', key: 'view' } as const
30const FILL = { plugin: 'cc-graph', key: 'fill' } as const
31const SELECTED = { plugin: 'cc-graph', key: 'selected' } as const
32const AGENTS = { plugin: 'cc-graph', key: 'agents' } as const
33const MEMORY = { plugin: 'cc-graph', key: 'memory' } as const
34const NOTES = { plugin: 'cc-graph', key: 'notes' } as const
35const LAYOUT = { plugin: 'cc-graph', key: 'layout' } as const
36const THINKING = { plugin: 'cc-graph', key: 'thinking' } as const
37
38// F03-R7: every key but `view` back to its empty value; each later feature adds its key here (the scan wants literal refs, so no table).
39const resetState = async ($: StateDollar) => {
40 await update($, GRAPH, () => emptyGraph)
41 await update($, TURN, () => 0)
42 await update($, SELECTED, () => null)
43 await update($, FILL, () => null)
44 await update($, AGENTS, () => ({}))
45 await update($, MEMORY, () => [])
46 await update($, NOTES, () => [])
47 await update($, LAYOUT, () => ({}))
48 await update($, THINKING, () => []) // F05-R11
49 named.clear()
50 searched.clear()
51}
52
53const setView = ($: StateDollar, view: 'list' | 'graph') => update($, VIEW, () => view)
54
55// D5: slow work runs off the hook's return path. Hooks enqueue; one coalesced `$.clock.after` callback drains the queue, one job at a time, and a failing job is dropped. F07 adds its jobs here.
56const QUEUE_DELAY_MS = 20
57const jobs: (() => Promise<void>)[] = []
58let draining = false // true from the first enqueue until the queue is empty: a job enqueued meanwhile joins the drain, so jobs never overlap
59function enqueue($: Pick<CoreEngineInterface, 'clock'>, job: () => Promise<void>) {
60 jobs.push(job)
61 if (draining) return
62 draining = true
63 $.clock.after(QUEUE_DELAY_MS, async () => {
64 for (let next = jobs.shift(); next; next = jobs.shift()) await next().catch(() => {})
65 draining = false
66 })
67}
68
69// F07-R9: a `git` job that fails or runs past its timeout is dropped; the reason goes to the debug log and the footer says so once.
70const TIMED_OUT = 'Blind-spot search timed out'
71const gitJob = ($: StateDollar & Pick<CoreEngineInterface, 'ui'>, job: () => Promise<void>) => async () => {
72 await job().catch(async (err: unknown) => {
73 $.ui.log(`git job dropped: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
74 await update($, NOTES, ns => ((ns ?? []).includes(TIMED_OUT) ? ns ?? [] : [...(ns ?? []), TIMED_OUT]))
75 })
76}
77
78// F09-R4: at most one layout pass waits in the queue; it reads the graph when it runs, so it lays out the latest. A graph that changed shape while the pass ran (a Read, or /clear) queues the next.
79let layoutQueued = false
80const queueLayout = ($: StateDollar & Pick<CoreEngineInterface, 'clock'>) => {
81 if (layoutQueued) return
82 layoutQueued = true
83 enqueue($, async () => {
84 layoutQueued = false
85 const graph = await readGraph($)
86 await update($, LAYOUT, previous => layout(previous ?? {}, graph))
87 if (reshaped(graph, await readGraph($))) queueLayout($)
88 })
89}
90
91// F09-R27: the size of the raster the last render drew (null: the list, or nothing), which a blit must carry. A render and each pulse take a new `pulses` number, so a render ends the pulse running, and a newer pulse keeps an older one from going on.
92// ponytail: one pane, one raster: a render on another surface (no Raster) nulls `drawn`, which only cancels pulses; give each surface its own size if two panes ever show at once.
93let drawn: { columns: number; rows: number } | null = null
94let pulses = 0
95const PULSE_MS = [0, 120, 120, 160] // the gaps before the white border, the shimmer, Claude's colour and the plain frame
96
97// The pulse starts after the queue has placed the nodes; a denied blit is ignored. The redraw a layout write asks for lands after the job returns, not in it, so the pulse waits one more delay, or the redraw wipes it.
98function queuePulse($: StateDollar & Pick<CoreEngineInterface, 'clock' | 'ui'>, paths: string[]) {
99 enqueue($, async () => {
100 $.clock.after(QUEUE_DELAY_MS, async () => {
101 const size = drawn
102 if (!size) return
103 const mine = ++pulses
104 const step = async (i: number): Promise<void> => {
105 if (pulses !== mine) return
106 await $.ui.blit({
107 requestId: PANE, key: 'graph', ...size,
108 cells: frame(await readGraph($), await readLayout($), size.columns, size.rows, (await read($, SELECTED)) ?? null, paths, i),
109 }).catch(() => ({}))
110 if (i < PULSE_MS.length - 1) $.clock.after(PULSE_MS[i + 1]!, () => step(i + 1))
111 }
112 await step(0)
113 })
114 })
115}
116
117const readGraph = async ($: StateDollar) => (await read($, GRAPH)) ?? emptyGraph
118// F04-R1, R2: the memory files the summary breakdown lists (never 'full', which costs a request per file). No breakdown, or no answer, no change; a throw here would run the handlers beneath twice (the hook's .catch).
119async function refreshMemory($: Pick<CoreEngineInterface, 'state' | 'session'>) {
120 const files = (await $.session.usage({ breakdown: 'summary' }).catch(() => undefined))?.context.breakdown?.memoryFiles
121 if (files) await update($, MEMORY, notes => refreshed(notes ?? [], listed(files, cwd)))
122}
123
124const readMemory = async ($: StateDollar) => (await read($, MEMORY)) ?? []
125const readLayout = async ($: StateDollar) => (await read($, LAYOUT)) ?? {}
126const readFill = async ($: StateDollar) => (await read($, FILL)) ?? null
127
128// I4: the working directory is kept here; the state contract has no key for it. A reload refills it (session.start fires again).
129let cwd = ''
130// F15-R4: agent ids already asked for, marked before the list is awaited so parallel calls ask once. A reload asks again, and keeps a name already stored (the list forgets ended agents).
131const named = new Set<string>()
132
133// The name is written by its own update, outside the call that writes `graph` (I9). An id the list does not name is "agent".
134async function nameAgent($: Pick<CoreEngineInterface, 'state' | 'agent'>, id: string) {
135 if (named.has(id)) return
136 named.add(id)
137 const row = (await $.agent.list().catch(() => [])).find(a => a.id === id)
138 await update($, AGENTS, as => ({ ...as, [id]: as?.[id] ?? { id, type: row?.type ?? 'agent', description: row?.description ?? id.slice(0, 8) } }))
139}
140
141// F15-R1: an action of a call that names an agent carries its id, and a touched node asks for the agent's name (R4).
142async function record($: Pick<CoreEngineInterface, 'state' | 'agent' | 'clock' | 'ui'>, action: FileAction, agentId?: string) {
143 await apply($, agentId ? { ...action, agentId } : action)
144 if (agentId) await nameAgent($, agentId)
145}
146
147// One action -> one update of `graph` (I9). The turn is read first; reducers stay pure.
148async function apply($: StateDollar & Pick<CoreEngineInterface, 'clock' | 'ui'>, action: Action) {
149 const turn = (await read($, TURN)) ?? 0
150 let before = emptyGraph
151 const graph = await update($, GRAPH, g => ((before = g ?? emptyGraph), evict(reduce(g, action, turn), maxNodes))) // I5: the cap is held in the same write
152 if (reshaped(before, graph)) queueLayout($)
153 const touched = Object.values(graph.files).filter(n => n.touched > before.seq && n.state !== 'ghost').map(n => n.path)
154 if (touched.length) queuePulse($, touched)
155 // I5: what could not be evicted is said in the footer. A write of `notes` only when the note changes.
156 const files = Object.keys(graph.files).length
157 const notes = (await read($, NOTES)) ?? []
158 if (withCapNote(notes, files, maxNodes) !== notes) await update($, NOTES, ns => withCapNote(ns ?? [], files, maxNodes))
159}
160
161// F15-R9: a change made in a worktree carries its name.
162const inWorktree = <A extends FileAction | null>(a: A, path: string): A => {
163 const worktree = worktreeOf(path, cwd)
164 return a && worktree ? { ...a, worktree } : a
165}
166
167// The observed tools: each turns its call and result into one action (F01-R11: the path is the call's own input).
168const parsers: Record<'Read' | 'Edit' | 'Write' | 'NotebookEdit' | 'Bash', (e: any, result: any, text?: string, start?: string) => FileAction | null> = {
169 Read: (e, r) => readAction(normalize(e.file_path, cwd), e.tool_use_id, r, cwd, importEdges),
170 Edit: (e, r) => inWorktree(editAction(normalize(e.file_path, cwd), r, cwd, importEdges), e.file_path),
171 Write: (e, r) => inWorktree(writeAction(normalize(e.file_path, cwd), r, cwd, importEdges), e.file_path),
172 NotebookEdit: (e, r) => inWorktree(notebookEditAction(normalize(e.notebook_path, cwd), r), e.notebook_path),
173 Bash: (e, r, text, start) => together([searchAction(e.command, e.tool_use_id, r, cwd, text, start), ...shellReads(e.command, e.tool_use_id, r, cwd, text, importEdges, start), commandedAction(e.command, start)]), // F02: the hits of a search; F07-R19: the files it prints; R21, R22: what git cannot see
174}
175
176// F07-R21: what an `rm` removed last; R22: with no git (`root` null), the files a command writes. Only paths with a node change.
177function commandedAction(command: string, start?: string): Commanded | null {
178 const deleted = removals(command, cwd, start), edited = root ? [] : writes(command, cwd, start)
179 return deleted.length || edited.length ? { type: 'commanded', command, deleted, edited } : null
180}
181
182// F07-R7: the main loop's Bash commands this turn, to tell Claude's own edit from an outside one when Claude Code shows it.
183const turnCommands: { command: string; named: (path: string) => boolean }[] = []
184
185// F07-R1, R5, R6: what shell commands do to files, from git's status around each Bash call, off the hook's path (R8). `root` is null outside a repository.
186let root: string | null = null
187type Snap = { status: Map<string, string>; head: string | null }
188// F07-R16: one baseline per tree, by folder: the working tree (`cwd`) and each agent worktree.
189const bases = new Map<string, Snap>()
190const NO_GIT = 'Shell changes need git'
191const GIT = { timeoutMs: 5000, env: { GIT_OPTIONAL_LOCKS: '0' } } // NFR-S5: never the index lock
192// F07-R14: each status takes `HEAD` with it (none in a repository without commits).
193async function status($: Pick<CoreEngineInterface, 'process'>, dir = cwd): Promise<Snap | null> {
194 const r = await $.process.run(['git', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: dir, ...GIT })
195 if (r.exitCode !== 0) return null
196 const h = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd: dir, ...GIT })
197 return { status: statusOf(r.stdout), head: h.exitCode === 0 ? h.stdout.trim() : null }
198}
199async function headDiff($: Pick<CoreEngineInterface, 'process'>, dir: string, from: string, to: string) {
200 const r = await $.process.run(['git', 'diff', '--name-status', '-z', '--no-renames', from, to], { cwd: dir, ...GIT })
201 return r.exitCode === 0 ? nameStatusOf(r.stdout) : undefined
202}
203// F07-R23: the lines of a file a command created (against /dev/null), or of a tracked edit the baseline did not hold (against the index), run where git's paths start (`top`: the root, or a worktree's folder).
204// The model keeps an untracked edit's count only on a node that is `created`. A failed run counts nothing, and its reason goes to the debug log (R9).
205// ponytail: at most 20 counts per call, one git process each; one `git diff --numstat` over all of them if commands that write many files matter.
206const MAX_COUNTS = 20
207async function withLines($: Pick<CoreEngineInterface, 'process' | 'ui'>, top: string, changes: ShellChange[]) {
208 let n = 0
209 const out: ShellChange[] = []
210 for (const c of changes) {
211 if (!c.count || (c.change !== 'created' && c.change !== 'edited') || n++ >= MAX_COUNTS) { out.push(c); continue }
212 const argv = c.count!.untracked ? ['git', 'diff', '--numstat', '--no-index', '--', '/dev/null', c.count!.path] : ['git', 'diff', '--numstat', '--', c.count!.path]
213 const r = await $.process.run(argv, { cwd: top, ...GIT }).catch((err: unknown) => ($.ui.log(`git count dropped: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' }), null))
214 const lines = r ? linesOf(r.stdout) : null // --no-index exits 1 when the file is not empty
215 out.push(lines ? { ...c, ...lines } : c)
216 }
217 return out
218}
219// Takes a tree's baseline anew (the working tree's, or a worktree's folder); a failing `git` keeps the old one.
220async function rebase($: Pick<CoreEngineInterface, 'process'>, dir = cwd) {
221 const now = await status($, dir)
222 if (now) bases.set(dir, now)
223}
224async function findRoot($: StateDollar & Pick<CoreEngineInterface, 'process'>) {
225 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, ...GIT }).catch(() => null)
226 root = r?.exitCode === 0 ? r.stdout.trim() : null
227 bases.clear()
228 if (root) await rebase($)
229 else await update($, NOTES, ns => (ns ?? []).includes(NO_GIT) ? ns ?? [] : [...(ns ?? []), NO_GIT])
230}
231
232// F07-R13: Claude's own change goes into the baseline, so a later command that removes the file is seen. One refresh waits at a time; it reads `git` when it runs, so it takes the latest.
233// A file under a worktree refreshes that worktree's baseline (R16); a worktree `git` cannot run in is dropped by the queue. ponytail: a Set of folders waiting. A refresh that runs after a command's effects absorbs them (an Edit and a Bash call within one queue delay), so that command's change goes unseen; order refresh and command jobs if it matters.
234const refreshQueued = new Set<string>()
235function queueRefresh($: StateDollar & Pick<CoreEngineInterface, 'clock' | 'process' | 'ui'>, path: string) {
236 const worktree = worktreeOf(path, cwd)
237 const dir = worktree ? `${cwd}/.claude/worktrees/${worktree}` : cwd
238 if (!root || refreshQueued.has(dir)) return
239 refreshQueued.add(dir)
240 enqueue($, gitJob($, async () => {
241 refreshQueued.delete(dir)
242 await rebase($, dir)
243 }))
244}
245
246// F07-R2, R3, R4: the files that import a path Claude changed and were never opened, found by `git grep` and confirmed with F06's rules. `grep` exits 1 when nothing matches.
247const searched = new Set<string>() // paths searched this main-loop turn, cleared at `turn.start`
248function queueImporters($: StateDollar & Pick<CoreEngineInterface, 'process' | 'clock' | 'ui'>, path: string) {
249 if (!root || searched.has(path)) return
250 searched.add(path)
251 enqueue($, gitJob($, async () => {
252 const r = await $.process.run(['git', 'grep', '-n', '-I', '--untracked', '-E', '-e', importPattern(path), '--', ...PATHSPECS], { cwd: root!, ...GIT })
253 const importers = r.exitCode === 0 ? importersOf(r.stdout, path, root!, cwd) : []
254 if (importers.length) await apply($, { type: 'importers', path, importers })
255 }))
256}
257
258// F07-R6, R14, R16: what one `Bash` call did. An agent's call looks in its worktree first, and in the working tree if `git` cannot run there.
259async function shellJob($: StateDollar & Pick<CoreEngineInterface, 'process' | 'clock' | 'ui' | 'agent'>, e: { command: string; agentId?: string }, start?: string) {
260 const wt = e.agentId ? `${cwd}/.claude/worktrees/agent-${e.agentId}` : null
261 const inWt = wt ? await status($, wt).catch(() => null) : null
262 const dir = inWt ? wt! : cwd
263 const now = inWt ?? (await status($))
264 const before = bases.get(dir) ?? (inWt ? { status: new Map<string, string>(), head: null } : undefined) // a new worktree is clean
265 if (!now || !before) return
266 const diff = !now.head || before.head === now.head ? undefined : before.head ? await headDiff($, dir, before.head, now.head) : 'first' // F07-R14
267 const reverts = !stashes(e.command) && (before.head === now.head || diff !== undefined) // F07-R6: `git stash pop` brings a stashed change back; a failed diff cannot tell a commit
268 const moved = moves(e.command, cwd, start) // F07-R17: a mv's new path takes what Claude had of the old one
269 const found = shellChanges(before.status, now.status, root!, cwd, diff, namedPaths(e.command, cwd, start), reverts, new Set(writes(e.command, cwd, start))).map(c => (c.change === 'created' && moved.has(c.path) ? { ...c, from: moved.get(c.path) } : c))
270 bases.set(dir, now)
271 const changes = await withLines($, inWt ? dir : root!, found)
272 if (changes.length) await apply($, { type: 'shellChanged', command: e.command, changes, ...(inWt ? { worktree: `agent-${e.agentId}` } : {}), ...(e.agentId ? { agentId: e.agentId } : {}) }) // F15-R1
273 if (changes.length && e.agentId) await nameAgent($, e.agentId)
274 for (const c of changes) if (c.named) queueImporters($, c.path) // F07-R2: a change the command named is Claude's own
275}
276
277// F14-R3: the clamped `maxNodes` (I5). The engine already refuses a value outside 20 to 200 (F14-Q1); the clamp is for a build that does not.
278let maxNodes = 60
279// F06-R12, F14-R2: read once; off means no import is parsed.
280let importEdges = true
281// F07-R12, F14-R2: read once; off, `root` stays null, so no git job is ever queued, and the list has no Blind spots section. Shell reads need no git.
282let blindSpots = true
283// F05-R5, F14-R2: read once; off, no `turn.step` hook and no Thinking section.
284let captureThinking = true
285// F05-R4: the newest notes kept.
286const MAX_THINKING = 20
287
288/** F05-R6, R9: the footer's hidden-thinking note on or off; `notes` is written only when that changes. */
289async function hiddenNote($: StateDollar, hidden: boolean) {
290 const notes = (await read($, NOTES)) ?? []
291 if (withHiddenNote(notes, hidden) !== notes) await update($, NOTES, ns => withHiddenNote(ns ?? [], hidden))
292}
293
294export const register: Register = (on, options) => {
295 maxNodes = Math.min(200, Math.max(20, Number(options.maxNodes ?? 60)))
296 importEdges = options.importEdges !== false
297 blindSpots = options.blindSpots !== false
298 captureThinking = options.captureThinking !== false
299
300 // F05-R1, R5, R7, R10, F14-R2: off, no hook at all. On, every chunk passes as it came and the stream's result is returned;
301 // a stream that rejects or is closed passes that on, and stores nothing. Recording sits in a `try`, so it never costs a chunk.
302 // F05-R2, R4, NFR-P2: a main-loop step keeps per block its first 400 code points, its length and whether it is all whitespace; one write of `thinking` after the result.
303 if (captureThinking) on('turn.step', async function* ($, e, next) {
304 if (e.agentId) return yield* next(e) // F05-R7: a subagent's step is forwarded and costs nothing
305 const stream = next(e)
306 const blocks = new Map<number, Block>()
307 let failed: unknown = null
308 for await (const chunk of stream) {
309 try {
310 if (chunk.kind === 'thinking' && !failed) blocks.set(chunk.index, addChunk(blocks.get(chunk.index) ?? EMPTY_BLOCK, chunk.text))
311 } catch (err) {
312 failed = err
313 }
314 yield chunk
315 }
316 const result = await stream.result
317 try {
318 if (failed) throw failed
319 const note = thinkingNote(blocks.values(), result.toolUses, cwd, (await read($, TURN)) ?? 0, e.index)
320 // F05-R6, R9: after a step with thinking chunks, the footer names the setting while no note is held.
321 if (blocks.size) {
322 const held = note ? await update($, THINKING, ts => [...(ts ?? []), note].slice(-MAX_THINKING)) : await read($, THINKING)
323 await hiddenNote($, !held?.length)
324 }
325 } catch (err) {
326 $.ui.log(`thinking note dropped: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
327 }
328 return result
329 })
330 on('session.start', async ($, e, next) => {
331 const out = await next(e)
332 cwd = out.cwd
333 // F05-R5: state outlives the reload that switched capture off, so what it held goes; each key written only if it holds something.
334 if (!captureThinking) {
335 if ((await read($, THINKING))?.length) await update($, THINKING, () => [])
336 await hiddenNote($, false)
337 }
338 await $.command.register({
339 name: 'graph', description: 'Show what Claude read, searched and changed', argumentHint: '[list|graph]', immediate: true,
340 })
341 await refreshMemory($)
342 if (blindSpots) enqueue($, () => findRoot($))
343 return out
344 }).catch(($, e, next) => next(e))
345
346 on('classic.SessionStart', async ($, e, next) => {
347 const out = await next(e)
348 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') await resetState($)
349 return out
350 }).catch(($, e, next) => next(e))
351
352 on('turn.start', async ($, e, next) => {
353 const out = await next(e)
354 if (e.agentId) return out // F15-R10: a subagent's run is no main-loop turn
355 await update($, TURN, n => (n ?? 0) + 1)
356 searched.clear()
357 turnCommands.length = 0
358 // F07-R5, R20: what changed between turns is not the next command's doing; it was changed outside Claude.
359 if (root) enqueue($, gitJob($, async () => {
360 const before = bases.get(cwd)
361 await rebase($)
362 const now = bases.get(cwd)
363 const changes = before && now && now !== before ? outsideChanges(before.status, now.status, root!, cwd) : []
364 if (changes.length) await apply($, { type: 'outside', changes })
365 }))
366 return out
367 }).catch(($, e, next) => next(e))
368
369 on('tool.call', { tool: /^(Read|Edit|Write|NotebookEdit|Bash)$/ }, async ($, e: any, next) => {
370 // F02-R8: where a command starts, read before it runs: the Bash tool keeps a `cd` from one call for the next.
371 const start = e.tool === 'Bash' ? await $.session.cwd().then(d => (d === cwd ? undefined : d), () => undefined) : undefined
372 const out = await next(e)
373 if (e.tool === 'Bash' && !out.deny && !e.agentId) turnCommands.push({ command: e.command, named: namedPaths(e.command, cwd, start) })
374 if (!out.deny && !out.isError && out.result) {
375 const action = parsers[e.tool as keyof typeof parsers](e, out.result, out.text, start)
376 if (action) await record($, action, e.agentId)
377 }
378 if (!out.deny && !out.isError && /^(Edit|Write|NotebookEdit)$/.test(e.tool)) queueRefresh($, e.file_path ?? e.notebook_path)
379 if (!out.deny && !out.isError && /^(Edit|Write)$/.test(e.tool)) queueImporters($, normalize(e.file_path, cwd)) // F07-R2
380 // F07-R6: a command that ran, even one that failed, may have created, deleted or changed files.
381 // F07-R18: a call the engine held read-only changed no file.
382 if (e.tool === 'Bash' && !out.deny && !out.isReadOnly && root) enqueue($, gitJob($, () => shellJob($, e, start)))
383 return out
384 }).catch(($, e, next) => next(e))
385
386 on('prompt.mention', async ($, e, next) => {
387 const out = await next(e)
388 if (!out.deny) {
389 const action = mentionAction(normalize(e.path, cwd), e, out.type ?? null)
390 if (action) await record($, action, e.agentId)
391 }
392 return out
393 }).catch(($, e, next) => next(e))
394
395 // F03-R4: the window's real fill, stored as reported.
396 on('session.measure', async ($, e, next) => {
397 const out = await next(e)
398 if (e.changed.includes('context')) {
399 await update($, FILL, () => ({ tokens: e.context.tokens ?? null, window: e.context.window, percent: e.context.percent ?? null }))
400 await refreshMemory($)
401 }
402 return out
403 }).catch(($, e, next) => next(e))
404
405 // F03-R1, R8: a compaction that stands, of the main loop. A subagent's (agentId), a skip and a precompute change nothing.
406 on('session.compact', async ($, e, next) => {
407 const out = await next(e)
408 if (out.messages && !e.agentId && e.trigger !== 'precompute') {
409 const keptIds = out.messages.flatMap(m => m.toolUses.map(t => t.tool_use_id))
410 await apply($, { type: 'compacted', keptIds, trigger: e.trigger, tokensBefore: out.tokensBefore ?? null, tokensAfter: out.tokensAfter ?? null })
411 }
412 return out
413 }).catch(($, e, next) => next(e))
414
415 // F03-R3: a file Claude Code re-attached after a compaction. The reducer skips every node but a compacted one.
416 on('prompt.attachment', { type: 'file' }, async ($, e, next) => {
417 const out = await next(e)
418 const action = e.agentId ? null : reattachedAction(e.text, cwd)
419 if (action) await apply($, action)
420 return out
421 }).catch(($, e, next) => next(e))
422
423 // F07-R7: a main-loop file changed outside Claude that Claude Code showed it. Not a blind spot. Needs no git.
424 on('prompt.attachment', { type: 'edited_text_file' }, async ($, e, next) => {
425 const out = await next(e)
426 const action = e.agentId ? null : externalAction(e.text, cwd)
427 const own = action?.path ? turnCommands.findLast(c => c.named(action.path!)) : undefined
428 if (own) await apply($, { type: 'commanded', command: own.command, deleted: [], edited: [action!.path!] }) // Claude's own edit, shown back to it
429 else if (action) await apply($, action)
430 return out
431 }).catch(($, e, next) => next(e))
432
433 // F04-R3: a memory file Claude Code loads, noted at once. Its size waits for the usage breakdown (ticket 03).
434 on('classic.InstructionsLoaded', async ($, e, next) => {
435 const out = await next(e)
436 await update($, MEMORY, notes => loaded(notes ?? [], memoryNote(e, cwd)))
437 return out
438 }).catch(($, e, next) => next(e))
439
440 // F04-R6: the size of a nested file, from the text Claude Code put in the window. A subagent's attachment (agentId) is not the main loop's.
441 on('prompt.attachment', { type: 'nested_memory' }, async ($, e, next) => {
442 const out = await next(e)
443 const size = e.agentId ? null : nestedSize(e.text, cwd)
444 if (size) await update($, MEMORY, notes => sized(notes ?? [], size.path, size.tokens))
445 return out
446 }).catch(($, e, next) => next(e))
447
448 on('command.run', { command: 'graph' }, async ($, e) => {
449 const arg = e.args.trim()
450 if (arg === 'list' || arg === 'graph') await setView($, arg) // F08-R2: also where nothing draws
451 // F08-R4 / NFR-C3: where nothing draws, answer in text and open nothing.
452 if ((await $.session.surfaces()).length === 0) return { text: summarize(await readGraph($), await readFill($)) }
453 const opened = await $.ui.open({ id: PANE, title: 'CC Graph', focus: true, closeOnEscape: true, columns: 64 })
454 if (!opened.isPlaced) $.ui.toast(`CC Graph is waiting for room: ${opened.reason}`)
455 return { text: 'CC Graph opened.' }
456 }).catch(($, e, next) => next(e))
457
458 // NFR-R4: only this plugin's pane. Reads state, never writes.
459 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
460 pulses++
461 const table = $.ui.resolve(e)
462 const { Box, Button, Text } = table
463 const notes = (await read($, NOTES)) ?? []
464 // F08-R10: the renderer follows the table (Raster on terminal: F09, Svg: F10, else the list), then `view`.
465 const rows = graphRows(e.props.scroll.bodyRows, notes)
466 const selected = (await read($, SELECTED)) ?? null
467 const wantsGraph = e.surface === 'terminal' && 'Raster' in table && (await read($, VIEW)) === 'graph'
468 const graphShown = wantsGraph && rows >= MIN_ROWS
469 drawn = graphShown ? { columns: graphColumns(e.props.bodyColumns), rows } : null
470 return (
471 <Box flexDirection="column">
472 <Box gap={2}>
473 {graphShown && <Text bold><Text color="claude">✻</Text> CC Graph</Text>}
474 {graphShown && <Text dimColor>what Claude knows</Text>}
475 <Button plain key="view-list" hotkey="l" onPress={() => setView($, 'list')}>List</Button>
476 <Button plain key="view-graph" hotkey="g" onPress={() => setView($, 'graph')}>Graph</Button>
477 </Box>
478 {graphShown
479 ? renderGraph(table, await readGraph($), await readLayout($), await readMemory($), await readFill($), notes, e.props.bodyColumns, rows, selected)
480 : renderList(table, await readGraph($), await readFill($), await readMemory($), wantsGraph ? [...notes, TOO_SHORT] : notes, e.props.bodyColumns, selected, path => update($, SELECTED, () => path), blindSpots, captureThinking ? (await read($, THINKING)) ?? [] : null)}
481 </Box>
482 )
483 })
484}
485hooks/layout/index.ts 84 lines1// Pure: F09-R4, R13. (previous positions, graph) -> positions. A small force layout: nodes repel, import edges pull to REST apart and settle importers FLOW above, a node is drawn to its folder's seed point.
2import type { Graph, Layout, Point } from '../../types'
3import { CANVAS, folderOf, seedPoint, startPoint } from './seed'
4
5const ITERATIONS = 200 // F09-R4
6const MAX_MOVE = 2 // F09-R4: units a node may move in one pass
7const REPEL = 40
8const SPRING = 0.02
9const REST = 14 // F09-R13: units an import edge pulls its ends to
10const FLOW = 10 // F09-R13: units an importer sits above the file it imports
11const BELOW = 8 // F09-R13: where a new node starts relative to its placed neighbour
12const PULL = 0.01
13const STEP = 0.05
14
15const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
16
17/** F09-R13: a new node starts below its most recently touched placed importer, else above the most recently touched placed file it imports, else at its folder's seed point (offset by a hash of its path). */
18function startOf(path: string, previous: Layout, graph: Graph): Point {
19 const offset = startPoint(path)
20 const newest = (paths: string[]) => paths.filter(p => previous[p]).sort((a, b) => (graph.files[b]?.touched ?? 0) - (graph.files[a]?.touched ?? 0) || (a < b ? -1 : 1))[0]
21 const by = (p: string | undefined, dy: number) => p && { x: clamp(previous[p]!.x + offset.x - seedPoint(folderOf(path)).x, 0, CANVAS.width), y: clamp(previous[p]!.y + dy, 0, CANVAS.height) }
22 const imports = graph.edges.filter(e => e.kind === 'import')
23 return by(newest(imports.filter(e => e.to === path).map(e => e.from)), BELOW) || by(newest(imports.filter(e => e.from === path).map(e => e.to)), -BELOW) || offset
24}
25
26export function layout(previous: Layout, graph: Graph): Layout {
27 const paths = Object.keys(graph.files).sort() // sorted, so the order of insertion never changes the result
28 const start = paths.map(p => previous[p] ?? startOf(p, previous, graph))
29 const pos = start.map(p => ({ ...p }))
30 const home = paths.map(p => seedPoint(folderOf(p)))
31 const index = new Map(paths.map((p, i) => [p, i]))
32 const links = graph.edges.flatMap(e => {
33 const a = index.get(e.from), b = index.get(e.to)
34 return a === undefined || b === undefined || e.kind !== 'import' ? [] : [[a, b] as const]
35 })
36 const linked = new Set(links.flatMap(([a, b]) => [a * paths.length + b, b * paths.length + a]))
37 for (let it = 0; it < ITERATIONS; it++) {
38 const force = pos.map((_, i) => ({ x: (home[i]!.x - pos[i]!.x) * PULL, y: (home[i]!.y - pos[i]!.y) * PULL }))
39 for (let i = 0; i < pos.length; i++)
40 for (let j = i + 1; j < pos.length; j++) {
41 if (linked.has(i * pos.length + j)) continue // F09-R13: the spring alone sets an import edge's length
42 const dx = pos[i]!.x - pos[j]!.x, dy = pos[i]!.y - pos[j]!.y
43 const d2 = Math.max(dx * dx + dy * dy, 1)
44 const f = REPEL / d2
45 force[i]!.x += dx * f; force[i]!.y += dy * f
46 force[j]!.x -= dx * f; force[j]!.y -= dy * f
47 }
48 for (const [a, b] of links) {
49 const dx = pos[b]!.x - pos[a]!.x, dy = pos[b]!.y - pos[a]!.y
50 const d = Math.max(Math.hypot(dx, dy), 0.01)
51 const f = (d - REST) * SPRING / d
52 force[a]!.x += dx * f; force[a]!.y += dy * f
53 force[b]!.x -= dx * f; force[b]!.y -= dy * f
54 }
55 pos.forEach((p, i) => {
56 p.x = clamp(p.x + force[i]!.x * STEP, 0, CANVAS.width)
57 p.y = clamp(p.y + force[i]!.y * STEP, 0, CANVAS.height)
58 })
59 // F09-R13: an importer less than FLOW above what it imports is pushed apart vertically, half the shortfall each.
60 for (const [a, b] of links) {
61 const shortfall = FLOW - (pos[b]!.y - pos[a]!.y)
62 if (shortfall <= 0) continue
63 pos[a]!.y = clamp(pos[a]!.y - shortfall / 2, 0, CANVAS.height)
64 pos[b]!.y = clamp(pos[b]!.y + shortfall / 2, 0, CANVAS.height)
65 }
66 }
67 // F09-R4: however far the forces pushed, no node with an earlier position ends more than MAX_MOVE from it; R13: a new node moves freely.
68 return Object.fromEntries(paths.map((path, i) => {
69 if (!previous[path]) return [path, pos[i]!]
70 const dx = pos[i]!.x - start[i]!.x, dy = pos[i]!.y - start[i]!.y
71 const d = Math.hypot(dx, dy)
72 const k = d > MAX_MOVE ? MAX_MOVE / d : 1
73 return [path, { x: start[i]!.x + dx * k, y: start[i]!.y + dy * k }]
74 }))
75}
76
77const edgeKey = (e: Graph['edges'][number]) => `${e.from}\0${e.to}\0${e.kind}`
78
79/** F09-R4: whether nodes or edges were added or removed between two graphs (a changed node alone is not). */
80export function reshaped(a: Graph, b: Graph): boolean {
81 const ka = Object.keys(a.files), kb = Object.keys(b.files)
82 return ka.length !== kb.length || ka.some(k => !(k in b.files)) || a.edges.length !== b.edges.length || a.edges.some((e, i) => edgeKey(e) !== edgeKey(b.edges[i]!))
83}
84hooks/model/index.ts 42 lines1// Pure reducers: (graph, action, turn) -> graph.
2import type { Graph } from '../../types'
3import { batch, type Batch } from './batch'
4import { commanded, type Commanded } from './commanded'
5import { compacted, type Compacted } from './compacted'
6import { external, type External } from './external'
7import { fileEdited, type FileEdited } from './fileEdited'
8import { fileRead, fileUnchanged, type FileRead, type FileUnchanged } from './fileRead'
9import { fileWritten, type FileWritten } from './fileWritten'
10import { importers, type Importers } from './importers'
11import { mention, type Mention } from './mention'
12import { outside, type Outside } from './outside'
13import { reattached, type Reattached } from './reattached'
14import { searched, type Searched } from './searched'
15import { shellChanged, type ShellChanged } from './shellChanged'
16
17// F15-R1: the actions that name a file (a Batch holds them), and so carry the calling agent's id.
18export type FileAction = FileRead | FileUnchanged | FileEdited | FileWritten | Mention | Searched | Commanded | Batch
19export type Action = FileAction | Compacted | Reattached | ShellChanged | Importers | External | Outside
20
21export const emptyGraph: Graph = { seq: 0, files: {}, edges: [], decisions: [], compactions: [] }
22
23export function reduce(g: Graph | undefined, a: Action, turn: number): Graph {
24 const graph = g ?? emptyGraph
25 switch (a.type) {
26 case 'fileRead': return fileRead(graph, a, turn)
27 case 'fileUnchanged': return fileUnchanged(graph, a, turn)
28 case 'mention': return mention(graph, a, turn)
29 case 'fileEdited': return fileEdited(graph, a, turn)
30 case 'fileWritten': return fileWritten(graph, a, turn)
31 case 'searched': return searched(graph, a, turn)
32 case 'batch': return batch(graph, a, turn, reduce)
33 case 'compacted': return compacted(graph, a, turn)
34 case 'reattached': return reattached(graph, a)
35 case 'shellChanged': return shellChanged(graph, a, turn)
36 case 'importers': return importers(graph, a, turn)
37 case 'external': return external(graph, a, turn)
38 case 'commanded': return commanded(graph, a, turn)
39 case 'outside': return outside(graph, a, turn)
40 }
41}
42hooks/model/commanded.ts 16 lines1// Pure: what a Bash call did that the status cannot show, from its command alone (F07-R21, R22; R7 for an edit Claude Code showed Claude).
2import type { Graph } from '../../types'
3import { cut } from '../parse/shell'
4import { shellChanged } from './shellChanged'
5import { touch } from './node'
6
7/** `deleted`: node paths, or folders, an `rm` removed last; `edited`: node paths the command wrote. Only paths with a node change. */
8export type Commanded = { type: 'commanded'; command: string; deleted: string[]; edited: string[]; agentId?: string }
9
10export const commanded = (g: Graph, a: Commanded, turn: number): Graph => {
11 const edited = a.edited.filter(p => g.files[p])
12 const out = edited.length ? shellChanged(g, { type: 'shellChanged', command: a.command, changes: edited.map(path => ({ path, change: 'edited', named: true })), ...(a.agentId ? { agentId: a.agentId } : {}) }, turn) : g
13 const gone = Object.keys(out.files).filter(p => a.deleted.some(d => p === d || p.startsWith(`${d}/`)) && out.files[p]!.change !== 'deleted')
14 return gone.reduce((g, p) => touch(g, p, turn, () => ({ change: 'deleted', reason: `deleted by \`${cut(a.command)}\`` }), a.agentId), out)
15}
16hooks/model/batch.ts 16 lines1// Pure: the actions of one call land in the graph together (I9: one call, one write).
2import type { Graph } from '../../types'
3import type { Action, FileAction } from '.'
4
5export type Batch = { type: 'batch'; actions: FileAction[]; agentId?: string }
6
7/** The call's own actions in order, each as if the agent that made the call (F15-R1) had made it. `reduce` is passed in: the index imports this module. */
8export const batch = (g: Graph, a: Batch, turn: number, reduce: (g: Graph, a: Action, turn: number) => Graph): Graph =>
9 a.actions.reduce((g, x) => reduce(g, a.agentId ? { ...x, agentId: a.agentId } : x, turn), g)
10
11/** One action, as it is, for a call that made one; a batch for several; null for none. */
12export const together = (actions: (FileAction | null)[]): FileAction | null => {
13 const some = actions.filter((a): a is FileAction => a !== null)
14 return some.length > 1 ? { type: 'batch', actions: some } : some[0] ?? null
15}
16hooks/model/evict.ts 23 lines1// Pure: I5. The cap on file nodes, held by evicting the least valuable nodes in the same write.
2import type { Graph, NodeState } from '../../types'
3
4// Cheapest to lose first. A state not listed (`unread`) is never evicted.
5const order: NodeState[] = ['ghost', 'search', 'compacted', 'partial', 'read']
6
7/** Drops nodes, state order then oldest `lastTurn`, until at most `max` remain. A node with a change, and one with an edge to or from one, stays; if nothing else can go the cap is exceeded. */
8export function evict(g: Graph, max: number): Graph {
9 const paths = Object.keys(g.files)
10 if (paths.length <= max) return g
11 const changed = new Set(paths.filter(p => g.files[p]!.change))
12 const linked = new Set(g.edges.flatMap(e => (changed.has(e.from) || changed.has(e.to) ? [e.from, e.to] : [])))
13 const rank = (p: string) => order.indexOf(g.files[p]!.state)
14 const drop = paths
15 .filter(p => !changed.has(p) && !linked.has(p) && rank(p) >= 0)
16 .sort((a, b) => rank(a) - rank(b) || g.files[a]!.lastTurn - g.files[b]!.lastTurn || g.files[a]!.touched - g.files[b]!.touched)
17 .slice(0, paths.length - max)
18 const files = { ...g.files }
19 for (const p of drop) delete files[p]
20 const gone = new Set(drop)
21 return { ...g, files, edges: g.edges.filter(e => !gone.has(e.from) && !gone.has(e.to)) } // I3: no edge outlives an endpoint
22}
23hooks/model/memory.ts 19 lines1// Pure: a loaded memory file replaces its note in place, or is added last (F04-R3).
2import type { MemoryNote } from '../../types'
3
4export const loaded = (notes: MemoryNote[], note: MemoryNote): MemoryNote[] =>
5 notes.some(n => n.path === note.path) ? notes.map(n => (n.path === note.path ? note : n)) : [...notes, note]
6
7// Loaded mid-conversation, the breakdown never lists them (F04-R1).
8const KEPT = new Set(['nested', 'path rule', 'import'])
9
10/** F04-R1: the notes the breakdown lists replace the old ones, keeping a listed note's reason; an unlisted mid-conversation note stays with its tokens. */
11export const refreshed = (notes: MemoryNote[], files: MemoryNote[]): MemoryNote[] => [
12 ...files.map(f => ({ ...f, reason: notes.find(n => n.path === f.path)?.reason ?? null })),
13 ...notes.filter(n => !files.some(f => f.path === n.path) && n.reason && KEPT.has(n.reason)),
14]
15
16/** F04-R6: the size of one note; no such note, no change. */
17export const sized = (notes: MemoryNote[], path: string, tokens: number): MemoryNote[] =>
18 notes.map(n => (n.path === path ? { ...n, tokens } : n))
19hooks/model/notes.ts 20 lines1// Pure: the footer notes for I5's "the cap is exceeded" (F14-AC8) and for hidden thinking (F05-R6).
2const CAP = / over the cap of \d+$/
3
4/** The notes with the cap note replaced by the current count, or dropped when the graph is within the cap; `notes` itself when that changes nothing. */
5export function withCapNote(notes: string[], files: number, max: number): string[] {
6 const over = files - max
7 const note = over > 0 ? `${over} ${over === 1 ? 'file' : 'files'} over the cap of ${max}` : undefined
8 if (notes.find(n => CAP.test(n)) === note) return notes
9 return [...notes.filter(n => !CAP.test(n)), ...(note ? [note] : [])]
10}
11
12/** F05-R6: the footer note of thinking that streams without text. */
13const HIDDEN = 'Thinking is hidden: turn on showThinkingSummaries to see it.'
14
15/** F05-R5, R6, R9: the notes with the hidden note once while `hidden`, without it otherwise; `notes` itself when that changes nothing. */
16export function withHiddenNote(notes: string[], hidden: boolean): string[] {
17 if (notes.includes(HIDDEN) === hidden) return notes
18 return hidden ? [...notes, HIDDEN] : notes.filter(n => n !== HIDDEN)
19}
20hooks/model/thinking.ts 29 lines1// Pure: F05-R2, R3. One step's note from its collected thinking blocks and the tool uses its result lists.
2import type { ThinkingNote } from '../../types'
3import { normalize } from '../parse/path'
4
5/** F05-R2: the most of a block, and of a note, kept (code points). */
6const EXCERPT = 400
7/** One block as the hook collects it: its first `EXCERPT` code points of text (`kept` of them), its full length, and whether all of it is whitespace. */
8export type Block = { text: string; kept: number; chars: number; blank: boolean }
9export const EMPTY_BLOCK: Block = { text: '', kept: 0, chars: 0, blank: true }
10const SEP = '\n\n'
11
12/** NFR-P2: `b` with one more chunk, in constant work. While the block is only whitespace that whitespace is not kept, so the excerpt starts at its text (F05-R2). */
13export function addChunk(b: Block, text: string): Block {
14 const cps = [...text]
15 const add = (b.blank ? [...text.trimStart()] : cps).slice(0, EXCERPT - b.kept)
16 return { text: b.text + add.join(''), kept: b.kept + add.length, chars: b.chars + cps.length, blank: b.blank && !/\S/.test(text) }
17}
18
19/** The blocks with text other than whitespace joined by a blank line and cut to `EXCERPT`; the files the step's tool uses name (I4, no duplicates). No text, no note. */
20export function thinkingNote(blocks: Iterable<Block>, toolUses: readonly { input: unknown }[], cwd: string, turn: number, step: number): ThinkingNote | null {
21 const kept = [...blocks].filter(b => !b.blank)
22 if (!kept.length) return null
23 const text = [...kept.map(b => b.text).join(SEP)].slice(0, EXCERPT).join('')
24 const chars = kept.reduce((n, b) => n + b.chars, SEP.length * (kept.length - 1))
25 const paths = toolUses.map(t => (t.input as { file_path?: unknown; notebook_path?: unknown } | null)).map(i => i?.file_path ?? i?.notebook_path)
26 const files = [...new Set(paths.filter((p): p is string => typeof p === 'string').map(p => normalize(p, cwd)))]
27 return { turn, step, text, chars, files }
28}
29hooks/parse/fileEdited.ts 19 lines1// Pure: an Edit's or NotebookEdit's result becomes a `fileEdited` action, or null when nothing was written.
2import type { FileEdited } from '../model/fileEdited'
3import { importCandidates } from './imports'
4import { count, countLines, type Hunk } from './measure'
5
6type EditResult = { newString: string; structuredPatch: Hunk[]; staged?: boolean }
7type NotebookResult = { new_source: string; old_source?: string; error?: string }
8
9export function editAction(path: string, r: EditResult, cwd: string, importEdges: boolean): FileEdited | null {
10 if (r.staged) return null
11 return { type: 'fileEdited', path, added: count(r.structuredPatch, '+'), removed: count(r.structuredPatch, '-'), line: r.structuredPatch[0]?.newStart ?? null, imports: importEdges ? importCandidates(r.newString, path, cwd) : undefined }
12}
13
14// A notebook has no patch, so `line` stays as it was.
15export function notebookEditAction(path: string, r: NotebookResult): FileEdited | null {
16 if (r.error) return null
17 return { type: 'fileEdited', path, added: countLines(r.new_source), removed: countLines(r.old_source), line: null }
18}
19hooks/parse/attachment.ts 21 lines1// Pure: a re-attached file (F03-R3). The first line of a `file` attachment's text holds the Read call's input as JSON.
2import type { External } from '../model/external'
3import type { Reattached } from '../model/reattached'
4import { estimateTokens } from './measure'
5import { normalize } from './path'
6
7export function reattachedAction(text: string, cwd: string): Reattached | null {
8 const json = /^Called the Read tool with the following input: (\{.*\})$/.exec(text.split('\n', 1)[0]!)?.[1]
9 let input: unknown
10 try { input = json && JSON.parse(json) } catch { return null }
11 const file = typeof input === 'object' && input !== null && 'file_path' in input ? input.file_path : null
12 return typeof file === 'string'
13 ? { type: 'reattached', path: normalize(file, cwd), tokens: estimateTokens(text) } : null
14}
15
16/** F07-R7: the file an `edited_text_file` attachment names, from its "Note: ‹path› changed on disk since you last read it" line (F07-Q1, recorded); null when the text has none (the reducer then matches it against known nodes). */
17export function externalAction(text: string, cwd: string): External {
18 const path = /^Note: (.+?) (?:changed on disk|was modified)\b/m.exec(text)?.[1]
19 return { type: 'external', path: path ? normalize(path, cwd) : null, text }
20}
21hooks/parse/grep.ts 41 lines1// Pure: F07-R2, R3. The `git grep` that finds the files importing a changed one, and the confirmation of its hits.
2import { candidatesFor, importsIn } from './imports'
3import { normalize, worktreeOf } from './path'
4
5export const PATHSPECS = ['*.ts', '*.tsx', '*.mts', '*.cts', '*.js', '*.jsx', '*.mjs', '*.cjs', '*.py']
6
7const escape = (s: string) => s.replace(/[.^$*+?()[\]{}|\\]/g, '\\$&')
8
9/**
10 * An extended regular expression for the lines that import a file named like `path` without its extension (a changed `index` or `__init__` is named by its folder): a JavaScript or TypeScript import line (F06-R2) whose quoted specifier ends in `/name`, or a Python `from`/`import` line that holds the name.
11 * Deliberately loose: `importersOf` confirms each hit. ponytail: one line at a time, so the names on the earlier lines of a multi-line import are not read.
12 */
13export function importPattern(path: string): string {
14 const [, folder, base = ''] = /(?:([^/]+)\/)?([^/]+?)(?:\.[^./]+)?$/.exec(path)!
15 const index = folder !== undefined && (base === 'index' || base === '__init__')
16 const name = escape(index ? folder! : base)
17 return `(import|export|require|from)[^'"]*['"][^'"]*/${name}${index ? `(/${base})?` : ''}(\\.[A-Za-z]+)?['"]|^[[:space:]]*(from|import)([[:space:]].*)?[^[:alnum:]_]${name}([^[:alnum:]_]|$)`
18}
19
20/** The hits of `git grep -n`: `file:line:text`. */
21export const grepHits = (stdout: string) =>
22 stdout.split('\n').flatMap(l => {
23 const m = /^(.+?):\d+:(.*)$/.exec(l)
24 return m ? [{ file: m[1]!, text: m[2]! }] : []
25 })
26
27/**
28 * F07-R3: the node paths (I4) of the hits whose import resolves to `changed`. `git grep` runs in `root`, so its paths are relative to it.
29 * A file of an agent's worktree is skipped (F07-R15).
30 */
31export function importersOf(stdout: string, changed: string, root: string, cwd: string): string[] {
32 const out: string[] = []
33 for (const { file, text } of grepHits(stdout)) {
34 const abs = `${root}/${file}`
35 const path = normalize(abs, cwd)
36 if (worktreeOf(abs, cwd) || path === changed || out.includes(path)) continue
37 if (importsIn(/^\s*}/.test(text) ? `import ${text}` : text, path).some(s => candidatesFor(s, path, cwd).includes(changed))) out.push(path)
38 }
39 return out
40}
41