SLOPSHOPPER

slash-coverage

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…

newpaneguardcommandprocesstimer
★ 1v0.1.0MITupdated 2026-10-07Davron2004/slash-coverage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slash-coverage
│ ┃ coverage ✕ › fix the failing auth test and add an audit log call │ ┃ ◉ Coverage h: ‹ m: [main] l: › a: all │ ┃ main session files 77 tok ●2 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ file held tokens ⏺ Update(src/auth.ts) │ ┃ ▾ src/ ● 1/3 49 ⎿ Added 2 lines, removed 1 line │ ┃ auth.ts ● all 1 49 ⏺ Bash(bun test) │ ┃ 2 untouched ⎿ 3 pass, 1 fail │ ┃ ◆ .env ● all 3 28 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ● full ◉ partial ● stale ◌ secondhand ○ │ ┃ compacted ◆ now ↩ re-read ✻ Worked for 42s · done 4:20 PM │ ┃ click a file for its detail, a folder to fol │ › /coverage │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · coverage
◉ Coverage h: ‹ m: [main] l: › a: all main session files 77 tok ●2 file held tokens last reads ▾ src/ ● 1/3 49 0s 1 auth.ts ● all 1 49 0s 1 2 untouched ◆ .env ● all 3 28 0s 1 ● full ◉ partial ● stale ◌ secondhand ○ grep hit ✕ compacted ◆ now ↩ re-read click a file for its detail, a folder to fold it
README

slash-coverage

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.

Coverage, one agent

Install

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.

Reading it

Pick an agent in the row at the top, and every column describes that agent:

ColumnShows
heldWhat'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
tokensWhat the file's tool results cost its context
lastTime since it last read, searched or edited the file
readsRead calls, with ↩n for reads of lines it already held
editsLines it added and removed
GlyphMeans
●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.

KeyDoes
h / lStep to the previous or next agent, in the order they were spawned. The row scrolls; ‹ 3 and 5 › count the agents off screen
mMain session
aAll agents side by side

All agents side by side

Coverage, all agents

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.

How it works

Every value comes from tool calls Claude Code already makes, and nothing asks an agent to report anything:

  • Read results give the exact line ranges and the file's length.
  • Edit and Write patches give lines added and removed. They also shift the editor's own line ranges, and they mark every other agent's copy of the file stale.
  • Grep and Glob results, and shell commands that search or count (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.
  • Shell reads are parsed as shell: quotes, heredocs, redirects, pipes and 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.
  • Worktrees of the repo (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.
  • Spawns give each subagent's parent, and a subagent counts as done when its turn ends.
  • Secondhand (◌) is structural: a subagent you spawned read the file. What its report said about the file isn't tracked.

It survives a restart

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.

Limits

  • Files Claude Code loads on its own, like CLAUDE.md, never pass through a tool call, so they don't show.
  • Token counts are estimates (characters ÷ 4).
  • A shell read through something the parser can't follow (sed '/re/,/re/p', a script, xargs) holds unknown lines, so it shows as partial.
  • A forked chat starts empty: the fork gets a new folder, and the logs aren't copied into it.
  • Only the terminal has been checked visually. The desktop app draws the same elements, untested.
  • The glyphs are chosen to sit in one cell of JetBrains Mono (Ghostty's default font). A font that lacks them can draw them wider.

Develop

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.

License

MIT

Source 5 files
hooks/register.tsx 1092 lines
1import { 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}
1092
hooks/knowledge.ts 190 lines
1import 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}
190
hooks/events.ts 106 lines
1import 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}
106
hooks/paths.ts 532 lines
1import 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 (`![](docs/home.png)`) 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}
532
types/index.d.ts 75 lines
1/** 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