Which files each Claude Code agent has in its context, and how much of each: line ranges, tokens, re-reads, edits, and which files only a subagent it spawned…

A Claude Code mod that shows which files each agent has in its context, and how much of each.
When Claude spawns subagents, the files they read go into their contexts, not the main session's. slash-coverage keeps that visible: for the main session and every subagent, which files it holds in full, which only in part (and which lines), which it never read itself but a subagent it spawned did, which copies went stale after an edit, and what each one costs in tokens.

In a Claude Code terminal session:
/plugin install slash-coverage --marketplace Davron2004/slash-coverage
Answer y to add the marketplace, then pick a scope. In a fullscreen terminal at least 144 columns wide the pane opens on the right when a session starts; in a narrower one, /coverage opens it. /coverage also hides it. Ctrl+X then Tab gives the pane the keyboard, so its keys below work.
slash-coverage is built on Claude Code's mod API (function hooks), which is early access and changes between versions. It was built against Claude Code 2.1.292.
Pick an agent in the row at the top, and every column describes that agent:
| Column | Shows |
|---|---|
| held | What's in its context: ● all, the line ranges it read (◉ 1–180/1323), ● stale ✎main when another agent edited the file after it read it, ◌ via ex1 when only a subagent it spawned read it, ○ grep hit, or ✕ compacted |
| tokens | What the file's tool results cost its context |
| last | Time since it last read, searched or edited the file |
| reads | Read calls, with ↩n for reads of lines it already held |
| edits | Lines it added and removed |
| Glyph | Means |
|---|---|
● | The whole file, current |
◉ | Some of its lines |
● (orange) | Read before another agent edited it |
◌ | This agent never read it, but a subagent it spawned did |
○ | Named in a search, listing or line count, never opened |
✕ | Read, then dropped by compaction |
◆ | The file an agent is working on right now |
Rows are the files the picked agent knows: its own, and those a subagent it spawned read. So main lists everything, and a subagent lists only what it touched. In all, rows are every file any agent touched. Untouched files fold into one "N untouched" line per folder. A folder's row sums what's inside it, strongest first: short runs as glyphs, longer ones counted, ●● ◉ ◌14 ○○○ 20/31. Click a file for what every agent knows of it, and a folder to fold it.
| Key | Does |
|---|---|
h / l | Step to the previous or next agent, in the order they were spawned. The row scrolls; ‹ 3 and 5 › count the agents off screen |
m | Main session |
a | All agents side by side |

a puts one narrow column per agent next to the file tree, then the team's tokens and last touch. It shows what no single agent's view can: two subagents paying for the same file, and folders no agent covered.
Each agent's header is a numbered toggle. Press its number (2 for 2: ex1) to leave that agent out of the view, as if it never ran. Its column goes and the others close up. It waits under the grid in a "left out" line, as ⊘ex1: click it to bring it back, or press 0 to bring back every agent left out. So you can see what your backend agents know without the UI agent's reads mixed in.
Every value comes from tool calls Claude Code already makes, and nothing asks an agent to report anything:
grep, rg, wc), give search hits. A path only counts when it starts a line of the output, so a file merely mentioned inside another file's content doesn't.for loops, with cds followed and globs expanded. Only commands that print file content count as reads, so python3 x.py or a heredoc's code never does, and output sent to a file doesn't either. cat, nl, head, tail, sed -n 'A,Bp' and awk 'NR>=A && NR<=B' give exact lines, through pipes too (cat -n f | sed -n 10,20p is lines 10–20). When Claude Code kept only a preview of a long output, only the lines in the preview count. git status after each command catches files a command changed.git worktree list) map onto the repo's own paths, so a subagent working in ../app-feature or .claude/worktrees/x fills in the same rows as main.◌) is structural: a subagent you spawned read the file. What its report said about the file isn't tracked.slash-coverage keeps a log per agent of what it read, searched and edited, and saves it in the chat's own folder next to the transcript: ~/.claude/projects/<project>/<chat id>/slash-coverage/main.json, plus one file per subagent. Resuming the chat (--resume, --continue) replays the logs in time order and the grid comes back as it was, stale copies included. Claude Code deletes a chat's folder along with its transcript, so the logs go with it. /coverage clear empties them.
CLAUDE.md, never pass through a tool call, so they don't show.sed '/re/,/re/p', a script, xargs) holds unknown lines, so it shows as partial.git clone https://github.com/Davron2004/slash-coverage
claude --plugin-dir ./slash-coverage # run a session with it; saving a file reloads it
cd slash-coverage
claude plugin validate .
claude plugin test .
tsc -p . # after the first load, which lays the API's types in .claude-plugin/types
hooks/knowledge.ts turns tool events into levels, hooks/events.ts holds the event log and its replay, hooks/paths.ts reads shell commands and their output, and hooks/register.tsx wires them to Claude Code and draws the pane.
MIT
hooks/register.tsx 1092 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GpsAgent, Hold, Holds, Range } from '../types'
5import {
6 type Hunk,
7 type Level,
8 RANK,
9 countPatch,
10 covered,
11 describeRanges,
12 level,
13 stripParts,
14 tokensOf,
15 via,
16} from './knowledge'
17import { type GpsEvent, type GpsState, MAIN, type Snapshot, applyEvent, parseSnapshot, replay, snapshotDir, spawnedIn } from './events'
18import { type Checkout, type FileEntry, type Repo, absolute, checkoutOf, logical, makeRepo, pathsIn, shellEffects } from './paths'
19
20type $ = EngineInterface
21
22const PANE = 'coverage'
23const TITLE = 'Coverage'
24const MAX_FILES = 20_000
25/** A search naming more files than this is a listing, not a lead: it marks nothing. */
26const MAX_HITS = 30
27/** An agent counts as working on a file for this long after touching it. */
28const NOW_MS = 20_000
29
30const holdsAtom = atom({ plugin: 'slash-coverage', key: 'holds' } as const, {} as Holds)
31const linesAtom = atom({ plugin: 'slash-coverage', key: 'lines' } as const, {} as Record<string, number>)
32const agentsAtom = atom({ plugin: 'slash-coverage', key: 'agents' } as const, [] as GpsAgent[])
33const pickAtom = atom({ plugin: 'slash-coverage', key: 'pick' } as const, 'auto')
34const anchorAtom = atom({ plugin: 'slash-coverage', key: 'anchor' } as const, null as string | null)
35const closedAtom = atom({ plugin: 'slash-coverage', key: 'closed' } as const, [] as string[])
36const excludedAtom = atom({ plugin: 'slash-coverage', key: 'excluded' } as const, [] as string[])
37const selectedAtom = atom({ plugin: 'slash-coverage', key: 'selected' } as const, null as string | null)
38const outsideAtom = atom({ plugin: 'slash-coverage', key: 'outside' } as const, 0)
39const indexAtom = atom({ plugin: 'slash-coverage', key: 'indexVersion' } as const, 0)
40const snapshotDirAtom = atom({ plugin: 'slash-coverage', key: 'snapshotDir' } as const, null as string | null)
41
42// ── Colors ──────────────────────────────────────────────────────────────────
43const LEVEL_COLOR: Record<Level, string> = { V: '#eef1f6', P: '#a9b3c2', T: '#f0a35e', S: '#b294f5', G: '#6c7487', X: '#e06c75' }
44const GLYPH: Record<Level, string> = { V: '●', P: '◉', T: '●', S: '◌', G: '○', X: '✕' }
45const LEVEL_NAME: Record<Level, string> = { V: 'full', P: 'partial', T: 'stale', S: 'secondhand', G: 'grep hit', X: 'compacted' }
46const EDIT = '#ff9e64'
47const NOW = '#e5c07b'
48const OK = '#8fd18f'
49const ADD = '#8fd18f'
50const DEL = '#e06c75'
51const FAINT = '#3a404c'
52const HOVER = '#2a2f39'
53const AGENT_HUES = ['#7aa2f7', '#73d0a0', '#e7a0d0', '#7fd4e6', '#e5c07b', '#c3a6ff', '#f78c6c', '#a3e635', '#2dd4bf', '#f472b6']
54
55// ── The repo index (module memory: rebuilt cheaply after a reload) ──────────
56let repo: Repo = makeRepo('', [])
57/** Where the session runs: an agent's shell starts here. */
58let sessionCwd = ''
59/** Each agent's shell cwd after its last command, absolute. */
60let cwds = new Map<string, string>()
61/** Settles once the index is built; tool calls that arrive sooner (right after a reload) wait for it. */
62let indexed: Promise<void> = Promise.resolve()
63/** Each folder's files, nested ones included, and its direct children. */
64let folders: { count: Map<string, number>; children: Map<string, Set<string>> } | null = null
65/** mtimes of the files git reports changed, by absolute path, to tell a fresh edit from an old one. */
66let changed = new Map<string, number>()
67/** The git repos the index covers, relative to its root: [''] when the root is one. */
68let gitRoots: string[] = []
69/** Every checkout git runs in: each repo's own, and each of its worktrees. */
70let gitCheckouts: Checkout[] = []
71/** Checkouts whose git status has been taken: a later one tells what a command changed. */
72let seen = new Set<string>()
73
74/** Runs git in the repo root, or in a nested repo (`at`, relative to the root), or in a directory given absolute. */
75async function git($: $, args: string[], at = '') {
76 try {
77 const cwd = at.startsWith('/') ? at : at ? repo.root + '/' + at : repo.root
78 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 15_000 })
79 return r.exitCode === 0 ? r.stdout : null
80 } catch {
81 return null
82 }
83}
84
85/** Each repo's worktrees, shown where the repo is: their files are its files. */
86async function listCheckouts($: $): Promise<Checkout[]> {
87 const out: Checkout[] = []
88 for (const at of gitRoots) {
89 const main = at ? repo.root + '/' + at : repo.root
90 out.push({ dir: main, prefix: at })
91 const list = (await git($, ['worktree', 'list', '--porcelain'], at)) ?? ''
92 for (const line of list.split('\n')) {
93 const dir = line.startsWith('worktree ') ? line.slice(9).trim() : ''
94 if (dir && dir !== main) out.push({ dir, prefix: at })
95 }
96 }
97 return out
98}
99
100/** Directories outside every checkout already looked at: each could be a worktree made since the index. */
101let outsideSeen = new Set<string>()
102
103/** Lists the worktrees again when a path lies outside every checkout and its directory is new. */
104async function placeOutside($: $, paths: readonly string[]) {
105 const fresh = paths.filter(p => !checkoutOf(repo, p)).map(p => p.slice(0, p.lastIndexOf('/')) || '/').filter(d => !outsideSeen.has(d))
106 if (!fresh.length) return
107 for (const d of fresh) outsideSeen.add(d)
108 gitCheckouts = await listCheckouts($)
109 repo = makeRepo(repo.root, repo.files, gitCheckouts, repo.home)
110}
111
112/** A repo's tracked files, plus untracked ones git doesn't ignore. */
113async function trackedFiles($: $, at: string): Promise<FileEntry[]> {
114 const prefix = at ? at + '/' : ''
115 const tracked = (await git($, ['ls-files'], at)) ?? ''
116 const extra = (await git($, ['ls-files', '--others', '--exclude-standard'], at)) ?? ''
117 return [...tracked.split('\n').filter(Boolean), ...extra.split('\n').filter(Boolean).slice(0, 500)].map(path => ({ path: prefix + path }))
118}
119
120const SKIP = new Set([
121 'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', '.expo', '.turbo', '.cache', 'coverage',
122 'target', '.venv', 'venv', '__pycache__', 'Pods', 'DerivedData', 'vendor', '.gradle',
123])
124
125/** Repos one or two levels under a root that isn't one: a folder of projects. */
126async function findGitRoots($: $): Promise<string[]> {
127 const found: string[] = []
128 const look = async (dir: string, depth: number) => {
129 let entries
130 try {
131 entries = await $.fs.list(dir ? repo.root + '/' + dir : repo.root)
132 } catch {
133 return
134 }
135 for (const entry of entries) {
136 if (entry.kind !== 'dir' || entry.isLink || SKIP.has(entry.name) || entry.name.startsWith('.')) continue
137 const path = dir ? dir + '/' + entry.name : entry.name
138 if (await $.fs.exists(repo.root + '/' + path + '/.git')) found.push(path)
139 else if (depth < 2) await look(path, depth + 1)
140 }
141 }
142 await look('', 1)
143 return found
144}
145
146/** Files taken from any one folder that isn't a repo. */
147const WALK_BUDGET = 150
148
149async function walk($: $, dir: string, out: FileEntry[], repos: ReadonlySet<string>, budget = { left: WALK_BUDGET }) {
150 if (out.length >= MAX_FILES || budget.left <= 0) return
151 let entries
152 try {
153 entries = await $.fs.list(dir ? repo.root + '/' + dir : repo.root)
154 } catch {
155 return
156 }
157 for (const entry of entries) {
158 const path = dir ? dir + '/' + entry.name : entry.name
159 if (repos.has(path)) continue
160 const share = dir === '' ? { left: WALK_BUDGET } : budget
161 if (entry.kind === 'dir' && !SKIP.has(entry.name) && !entry.isLink) await walk($, path, out, repos, share)
162 else if (entry.kind === 'file' && share.left > 0) {
163 out.push({ path })
164 share.left--
165 }
166 if (out.length >= MAX_FILES || budget.left <= 0) return
167 }
168}
169
170async function indexRepo($: $) {
171 let root = await $.session.root()
172 sessionCwd = await $.session.cwd()
173 const home = (await $.env.get('HOME')) ?? ''
174 repo = makeRepo(root, [], [], home)
175 const top = await git($, ['rev-parse', '--show-toplevel'])
176 const list: FileEntry[] = []
177 if (top) {
178 root = top.trim()
179 repo = makeRepo(root, [], [], home)
180 gitRoots = ['']
181 list.push(...(await trackedFiles($, '')))
182 } else {
183 gitRoots = await findGitRoots($)
184 for (const at of gitRoots) list.push(...(await trackedFiles($, at)))
185 await walk($, '', list, new Set(gitRoots))
186 }
187 gitCheckouts = await listCheckouts($)
188 repo = makeRepo(root, list.slice(0, MAX_FILES), gitCheckouts, home)
189 folders = null
190 outsideSeen = new Set()
191 seen = new Set()
192 changed = new Map()
193 // Worktrees are looked at when an agent first works in one.
194 await serial(() => snapshotChanges($, gitCheckouts.filter(c => c.dir === (c.prefix ? root + '/' + c.prefix : root))))
195 await update($, indexAtom, v => v + 1)
196}
197
198/** Touched paths the git index lacks (ignored or past the cap), added to `folders` as they show up. */
199let extraPaths = new Set<string>()
200
201function addToFolders(f: NonNullable<typeof folders>, path: string) {
202 const parts = path.split('/')
203 for (let i = 0; i < parts.length; i++) {
204 const parent = parts.slice(0, i).join('/')
205 const self = parts.slice(0, i + 1).join('/')
206 let kids = f.children.get(parent)
207 if (!kids) f.children.set(parent, (kids = new Set()))
208 kids.add(self)
209 if (i < parts.length - 1) f.count.set(self, (f.count.get(self) ?? 0) + 1)
210 }
211}
212
213/** Each folder's file count and direct children, from the index plus every touched path. */
214function folderIndex(touched: Iterable<string>) {
215 if (!folders) {
216 folders = { count: new Map(), children: new Map() }
217 extraPaths = new Set()
218 for (const { path } of repo.files) addToFolders(folders, path)
219 }
220 for (const path of touched) {
221 if (repo.byPath.has(path) || extraPaths.has(path)) continue
222 extraPaths.add(path)
223 addToFolders(folders, path)
224 }
225 return folders
226}
227
228/** Git checks run one at a time: parallel subagent calls must not see a half-built snapshot. */
229let gitQueue: Promise<unknown> = Promise.resolve()
230function serial<T>(fn: () => Promise<T>): Promise<T> {
231 const run = gitQueue.then(fn, fn)
232 gitQueue = run.catch(() => undefined)
233 return run
234}
235
236/** The checkouts a command or edit could have changed: the ones holding its paths or the directories it worked in. */
237function checkoutsFor(places: readonly string[]): Checkout[] {
238 return gitCheckouts.filter(c => places.some(p => p === c.dir || p.startsWith(c.dir + '/')))
239}
240
241/** Re-reads git status in some checkouts; the snapshot is built aside and swapped in whole. */
242async function snapshotChanges($: $, roots: readonly Checkout[]) {
243 const inside = (path: string) => roots.some(c => path.startsWith(c.dir + '/'))
244 const next = new Map([...changed].filter(([path]) => !inside(path)))
245 for (const c of roots) {
246 const status = await git($, ['status', '--porcelain', '--untracked-files=all'], c.dir)
247 if (status === null) continue
248 seen.add(c.dir)
249 for (const line of status.split('\n')) {
250 const rel = line.slice(3).split(' -> ').pop()?.trim().replace(/^"|"$/g, '')
251 if (!rel) continue
252 const path = c.dir + '/' + rel
253 try {
254 next.set(path, (await $.fs.stat(path)).mtimeMs)
255 } catch {
256 next.set(path, 0)
257 }
258 }
259 }
260 changed = next
261}
262
263/** Takes git status in checkouts not looked at yet, so their old changes don't read as this command's. */
264function lookFirst($: $, roots: readonly Checkout[]) {
265 const fresh = roots.filter(c => !seen.has(c.dir))
266 return fresh.length ? serial(() => snapshotChanges($, fresh)) : Promise.resolve()
267}
268
269/** Files git now reports changed whose mtime moved since the last look, absolute: edits by any tool, shell included. */
270function freshEdits($: $, roots: readonly Checkout[]): Promise<string[]> {
271 if (roots.length === 0) return Promise.resolve([])
272 return serial(async () => {
273 const before = changed
274 await snapshotChanges($, roots)
275 return [...changed].filter(([path, mtime]) => before.get(path) !== mtime).map(([path]) => path)
276 })
277}
278
279async function ensureIndexed($: $, path: string) {
280 if (repo.byPath.has(path) || repo.files.length >= MAX_FILES) return
281 repo.byPath.set(path, repo.files.length)
282 repo.files.push({ path })
283 folders = null
284 await update($, indexAtom, v => v + 1)
285}
286
287// ── The event log, and its snapshot on disk ─────────────────────────────────
288/** Each agent's events this session, by agent id: what its snapshot file holds. */
289let logs = new Map<string, GpsEvent[]>()
290
291/** Writes run one at a time, so parallel subagents never interleave a read-modify-write. */
292let recordQueue: Promise<unknown> = Promise.resolve()
293
294const fileFor = (agent: string) => `${agent === 'main' ? 'main' : agent}.json`
295
296async function writeSnapshot($: $, agent: string) {
297 const dir = await read($, snapshotDirAtom)
298 if (!dir) return
299 const snap: Snapshot = { version: 1, agent, events: logs.get(agent) ?? [] }
300 try {
301 await $.fs.write(`${dir}/${fileFor(agent)}`, JSON.stringify(snap))
302 } catch (err) {
303 $.ui.log(`could not save the snapshot: ${String((err as Error)?.message ?? err).slice(0, 80)}`)
304 }
305}
306
307/** Records what an agent did: into its log, onto the grid, and into its snapshot. */
308function record($: $, events: readonly GpsEvent[]): Promise<void> {
309 if (events.length === 0) return Promise.resolve()
310 const run = recordQueue.then(async () => {
311 for (const ev of events) logs.set(ev.a, [...(logs.get(ev.a) ?? []), ev])
312 const before: GpsState = { holds: await read($, holdsAtom), lines: await read($, linesAtom), agents: await read($, agentsAtom) }
313 const after = events.reduce(applyEvent, before)
314 if (after.holds !== before.holds) await update($, holdsAtom, () => after.holds)
315 if (after.lines !== before.lines) await update($, linesAtom, () => after.lines)
316 if (after.agents !== before.agents) await update($, agentsAtom, () => after.agents)
317 for (const agent of new Set(events.map(ev => ev.a))) await writeSnapshot($, agent)
318 })
319 recordQueue = run.catch(() => undefined)
320 return run
321}
322
323/** Rebuilds the grid from a chat's snapshots: main's, then every agent it (or they) spawned. */
324async function restore($: $) {
325 const dir = await read($, snapshotDirAtom)
326 logs = new Map()
327 if (dir) {
328 const queue = ['main']
329 while (queue.length) {
330 const agent = queue.shift()!
331 if (logs.has(agent)) continue
332 let events: GpsEvent[] = []
333 try {
334 events = parseSnapshot(await $.fs.read(`${dir}/${fileFor(agent)}`))
335 } catch {
336 // No snapshot yet: a new chat, or an agent that never acted.
337 }
338 logs.set(agent, events)
339 queue.push(...spawnedIn(events))
340 }
341 }
342 const s = replay(logs.values())
343 await update($, holdsAtom, () => s.holds)
344 await update($, linesAtom, () => s.lines)
345 await update($, agentsAtom, () => s.agents)
346}
347
348/** `<config>/projects/<project>/<session id>.jsonl`, looked up by the session's id. */
349async function findTranscript($: $): Promise<string | null> {
350 try {
351 const id = await $.session.id()
352 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
353 for (const project of await $.fs.list(`${config}/projects`)) {
354 const path = `${config}/projects/${project.name}/${id}.jsonl`
355 if (project.kind === 'dir' && (await $.fs.exists(path))) return path
356 }
357 } catch {
358 // No config folder we can read: the grid just won't outlive the session.
359 }
360 return null
361}
362
363/** Forgets everything: the grid, and every snapshot file this chat wrote. */
364async function reset($: $) {
365 const agents = [...logs.keys()]
366 logs = new Map()
367 await update($, holdsAtom, () => ({}))
368 await update($, linesAtom, () => ({}))
369 await update($, agentsAtom, () => [])
370 await update($, selectedAtom, () => null)
371 await update($, pickAtom, () => 'auto')
372 await update($, anchorAtom, () => null)
373 await update($, outsideAtom, () => 0)
374 for (const agent of agents) await writeSnapshot($, agent)
375}
376
377async function openPane($: $, focus: boolean) {
378 return $.ui.open(focus ? { id: PANE, title: TITLE, focus: true } : { id: PANE, title: TITLE })
379}
380
381// ── Text helpers ────────────────────────────────────────────────────────────
382type Seg = { t: string; c?: string; dim?: boolean; bold?: boolean }
383
384const segLen = (segs: readonly Seg[]) => segs.reduce((n, s) => n + [...s.t].length, 0)
385
386/** Segments cut or padded to exactly `width` cells. */
387function fitSegs(segs: readonly Seg[], width: number, align: 'left' | 'right' = 'left'): Seg[] {
388 const len = segLen(segs)
389 if (len <= width) {
390 const pad = { t: ' '.repeat(width - len) }
391 return align === 'right' ? [pad, ...segs] : [...segs, pad]
392 }
393 const out: Seg[] = []
394 let left = width - 1
395 for (const s of segs) {
396 const chars = [...s.t]
397 if (chars.length <= left) {
398 out.push(s)
399 left -= chars.length
400 } else {
401 out.push({ ...s, t: chars.slice(0, left).join('') })
402 break
403 }
404 }
405 out.push({ t: '…', dim: true })
406 return out
407}
408
409function ago(ms: number): string {
410 const s = Math.max(0, Math.round(ms / 1000))
411 if (s < 60) return `${s}s`
412 const m = Math.floor(s / 60)
413 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h`
414}
415
416function tok(n: number): string {
417 if (!n) return ''
418 return n >= 1000 ? (n / 1000).toFixed(n >= 10_000 ? 0 : 1).replace(/\.0$/, '') + 'k' : String(n)
419}
420
421// ── What a cell says ────────────────────────────────────────────────────────
422type Cell = {
423 lvl: Level
424 held: Seg[]
425 tokens: number
426 last: number | null
427 reads: number
428 rereads: number
429 added: number
430 removed: number
431 isShellEdit: boolean
432}
433
434type World = { holds: Holds; agents: GpsAgent[]; lines: Record<string, number>; names: Map<string, string>; hue: Map<string, string>; now: number }
435
436function agentCell(w: World, agent: string, path: string): Cell | null {
437 const lvl = level(w.holds, w.agents, agent, path, w.lines[path])
438 if (!lvl) return null
439 const h: Hold | undefined = w.holds[agent]?.[path]
440 const total = w.lines[path]
441 let held: Seg[]
442 if (lvl === 'V') held = [{ t: 'all' }, ...(total ? [{ t: ` ${total}`, dim: true }] : [])]
443 else if (lvl === 'P') {
444 held = h?.ranges.length
445 ? [{ t: describeRanges(h.ranges) }, ...(total ? [{ t: `/${total}`, dim: true }] : [])]
446 : [{ t: h?.isShellRead ? 'shell read' : 'edited', dim: true }]
447 } else if (lvl === 'T') held = [{ t: 'stale ' }, { t: `✎${w.names.get(h!.stale!.by) ?? h!.stale!.by}`, c: EDIT }]
448 else if (lvl === 'S') held = [{ t: 'via ', c: LEVEL_COLOR.S }, ...via(w.holds, w.agents, agent, path).flatMap((id, i) => [{ t: (i ? ' ' : '') + (w.names.get(id) ?? id), c: w.hue.get(id) }])]
449 else held = [{ t: LEVEL_NAME[lvl], c: LEVEL_COLOR[lvl] }]
450 return {
451 lvl,
452 held: [{ t: GLYPH[lvl] + ' ', c: LEVEL_COLOR[lvl] }, ...held],
453 tokens: h?.tokens ?? 0,
454 last: h ? w.now - h.last : null,
455 reads: h?.reads ?? 0,
456 rereads: h?.rereads ?? 0,
457 added: h?.added ?? 0,
458 removed: h?.removed ?? 0,
459 isShellEdit: h?.isShellEdit ?? false,
460 }
461}
462
463/** The whole team: the best any agent knows, and who holds lines of it. */
464function teamCell(w: World, path: string): Cell | null {
465 const cells = w.agents.map(a => [a.id, agentCell(w, a.id, path)] as const).filter((x): x is readonly [string, Cell] => !!x[1] && x[1].lvl !== 'S')
466 if (!cells.length) return null
467 const best = [...cells].sort((a, b) => RANK[b[1].lvl] - RANK[a[1].lvl])[0]![1].lvl
468 const holders = w.agents.filter(a => {
469 const h = w.holds[a.id]?.[path]
470 return h && (h.ranges.length || h.isShellRead) && !h.stale
471 })
472 const lasts = cells.map(([, c]) => c.last).filter((x): x is number => x !== null)
473 const sum = (k: 'tokens' | 'reads' | 'rereads' | 'added' | 'removed') => cells.reduce((n, [, c]) => n + c[k], 0)
474 return {
475 lvl: best,
476 held: [
477 { t: GLYPH[best] + ' ', c: LEVEL_COLOR[best] },
478 ...(holders.length
479 ? holders.flatMap((a, i) => [{ t: (i ? ' ' : '') + (w.names.get(a.id) ?? a.id), c: w.hue.get(a.id) }])
480 : [{ t: LEVEL_NAME[best], c: LEVEL_COLOR[best] }]),
481 ],
482 tokens: sum('tokens'),
483 last: lasts.length ? Math.min(...lasts) : null,
484 reads: sum('reads'),
485 rereads: sum('rereads'),
486 added: sum('added'),
487 removed: sum('removed'),
488 isShellEdit: cells.some(([, c]) => c.isShellEdit),
489 }
490}
491
492// ── Columns ─────────────────────────────────────────────────────────────────
493type Column = { key: 'held' | 'tokens' | 'last' | 'reads' | 'edits'; title: string; width: number; align: 'left' | 'right' }
494const COLUMNS: Column[] = [
495 { key: 'held', title: 'held', width: 16, align: 'left' },
496 { key: 'tokens', title: 'tokens', width: 7, align: 'right' },
497 { key: 'last', title: 'last', width: 6, align: 'right' },
498 { key: 'reads', title: 'reads', width: 7, align: 'right' },
499 { key: 'edits', title: ' edits', width: 10, align: 'left' },
500]
501const MIN_NAME = 18
502
503const HELD_MIN = 16
504
505/**
506 * The name column fits the longest name shown; "held" takes the rest, so a wider
507 * pane widens it. Columns drop from the right when even that leaves too little.
508 */
509function columnsFor(width: number, wantName: number): { name: number; cols: Column[] } {
510 for (let n = COLUMNS.length; n >= 1; n--) {
511 const cols = COLUMNS.slice(0, n)
512 const fixed = cols.reduce((s, c) => s + (c.key === 'held' ? 0 : c.width), 0)
513 const name = Math.max(MIN_NAME, Math.min(wantName, width - fixed - HELD_MIN))
514 const held = width - fixed - name
515 if (held >= HELD_MIN || n === 1) return { name, cols: cols.map(c => (c.key === 'held' ? { ...c, width: Math.max(8, held) } : c)) }
516 }
517 return { name: width, cols: [] }
518}
519
520function lastColor(ms: number | null) {
521 if (ms === null) return undefined
522 return ms < 15_000 ? LEVEL_COLOR.V : ms < 90_000 ? LEVEL_COLOR.P : undefined
523}
524
525/** `+6 −2`, leaving out a side that is zero. */
526function editSegs(added: number, removed: number): Seg[] {
527 return [{ t: ' ' }, ...(added ? [{ t: `+${added}`, c: ADD }] : []), ...(added && removed ? [{ t: ' ' }] : []), ...(removed ? [{ t: `−${removed}`, c: DEL }] : [])]
528}
529
530function cellSegs(c: Cell | null, col: Column): Seg[] {
531 if (!c) return []
532 switch (col.key) {
533 case 'held':
534 return c.held
535 case 'tokens':
536 return c.lvl === 'X' ? [{ t: '0', c: LEVEL_COLOR.X }] : [{ t: tok(c.tokens) }]
537 case 'last':
538 return c.last === null ? [] : [{ t: ago(c.last), c: lastColor(c.last), dim: c.last >= 90_000 }]
539 case 'reads':
540 return [{ t: c.reads ? String(c.reads) : '' }, ...(c.rereads ? [{ t: ` ↩${c.rereads}`, c: LEVEL_COLOR.T }] : [])]
541 case 'edits':
542 if (c.added || c.removed) return editSegs(c.added, c.removed)
543 return c.isShellEdit ? [{ t: ' ✎ shell', c: EDIT }] : []
544 }
545}
546
547/**
548 * A folder's levels as glyphs: short runs packed (`●●◉`), long ones a glyph and
549 * a count with a space on each side (`●●◉ ◌14 ○○○`).
550 */
551function stripSegs(levels: readonly Level[]): Seg[] {
552 const parts = stripParts(levels)
553 const out: Seg[] = []
554 parts.forEach((p, i) => {
555 if (i > 0 && (p.isCounted || parts[i - 1]!.isCounted)) out.push({ t: ' ' })
556 // A lone counted group would repeat the folder's own count (`◌34 34/3078`): the glyph says enough.
557 const isAlone = parts.length === 1
558 out.push({ t: p.isCounted ? `${GLYPH[p.lvl]}${isAlone ? '' : p.n}` : GLYPH[p.lvl].repeat(p.n), c: LEVEL_COLOR[p.lvl] })
559 })
560 return out
561}
562
563/** A folder's cells: its levels as a strip, and the sums. */
564function folderSegs(cells: readonly Cell[], total: number, col: Column): Seg[] {
565 switch (col.key) {
566 case 'held':
567 return [...stripSegs(cells.map(c => c.lvl)), { t: ` ${cells.length}/${total}`, dim: true }]
568 case 'tokens':
569 return [{ t: tok(cells.reduce((n, c) => n + c.tokens, 0)), dim: true }]
570 case 'last': {
571 const lasts = cells.map(c => c.last).filter((x): x is number => x !== null)
572 const m = lasts.length ? Math.min(...lasts) : null
573 return m === null ? [] : [{ t: ago(m), c: lastColor(m), dim: m >= 90_000 }]
574 }
575 case 'reads': {
576 const reads = cells.reduce((n, c) => n + c.reads, 0)
577 const rereads = cells.reduce((n, c) => n + c.rereads, 0)
578 return [{ t: reads ? String(reads) : '', dim: true }, ...(rereads ? [{ t: ` ↩${rereads}`, c: LEVEL_COLOR.T }] : [])]
579 }
580 case 'edits': {
581 const added = cells.reduce((n, c) => n + c.added, 0)
582 const removed = cells.reduce((n, c) => n + c.removed, 0)
583 if (added || removed) return editSegs(added, removed)
584 return cells.some(c => c.isShellEdit) ? [{ t: ' ✎', c: EDIT }] : []
585 }
586 }
587}
588
589/** Lines of output the agent saw when Claude Code kept only a preview of it. */
590function previewLines(text: string): number {
591 const m = /Preview[^\n]*:\n([\s\S]*?)(?:\n\.\.\.)?\s*(?:<\/persisted-output>)?\s*$/.exec(text)
592 return m ? m[1]!.split('\n').length : 0
593}
594
595export const register: Register = on => {
596 on('session.start', async ($, e, next) => {
597 indexed = indexRepo($)
598 await indexed
599 // A mod loaded after the session started never saw SessionStart: find the chat's transcript by its id.
600 if (!(await read($, snapshotDirAtom))) {
601 const found = await findTranscript($)
602 if (found) await update($, snapshotDirAtom, () => snapshotDir(found))
603 }
604 // A reload keeps the session's state but not this module's log: read it back from disk.
605 await restore($)
606 // Ages in the "last" column move on their own.
607 $.clock.every(5000, () => $.ui.invalidate('ui.render'))
608 void openPane($, false)
609 await $.command.register({
610 name: 'coverage',
611 description: 'Coverage: show or hide which files each agent has in its context (args: clear, reindex)',
612 argumentHint: '[clear|reindex]',
613 immediate: true,
614 })
615 return next(e)
616 })
617
618 // The transcript names the chat's folder: snapshots live there, and come back on resume.
619 on('classic.SessionStart', async ($, e, next) => {
620 await update($, snapshotDirAtom, () => snapshotDir(e.transcript_path))
621 if (e.source === 'clear') await reset($)
622 else await restore($)
623 return next(e)
624 })
625
626 on('command.run', { command: 'coverage' }, async ($, e) => {
627 const arg = e.args.trim()
628 if (arg === 'reindex') {
629 await indexRepo($)
630 return { text: `Coverage indexed ${repo.files.length} files` }
631 }
632 if (arg === 'clear') {
633 await reset($)
634 return { text: 'Coverage cleared' }
635 }
636 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
637 if (isOpen) await $.ui.close({ id: PANE })
638 else await openPane($, true)
639 return {}
640 })
641
642 on('agent.spawn', async ($, e, next) => {
643 const started = await next(e)
644 if (started.agentId) {
645 const t = await $.clock.now()
646 await record($, [{ k: 'spawn', t, a: e.parentAgentId ?? 'main', id: started.agentId, type: e.subagentType, label: e.description || e.subagentType }])
647 }
648 return started
649 })
650
651 on('turn.complete', async ($, e, next) => {
652 const done = await next(e)
653 if (e.agentId) await record($, [{ k: 'done', t: await $.clock.now(), a: e.agentId }])
654 return done
655 })
656
657 on('session.compact', async ($, e, next) => {
658 const done = await next(e)
659 await record($, [{ k: 'compact', t: await $.clock.now(), a: e.agentId ?? 'main' }])
660 return done
661 })
662
663 on('tool.call', async ($, e, next) => {
664 const a = e.agentId ?? 'main'
665 const tool = String(e.tool)
666 const args = e as unknown as Record<string, unknown>
667 const command = tool === 'Bash' && typeof args.command === 'string' ? args.command : ''
668 // Before a command runs, look at the checkouts it works in, so what it changes stands out after.
669 let cwd = ''
670 if (command) {
671 await indexed
672 cwd = cwds.get(a) ?? sessionCwd
673 try {
674 const { places } = shellEffects(repo, command, cwd)
675 await placeOutside($, places)
676 await lookFirst($, checkoutsFor(places))
677 } catch (err) {
678 $.ui.log(`could not read a command: ${String((err as Error)?.message ?? err).slice(0, 80)}`)
679 }
680 }
681 const ran = await next(e)
682 if (ran.deny !== undefined || ran.isError) return ran
683 await indexed
684 const result = ran.result as Record<string, any> | undefined
685 const text = ran.text ?? ''
686 /** A path argument as the map knows it, with its absolute form; a worktree made since the index is found here. */
687 const pathArg = async (k: string) => {
688 const abs = typeof args[k] === 'string' ? absolute(args[k] as string, cwds.get(a) ?? sessionCwd, repo.home) : null
689 if (!abs) return null
690 await placeOutside($, [abs])
691 const p = logical(repo, abs)
692 return p ? { p, abs } : null
693 }
694 try {
695 const t = await $.clock.now()
696 if (tool === 'Read') {
697 const at = await pathArg('file_path')
698 if (!at) {
699 await update($, outsideAtom, n => n + 1)
700 return ran
701 }
702 const { p } = at
703 const file = result?.file ?? {}
704 const tok = tokensOf(text)
705 if (result?.type === 'text') {
706 const r: Range = file.numLines > 0 ? [file.startLine, file.startLine + file.numLines - 1] : [1, 1]
707 await record($, [{ k: 'read', t, a, p, r, tok, n: Math.max(1, Number(file.totalLines) || 0) }])
708 } else if (result?.type === 'file_unchanged') {
709 // The engine answered with a stub: the agent asked for lines it already has.
710 const known = (await read($, linesAtom))[p] ?? 1
711 const from = typeof args.offset === 'number' ? args.offset : 1
712 await record($, [{ k: 'read', t, a, p, r: [from, typeof args.limit === 'number' ? from + args.limit - 1 : known], tok }])
713 } else {
714 // An image, PDF or notebook: whole or nothing.
715 await record($, [{ k: 'read', t, a, p, r: [1, 1], tok, n: 1 }])
716 }
717 } else if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
718 const at = (await pathArg('file_path')) ?? (await pathArg('notebook_path'))
719 if (!at || result?.staged) return ran
720 const { p, abs } = at
721 await ensureIndexed($, p)
722 const { added, removed } = countPatch(result?.structuredPatch)
723 if (tool === 'Write' && typeof result?.content === 'string') {
724 const written = result.content.split('\n').length
725 const isNew = result.originalFile == null && result.type === 'create'
726 await record($, [{ k: 'edit', t, a, p, add: isNew ? written : added, del: isNew ? 0 : removed, written }])
727 } else {
728 const hunks = (result?.structuredPatch ?? []).map((h: Hunk) => ({ oldStart: h.oldStart, oldLines: h.oldLines, newStart: h.newStart, newLines: h.newLines }))
729 await record($, [{ k: 'edit', t, a, p, add: added, del: removed, hunks }])
730 }
731 // Take in the edit so the next shell command doesn't count it again.
732 await serial(() => snapshotChanges($, checkoutsFor([abs])))
733 } else if (tool === 'Grep' || tool === 'Glob') {
734 const ps = pathsIn(repo, text, typeof args.path === 'string' ? absolute(args.path, sessionCwd, repo.home) ?? sessionCwd : sessionCwd)
735 if (ps.length && ps.length <= MAX_HITS) await record($, [{ k: 'hit', t, a, ps, tok: tokensOf(text) }])
736 } else if (command) {
737 const reset = /Shell cwd was reset to (\/[^\n]+)/.exec(text)
738 // First pass: which files the command prints from, to count their lines.
739 const wanted = new Set<string>()
740 shellEffects(repo, command, cwd, abs => (wanted.add(abs), undefined))
741 const totals = new Map<string, number>()
742 for (const abs of [...wanted].slice(0, 50)) {
743 try {
744 const body = await $.fs.read(abs)
745 // Counted as the Read tool counts (a trailing newline ends one more, empty, line), so a whole cat is a full read.
746 totals.set(abs, body.split('\n').length)
747 } catch {
748 // Gone, a directory, or never a file.
749 }
750 }
751 const fx = shellEffects(repo, command, cwd, abs => totals.get(abs), result?.persistedOutputPath ? previewLines(text) : undefined)
752 cwds.set(a, reset ? reset[1]!.trim() : fx.cwd)
753 const edits = (await freshEdits($, checkoutsFor(fx.places))).map(abs => logical(repo, abs)).filter((p): p is string => !!p)
754 const reads = fx.reads.filter(r => !edits.includes(r.path))
755 const readPaths = new Set(reads.map(r => r.path))
756 const hits = [...new Set([...fx.searched, ...pathsIn(repo, text, fx.cwd)])].filter(p => !readPaths.has(p) && !edits.includes(p))
757 // The output's tokens, shared by lines read; a read of unknown lines weighs as much as an average one.
758 const share = tokensOf(text)
759 const known = reads.filter(r => r.range).map(r => r.range![1] - r.range![0] + 1)
760 const avg = known.length ? known.reduce((x, y) => x + y, 0) / known.length : 1
761 const weight = (r: (typeof reads)[number]) => (r.range ? r.range[1] - r.range[0] + 1 : avg)
762 const sum = reads.reduce((n, r) => n + weight(r), 0)
763 for (const p of edits) await ensureIndexed($, p)
764 await record($, [
765 ...(hits.length && hits.length <= MAX_HITS ? [{ k: 'hit' as const, t, a, ps: hits, tok: reads.length ? 0 : share }] : []),
766 ...reads.map(r => ({
767 k: 'read' as const,
768 t,
769 a,
770 p: r.path,
771 r: r.range,
772 tok: Math.ceil((share * weight(r)) / sum),
773 ...(totals.has(r.abs) ? { n: Math.max(1, totals.get(r.abs)!) } : {}),
774 })),
775 ...edits.map(p => ({ k: 'edit' as const, t, a, p, add: 0, del: 0, shell: true as const })),
776 ])
777 }
778 } catch (err) {
779 $.ui.log(`could not place ${tool}: ${String((err as Error)?.message ?? err).slice(0, 80)}`)
780 }
781 return ran
782 })
783
784 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
785 const { Box, Text, Button } = $.ui.resolve(e)
786 const holdsAll = await read($, holdsAtom)
787 const excluded = new Set(await read($, excludedAtom))
788 const lines = await read($, linesAtom)
789 const known = await read($, agentsAtom)
790 const pick = await read($, pickAtom)
791 const anchor = await read($, anchorAtom)
792 const closed = new Set(await read($, closedAtom))
793 const selected = await read($, selectedAtom)
794 const outside = await read($, outsideAtom)
795 await read($, indexAtom)
796 const now = await $.clock.now()
797 const width = Math.max(30, e.props.bodyColumns)
798
799 const agentsAll: GpsAgent[] = known.some(a => a.id === 'main')
800 ? known
801 : [MAIN(0), ...known]
802 // Cells name agents by tag. Names and colors come from every agent, so leaving one out moves no color.
803 const names = new Map(agentsAll.map((a, i) => [a.id, a.tag ?? (a.id === 'main' ? 'main' : 'ag' + i)]))
804 const hue = new Map(agentsAll.map((a, i) => [a.id, AGENT_HUES[i % AGENT_HUES.length]!]))
805
806 // Which agent: the one picked, else the transcript on screen, else main.
807 const viewed = e.props.view.agentId
808 const shown = pick === 'all' || agentsAll.some(a => a.id === pick) ? pick : viewed && agentsAll.some(a => a.id === viewed) ? viewed : 'main'
809 // All leaves out the agents the person left out, as if they never ran.
810 const agents = shown === 'all' ? agentsAll.filter(a => !excluded.has(a.id)) : agentsAll
811 const holds = shown === 'all' ? Object.fromEntries(Object.entries(holdsAll).filter(([id]) => !excluded.has(id))) : holdsAll
812 const w: World = { holds, agents, lines, names, hue, now }
813 const cellOf = (path: string) => (shown === 'all' ? teamCell(w, path) : agentCell(w, shown, path))
814 const isRunning = (a: GpsAgent) => (a.id === 'main' ? now - a.nowAt < NOW_MS : a.doneAt === null)
815 const nowPaths = new Set(
816 agents.filter(a => (shown === 'all' || a.id === shown) && a.now && isRunning(a) && now - a.nowAt < (a.id === 'main' ? NOW_MS : 120_000)).map(a => a.now!),
817 )
818
819 // Rows: in all, every file any agent touched; for one agent, the files it knows, its subagents' included.
820 const everTouched = new Set<string>()
821 for (const mine of Object.values(holds)) for (const path of Object.keys(mine)) everTouched.add(path)
822 const touched = shown === 'all' ? everTouched : new Set([...everTouched].filter(p => level(holds, agents, shown, p, lines[p])))
823 const { count, children } = folderIndex(everTouched)
824 const isTouchedDir = (dir: string) => [...touched].some(p => p.startsWith(dir + '/'))
825 // Each row's name is its indent, a marker and the last part of its path.
826 const wantName = Math.max(
827 'file'.length,
828 ...[...touched].flatMap(p => {
829 const parts = p.split('/')
830 return parts.map((part, i) => i * 2 + 2 + part.length + (i < parts.length - 1 ? 1 : 0))
831 }),
832 ) + 2
833 // One agent: its numbers in columns. All: one narrow column per agent, then the team's sums.
834 const isTeam = shown === 'all'
835 // Wide enough for its header, a numbered toggle: `1: main`.
836 const AGENT_COL = 8
837 const teamTail = COLUMNS.filter(c => c.key === 'tokens' || c.key === 'last')
838 const tailW = teamTail.reduce((n, c) => n + c.width, 0)
839 // Agents left out lose their column; they're listed under the grid to bring back.
840 let fit = agents.length
841 while (isTeam && fit > 1 && width - 1 - fit * AGENT_COL - tailW < MIN_NAME) fit--
842 const teamAgents = agents.slice(0, fit)
843 const { name: nameW, cols } = isTeam
844 ? { name: Math.max(MIN_NAME, Math.min(wantName, width - 1 - fit * AGENT_COL - tailW)), cols: teamTail }
845 : columnsFor(width - 1, wantName)
846 // Glyphs sit under the agent's name, past the header's `1: `.
847 const agentCol = (segs: Seg[]) => fitSegs([{ t: ' ' }, ...segs], AGENT_COL)
848 const teamFile = (p: string): Seg[] =>
849 teamAgents.flatMap(a => {
850 const l = level(holds, agents, a.id, p, lines[p])
851 return agentCol(l ? [{ t: GLYPH[l], c: LEVEL_COLOR[l] }] : [{ t: '·', c: FAINT }])
852 })
853 const teamDir = (inside: readonly string[]): Seg[] =>
854 teamAgents.flatMap(a => {
855 const ls = inside.map(p => level(holds, agents, a.id, p, lines[p])).filter((l): l is Level => !!l)
856 if (!ls.length) return agentCol([{ t: '·', c: FAINT }])
857 const best = [...ls].sort((x, y) => RANK[y] - RANK[x])[0]!
858 return agentCol([{ t: GLYPH[best], c: LEVEL_COLOR[best] }, ...(ls.length > 1 ? [{ t: String(ls.length), dim: true }] : [])])
859 })
860
861 const rows: JSX.Element[] = []
862 const line = (key: string, name: JSX.Element, segs: Seg[], isSelected = false) =>
863 rows.push(
864 <Box key={key} flexDirection="row" width={width} hover={{ backgroundColor: HOVER }} backgroundColor={isSelected ? HOVER : undefined}>
865 {name}
866 <Text wrap="truncate-end">{segs.map(s => <Text color={s.c} dimColor={s.dim} bold={s.bold}>{s.t}</Text>)}</Text>
867 </Box>,
868 )
869 const toggle = (dir: string) => () => void update($, closedAtom, list => (list.includes(dir) ? list.filter(d => d !== dir) : [...list, dir]))
870 const select = (path: string) => () => void update($, selectedAtom, s => (s === path ? null : path))
871
872 const walkDir = (dir: string, depth: number) => {
873 const kids = [...(children.get(dir) ?? [])].sort()
874 const dirs = kids.filter(k => count.has(k))
875 const files = kids.filter(k => !count.has(k))
876 const indent = ' '.repeat(depth)
877 let untouched = 0
878 for (const d of dirs) {
879 if (!isTouchedDir(d)) {
880 untouched++
881 continue
882 }
883 const isOpen = !closed.has(d)
884 const inside = [...touched].filter(p => p.startsWith(d + '/'))
885 const cells = inside.map(cellOf).filter((c): c is Cell => !!c)
886 const label = fitSegs([{ t: indent + (isOpen ? '▾ ' : '▸ ') + d.split('/').pop() + '/' }], nameW)
887 line(
888 'd:' + d,
889 <Button key={'fold:' + d} plain label={label.map(s => s.t).join('')} onPress={toggle(d)} />,
890 [...(isTeam ? teamDir(inside) : []), ...cols.flatMap(c => fitSegs(folderSegs(cells, count.get(d) ?? 0, c), c.width, c.align))],
891 )
892 if (isOpen) walkDir(d, depth + 1)
893 }
894 for (const p of files) {
895 if (!touched.has(p)) {
896 untouched++
897 continue
898 }
899 const c = cellOf(p)
900 const isNow = nowPaths.has(p)
901 const label = fitSegs([{ t: indent + (isNow ? '◆ ' : ' ') + p.split('/').pop() }], nameW)
902 line(
903 'f:' + p,
904 <Button key={'sel:' + p} plain label={label.map(s => s.t).join('')} dimColor={!c} onPress={select(p)} />,
905 [...(isTeam ? teamFile(p) : []), ...(c ? cols.flatMap(col => fitSegs(cellSegs(c, col), col.width, col.align)) : [])],
906 selected === p,
907 )
908 }
909 if (untouched) rows.push(<Text key={'u:' + dir} color={FAINT}>{indent} {untouched} untouched</Text>)
910 }
911 walkDir('', 0)
912
913 // The picker: a row of agents in spawn order that scrolls to keep the picked one in view.
914 // h and l step through them, m jumps to main, a to all. In all, the row stays where the
915 // last agent picked left it, and h and l step on from that agent.
916 const order = agentsAll
917 const at = order.findIndex(a => a.id === (shown === 'all' ? anchor : shown))
918 const go = (id: string) => () =>
919 void (async () => {
920 if (id !== 'all') await update($, anchorAtom, () => id)
921 await update($, pickAtom, () => id)
922 })()
923 const stepTo = (d: number) => order[at < 0 ? (d > 0 ? 0 : order.length - 1) : Math.max(0, Math.min(order.length - 1, at + d))]!.id
924 const itemLabel = (a: GpsAgent) => {
925 const status = a.id === 'main' ? '' : a.doneAt === null ? ' ◆' : ' ✓'
926 const text = `${excluded.has(a.id) ? '⊘' : ''}${names.get(a.id)}${status}`
927 return a.id === shown ? `[${text}]` : text
928 }
929 // The anchor in all is measured as if still picked, so the row keeps its exact place.
930 const placeLabel = (a: GpsAgent) => (shown === 'all' && a.id === anchor ? `[${itemLabel(a)}]` : itemLabel(a))
931 // Fixed parts: the title, the two scroll ends and all, with their hotkeys and gaps.
932 const fixed = '◉ Coverage'.length + 'h: ‹ 99'.length + '99 › :l'.length + 'a: all'.length + 8
933 const widthOf = (a: GpsAgent) => placeLabel(a).length + (a.id === 'main' ? 3 : 0) + 2
934 let lo = Math.max(0, at)
935 let hi = lo
936 let used = order.length ? widthOf(order[lo]!) : 0
937 for (let grew = true; grew; ) {
938 grew = false
939 if (hi + 1 < order.length && used + widthOf(order[hi + 1]!) <= width - fixed) {
940 used += widthOf(order[++hi]!)
941 grew = true
942 }
943 if (lo > 0 && used + widthOf(order[lo - 1]!) <= width - fixed) {
944 used += widthOf(order[--lo]!)
945 grew = true
946 }
947 }
948 const picker = [
949 <Button key="pick:prev" plain hotkey="h" dimColor label={lo > 0 ? `‹ ${lo}` : '‹'} onPress={go(stepTo(-1))} />,
950 ...order.slice(lo, hi + 1).map(a => (
951 <Button
952 key={'pick:' + a.id}
953 plain
954 hotkey={a.id === 'main' ? 'm' : undefined}
955 dimColor={shown !== a.id}
956 label={itemLabel(a)}
957 onPress={go(a.id)}
958 />
959 )),
960 <Button key="pick:next" plain hotkey="l" dimColor label={hi < order.length - 1 ? `${order.length - 1 - hi} ›` : '›'} onPress={go(stepTo(1))} />,
961 <Button key="pick:all" plain hotkey="a" dimColor={shown !== 'all'} label={shown === 'all' ? '[all]' : 'all'} onPress={go('all')} />,
962 ]
963 const toggleOut = (id: string) => () =>
964 void update($, excludedAtom, list => (list.includes(id) ? list.filter(x => x !== id) : [...list, id]))
965
966 // The summary under the picker.
967 const all = [...touched]
968 let summary: Seg[]
969 if (shown === 'all') {
970 const held = all.filter(p => agents.some(a => (holds[a.id]?.[p]?.ranges.length ?? 0) > 0 || holds[a.id]?.[p]?.isShellRead)).length
971 const onlySubs = all.filter(p => {
972 const m = holds.main?.[p]
973 return !(m && (m.ranges.length || m.isShellRead)) && agents.some(a => a.id !== 'main' && ((holds[a.id]?.[p]?.ranges.length ?? 0) > 0 || holds[a.id]?.[p]?.isShellRead))
974 }).length
975 const stale = all.reduce((n, p) => n + agents.filter(a => holds[a.id]?.[p]?.stale).length, 0)
976 summary = [
977 { t: `team holds ${held}/${repo.files.length} files · ${onlySubs} only in subagents`, dim: true },
978 ...(excluded.size ? [{ t: ` · ${excluded.size} agent${excluded.size > 1 ? 's' : ''} left out`, c: LEVEL_COLOR.X }] : []),
979 ...(stale ? [{ t: ` · ${stale} stale cop${stale > 1 ? 'ies' : 'y'}`, c: LEVEL_COLOR.T }] : []),
980 ...(outside ? [{ t: ` · ${outside} reads outside the repo`, dim: true }] : []),
981 ]
982 } else {
983 const a = agents.find(x => x.id === shown)!
984 const levels = all.map(p => level(holds, agents, shown, p, lines[p])).filter((l): l is Level => !!l)
985 const n = (l: Level) => levels.filter(x => x === l).length
986 const tokens = Object.values(holds[shown] ?? {}).reduce((s, h) => s + h.tokens, 0)
987 const rereads = Object.values(holds[shown] ?? {}).reduce((s, h) => s + h.rereads, 0)
988 const head = a.id === 'main' ? 'main session' : `${a.label} · ${a.doneAt === null ? 'running ' + ago(now - a.spawnedAt) : 'done in ' + ago(a.doneAt - a.spawnedAt)}`
989 summary = [
990 { t: head + '\n', dim: true },
991 { t: `files ${tok(tokens) || 0} tok `, dim: true },
992 ...(['V', 'P', 'T', 'S', 'G', 'X'] as Level[]).filter(l => n(l)).flatMap(l => [{ t: ` ${GLYPH[l]}`, c: LEVEL_COLOR[l] }, { t: `${n(l)}` }]),
993 ...(rereads ? [{ t: ` ↩${rereads} re-reads`, c: LEVEL_COLOR.T }] : []),
994 ...(outside ? [{ t: ` · ${outside} reads outside the repo`, dim: true }] : []),
995 ]
996 }
997
998 // What every agent knows of the selected file.
999 const detail =
1000 selected && Object.values(holdsAll).some(m => m[selected]) ? (
1001 <Box key="detail" flexDirection="column" marginTop={1}>
1002 <Text wrap="truncate-start">
1003 <Text bold>{selected}</Text>
1004 <Text dimColor>{lines[selected] ? ` ${lines[selected]} lines` : ''}</Text>
1005 </Text>
1006 {agentsAll.map(a => {
1007 const lvl = level(holdsAll, agentsAll, a.id, selected, lines[selected])
1008 const hold = holdsAll[a.id]?.[selected]
1009 const parts: Seg[] = [{ t: (names.get(a.id) ?? a.id).padEnd(6) + ' ', c: hue.get(a.id) }, ...(excluded.has(a.id) ? [{ t: '⊘ ', dim: true }] : [])]
1010 if (!lvl) parts.push({ t: 'never seen', c: FAINT })
1011 else {
1012 parts.push({ t: GLYPH[lvl] + ' ' + LEVEL_NAME[lvl], c: LEVEL_COLOR[lvl] })
1013 if (hold?.ranges.length) parts.push({ t: ` lines ${describeRanges(hold.ranges)}` })
1014 if (lvl === 'T') parts.push({ t: ` · ${names.get(hold!.stale!.by) ?? hold!.stale!.by} edited it ${ago(now - hold!.stale!.at)} ago`, c: EDIT })
1015 if (lvl === 'S') parts.push({ t: ' via ' + via(holdsAll, agentsAll, a.id, selected).map(id => names.get(id) ?? id).join(', ') })
1016 if (hold && (hold.added || hold.removed)) parts.push({ t: ` +${hold.added}`, c: ADD }, { t: ` −${hold.removed}`, c: DEL })
1017 }
1018 return <Text key={'det:' + a.id} wrap="truncate-end">{parts.map(s => <Text color={s.c} dimColor={s.dim}>{s.t}</Text>)}</Text>
1019 })}
1020 </Box>
1021 ) : null
1022
1023 const legend: Seg[] = (['V', 'P', 'T', 'S', 'G', 'X'] as Level[]).flatMap(l => [{ t: GLYPH[l], c: LEVEL_COLOR[l] }, { t: ` ${LEVEL_NAME[l]} `, dim: true }])
1024
1025 return (
1026 <Box flexDirection="column" width={width}>
1027 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1028 <Text bold color="claude">◉ Coverage</Text>
1029 {picker}
1030 </Box>
1031 <Text wrap="wrap">{summary.map(s => <Text color={s.c} dimColor={s.dim}>{s.t}</Text>)}</Text>
1032 <Box marginTop={1} flexDirection="row" width={width}>
1033 <Text dimColor>{'file'.padEnd(nameW)}</Text>
1034 {/* In all, each agent's header is a toggle: its number leaves it out, or brings it back. */}
1035 {isTeam &&
1036 teamAgents.map((a, i) => (
1037 <Box key={'col:' + a.id} width={AGENT_COL}>
1038 <Button
1039 key={'out:' + a.id}
1040 plain
1041 hotkey={i < 9 ? String(i + 1) : undefined}
1042 label={names.get(a.id) ?? a.id}
1043 onPress={toggleOut(a.id)}
1044 />
1045 </Box>
1046 ))}
1047 <Text dimColor>
1048 {cols.map(c => (c.align === 'right' ? c.title.padStart(c.width) : c.title.padEnd(c.width))).join('')}
1049 {isTeam && fit < agents.length ? ` +${agents.length - fit} agents` : ''}
1050 </Text>
1051 </Box>
1052 {touched.size === 0 ? (
1053 <Text dimColor wrap="wrap">
1054 {isTeam && excluded.size && !agents.length
1055 ? 'Every agent is left out.'
1056 : everTouched.size === 0
1057 ? 'Nothing read yet. Files appear here as Claude and its subagents read, search and edit them, with what each one holds.'
1058 : `${names.get(shown) ?? shown} hasn’t read, searched or edited a file yet.`}
1059 </Text>
1060 ) : (
1061 rows
1062 )}
1063 {/* The agents left out of all: each one comes back on a click, all of them on 0. */}
1064 {isTeam && excluded.size > 0 && (
1065 <Box key="left-out" marginTop={1} flexDirection="row" flexWrap="wrap" columnGap={2}>
1066 <Text color={LEVEL_COLOR.X}>left out</Text>
1067 {agentsAll
1068 .filter(a => excluded.has(a.id))
1069 .map(a => (
1070 <Button key={'in:' + a.id} plain dimColor label={'⊘' + (names.get(a.id) ?? a.id)} onPress={toggleOut(a.id)} />
1071 ))}
1072 <Button key="in:all" plain hotkey="0" label="bring all back" onPress={() => void update($, excludedAtom, () => [])} />
1073 </Box>
1074 )}
1075 {detail}
1076 <Box marginTop={1} flexDirection="row" flexWrap="wrap">
1077 <Text wrap="wrap">
1078 {legend.map(s => <Text color={s.c} dimColor={s.dim}>{s.t}</Text>)}
1079 <Text color={NOW}>◆</Text>
1080 <Text dimColor> now </Text>
1081 <Text color={LEVEL_COLOR.T}>↩</Text>
1082 <Text dimColor> re-read</Text>
1083 </Text>
1084 </Box>
1085 <Text color={FAINT} wrap="wrap">
1086 click a file for its detail, a folder to fold it{isTeam ? ' · an agent’s number leaves it out' : ''}
1087 </Text>
1088 </Box>
1089 )
1090 })
1091}
1092hooks/knowledge.ts 190 lines1import type { GpsAgent, Hold, Holds, Range } from '../types'
2
3/**
4 * How well an agent knows a file. V the whole file, P some lines, T lines read
5 * before someone else's edit, S only through a subagent it spawned, G a search
6 * hit, X lost to compaction.
7 */
8export type Level = 'V' | 'P' | 'T' | 'S' | 'G' | 'X'
9
10export const RANK: Record<Level, number> = { V: 5, P: 4, T: 3, S: 2, G: 1, X: 0 }
11
12export function emptyHold(now: number): Hold {
13 return { ranges: [], isShellRead: false, reads: 0, rereads: 0, isGrepHit: false, tokens: 0, added: 0, removed: 0, isShellEdit: false, last: now, isForgotten: false }
14}
15
16export function mergeRanges(ranges: readonly Range[]): Range[] {
17 const out: Range[] = []
18 for (const [a, b] of [...ranges].sort((x, y) => x[0] - y[0])) {
19 const last = out[out.length - 1]
20 if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b)
21 else out.push([a, b])
22 }
23 return out
24}
25
26export const covered = (ranges: readonly Range[]) => ranges.reduce((n, [a, b]) => n + b - a + 1, 0)
27
28const overlaps = (ranges: readonly Range[], [a, b]: Range) => ranges.some(([x, y]) => a <= y && b >= x)
29
30/** Rough token count of a tool result's text. */
31export const tokensOf = (text: string) => Math.ceil(text.length / 4)
32
33function withHold(holds: Holds, agent: string, path: string, now: number, change: (h: Hold) => Hold): Holds {
34 const mine = holds[agent] ?? {}
35 return { ...holds, [agent]: { ...mine, [path]: change({ ...(mine[path] ?? emptyHold(now)), last: now }) } }
36}
37
38/** A read of `range`, or of unknown lines when null (a shell read). */
39export function applyRead(holds: Holds, agent: string, path: string, range: Range | null, tokens: number, now: number): Holds {
40 return withHold(holds, agent, path, now, h => {
41 // A read after someone else's edit replaces the old copy.
42 const ranges = h.stale ? [] : h.ranges
43 const isReread = range !== null && !h.stale && overlaps(ranges, range)
44 return {
45 ...h,
46 ranges: range ? mergeRanges([...ranges, range]) : ranges,
47 isShellRead: h.isShellRead || range === null,
48 reads: h.reads + 1,
49 rereads: h.rereads + (isReread ? 1 : 0),
50 tokens: h.tokens + tokens,
51 stale: h.stale && range === null ? h.stale : undefined,
52 isForgotten: false,
53 }
54 })
55}
56
57export function applyGrepHits(holds: Holds, agent: string, paths: readonly string[], tokens: number, now: number): Holds {
58 const each = paths.length ? Math.ceil(tokens / paths.length) : 0
59 let out = holds
60 for (const path of paths) out = withHold(out, agent, path, now, h => ({ ...h, isGrepHit: true, tokens: h.tokens + each }))
61 return out
62}
63
64export type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number }
65
66/**
67 * The editor's own ranges after its edit: lines past a hunk move by what the
68 * hunk added or removed, and the hunk's new lines (which it just wrote) join them.
69 */
70export function shiftRanges(ranges: readonly Range[], hunks: readonly Hunk[]): Range[] {
71 const map = (line: number, isEnd: boolean) => {
72 let delta = 0
73 for (const k of hunks) {
74 const oldEnd = k.oldStart + k.oldLines - 1
75 if (line > oldEnd) delta += k.newLines - k.oldLines
76 else if (line >= k.oldStart) return isEnd ? k.newStart + Math.max(0, k.newLines - 1) : k.newStart
77 }
78 return line + delta
79 }
80 const moved = ranges.map(([a, b]): Range => [map(a, false), map(b, true)]).filter(([a, b]) => b >= a)
81 const written = hunks.filter(k => k.newLines > 0).map((k): Range => [k.newStart, k.newStart + k.newLines - 1])
82 return mergeRanges([...moved, ...written])
83}
84
85/**
86 * An edit by `agent`. Lines are unknown for a shell edit (null). `written` is
87 * the new line count when the agent wrote the whole file, which it then holds.
88 * Every other agent holding lines of the file now holds a stale copy.
89 */
90export function applyEdit(
91 holds: Holds,
92 agent: string,
93 path: string,
94 lines: { added: number; removed: number } | null,
95 now: number,
96 written?: number,
97 hunks: readonly Hunk[] = [],
98): Holds {
99 let out = withHold(holds, agent, path, now, h => ({
100 ...h,
101 added: h.added + (lines?.added ?? 0),
102 removed: h.removed + (lines?.removed ?? 0),
103 isShellEdit: h.isShellEdit || lines === null,
104 ranges: written !== undefined ? [[1, Math.max(1, written)]] : hunks.length ? shiftRanges(h.ranges, hunks) : h.ranges,
105 stale: undefined,
106 }))
107 for (const [other, theirs] of Object.entries(out)) {
108 const h = theirs[path]
109 if (other === agent || !h || (!h.ranges.length && !h.isShellRead)) continue
110 out = { ...out, [other]: { ...theirs, [path]: { ...h, stale: { by: agent, at: now } } } }
111 }
112 return out
113}
114
115/** A compaction drops everything the agent read from its context. */
116export function applyCompact(holds: Holds, agent: string): Holds {
117 const mine = holds[agent]
118 if (!mine) return holds
119 const next: Record<string, Hold> = {}
120 for (const [path, h] of Object.entries(mine)) {
121 const had = h.ranges.length > 0 || h.isShellRead || h.isGrepHit
122 next[path] = had ? { ...h, ranges: [], isShellRead: false, isGrepHit: false, tokens: 0, stale: undefined, isForgotten: true } : h
123 }
124 return { ...holds, [agent]: next }
125}
126
127/** The agents `id` spawned, and theirs. */
128export function descendants(agents: readonly GpsAgent[], id: string): string[] {
129 const out: string[] = []
130 for (const a of agents) if (a.parent === id) out.push(a.id, ...descendants(agents, a.id))
131 return out
132}
133
134const holdsLines = (h: Hold | undefined) => !!h && (h.ranges.length > 0 || h.isShellRead)
135
136/** The subagents of `agent` that hold lines of `path`. */
137export function via(holds: Holds, agents: readonly GpsAgent[], agent: string, path: string): string[] {
138 return descendants(agents, agent).filter(id => holdsLines(holds[id]?.[path]))
139}
140
141export function level(holds: Holds, agents: readonly GpsAgent[], agent: string, path: string, total: number | undefined): Level | null {
142 const h = holds[agent]?.[path]
143 if (h && holdsLines(h)) {
144 if (h.stale) return 'T'
145 if (h.ranges.length && total !== undefined && covered(h.ranges) >= total) return 'V'
146 return 'P'
147 }
148 if (via(holds, agents, agent, path).length) return 'S'
149 if (h?.isGrepHit) return 'G'
150 if (h?.isForgotten) return 'X'
151 // An edit with no read behind it (a shell edit, a Write of a new file) still means it knows the change.
152 if (h && (h.added || h.removed || h.isShellEdit)) return 'P'
153 return null
154}
155
156/** Lines added and removed, from an Edit or Write result's structured patch. */
157export function countPatch(hunks: readonly { lines: readonly string[] }[] | undefined): { added: number; removed: number } {
158 let added = 0
159 let removed = 0
160 for (const hunk of hunks ?? []) {
161 for (const line of hunk.lines) {
162 if (line.startsWith('+')) added++
163 else if (line.startsWith('-')) removed++
164 }
165 }
166 return { added, removed }
167}
168
169/** The order levels appear in a folder's strip: strongest first. */
170export const STRIP_ORDER: readonly Level[] = ['V', 'P', 'T', 'S', 'G', 'X']
171
172/** Up to this many of one level show as repeated glyphs; more become a glyph and a count. */
173export const STRIP_RUN = 3
174
175export type StripPart = { lvl: Level; n: number; isCounted: boolean }
176
177/** A folder's levels grouped in a fixed order: `●●◉ ◌14 ○○○`. */
178export function stripParts(levels: readonly Level[]): StripPart[] {
179 return STRIP_ORDER.map(lvl => {
180 const n = levels.filter(l => l === lvl).length
181 return { lvl, n, isCounted: n > STRIP_RUN }
182 }).filter(p => p.n > 0)
183}
184
185/** `1–120,200–240`, or a count past two ranges. */
186export function describeRanges(ranges: readonly Range[]): string {
187 if (ranges.length > 2) return `${ranges.length} ranges`
188 return ranges.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(',')
189}
190hooks/events.ts 106 lines1import type { GpsAgent, Holds, Range } from '../types'
2import { type Hunk, applyCompact, applyEdit, applyGrepHits, applyRead } from './knowledge'
3
4/**
5 * One thing an agent did, as Coverage records it. The grid is these events folded in
6 * time order, live and on resume alike: `t` is epoch ms, `a` the agent that acted.
7 */
8export type GpsEvent =
9 /** A read of lines `r`, or of unknown lines when null (a shell read); `n` the file's line count when known. */
10 | { k: 'read'; t: number; a: string; p: string; r: Range | null; tok: number; n?: number }
11 /** Paths named by a search, a listing or a count. */
12 | { k: 'hit'; t: number; a: string; ps: string[]; tok: number }
13 /** An edit: line counts and hunks from a patch, or `shell` when a command changed the file. */
14 | { k: 'edit'; t: number; a: string; p: string; add: number; del: number; shell?: true; written?: number; hunks?: Hunk[] }
15 | { k: 'compact'; t: number; a: string }
16 /** `a` started subagent `id`. */
17 | { k: 'spawn'; t: number; a: string; id: string; type: string; label: string }
18 /** `a`'s turn ended. */
19 | { k: 'done'; t: number; a: string }
20
21export type GpsState = { holds: Holds; lines: Record<string, number>; agents: GpsAgent[] }
22
23export const EMPTY: GpsState = { holds: {}, lines: {}, agents: [] }
24
25const TYPE_TAG: Record<string, string> = { Explore: 'ex', 'general-purpose': 'gp', Plan: 'pl', fork: 'fk' }
26
27/** `ex1`, `ex2`, `gp1`: the agent's type and its place among agents of that type. */
28export function tagFor(list: readonly GpsAgent[], type: string): string {
29 const prefix = TYPE_TAG[type] ?? (type.replace(/[^a-z]/gi, '').slice(0, 2).toLowerCase() || 'ag')
30 let n = 1
31 while (list.some(a => a.tag === prefix + n)) n++
32 return prefix + n
33}
34
35export const MAIN = (now: number): GpsAgent => ({ id: 'main', tag: 'main', label: 'main', parent: null, spawnedAt: now, doneAt: null, now: null, nowAt: 0 })
36
37/** The agent acting is running, and on `path` when it names one. One seen before its spawn counts as main's. */
38function noteAgent(agents: GpsAgent[], id: string, path: string | null, t: number): GpsAgent[] {
39 const list = agents.some(a => a.id === 'main') ? agents : [MAIN(t), ...agents]
40 const known = list.some(a => a.id === id)
41 const base = known ? list : [...list, { id, tag: tagFor(list, 'subagent'), label: 'subagent', parent: 'main', spawnedAt: t, doneAt: null, now: null, nowAt: 0 }]
42 return base.map(a => (a.id === id ? { ...a, doneAt: null, now: path ?? a.now, nowAt: path ? t : a.nowAt } : a))
43}
44
45export function applyEvent(s: GpsState, ev: GpsEvent): GpsState {
46 switch (ev.k) {
47 case 'read':
48 return {
49 holds: applyRead(s.holds, ev.a, ev.p, ev.r, ev.tok, ev.t),
50 lines: ev.n !== undefined ? { ...s.lines, [ev.p]: ev.n } : s.lines,
51 agents: noteAgent(s.agents, ev.a, ev.p, ev.t),
52 }
53 case 'hit':
54 return { ...s, holds: applyGrepHits(s.holds, ev.a, ev.ps, ev.tok, ev.t), agents: noteAgent(s.agents, ev.a, null, ev.t) }
55 case 'edit': {
56 const known = s.lines[ev.p]
57 const lines = ev.written !== undefined ? ev.written : known !== undefined && !ev.shell ? Math.max(1, known + ev.add - ev.del) : known
58 return {
59 holds: applyEdit(s.holds, ev.a, ev.p, ev.shell ? null : { added: ev.add, removed: ev.del }, ev.t, ev.written, ev.hunks ?? []),
60 lines: lines !== undefined ? { ...s.lines, [ev.p]: lines } : s.lines,
61 agents: noteAgent(s.agents, ev.a, ev.p, ev.t),
62 }
63 }
64 case 'compact':
65 return { ...s, holds: applyCompact(s.holds, ev.a) }
66 case 'spawn': {
67 const list = s.agents.some(a => a.id === 'main') ? s.agents : [MAIN(ev.t), ...s.agents]
68 const was = list.find(a => a.id === ev.id)
69 // A subagent whose tool calls arrived before its spawn did keeps its place, and gets its real name.
70 const agents = was
71 ? list.map(a => (a.id === ev.id ? { ...a, label: ev.label, parent: ev.a, tag: a.label === 'subagent' ? tagFor(list, ev.type) : a.tag } : a))
72 : [...list, { id: ev.id, tag: tagFor(list, ev.type), label: ev.label, parent: ev.a, spawnedAt: ev.t, doneAt: null, now: null, nowAt: 0 }]
73 return { ...s, agents }
74 }
75 case 'done':
76 return { ...s, agents: s.agents.map(a => (a.id === ev.a ? { ...a, doneAt: ev.t } : a)) }
77 }
78}
79
80/** Every agent's events in time order, folded from nothing. */
81export function replay(logs: Iterable<readonly GpsEvent[]>): GpsState {
82 const all = [...logs].flat().sort((x, y) => x.t - y.t)
83 return all.reduce(applyEvent, EMPTY)
84}
85
86// ── Snapshots on disk ───────────────────────────────────────────────────────
87// One file per agent in the chat's own folder, next to its `subagents/`:
88// `<chat id>/slash-coverage/main.json` and `<agent id>.json`. Claude Code deletes the
89// folder with the transcript, so a snapshot lives exactly as long as its chat.
90
91export type Snapshot = { version: 1; agent: string; events: GpsEvent[] }
92
93export const snapshotDir = (transcriptPath: string) => transcriptPath.replace(/\.jsonl$/, '') + '/slash-coverage'
94
95/** The agents a log started: their snapshots are read next. */
96export const spawnedIn = (events: readonly GpsEvent[]) => events.flatMap(ev => (ev.k === 'spawn' ? [ev.id] : []))
97
98export function parseSnapshot(text: string): GpsEvent[] {
99 try {
100 const s = JSON.parse(text) as Snapshot
101 return s?.version === 1 && Array.isArray(s.events) ? s.events : []
102 } catch {
103 return []
104 }
105}
106hooks/paths.ts 532 lines1import type { Range } from '../types'
2
3/** A file the index knows, by its path relative to the repo root. */
4export type FileEntry = { path: string }
5
6/**
7 * A directory on disk whose files the map shows at `prefix`: the root at '', and
8 * every worktree of a repo at that repo's place, so a subagent working in
9 * `../app-feature` reads the same files as main working in `app`.
10 */
11export type Checkout = { dir: string; prefix: string }
12
13/** The repo as the map indexes it: its root and its files, with a lookup by path. */
14export type Repo = { root: string; files: FileEntry[]; byPath: Map<string, number>; checkouts: Checkout[]; home: string }
15
16export function makeRepo(root: string, files: FileEntry[], worktrees: readonly Checkout[] = [], home = ''): Repo {
17 // Longest first: a worktree inside the root (`.claude/worktrees/x`) wins over the root.
18 const checkouts = [{ dir: root, prefix: '' }, ...worktrees.filter(w => w.dir !== root)].sort((a, b) => b.dir.length - a.dir.length)
19 return { root, files, byPath: new Map(files.map((f, i) => [f.path, i])), checkouts, home }
20}
21
22const clean = (path: string) => path.trim().replace(/^["'`]+|["'`,;)]+$/g, '')
23
24/** `path` seen from `cwd`, absolute and normalized; null for one that can't be placed (`~user`, `$VAR`, an option). */
25export function absolute(path: string, cwd: string, home = ''): string | null {
26 const p = clean(path)
27 if (!p || p === '-' || p.startsWith('-') || p.includes('$')) return null
28 let full: string
29 if (p.startsWith('/')) full = p
30 else if (p === '~' || p.startsWith('~/')) {
31 if (!home) return null
32 full = home + p.slice(1)
33 } else if (p.startsWith('~')) return null
34 else full = cwd + '/' + p
35 const out: string[] = []
36 for (const part of full.split('/')) {
37 if (part === '' || part === '.') continue
38 if (part === '..') out.pop()
39 else out.push(part)
40 }
41 return '/' + out.join('/')
42}
43
44/** The checkout an absolute path lies in, if any. */
45export function checkoutOf(repo: Repo, abs: string): Checkout | null {
46 return repo.checkouts.find(c => abs === c.dir || abs.startsWith(c.dir + '/')) ?? null
47}
48
49/** An absolute path as the map knows it, or null when it lies in no checkout. */
50export function logical(repo: Repo, abs: string): string | null {
51 const c = checkoutOf(repo, abs)
52 if (!c) return null
53 const rest = abs.slice(c.dir.length + 1)
54 return c.prefix && rest ? c.prefix + '/' + rest : c.prefix || rest
55}
56
57/** A path as the map knows it, seen from `cwd` (absolute; the root by default), or null when it's outside. */
58export function relative(repo: Repo, path: string, cwd = repo.root): string | null {
59 const abs = absolute(path, cwd, repo.home)
60 return abs === null ? null : logical(repo, abs)
61}
62
63/** A shell glob (`src/*.ts`, `src/**`) as a test on indexed paths. */
64export function globTest(pattern: string): RegExp {
65 const body = pattern
66 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
67 .replace(/\*\*\/?/g, '\u0000')
68 .replace(/\*/g, '[^/]*')
69 .replace(/\?/g, '[^/]')
70 .replace(/\u0000/g, '.*')
71 return new RegExp('^' + body + '$')
72}
73
74const TOKEN_SPLIT = /[\s"'`=(<>{}]+/
75
76/** ` 123 path` (wc), or `-rw-r--r-- 1 me staff 812 Oct 6 12:00 path` (ls -l): the path after the count or listing. */
77const LINE_LEAD = /^\s*(?:\d+\s+|[-dlcbps][rwxsStT-]{9}[@+.]?\s+(?:\S+\s+){7})?/
78
79/**
80 * Every indexed path that starts a line of `text`, read from `cwd`: the shape of
81 * search hits (`path:12: …`), listings and line counts. A path quoted inside a
82 * file's content (``) starts no line, so a `cat` names nothing.
83 */
84export function pathsIn(repo: Repo, text: string, cwd = repo.root, limit = 400): string[] {
85 const found = new Set<string>()
86 for (const line of text.split('\n').slice(0, limit)) {
87 const token = line.replace(LINE_LEAD, '').split(TOKEN_SPLIT)[0] ?? ''
88 if (!token || token.includes('*') || token.length > 400) continue
89 const rel = relative(repo, token.split(':')[0]!, cwd)
90 if (rel && repo.byPath.has(rel)) found.add(rel)
91 }
92 return [...found]
93}
94
95// ── Shell syntax ────────────────────────────────────────────────────────────
96// Enough of sh to tell which words are a command's file arguments: quotes,
97// escapes, `$(…)`, redirects, heredocs, pipes and lists. Not a full shell.
98
99/** A word with its quotes removed. `isGlob`: an unquoted `*` or `?`. `isDynamic`: it holds `$…` or a backtick. */
100export type Word = { text: string; isGlob: boolean; isDynamic: boolean }
101export type Redirect = { fd: number; op: string; target: Word | null }
102export type Command = { argv: Word[]; redirects: Redirect[] }
103/** Commands joined by `|`. */
104export type Pipeline = Command[]
105
106const OPERATORS = ['&&', '||', ';;', '|&', '&>>', '&>', '>>', '>&', '>|', '<<<', '<<-', '<<', '<&', '<>', '|', '&', ';', '(', ')', '<', '>']
107
108/** A command line as pipelines in the order they run: `;`, `&&`, `||`, `&` and newlines all separate them. */
109export function parseShell(src: string): Pipeline[] {
110 const pipelines: Pipeline[] = []
111 let pipeline: Pipeline = []
112 let cmd: Command = { argv: [], redirects: [] }
113 let pendingRedirect: Redirect | null = null
114 const heredocs: { delim: string; strip: boolean }[] = []
115 let i = 0
116
117 const endCommand = () => {
118 if (cmd.argv.length || cmd.redirects.length) pipeline.push(cmd)
119 cmd = { argv: [], redirects: [] }
120 pendingRedirect = null
121 }
122 const endPipeline = () => {
123 endCommand()
124 if (pipeline.length) pipelines.push(pipeline)
125 pipeline = []
126 }
127 const pushWord = (w: Word) => {
128 if (pendingRedirect) {
129 pendingRedirect.target = w
130 if (pendingRedirect.op === '<<' || pendingRedirect.op === '<<-') heredocs.push({ delim: w.text, strip: pendingRedirect.op === '<<-' })
131 pendingRedirect = null
132 } else cmd.argv.push(w)
133 }
134 /** The index past a `$(…)` (from its `(`) or a `` `…` `` (from its backtick). */
135 const skipSubst = (at: number): number => {
136 if (src[at] === '`') {
137 let j = at + 1
138 while (j < src.length && src[j] !== '`') j += src[j] === '\\' ? 2 : 1
139 return j + 1
140 }
141 let depth = 0
142 let j = at
143 for (; j < src.length; j++) {
144 const c = src[j]
145 if (c === '\\') j++
146 else if (c === "'") {
147 const end = src.indexOf("'", j + 1)
148 j = end < 0 ? src.length : end
149 } else if (c === '(') depth++
150 else if (c === ')' && --depth === 0) return j + 1
151 }
152 return j
153 }
154
155 while (i < src.length) {
156 const c = src[i]!
157 if (c === ' ' || c === '\t') {
158 i++
159 continue
160 }
161 if (c === '\\' && src[i + 1] === '\n') {
162 i += 2
163 continue
164 }
165 if (c === '#') {
166 while (i < src.length && src[i] !== '\n') i++
167 continue
168 }
169 if (c === '\n') {
170 endPipeline()
171 i++
172 // Heredoc bodies follow the line that opened them; they are stdin, never arguments.
173 for (const { delim, strip } of heredocs.splice(0)) {
174 while (i < src.length) {
175 const end = src.indexOf('\n', i)
176 const line = src.slice(i, end < 0 ? src.length : end)
177 i = end < 0 ? src.length : end + 1
178 if ((strip ? line.replace(/^\t+/, '') : line) === delim) break
179 }
180 }
181 continue
182 }
183 const op = OPERATORS.find(o => src.startsWith(o, i))
184 if (op) {
185 // `2>`: a word of digits right before the operator, with no space, is its file descriptor.
186 const last = cmd.argv[cmd.argv.length - 1]
187 const isFd = !!last && /^\d+$/.test(last.text) && i > 0 && !/\s/.test(src[i - 1]!)
188 i += op.length
189 if (op === '|' || op === '|&') endCommand()
190 else if (op.includes('<') || op.includes('>')) {
191 let fd = op.startsWith('<') ? 0 : op.startsWith('&') ? -1 : 1
192 if (isFd) {
193 fd = Number(last!.text)
194 cmd.argv.pop()
195 }
196 const r: Redirect = { fd, op, target: null }
197 cmd.redirects.push(r)
198 // `2>&1`: the target is a descriptor, read as an ordinary word.
199 pendingRedirect = r
200 } else endPipeline()
201 continue
202 }
203 // A word.
204 let text = ''
205 let isGlob = false
206 let isDynamic = false
207 while (i < src.length) {
208 const ch = src[i]!
209 if (ch === ' ' || ch === '\t' || ch === '\n' || '|&;<>()'.includes(ch)) break
210 if (ch === '\\') {
211 text += src[i + 1] ?? ''
212 i += 2
213 } else if (ch === "'") {
214 const end = src.indexOf("'", i + 1)
215 text += src.slice(i + 1, end < 0 ? src.length : end)
216 i = end < 0 ? src.length : end + 1
217 } else if (ch === '"') {
218 i++
219 while (i < src.length && src[i] !== '"') {
220 if (src[i] === '\\' && '"\\$`\n'.includes(src[i + 1] ?? '')) {
221 text += src[i + 1]
222 i += 2
223 } else if (src.startsWith('$(', i) || src[i] === '`') {
224 const end = skipSubst(src[i] === '`' ? i : i + 1)
225 text += src.slice(i, end)
226 isDynamic = true
227 i = end
228 } else {
229 if (src[i] === '$') isDynamic = true
230 text += src[i++]
231 }
232 }
233 i++
234 } else if (src.startsWith('$(', i) || ch === '`') {
235 const end = skipSubst(ch === '`' ? i : i + 1)
236 text += src.slice(i, end)
237 isDynamic = true
238 i = end
239 } else {
240 if (ch === '$') isDynamic = true
241 if (ch === '*' || ch === '?') isGlob = true
242 text += ch
243 i++
244 }
245 }
246 pushWord({ text, isGlob, isDynamic })
247 }
248 endPipeline()
249 return pipelines
250}
251
252// ── What a command line reads ───────────────────────────────────────────────
253
254type FileRef = { path: string; abs: string }
255/** Lines `from`–`to` of a file, lines of it that can't be placed (`from: null`), or output that is no file's (`opaque` lines, null when unknown). */
256type Seg = (FileRef & { from: number; to: number }) | (FileRef & { from: null }) | { opaque: number | null }
257
258/** A file a command line reads: lines `range`, or unknown lines when null. */
259export type ShellRead = FileRef & { range: Range | null }
260
261export type ShellEffects = {
262 reads: ShellRead[]
263 /** Files searched or counted (grep, wc): named, never read. */
264 searched: string[]
265 /** Where the shell ends up. */
266 cwd: string
267 /** Every directory the command line worked in or named a file in, absolute: where it could have changed files. */
268 places: string[]
269}
270
271/** Commands that print their files whole, or every line of them transformed. */
272const WHOLE = new Set(['cat', 'nl', 'tac', 'less', 'more', 'bat', 'batcat', 'cut', 'sort', 'uniq', 'column', 'fold', 'expand', 'rev', 'jq', 'yq', 'xxd', 'od', 'hexdump', 'strings', 'fmt', 'pr', 'paste'])
273/** Of those, the ones that keep lines one for one, so a `head` after them still counts lines of the file. */
274const LINEWISE = new Set(['cat', 'nl', 'cut', 'expand', 'rev', 'column'])
275/** Commands that look inside files but print only matches, names or counts. */
276const SEARCHING = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'wc'])
277/** Words that start a command without being it. */
278const PREFIX = new Set(['do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', 'time', 'done', 'fi', 'esac', 'command', 'builtin', 'exec', 'nice', 'nohup'])
279/** A pipeline whose stdout goes here still reaches the agent. */
280const SHOWN = new Set(['/dev/stdout', '/dev/stderr', '/dev/tty'])
281
282const segLen = (g: Seg) => ('opaque' in g ? g.opaque : g.from === null ? null : g.to - g.from + 1)
283
284const fuzz = (g: Seg): Seg => ('opaque' in g ? { opaque: null } : { path: g.path, abs: g.abs, from: null })
285
286/** Lines `a`–`b` (1-based, inclusive; `b` may be Infinity) of a stream. Past a part of unknown length nothing can be placed, so the rest is kept unplaced. */
287function slice(stream: readonly Seg[], a: number, b: number): Seg[] {
288 const out: Seg[] = []
289 let pos = 0
290 for (let k = 0; k < stream.length; k++) {
291 const g = stream[k]!
292 const n = segLen(g)
293 if (n === null) {
294 if (pos < b) out.push(...stream.slice(k).map(fuzz))
295 break
296 }
297 const lo = Math.max(a, pos + 1)
298 const hi = Math.min(b, pos + n)
299 if (lo <= hi) out.push('opaque' in g ? { opaque: hi - lo + 1 } : { path: g.path, abs: g.abs, from: g.from! + lo - pos - 1, to: g.from! + hi - pos - 1 })
300 pos += n
301 if (pos >= b) break
302 }
303 return out
304}
305
306function streamLen(s: readonly Seg[]): number | null {
307 let n = 0
308 for (const g of s) {
309 const l = segLen(g)
310 if (l === null) return null
311 n += l
312 }
313 return n
314}
315
316/** The last `n` lines of a stream, or its lines from `n` on (`tail -n +n`). */
317function tail(s: readonly Seg[], n: number, isFrom: boolean): Seg[] {
318 if (isFrom) return slice(s, n, Infinity)
319 const len = streamLen(s)
320 return len === null ? s.map(fuzz) : slice(s, Math.max(1, len - n + 1), len)
321}
322
323const isOption = (w: Word) => w.text.startsWith('-') && w.text !== '-'
324
325/** `-n 20`, `-n20`, `-20`, `--lines=20`, `-n +5`: head's and tail's count, and their files. null for bytes or a count it can't read. */
326function countOpt(args: readonly Word[]): { n: number; isFrom: boolean; files: Word[] } | null {
327 let n = 10
328 let isFrom = false
329 const files: Word[] = []
330 for (let k = 0; k < args.length; k++) {
331 const t = args[k]!.text
332 let v: string | undefined
333 if (t === '-n' || t === '--lines') v = args[++k]?.text
334 else if (/^-n./.test(t)) v = t.slice(2)
335 else if (t.startsWith('--lines=')) v = t.slice(8)
336 else if (/^-\d+$/.test(t)) v = t.slice(1)
337 else if (t.startsWith('-c') || t.startsWith('--bytes')) return null
338 else if (isOption(args[k]!)) continue
339 else files.push(args[k]!)
340 if (v !== undefined) {
341 if (!/^\+?\d+$/.test(v)) return null
342 isFrom = v.startsWith('+')
343 n = Number(v.replace('+', ''))
344 }
345 }
346 return { n, isFrom, files }
347}
348
349type SedPlan = { kind: 'ranges'; ranges: [number, number][] } | { kind: 'whole' } | { kind: 'edit' } | null
350
351/** What `sed` prints: line ranges for `-n 'A,Bp'`, the first lines for `Nq`, the whole input otherwise; null when it can't tell. */
352function sedPlan(args: readonly Word[]): { plan: SedPlan; files: Word[] } {
353 let quiet = false
354 const scripts: string[] = []
355 const files: Word[] = []
356 let hasE = false
357 for (let k = 0; k < args.length; k++) {
358 const t = args[k]!.text
359 if (t === '-n' || t === '--quiet' || t === '--silent') quiet = true
360 else if (/^-i|^--in-place/.test(t)) return { plan: { kind: 'edit' }, files: [] }
361 else if (t === '-e' || t === '--expression') {
362 scripts.push(args[++k]?.text ?? '')
363 hasE = true
364 } else if (t.startsWith('--expression=')) {
365 scripts.push(t.slice(13))
366 hasE = true
367 } else if (t === '-f' || t === '--file') return { plan: null, files: args.slice(k + 2).filter(w => !isOption(w)) }
368 else if (/^-[a-zA-Z]+$/.test(t)) quiet = quiet || t.includes('n')
369 else if (!hasE && !scripts.length) scripts.push(t)
370 else files.push(args[k]!)
371 }
372 const ranges: [number, number][] = []
373 const at = (s: string) => (s === '$' ? Infinity : Number(s))
374 for (const part of scripts.join(';').split(/[;\n]/).map(s => s.trim()).filter(Boolean)) {
375 const m = /^(\d+|\$)(?:\s*,\s*(\d+|\$|\+\d+))?\s*([pq])$/.exec(part)
376 if (!m) return { plan: quiet ? null : { kind: 'whole' }, files }
377 const a = at(m[1]!)
378 const b = m[2] === undefined ? a : m[2].startsWith('+') ? a + Number(m[2].slice(1)) : at(m[2])
379 if (m[3] === 'q') {
380 if (!quiet) return { plan: { kind: 'ranges', ranges: [[1, a]] }, files }
381 continue
382 }
383 ranges.push([a, b])
384 }
385 return { plan: quiet ? { kind: 'ranges', ranges } : { kind: 'whole' }, files }
386}
387
388/** The lines `awk` prints when its program only picks them by number: `NR>=10 && NR<=20`, `NR==5,NR==9`. */
389function awkRange(program: string): [number, number] | null {
390 const cond = program.replace(/\{\s*(print(\s+\$0)?)?\s*;?\s*\}\s*$/, '').trim()
391 const between = /^NR\s*==\s*(\d+)\s*,\s*NR\s*==\s*(\d+)$/.exec(cond)
392 if (between) return [Number(between[1]), Number(between[2])]
393 let a = 1
394 let b = Infinity
395 for (const part of cond.split('&&').map(s => s.trim())) {
396 const m = /^NR\s*(>=|>|<=|<|==)\s*(\d+)$/.exec(part)
397 if (!m) return null
398 const v = Number(m[2])
399 if (m[1] === '>=') a = Math.max(a, v)
400 else if (m[1] === '>') a = Math.max(a, v + 1)
401 else if (m[1] === '<=') b = Math.min(b, v)
402 else if (m[1] === '<') b = Math.min(b, v - 1)
403 else {
404 a = v
405 b = v
406 }
407 }
408 return a <= b ? [a, b] : null
409}
410
411/**
412 * What a command line does with files, run from `cwd` (absolute): which lines of
413 * which files reach the agent, which files it only searched, and where it ends up.
414 * `totals` gives a file's line count by its absolute path, when known. `visible`,
415 * when the output was cut to a preview, is how many lines of it the agent saw.
416 */
417export function shellEffects(
418 repo: Repo,
419 command: string,
420 cwd: string,
421 totals: (abs: string) => number | undefined = () => undefined,
422 visible?: number,
423): ShellEffects {
424 const searched = new Set<string>()
425 const places = new Set<string>([cwd])
426 const vars = new Map<string, Word[]>()
427 const shown: Seg[] = []
428
429 /** The files a word names: one path, a glob's indexed matches, or a for-loop variable's words. */
430 const filesOf = (w: Word): FileRef[] => {
431 const loop = /^\$\{?(\w+)\}?$/.exec(w.text)
432 if (loop && vars.has(loop[1]!)) return vars.get(loop[1]!)!.flatMap(filesOf)
433 if (w.isDynamic || w.text.length > 400 || w.text.endsWith('/')) return []
434 const abs = absolute(w.text, cwd, repo.home)
435 if (!abs) return []
436 places.add(abs.slice(0, abs.lastIndexOf('/')) || '/')
437 const rel = logical(repo, abs)
438 if (rel === null) return []
439 if (!w.isGlob) return [{ path: rel, abs }]
440 const test = globTest(rel)
441 const c = checkoutOf(repo, abs)!
442 return repo.files
443 .filter(f => test.test(f.path) && (!c.prefix || f.path.startsWith(c.prefix + '/')))
444 .map(f => ({ path: f.path, abs: c.dir + '/' + (c.prefix ? f.path.slice(c.prefix.length + 1) : f.path) }))
445 }
446 /** A file's every line. A name that is no file (a typo, a grep pattern) prints nothing of one. */
447 const whole = (f: FileRef): Seg => {
448 const n = totals(f.abs)
449 if (n !== undefined) return { ...f, from: 1, to: Math.max(1, n) }
450 return repo.byPath.has(f.path) ? { ...f, from: null } : { opaque: null }
451 }
452
453 /** A stage's output: from its file arguments, or from its stdin (`input`) when it names none. */
454 const stage = (name: string, args: Word[], input: Seg[] | null): Seg[] | null => {
455 const source = (files: Word[]): Seg[] => {
456 const fs = files.flatMap(filesOf)
457 return files.length || !input ? fs.map(whole) : input
458 }
459 if (name === 'head' || name === 'tail') {
460 const o = countOpt(args)
461 // Bytes, or a count it can't read: the files, without the values their options took.
462 if (!o) return source(args.filter((w, k) => !isOption(w) && !/^(-[nc]|--lines|--bytes)$/.test(args[k - 1]?.text ?? ''))).map(fuzz)
463 const cut = (s: Seg[]) => (name === 'head' ? slice(s, 1, o.n) : tail(s, o.n, o.isFrom))
464 // Files named are cut one by one; stdin is one stream.
465 return o.files.length || !input ? o.files.flatMap(filesOf).flatMap(f => cut([whole(f)])) : cut(input)
466 }
467 if (name === 'sed') {
468 const { plan, files } = sedPlan(args)
469 if (plan?.kind === 'edit') return null
470 const s = source(files)
471 if (!plan) return s.map(fuzz)
472 return plan.kind === 'whole' ? s : plan.ranges.flatMap(([a, b]) => slice(s, a, b))
473 }
474 if (name === 'awk') {
475 const rest = args.filter((w, k) => !isOption(w) && !['-F', '-v', '-f'].includes(args[k - 1]?.text ?? ''))
476 const r = awkRange(rest[0]?.text ?? '')
477 const s = source(rest.slice(1))
478 return r ? slice(s, r[0], r[1]) : s.map(fuzz)
479 }
480 if (name === 'tee') return input ?? []
481 if (WHOLE.has(name)) {
482 const s = source(args.filter(w => !isOption(w)))
483 return LINEWISE.has(name) ? s : s.map(fuzz)
484 }
485 if (name === 'echo' || name === 'printf') return [{ opaque: 1 }]
486 return null
487 }
488
489 for (const pipeline of parseShell(command)) {
490 let stream: Seg[] | null = null
491 let isShown = true
492 for (const cmd of pipeline) {
493 let argv = cmd.argv
494 while (argv.length && (PREFIX.has(argv[0]!.text) || /^\w+=/.test(argv[0]!.text))) argv = argv.slice(1)
495 const name = (argv[0]?.text ?? '').split('/').pop()!.replace(/^g(?=sed$|awk$|head$|tail$|cat$|grep$)/, '')
496 const args = argv.slice(1)
497 const fileIn = cmd.redirects.filter(r => r.op === '<' && r.target).flatMap(r => filesOf(r.target!)).map(whole)
498 const input: Seg[] | null = fileIn.length ? fileIn : stream
499 isShown = !cmd.redirects.some(
500 r => (r.fd === 1 || r.fd === -1) && ['>', '>>', '>|', '&>', '&>>'].includes(r.op) && r.target && !SHOWN.has(r.target.text),
501 )
502 if (name === 'cd' || name === 'pushd') {
503 const to = args.find(w => !isOption(w))
504 const next = to ? (to.isDynamic ? null : absolute(to.text, cwd, repo.home)) : repo.home || null
505 if (next) cwd = next
506 places.add(cwd)
507 stream = null
508 } else if (name === 'for' && args[1]?.text === 'in') {
509 vars.set(args[0]!.text, args.slice(2))
510 stream = null
511 } else if (SEARCHING.has(name) || (name === 'git' && args[0]?.text === 'grep')) {
512 // The files searched, or the stream piped in: named either way, not read.
513 // A grep's first word is its pattern, unless -e or -f gave it.
514 const hasPattern = name !== 'wc' && !args.some(w => /^-[a-zA-Z]*[ef]$|^--(regexp|file)\b/.test(w.text))
515 const words = name === 'git' ? [] : args.filter(w => !isOption(w)).slice(hasPattern ? 1 : 0)
516 const files = words.flatMap(filesOf)
517 const from: FileRef[] = files.length || words.length ? files : (input ?? []).flatMap(g => ('opaque' in g ? [] : [g]))
518 for (const f of from) if (repo.byPath.has(f.path) || totals(f.abs) !== undefined) searched.add(f.path)
519 stream = [{ opaque: null }]
520 } else stream = (name ? stage(name, args, input) : input) ?? [{ opaque: null }]
521 }
522 if (stream && isShown) shown.push(...stream)
523 }
524
525 const reads: ShellRead[] = []
526 for (const g of visible === undefined ? shown : slice(shown, 1, visible)) {
527 if (!('opaque' in g)) reads.push({ path: g.path, abs: g.abs, range: g.from === null ? null : [g.from, g.to] })
528 }
529 const readPaths = new Set(reads.map(r => r.path))
530 return { reads, searched: [...searched].filter(p => !readPaths.has(p)), cwd, places: [...places] }
531}
532types/index.d.ts 75 lines1/** A 1-based, inclusive line range. */
2export type Range = [number, number]
3
4/** What one agent holds of one file, built from its own tool calls. */
5export type Hold = {
6 /** Line ranges of this file its Read calls put in its context, merged. */
7 ranges: Range[]
8 /** Read through the shell (cat, sed, head): the lines it covered are unknown. */
9 isShellRead: boolean
10 /** Read calls, shell reads included. */
11 reads: number
12 /** Reads that covered lines it already held. */
13 rereads: number
14 /** Named in a search result or listing. */
15 isGrepHit: boolean
16 /** Estimated tokens this file's tool results put in its context. */
17 tokens: number
18 /** Another agent edited the file after this one read it. */
19 stale?: { by: string; at: number }
20 added: number
21 removed: number
22 /** Changed by a shell command, so the line counts are unknown. */
23 isShellEdit: boolean
24 /** Epoch ms of the latest read, hit or edit. */
25 last: number
26 /** It held lines of this file until a compaction removed them. */
27 isForgotten: boolean
28}
29
30export type GpsAgent = {
31 /** 'main', or a subagent's id. */
32 id: string
33 /** A few characters naming it in cells: `ex1` for the first Explore agent. */
34 tag: string
35 label: string
36 /** The loop that spawned it; null for main. */
37 parent: string | null
38 spawnedAt: number
39 /** When its latest turn ended; null while it runs. */
40 doneAt: number | null
41 /** The file it touched last, and when. */
42 now: string | null
43 nowAt: number
44}
45
46/** Holds by agent id, then by path relative to the repo root. */
47export type Holds = Record<string, Record<string, Hold>>
48
49declare module 'claude-code' {
50 interface PluginState {
51 'slash-coverage': {
52 holds: Holds
53 /** Line counts the Read results reported. */
54 lines: Record<string, number>
55 agents: GpsAgent[]
56 /** 'auto' follows the transcript on screen; otherwise an agent id or 'all'. */
57 pick: string
58 /** The agent picked last: in all, the picker row stays scrolled to it. */
59 anchor: string | null
60 /** Agents the person left out of the all view. */
61 excluded: string[]
62 /** Folders the person folded. */
63 closed: string[]
64 /** The file whose detail shows under the grid. */
65 selected: string | null
66 /** Touched paths outside the repo. */
67 outside: number
68 /** Bumped when the file index changes. */
69 indexVersion: number
70 /** The chat's snapshot folder, next to its transcript; null until the session names it. */
71 snapshotDir: string | null
72 }
73 }
74}
75