SLOPSHOPPER

cc-graph

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

paneguardcommandtoastpromptprocess
★ 1v0.3.0MITupdated 2026-10-17kuehn-lars/cc-graph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-graph
│ ┃ CC Graph ✕ › fix the failing auth test and add an audit log call │ ┃ l: List g: Graph │ ┃ ≈28 in 1 file · window 49% of 200k ⏺ Read(src/auth.ts) │ ┃ Files ⎿ Read 6 lines │ ┃ 1: .env ⏺ Update(src/auth.ts) │ ┃ Thinking ⎿ Added 2 lines, removed 1 line │ ┃ No thinking captured yet. ⏺ Bash(bun test) │ ┃ Blind spots ⎿ 3 pass, 1 fail │ ┃ No blind spots. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /graph │ ⎿ cc-graph: CC Graph opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · CC Graph
l: List g: Graph ≈28 in 1 file · window 49% of 200k Files 1: .env Thinking No thinking captured yet. Blind spots No blind spots.
README

<img src=".github/assets/header.svg" alt="cc-graph: see what Claude knows" width="100%">

Version Claude Code Surface TypeScript CI Network License

<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>

  • <samp>Nothing leaves your machine. No network requests, no model calls, no files written.</samp>
  • <samp>Paths, sizes and counts, never file contents. The one exception is Claude's newest 20 thinking excerpts, at most 400 characters each, and only while captureThinking is on. All of it is gone when the conversation ends or on /clear.</samp>
  • <samp>The only process it starts is read-only git, by argument list, never through a shell.</samp>
  • <samp>Check it yourself: claude plugin validate lists every event cc-graph hooks and every API method it calls. CI fails if the code goes beyond the allowed list.</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>

Source 48 files
hooks/register.tsx 485 lines
1import { 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}
485
hooks/layout/index.ts 84 lines
1// 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}
84
hooks/model/index.ts 42 lines
1// 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}
42
hooks/model/commanded.ts 16 lines
1// 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}
16
hooks/model/batch.ts 16 lines
1// 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}
16
hooks/model/evict.ts 23 lines
1// 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}
23
hooks/model/memory.ts 19 lines
1// 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))
19
hooks/model/notes.ts 20 lines
1// 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}
20
hooks/model/thinking.ts 29 lines
1// 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}
29
hooks/parse/fileEdited.ts 19 lines
1// 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}
19
hooks/parse/attachment.ts 21 lines
1// 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}
21
hooks/parse/grep.ts 41 lines
1// 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