A debugger for Claude Code tool calls: breakpoints, pause/continue/reject/step, a timeline, an inspector, Error Lens failure diagnosis and sanitized trace…

<img src="docs/media/banner.svg" alt="Claude DevTools: a paused git push at a breakpoint, and a failed Write with a why? link" width="720">
<h1 align="center">Claude DevTools</h1>
<strong>Breakpoints for your coding agent. Stop it before it runs <code>git push</code>. See why its last call failed.</strong>
<sub>A Claude Code mod that works like a debugger for tool calls: breakpoints on tools, commands, files and errors · Continue, Step and Reject · a live dashboard · Error Lens failure diagnosis. Everything stays on your machine.</sub>
<a href="https://github.com/NMenzel/claude-devtools-mod/stargazers"><img src="https://img.shields.io/github/stars/NMenzel/claude-devtools-mod?style=flat-square&color=yellow&label=stars" alt="GitHub stars"></a> <a href="https://github.com/NMenzel/claude-devtools-mod/releases/latest"><img src="https://img.shields.io/github/v/release/NMenzel/claude-devtools-mod?style=flat-square&label=version&color=blue" alt="Latest release"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License: MIT"></a> <a href="https://claude.com/blog/claude-code-mods"><img src="https://img.shields.io/badge/Claude%20Code-2.1.294%2B%20mod-d97757?style=flat-square" alt="Claude Code 2.1.294+ mod"></a> <img src="https://img.shields.io/badge/surfaces-terminal%20%7C%20desktop-lightgrey?style=flat-square" alt="Surfaces: terminal and desktop">
<a href="#install">Install</a> · <a href="#use-it">Use it</a> · <a href="#error-lens-why-a-tool-call-failed">Error Lens</a> · <a href="#commands">Commands</a> · <a href="#options">Options</a> · <a href="docs/SECURITY.md">Security</a>
<img src="docs/media/demo.gif" alt="Claude DevTools in a live session: /devtools-break command git push sets a breakpoint, Claude's git push is held and rejected, and the Error Lens tab explains what happened" width="900">
Claude Code acts on its own, one tool call after another. Permission rules decide whether a call may run. They don't let you stop the agent at a point you choose, look at what it is about to do, and step through it.
npm publish", "stop whenever it touches src/auth/**" or "stop on the next call after a failure", then decide with the full call in front of you.EPERM: operation not permitted scrolls past. Was the file locked? Did the parent folder exist? Did the write land anyway? Claude retries, and you guess.Claude DevTools is a debugger for the agent's tool level, built on Claude Code's native Mods API.
| In Claude Code today | With Claude DevTools |
|---|---|
| A permission prompt per call, allow or deny | Breakpoints on tools, command patterns, path globs and failures. Continue, Step (pause on the next call too) or Reject with a note Claude reads |
Bash(npm test) in the transcript | A "break on" gutter under every tool row, like clicking a line number in Chrome DevTools, plus a one-key bar above the prompt |
Error: EPERM: operation not permitted | Error Lens: the kind of failure, what is ✔ confirmed, ? possible or · unknown, each with its evidence, read-only file checks, and fixes to try |
| The same error, again and again | Repeats grouped and counted. A notification for the first, then every fifth |
| Scrollback | A timeline and inspector: status, duration, agent, permission verdict and breakpoint of every call |
| Copying from the terminal | Sanitized exports, versioned JSON and Markdown, with secrets redacted and file contents omitted |
Native mod. No wrappers, no API keys, no network calls, no model calls. Two commands to install.
It works at the tool level only. It does not show hidden reasoning, model internals or token-level steps. It sees what the Mods API exposes: each tool call's name, arguments, agent, permission verdict and result. It never approves anything: Claude Code's permission rules still decide after you press Continue.
[!TIP] If Claude DevTools saves you a bad
git pushor an hour of guessing at an error, a ⭐ on the repo helps other developers find it.
Claude DevTools is a community project. It is not made or endorsed by Anthropic.
claude -p the hooks run but nothing is drawn; use the /devtools-* commands there.Inside Claude Code:
/plugin marketplace add NMenzel/claude-devtools-mod
/plugin install devtools@claude-devtools-mod
/reload-plugins
/devtools
Or from a terminal, in one command (it adds the marketplace too):
claude plugin install devtools --marketplace NMenzel/claude-devtools-mod
Then run /reload-plugins in an open session, or start a new one, and run /devtools.
The installer may say config options aren't set. The defaults are fine, and /config changes them. Mods are an early-access Claude Code feature, and their API can change between releases.
Check that it loaded: run /plugin and look for devtools in the mods active line. You can also run /devtools-help.
To update: claude plugin update devtools@claude-devtools-mod. To remove: claude plugin uninstall devtools@claude-devtools-mod.
Paste this into Claude Code, or any coding agent that can run shell commands:
Install the Claude DevTools mod for Claude Code from
https://github.com/NMenzel/claude-devtools-mod. Follow the
"Instructions for agents" in its README, then tell me what to run next.
If you are an AI agent installing Claude DevTools for a user, follow these steps.
claude --version. Claude Code 2.1.287 or later supports mods, and Claude DevTools is tested on 2.1.294. If the version is older, stop and tell the user to update Claude Code (claude update).sh claude plugin install devtools --marketplace NMenzel/claude-devtools-mod --json ` This adds the marketplace to the user's settings and installs at user scope. Exit code 0 means it is installed. To share it with everyone working in the current repository, add --scope project (it is then recorded in .claude/settings.json`). Ask the user before using a scope other than the default.--config <option>=<value> on the install command. Examples: --config errorLens=classify (no file checks), --config openOnStart=false. All options are listed under Options.claude plugin list --json includes devtools@claude-devtools-mod./reload-plugins, then /devtools. A newly started session loads the mod by itself.Notes for agents:
sudo. Do not clone the repository to install it.claude -p "/devtools-help" prints the help without a model call. In Git Bash on Windows, prefix MSYS_NO_PATHCONV=1 so the shell does not turn /devtools-help into a path./devtools-break command npm install # pause before any `npm install`
/bp command git push # the same, short: /bp is /devtools-break
/devtools-break file src/auth/** # pause before reading/editing auth code
/devtools-break tool Write,Edit --action warn
/devtools-break error Bash --action pause # after a failed Bash call, pause the next call
/devtools # open the pane
When a call matches a pause breakpoint, Claude Code shows a DevTools question with:
| Choice | What happens | | - | - | | Continue | The call goes on through Claude Code's normal permission checks and runs. | | Step | Same as Continue. Then the next tool call pauses too, one tool call at a time. | | Reject | The tool does not run. Claude is told the developer rejected it and to choose another approach. | | Simulate | Only offered when the simulation option is on. The tool does not run. Claude gets a clearly labeled synthetic result (see below). | | typed text | Rejects the call and passes your text to Claude as a note ("use pnpm instead"). | | Esc / Chat about this | Rejects the call ("dismissed"). |
The question dialog is the control for a held call. It holds the call inside Claude Code's own dialog, so the wait never times out the hook.
There are three ways to set a breakpoint without typing a rule.
Bash(npm test), Read(src/auth/login.ts), a folded "Read 3 files, searched 2 patterns" line) sits one dim line: break on ○ Bash ○ "npm test" ○ src/x.ts. Press one to set a pause breakpoint on that tool, that command (program and subcommand), or that path. Press it again to remove it. A row that a breakpoint covers gets a red ● breakpoint bp1 … line. Clicks need a pointer (the fullscreen terminal or Claude Desktop); the bar below works by keyboard everywhere. Set inlineControls to hover to show the controls only while the pointer is over a row, or off.DevTools ✓ Bash git push origin main · break on ○ Bash ○ "git push" · DevTools · hide. Press ctrl+x tab to focus it, then t for the tool, c for the command, f for the path, d to open the dashboard and x to hide it. When that call failed, e (✗ why?) opens its Error Lens. A dim hint at its end names /bp <rule> and /devtools-help. The bar never wraps: when the line is narrow, the hint, the call's summary, DevTools and hide give way in that order, and the breakpoint keys and why? stay. Before the first tool call the bar is the hint alone. This works on every terminal layout, keyboard only.pause on ■ Shell □ Read □ Search □ Edit □ Web □ Agents. They are on the dashboard and the Breakpoints tab, and each one adds or removes a tool breakpoint for the whole family.The Inspector has the same break on buttons for any recorded call, on keys 1 2 3.
/devtools opens the dashboard, a live pane of bordered panels. It also opens by itself when an interactive session starts in a terminal at least 144 columns wide (set openOnStart to false to stop that).
CLAUDE DEVTOOLS · ● active · ⏺ recording · 3 breakpoints · ⏸ 1 paused, and a color legend (ok, failed, denied, simulated, paused).✗ 3× Bash · exit-code · npm ERR! …). Press one to open its Error Lens.p pause next call, r recording, m cycle mode, s export.Docked from 110 columns it lays out two columns (rules and calls on the left, the timeline on the right). Narrower, it stacks. Inline above the prompt (main-screen terminal) it is a three-line summary.
The tabs (d t i b e; ctrl+x tab focuses the pane, Tab walks its controls):
● marks calls that matched a breakpoint. j/k page, x clear. Press a row to inspect it, or the why? beside a failed row to open its Error Lens.break on buttons on 1/2/3, h/l older/newer, and on a failed call w "Why it failed"./devtools-break.Error Lens watches every tool call that fails (Bash, PowerShell, Read, Write, Edit, Grep, Glob, web and MCP tools) and explains the failure from evidence. It is passive: it never retries a call, changes a result, asks a model or interrupts you, and its file checks run after the result has gone back to Claude. It is on by default (errorLens).
For each failure it keeps:
ENOENT, EACCES, EPERM, ...) or shell exit code;✔ CONFIRMED: the result text, a permission verdict or a file check shows it. The evidence (the quoted line, the verdict, the observed file) is shown under it.? POSSIBLE: a known explanation the evidence does not prove (another program holding the file, CRLF line endings, a sandbox).· UNKNOWN: what cannot be determined from here (which hook refused, permission bits, which process changed a file). When no signature matches, it says the cause is unknown. It never guesses past the evidence.probe mode, the default): after the result has gone back to Claude, one stat per path. It checks the target file, its parent folder and paths the error names: whether each exists, what kind it is, its size, its modification time and whether it is a link. So it can say "the parent directory /work/new does not exist", or that a Write reported an error but the file changed during the call and has exactly the intended size, so the write may have landed. It never reads file contents.Repeats of one failure (same tool, kind and message with paths and numbers masked) are grouped and counted. A one-line notification announces the first failure of each kind and then every fifth repeat: DevTools ✗ Read failed (not-found): ENOENT: no such file … · /devtools-errors to inspect. DevTools' own refusals, simulations and interrupted calls are kept but never announced.
Ways to reach it: the Errors tab (e), the dashboard's ERRORS panel, why? on a failed timeline row, w in the Inspector, e on the bar above the prompt, or /devtools-errors, which prints the same report as text and works headless too. /devtools-errors clear empties it. Exports include every Error Lens record and group, and the Markdown report has an Error Lens section.
An MCP tool that reports success but whose output begins like an error (Error: …; some servers answer errors as plain text) is kept as suspected, with its cause marked possible. Built-in tools flag their own errors, so they are never judged this way.
| Command | Does | | - | - | | /devtools | Open the pane (in a headless session: print the status) | | /devtools-status | Debugger state and the last few events, as text | | /devtools-break <rule> | Add a breakpoint. /devtools-break delete <id>, toggle <id>, enable <id>, disable <id>, clear | | /devtools-list | List breakpoints with ids and hit counts | | /devtools-pause | Pause on the next tool call | | /devtools-continue | Disarm pause-next and stepping (a call already held is answered in its dialog) | | /devtools-disable [observe\|off] | observe (default): breakpoints record but never pause. off: no interception, no recording | | /devtools-enable | Back to active: breakpoints pause | | /devtools-record [on\|off] | Record every call, or only breakpoint matches | | /devtools-errors [clear] | Error Lens: the failure kinds this session and the latest failure's diagnosis. Opens the Errors tab when interactive | | /devtools-export [path.json\|path.md] [--md] | Write a sanitized JSON trace (and a Markdown report). Default: .claude-devtools/trace-<time>.json in the working directory | | /devtools-help | Usage and the rule language | | /bp <rule> | Short for /devtools-break; /bp alone lists the breakpoints and the rule language | | /bpl · /bpn · /bpc · /bpe | Short for /devtools-list, /devtools-pause (next call), /devtools-continue and /devtools-errors |
All of these run immediately, even while Claude is working.
tool <Name>[,<Name>...] every call to these tools (MCP tools by full name: mcp__github__create_issue)
command <pattern> shell command contains the words, in order; * is a wildcard; re:<regex> for a regex
file <glob> a call names a matching path: src/auth/**, prisma/schema.prisma, .env*
error [<Tool>,...] after a failed call (default action warn; --action pause arms the next call)
when tool=A,B command=".." path=.. all given conditions must match
--action pause|record|warn --scope all|main|subagents --name "..."
--after <N> act from the Nth hit (hit counts reset each session)
--simulate fail|stub --text "..." what Simulate answers at this breakpoint
--disabled
Path globs are case-insensitive whenever either side is a Windows path. A glob with no / matches any path segment. A relative glob matches below the working directory or at any directory boundary.
Simulation is off by default. Turn on the simulation option first. Simulate then appears only for allowlisted tools (Bash, PowerShell, Read, Edit, Write, NotebookEdit, Glob, Grep, WebFetch, WebSearch, and MCP tools):
fail (default): the call is refused with [Claude DevTools · SIMULATED FAILURE] <text> The <tool> call was NOT executed, so nothing changed.stub: only for shell commands classified read-only. It returns stdout starting with [Claude DevTools · SIMULATED OUTPUT: the command was NOT executed].A simulated call never reaches the real tool. The timeline, inspector and exports mark it simulated.
claude -p, SDK)Nobody can answer a question there, so nothing waits. A call that matches a pause breakpoint is rejected with an explanation, and calls that match nothing run normally. To record such calls and let them through instead, set headlessPause to record-only.
Set them in /config, or at install time with claude plugin install ... --config <option>=<value>:
| Option | Default | Meaning | | - | - | - | | recording | true | Record every call (until a saved preference exists) | | maxTimelineEntries | 500 | Ring buffer size, 10 to 5000 | | maxSummaryChars | 160 | Longest input/result summary, 40 to 2000 | | defaultScope | all | Scope of new rules: all, main or subagents | | headlessPause | reject | reject or record-only | | simulation | false | Offer Simulate at breakpoints | | redaction | true | Redact credentials, tokens and environment values | | captureRaw | false | Also keep raw inputs/outputs (truncated). Privacy risk: may capture file contents | | persistBreakpoints | true | Save rules and mode in the plugin store across sessions | | inlineControls | always | The transcript gutter and the bar above the prompt: always (a dim line under each row), hover (only while the pointer is over a row) or off | | openOnStart | true | Open the dashboard when an interactive session starts (seated unasked only from 144 columns) | | errorLens | probe | probe: diagnose failures and check the paths involved (stat only). classify: from the result alone, no file system access. off | | errorToasts | true | Notify on a failure: the first of each kind, then every fifth repeat |
npm install # local TypeScript only; nothing global
claude plugin validate . # manifest, hooks, calls, state contract
claude plugin test . # 146 tests: pure engine + real hooks and UI through claude-code/testing
npx tsc -p . # type-check (after one load has laid .claude-plugin/types)
The engine writes .claude-plugin/types/ the first time it loads the folder. To lay it without starting an interactive session, run once: claude -p --plugin-dir . "/devtools-help".
See docs/ARCHITECTURE.md, docs/API-COMPATIBILITY.md, docs/SECURITY.md and docs/LIMITATIONS.md. CONTRIBUTING.md has the ground rules; changes are listed in CHANGELOG.md.
hooks/register.tsx 1150 lines1// Claude DevTools: the native Mod layer. It connects the pure engine in src/
2// to Claude Code's events: it holds tool calls at breakpoints through the
3// engine's own question dialog, records what really ran, draws the pane and
4// answers the /devtools commands. Decisions live in src/core; this file acts.
5//
6// The engine requires `$` to be passed only to functions declared at the top
7// of this file, so every helper that touches the API lives here, and the
8// options `register` receives are kept in module variables.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type {
14 ArmState,
15 Breakpoint,
16 DevtoolsMode,
17 DevtoolsSettings,
18 DevtoolsStats,
19 ErrorCategory,
20 LensProbe,
21 PauseDecision,
22 PendingCall,
23 PermissionInfo,
24 TraceEvent,
25 TraceOutcome,
26 TraceStatus,
27 ViewState,
28} from '../types'
29import { DEFAULT_OPTIONS, defaultSettings, type DevtoolsOptions, parseOptions, parsePersisted, STORE_KEY, toPersisted } from '../src/config/schema.ts'
30import { criteriaMatch, describeBreakpoint, nextBreakpointId, parseBreakpointSpec, SPEC_HELP, validateBreakpoint } from '../src/core/breakpoints.ts'
31import {
32 classifyResult,
33 DISARMED,
34 interpretAnswer,
35 type Planned,
36 pauseQuestion,
37 planAfterError,
38 planCall,
39 refusalText,
40 wouldPause,
41} from '../src/core/controller.ts'
42import {
43 errorTextOf,
44 normalizeCall,
45 type NormalizedCall,
46 rawOf,
47 type ResultLike,
48 sanitizeInput,
49 type SummaryOptions,
50 summarizeInput,
51 summarizeResult,
52 type ToolCallLike,
53 truncate,
54} from '../src/core/events.ts'
55import {
56 addToGroups,
57 buildLensRecord,
58 errorTextOfResult,
59 lensReport,
60 looksLikeFailure,
61 MAX_LENS,
62 type ProbeTarget,
63 shouldNotify,
64 withProbes,
65} from '../src/core/lens.ts'
66import {
67 appendBounded,
68 buildExport,
69 closeStale,
70 exportPaths,
71 exportToMarkdown,
72 formatDuration,
73 patchEvent,
74 serializeExport,
75 statusIcon,
76} from '../src/core/recorder.ts'
77import { checkSimulation, synthesize } from '../src/core/simulation.ts'
78import { type Category, findCategoryRule, findRule, type Suggestion, suggestBreakpoints, withHits } from '../src/core/suggest.ts'
79import { redactString } from '../src/security/redaction.ts'
80import { type Offer, renderBar, renderGutter } from '../src/ui/inline.tsx'
81import type { Layout } from '../src/ui/model.ts'
82import { type PaneActions, renderPane } from '../src/ui/pane.tsx'
83
84const VERSION = '0.1.2'
85const PANE = 'devtools'
86
87// The $.state values kept for the session (declared in ../types/index.d.ts):
88// host-held, so they survive a hot reload of this module. Settings have no
89// static initial: never written means "load from the store first".
90const SETTINGS = { plugin: 'devtools', key: 'settings' } as const
91const ARM = { plugin: 'devtools', key: 'arm' } as const
92const ARM_ATOM = atom({ plugin: 'devtools', key: 'arm' } as const, DISARMED)
93const TRACE = atom({ plugin: 'devtools', key: 'trace' } as const, [])
94const PENDING = atom({ plugin: 'devtools', key: 'pending' } as const, [])
95const HITS = atom({ plugin: 'devtools', key: 'hits' } as const, {})
96const VIEW = atom({ plugin: 'devtools', key: 'view' } as const, { tab: 'dashboard', page: 0 })
97const SESSION = atom({ plugin: 'devtools', key: 'session' } as const, { sessionId: '', isInteractive: true, cwd: '', surface: null })
98const STATS = atom({ plugin: 'devtools', key: 'stats' } as const, {
99 observed: 0,
100 completed: 0,
101 failed: 0,
102 denied: 0,
103 simulated: 0,
104 paused: 0,
105})
106const LENS = atom({ plugin: 'devtools', key: 'lens' } as const, [])
107const GROUPS = atom({ plugin: 'devtools', key: 'errorGroups' } as const, [])
108
109type Dollar = EngineInterface
110type ToolResult = { deny: string } | { result: unknown }
111type Held = { answer: ToolResult } | { decision: Extract<PauseDecision, 'continue' | 'step'>; permission?: PermissionInfo }
112
113const STATUS_COUNTER: Partial<Record<TraceStatus, keyof DevtoolsStats>> = {
114 completed: 'completed',
115 failed: 'failed',
116 denied: 'denied',
117 simulated: 'simulated',
118}
119
120// Set by `register`; a reload runs it again with the current options.
121let options: DevtoolsOptions = DEFAULT_OPTIONS
122let optionWarnings: string[] = []
123let summary: SummaryOptions = { maxChars: DEFAULT_OPTIONS.maxSummaryChars, redaction: true, captureRaw: false }
124// Snapshot for the .catch handler, which may not call $ on a re-entry and has
125// one second: the settings, arm and cwd last read. Refreshed on every call.
126const mirror: { settings?: DevtoolsSettings; arm?: ArmState; cwd?: string } = {}
127// tool.check verdicts by tool_use_id, consumed when the call finishes.
128const verdicts = new Map<string, PermissionInfo>()
129let localSeq = 0
130
131function message(error: unknown): string {
132 return error instanceof Error ? error.message : String(error)
133}
134
135function redactIf(text: string): string {
136 return options.redaction ? redactString(text) : text
137}
138
139function debug($: Dollar, text: string): void {
140 $.ui.log(`devtools: ${text}`, { to: 'debug' })
141}
142
143// ------------------------------------------------------------------ settings
144
145/**
146 * The session's rules (hit counts not merged); on first use (a new session,
147 * or after /clear) loaded from the store.
148 */
149async function loadSettings($: Dollar): Promise<DevtoolsSettings> {
150 const held = await $.state.get(SETTINGS)
151 if (held.value !== undefined) return held.value
152 let settings = defaultSettings(options)
153 if (options.persistBreakpoints) {
154 try {
155 const parsed = parsePersisted(await $.store.get(STORE_KEY), options)
156 settings = parsed.settings
157 for (const warning of parsed.warnings) debug($, warning)
158 } catch (error) {
159 debug($, `could not read saved settings: ${message(error)}`)
160 }
161 }
162 const written = await $.state.set(SETTINGS, settings, { ifVersion: held.version })
163 if (!written.isSet) settings = (await $.state.get(SETTINGS)).value ?? settings
164 return settings
165}
166
167/** The rules with this session's hit counts, as planning and display need them; also the guard's snapshot. */
168async function liveSettings($: Dollar): Promise<DevtoolsSettings> {
169 const [settings, hits] = await Promise.all([loadSettings($), read($, HITS)])
170 return (mirror.settings = withHits(settings, hits))
171}
172
173/** A person's change: written to the session and, when enabled, to the store. */
174async function changeSettings($: Dollar, change: (settings: DevtoolsSettings) => DevtoolsSettings): Promise<DevtoolsSettings> {
175 await loadSettings($)
176 const next = await update($, SETTINGS, current => change(current ?? defaultSettings(options)))
177 mirror.settings = withHits(next, await read($, HITS))
178 if (options.persistBreakpoints) {
179 try {
180 await $.store.set(STORE_KEY, toPersisted(next))
181 } catch (error) {
182 debug($, `could not save settings: ${message(error)}`)
183 }
184 }
185 await refreshStatus($)
186 return next
187}
188
189/** Counts hits in their own state key: the rules, which transcript rows read, do not change. */
190async function bumpHits($: Dollar, ids: readonly string[]): Promise<void> {
191 await update($, HITS, hits => {
192 const next = { ...hits }
193 for (const id of ids) next[id] = (next[id] ?? 0) + 1
194 return next
195 })
196}
197
198async function forgetHits($: Dollar, ids: readonly string[] | 'all'): Promise<void> {
199 await update($, HITS, hits => (ids === 'all' ? {} : Object.fromEntries(Object.entries(hits).filter(([id]) => !ids.includes(id)))))
200}
201
202/**
203 * Sets or removes the rule a suggestion or category stands for, as a click
204 * on a line's gutter does in Chrome DevTools. Returns what happened.
205 */
206async function toggleRule($: Dollar, kind: Breakpoint['kind'], match: Breakpoint['match'], name: string): Promise<string> {
207 const settings = await loadSettings($)
208 const existing = findRule(settings.breakpoints, kind, match)
209 if (existing !== undefined) {
210 await changeSettings($, current => ({ ...current, breakpoints: current.breakpoints.filter(bp => bp.id !== existing.id) }))
211 await forgetHits($, [existing.id])
212 return `Breakpoint removed: ${existing.name}`
213 }
214 const checked = validateBreakpoint({ id: nextBreakpointId(settings.breakpoints), name, enabled: true, kind, match, scope: options.defaultScope, action: 'pause', hitCount: 0 })
215 if (!checked.ok) return `Breakpoint not set: ${checked.error}`
216 let id = checked.breakpoint.id
217 await changeSettings($, current => {
218 id = nextBreakpointId(current.breakpoints)
219 return { ...current, breakpoints: [...current.breakpoints, { ...checked.breakpoint, id }] }
220 })
221 await forgetHits($, [id])
222 const mode = (await loadSettings($)).mode
223 return `Breakpoint set: ${name} → pause${mode === 'active' ? '' : ` (mode ${mode}: /devtools-enable to pause)`}`
224}
225
226async function toggleSuggestion($: Dollar, suggestion: Suggestion): Promise<void> {
227 const text = await toggleRule($, suggestion.kind, suggestion.match, suggestion.name)
228 $.ui.toast(`DevTools: ${text}`)
229 await setView($, { notice: text })
230}
231
232async function toggleCategory($: Dollar, category: Category): Promise<void> {
233 const settings = await loadSettings($)
234 const existing = findCategoryRule(settings.breakpoints, category)
235 // A category rule switched off by hand is switched back on, not deleted and re-added.
236 if (existing !== undefined && !existing.enabled) {
237 await setView($, { notice: await editBreakpoint($, 'enable', existing.id) })
238 return
239 }
240 await setView($, { notice: await toggleRule($, 'tool', { tools: [...category.tools] }, `Pause on ${category.label}`) })
241}
242
243async function hideBar($: Dollar): Promise<void> {
244 await setView($, { barHidden: true })
245}
246
247async function openPane($: Dollar, isAsked: boolean): Promise<string | undefined> {
248 const opened = await $.ui.open(isAsked ? { id: PANE, title: 'Claude DevTools', focus: true } : { id: PANE, title: 'Claude DevTools' })
249 return opened.isPlaced ? undefined : opened.reason
250}
251
252/** The bar's "why?": the pane, on this failure's Error Lens. */
253async function openLens($: Dollar, id: string): Promise<void> {
254 await setView($, { tab: 'errors', lensId: id })
255 await openPane($, true)
256}
257
258async function exportFromPane($: Dollar): Promise<void> {
259 try {
260 const text = await exportTrace($, '')
261 await setView($, { notice: text.replace(/\s*\n\s*/g, ' ') })
262 } catch (error) {
263 await setView($, { notice: `Export failed: ${message(error)}` })
264 }
265}
266
267async function setArm($: Dollar, arm: ArmState): Promise<void> {
268 await $.state.set(ARM, arm)
269 mirror.arm = arm
270 await refreshStatus($)
271}
272
273async function toggleArm($: Dollar): Promise<void> {
274 const current = await read($, ARM_ATOM)
275 await setArm($, current.pauseNext || current.step ? DISARMED : { pauseNext: true, step: false, reason: 'armed in the pane' })
276}
277
278/** Merges a view change and clears the last notice; a key set to undefined is removed (state holds JSON). */
279async function setView($: Dollar, change: Partial<ViewState>): Promise<void> {
280 await update($, VIEW, ({ notice: _cleared, ...view }) => {
281 const merged: Record<string, unknown> = { ...view, ...change }
282 for (const key of Object.keys(merged)) if (merged[key] === undefined) delete merged[key]
283 return merged as ViewState
284 })
285}
286
287async function movePage($: Dollar, delta: number): Promise<void> {
288 await update($, VIEW, ({ notice: _cleared, ...view }) => ({ ...view, page: Math.max(0, view.page + delta) }))
289}
290
291async function clearTimeline($: Dollar): Promise<void> {
292 await update($, TRACE, () => [])
293 await setView($, { page: 0, selectedId: undefined })
294}
295
296async function refreshStatus($: Dollar): Promise<void> {
297 const [arm, pending, held] = await Promise.all([read($, ARM_ATOM), read($, PENDING), $.state.get(SETTINGS)])
298 const mode = held.value?.mode ?? 'active'
299 if (pending.length > 0) $.ui.status(`⏸ DevTools: ${pending.length === 1 ? `${pending[0]?.tool} paused` : `${pending.length} calls paused`}`)
300 else if (mode === 'active' && (arm.pauseNext || arm.step)) $.ui.status('DevTools: the next tool call will pause')
301 else if (mode === 'observe') $.ui.status('DevTools: observing (breakpoints do not pause)')
302 else $.ui.status(undefined)
303}
304
305// ------------------------------------------------------------------ recording
306
307async function addEvent($: Dollar, event: TraceEvent): Promise<void> {
308 await update($, TRACE, list => appendBounded(list, event, options.maxTimelineEntries))
309}
310
311async function patch($: Dollar, id: string, change: Partial<TraceEvent>): Promise<void> {
312 await update($, TRACE, list => patchEvent(list, id, change))
313}
314
315async function count($: Dollar, status: TraceStatus): Promise<void> {
316 const key = STATUS_COUNTER[status]
317 if (key !== undefined) await update($, STATS, stats => ({ ...stats, [key]: stats[key] + 1 }))
318}
319
320/**
321 * Claims the plan for a call. A consumed arm (pause-next, step) is written
322 * back only if nobody consumed it first, so of two concurrent calls only one
323 * pauses for a single step.
324 */
325async function claimPlan($: Dollar, settings: DevtoolsSettings, call: NormalizedCall, cwd: string | undefined): Promise<Planned> {
326 for (let attempt = 0; attempt < 5; attempt += 1) {
327 const held = await $.state.get(ARM)
328 const arm = held.value ?? DISARMED
329 mirror.arm = arm
330 const planned = planCall(settings, arm, call, cwd)
331 if (planned.arm === arm) return planned
332 const written = await $.state.set(ARM, planned.arm, { ifVersion: held.version })
333 if (written.isSet) return planned
334 }
335 return planCall(settings, DISARMED, call, cwd)
336}
337
338/** What the permission rules and mode would decide, asked without running anything. */
339async function previewPermission($: Dollar, call: NormalizedCall): Promise<PermissionInfo | undefined> {
340 try {
341 const verdict = await $.tool.check({ tool: call.tool, input: call.args })
342 return { decision: verdict.decision, rule: verdict.rule, reason: verdict.reason, source: 'preview' }
343 } catch {
344 return undefined
345 }
346}
347
348// ------------------------------------------------------------------ pausing
349
350async function refuse($: Dollar, event: TraceEvent, status: TraceStatus, outcome: TraceOutcome, deny: string, decision?: PauseDecision): Promise<Held> {
351 await patch($, event.id, {
352 status,
353 outcome,
354 ...(decision !== undefined ? { decision } : {}),
355 errorText: redactIf(deny),
356 durationMs: 0,
357 resultSummary: `not run: ${outcome}`,
358 })
359 await count($, status)
360 const now = await $.clock.now()
361 await captureFailure($, event, true, { startedMs: now, endedMs: now, status, outcome, text: deny })
362 return { answer: { deny } }
363}
364
365/**
366 * Holds a call until the person decides, inside the engine's own question
367 * dialog ($.ui.ask), whose wait does not count against the hook's budget.
368 * Headless, nobody can answer: the configured policy decides at once.
369 */
370async function hold(
371 $: Dollar,
372 signal: AbortSignal,
373 call: NormalizedCall,
374 event: TraceEvent,
375 reason: string,
376 at: Breakpoint | undefined,
377 isInteractive: boolean,
378): Promise<Held> {
379 const pending: PendingCall = { id: event.id, tool: call.tool, inputSummary: event.inputSummary, reason, sinceMs: event.startedAtMs }
380 await update($, PENDING, list => [...list, call.agentId === undefined ? pending : { ...pending, agentId: call.agentId }])
381 await update($, STATS, stats => ({ ...stats, paused: stats.paused + 1 }))
382 await refreshStatus($)
383 try {
384 if (!isInteractive) {
385 if (options.headlessPause === 'record-only') {
386 $.ui.log(`devtools: ${reason} matched ${call.tool} in a headless session; recorded and let through (headlessPause=record-only).`)
387 return { decision: 'continue' }
388 }
389 const deny = refusalText('headless', call.tool, reason)
390 $.ui.log(`devtools: ${deny}`)
391 return await refuse($, event, 'denied', 'headless-rejected', deny)
392 }
393 const permission = await previewPermission($, call)
394 const simulation = checkSimulation(options.simulation, at, call)
395 if (permission !== undefined) await patch($, event.id, { permission })
396 const question = pauseQuestion({
397 tool: call.tool,
398 summary: event.inputSummary,
399 reason,
400 risk: call.risk,
401 agentId: call.agentId,
402 permission,
403 canSimulate: simulation.ok,
404 })
405 let answer: string | undefined
406 try {
407 answer = await $.ui.ask(question.question, { options: question.options, header: question.header })
408 } catch {
409 // Dismissed, "Chat about this", or nobody to ask: the call does not run.
410 answer = undefined
411 }
412 if (signal.aborted) return await refuse($, event, 'failed', 'aborted', refusalText('aborted', call.tool, reason))
413 if (answer === undefined) return await refuse($, event, 'denied', 'user-cancelled', refusalText('cancelled', call.tool, reason))
414 const { decision, note } = interpretAnswer(answer, simulation.ok)
415 if (decision === 'reject') return await refuse($, event, 'denied', 'debugger-rejected', refusalText('debugger', call.tool, reason, note), 'reject')
416 if (decision === 'simulate' && simulation.ok) {
417 const synthetic = synthesize(simulation.kind, call, simulation.text)
418 const shown: ResultLike = 'deny' in synthetic ? { deny: synthetic.deny } : { result: synthetic.result }
419 await patch($, event.id, {
420 status: 'simulated',
421 outcome: 'simulated',
422 decision: 'simulate',
423 simulated: true,
424 durationMs: 0,
425 resultSummary: `SIMULATED ${simulation.kind === 'fail' ? 'failure' : 'output'}: ${summarizeResult(call.tool, shown, summary)}`,
426 })
427 await count($, 'simulated')
428 if ('deny' in synthetic) {
429 const now = await $.clock.now()
430 await captureFailure($, event, true, { startedMs: now, endedMs: now, status: 'simulated', outcome: 'simulated', text: synthetic.deny })
431 }
432 return { answer: synthetic }
433 }
434 if (decision === 'step') await $.state.set(ARM, { pauseNext: false, step: true })
435 return { decision: decision === 'step' ? 'step' : 'continue', ...(permission !== undefined ? { permission } : {}) }
436 } finally {
437 await update($, PENDING, list => list.filter(item => item.id !== event.id))
438 await refreshStatus($)
439 }
440}
441
442/** Records what the call really returned, then applies error breakpoints. */
443async function afterRun(
444 $: Dollar,
445 call: NormalizedCall,
446 event: TraceEvent,
447 isRecorded: boolean,
448 startedMs: number,
449 result: ResultLike,
450 preview: PermissionInfo | undefined,
451 cwd: string | undefined,
452): Promise<void> {
453 const endedMs = await $.clock.now()
454 const observed = call.toolUseId === undefined ? undefined : verdicts.get(call.toolUseId)
455 if (call.toolUseId !== undefined) verdicts.delete(call.toolUseId)
456 const permission = observed ?? preview
457 const { status, outcome } = classifyResult(result, permission)
458 const errorText = errorTextOf(result, summary)
459 const change: Partial<TraceEvent> = {
460 status,
461 outcome,
462 durationMs: Math.max(0, endedMs - startedMs),
463 resultSummary: summarizeResult(call.tool, result, summary),
464 ...(errorText !== undefined ? { errorText } : {}),
465 ...(permission !== undefined ? { permission } : {}),
466 ...(result.isReadOnly === true ? { isReadOnly: true } : {}),
467 ...(options.captureRaw ? { raw: rawOf(call, result, summary) } : {}),
468 }
469 if (isRecorded) await patch($, event.id, change)
470 await count($, status)
471 const suspected = status === 'completed' && looksLikeFailure(call.tool, result)
472 let errorCategory: ErrorCategory | undefined
473 if (status === 'failed' || status === 'denied' || suspected) {
474 const content = call.tool === 'Write' && typeof call.args.content === 'string' ? call.args.content : undefined
475 errorCategory = await captureFailure(
476 $,
477 event,
478 isRecorded,
479 {
480 startedMs,
481 endedMs,
482 status,
483 outcome,
484 text: errorTextOfResult(result),
485 ...(suspected ? { suspected: true } : {}),
486 ...(permission !== undefined ? { permission } : {}),
487 },
488 content === undefined ? undefined : new TextEncoder().encode(content).length,
489 )
490 }
491 if (outcome !== 'tool-error') return
492
493 const [settings, arm] = await Promise.all([liveSettings($), read($, ARM_ATOM)])
494 const planned = planAfterError(settings, arm, call, cwd)
495 if (planned.triggered.length === 0) return
496 const ids = planned.triggered.map(bp => bp.id)
497 await bumpHits($, ids)
498 if (!isRecorded) await addEvent($, { ...event, ...change, breakpointIds: ids, ...(errorCategory !== undefined ? { errorCategory } : {}) })
499 else await patch($, event.id, { breakpointIds: [...(event.breakpointIds ?? []), ...ids] })
500 if (planned.arm !== arm) await setArm($, planned.arm)
501 const next = planned.arm.pauseNext ? ' The next tool call will pause.' : ''
502 $.ui.toast(`DevTools: ${call.tool} failed (${planned.triggered.map(bp => bp.name).join(', ')}).${next}`, { timeoutMs: 6000 })
503}
504
505// ------------------------------------------------------------------ error lens
506
507/** What the person did themselves: kept and shown, never announced. */
508const QUIET: readonly ErrorCategory[] = ['debugger', 'simulated', 'interrupted']
509
510type Failure = {
511 startedMs: number
512 endedMs: number
513 status: TraceStatus
514 outcome: TraceOutcome
515 /** The error as Claude read it, before redaction. */
516 text: string
517 suspected?: true
518 permission?: PermissionInfo
519}
520
521/**
522 * Error Lens: diagnoses a failed call from its result, keeps it, groups
523 * repeats, and in probe mode schedules read-only checks for after the result
524 * has gone back. It never retries or changes the call, and its own failure
525 * is only logged. Returns the category it recorded.
526 */
527async function captureFailure($: Dollar, event: TraceEvent, isRecorded: boolean, failure: Failure, contentBytes?: number): Promise<ErrorCategory | undefined> {
528 if (options.errorLens === 'off') return undefined
529 try {
530 const { record, plan } = buildLensRecord({
531 id: event.id,
532 seq: event.seq,
533 tool: event.tool,
534 ...(event.agentId !== undefined ? { agentId: event.agentId } : {}),
535 startedAtMs: failure.startedMs,
536 endedAtMs: failure.endedMs,
537 status: failure.status,
538 outcome: failure.outcome,
539 ...(failure.suspected === true ? { suspected: true } : {}),
540 text: redactIf(failure.text),
541 args: event.input ?? {},
542 paths: event.paths ?? [],
543 ...(failure.permission !== undefined ? { permission: failure.permission } : {}),
544 ...(contentBytes !== undefined ? { contentBytes } : {}),
545 probing: options.errorLens,
546 })
547 await update($, LENS, list => [...list.filter(one => one.id !== record.id), record].slice(-MAX_LENS))
548 const groups = await update($, GROUPS, list => addToGroups(list, record).groups)
549 const group = groups.find(one => one.signature === record.signature)
550 if (isRecorded) await patch($, record.id, { errorCategory: record.category })
551 if (options.errorToasts && group !== undefined && shouldNotify(group) && !QUIET.includes(record.category)) {
552 const verb = record.suspected === true ? 'may have failed' : record.status === 'denied' ? 'denied' : 'failed'
553 const repeat = group.count > 1 ? ` · ${group.count}× this session` : ''
554 $.ui.toast(`DevTools ✗ ${record.tool} ${verb} (${record.category})${repeat}: ${truncate(record.headline, 90)} · /devtools-errors to inspect`, { timeoutMs: 6000 })
555 }
556 // Deferred: the checks run once the result is on its way back to Claude.
557 if (plan.length > 0) $.clock.after(0, () => settle($, runProbes($, record.id, plan)))
558 return record.category
559 } catch (error) {
560 debug($, `Error Lens could not record ${event.tool}: ${message(error)}`)
561 return undefined
562 }
563}
564
565/** One $.fs.stat per planned path: read-only, metadata only, never a file's contents. */
566async function runProbes($: Dollar, id: string, plan: readonly ProbeTarget[]): Promise<void> {
567 const probes = await Promise.all(
568 plan.map(async ({ path, role }): Promise<LensProbe> => {
569 try {
570 const stat = await $.fs.stat(path, { resolve: true })
571 return {
572 path,
573 role,
574 exists: true,
575 kind: stat.kind,
576 size: stat.size,
577 mtimeMs: stat.mtimeMs,
578 isLink: stat.isLink,
579 ...(stat.realPath !== undefined ? { realPath: redactIf(stat.realPath) } : {}),
580 }
581 } catch (error) {
582 const text = message(error)
583 return /\bENOENT\b|no such file/i.test(text) ? { path, role, exists: false } : { path, role, exists: null, error: truncate(redactIf(text), 160) }
584 }
585 }),
586 )
587 await update($, LENS, list => list.map(one => (one.id === id ? withProbes(one, probes) : one)))
588}
589
590async function clearErrors($: Dollar): Promise<void> {
591 await update($, LENS, () => [])
592 await update($, GROUPS, () => [])
593 await setView($, { lensId: undefined })
594}
595
596async function errorsText($: Dollar): Promise<string> {
597 if (options.errorLens === 'off') return 'Error Lens is off (the devtools option errorLens).'
598 const [lens, groups] = await Promise.all([read($, LENS), read($, GROUPS)])
599 const latest = lens.at(-1)
600 if (latest === undefined) return 'Error Lens: no failed tool calls this session.'
601 return [
602 `Error Lens: ${lens.length} failure${lens.length === 1 ? '' : 's'} kept, ${groups.length} kind${groups.length === 1 ? '' : 's'}`,
603 ...groups.slice(0, 8).map(group => ` ${group.count}× ${group.tool} · ${group.category} · ${truncate(group.headline, 100)}`),
604 '',
605 'Latest:',
606 ...lensReport(latest),
607 ].join('\n')
608}
609
610// ------------------------------------------------------------------ commands
611
612async function statusText($: Dollar): Promise<string> {
613 const [settings, arm, stats, trace, pending, session] = await Promise.all([
614 liveSettings($),
615 read($, ARM_ATOM),
616 read($, STATS),
617 read($, TRACE),
618 read($, PENDING),
619 read($, SESSION),
620 ])
621 const enabled = settings.breakpoints.filter(bp => bp.enabled).length
622 const lines = [
623 `Claude DevTools — mode ${settings.mode} · recording ${settings.recording ? 'on' : 'off'} · simulation ${options.simulation ? 'on' : 'off'} · ${session.isInteractive ? 'interactive' : `headless (pause → ${options.headlessPause})`}`,
624 `Paused: ${pending.length === 0 ? 'none' : pending.map(p => `${p.tool} ${p.inputSummary} (${p.reason})`).join('; ')}`,
625 `Armed: ${arm.pauseNext || arm.step ? `the next call pauses${arm.step ? ' (step)' : ''}${arm.reason !== undefined ? ` (${arm.reason})` : ''}` : 'no'}`,
626 `Calls: ${stats.observed} observed · ${stats.completed} ok · ${stats.failed} failed · ${stats.denied} denied · ${stats.simulated} simulated · ${stats.paused} paused`,
627 `Breakpoints: ${enabled} enabled of ${settings.breakpoints.length} (/devtools-list)`,
628 `Timeline: ${trace.length} of ${options.maxTimelineEntries} events kept · redaction ${options.redaction ? 'on' : 'OFF'}${options.captureRaw ? ' · RAW CAPTURE ON' : ''}`,
629 ]
630 const recent = trace.slice(-5)
631 if (recent.length > 0) {
632 lines.push('Recent:')
633 for (const ev of recent) {
634 lines.push(` ${ev.startedAt.slice(11, 19)} ${statusIcon(ev.status)} ${ev.tool} ${formatDuration(ev.durationMs)} ${ev.simulated ? 'SIMULATED ' : ''}${ev.inputSummary}`)
635 }
636 }
637 return lines.join('\n')
638}
639
640function listText(settings: DevtoolsSettings): string {
641 if (settings.breakpoints.length === 0) return 'No breakpoints. Add one with /devtools-break, e.g. /devtools-break command npm install'
642 return [
643 `Breakpoints (mode ${settings.mode}):`,
644 ...settings.breakpoints.map(bp => ` ${bp.id} ${bp.enabled ? '●' : '○'} ${bp.name} — ${describeBreakpoint(bp)} · hits ${bp.hitCount}`),
645 ].join('\n')
646}
647
648function helpText(): string {
649 return [
650 'Claude DevTools: inspect, pause and control tool calls before they run.',
651 '',
652 '/devtools open the pane (Overview, Timeline, Inspector, Breakpoints, Errors)',
653 '/devtools-status debugger state as text',
654 '/devtools-break <rule> add a breakpoint; /devtools-break delete|toggle <id>; /devtools-break clear',
655 '/devtools-list list breakpoints',
656 '/devtools-pause pause on the next tool call',
657 '/devtools-continue disarm pause-next and stepping',
658 '/devtools-disable [observe|off] stop pausing, or turn the debugger off',
659 '/devtools-enable pause on breakpoints again',
660 '/devtools-record [on|off] timeline recording',
661 '/devtools-export [path] [--md] write a sanitized JSON trace (and a Markdown report)',
662 '/devtools-errors [clear] Error Lens: why recent calls failed (confirmed / possible / unknown), with fixes',
663 '',
664 'Short aliases: /bp <rule> (/bp alone lists) · /bpl list · /bpn pause next · /bpc continue · /bpe errors',
665 '',
666 'Rules:',
667 ...SPEC_HELP.map(line => ` ${line}`),
668 '',
669 'A paused call is answered in the question dialog: Continue (normal permission checks still run),',
670 'Step (run it, pause on the next call), Reject (Claude is told it did not run), Simulate (only with',
671 'the simulation option, labeled, never claiming side effects). Typed text rejects and is passed to Claude.',
672 ].join('\n')
673}
674
675async function addBreakpoint($: Dollar, spec: string): Promise<{ ok: true; breakpoint: Breakpoint } | { ok: false; error: string }> {
676 const settings = await loadSettings($)
677 const parsed = parseBreakpointSpec(spec, nextBreakpointId(settings.breakpoints), options.defaultScope)
678 if (!parsed.ok) return parsed
679 let added = parsed.breakpoint
680 await changeSettings($, current => {
681 added = { ...parsed.breakpoint, id: nextBreakpointId(current.breakpoints) }
682 return { ...current, breakpoints: [...current.breakpoints, added] }
683 })
684 return { ok: true, breakpoint: added }
685}
686
687async function addFromPane($: Dollar, spec: string): Promise<void> {
688 const added = await addBreakpoint($, spec)
689 await setView($, { notice: added.ok ? `Added ${added.breakpoint.id}: ${describeBreakpoint(added.breakpoint)}` : `Not added: ${added.error}` })
690}
691
692async function editBreakpoint($: Dollar, verb: string, id: string): Promise<string> {
693 const settings = await loadSettings($)
694 const found = settings.breakpoints.find(bp => bp.id === id)
695 if (found === undefined) return `No breakpoint ${id}. ${listText(settings)}`
696 if (verb === 'delete' || verb === 'rm' || verb === 'remove') {
697 await changeSettings($, current => ({ ...current, breakpoints: current.breakpoints.filter(bp => bp.id !== id) }))
698 await forgetHits($, [id])
699 return `Deleted ${id} (${found.name}).`
700 }
701 const enabled = verb === 'enable' ? true : verb === 'disable' ? false : !found.enabled
702 await changeSettings($, current => ({ ...current, breakpoints: current.breakpoints.map(bp => (bp.id === id ? { ...bp, enabled } : bp)) }))
703 return `${enabled ? 'Enabled' : 'Disabled'} ${id} (${found.name}).`
704}
705
706async function setMode($: Dollar, mode: DevtoolsMode): Promise<void> {
707 await changeSettings($, current => ({ ...current, mode }))
708}
709
710async function toggleRecording($: Dollar): Promise<void> {
711 await changeSettings($, current => ({ ...current, recording: !current.recording }))
712}
713
714async function exportTrace($: Dollar, args: string): Promise<string> {
715 const words = args.split(/\s+/).filter(word => word !== '')
716 const wantsMarkdown = words.includes('--md')
717 const target = words.find(word => word !== '--md')
718 const [cwd, now, settings, trace, stats, session, errors, errorGroups] = await Promise.all([
719 $.session.cwd(),
720 $.clock.now(),
721 loadSettings($),
722 read($, TRACE),
723 read($, STATS),
724 read($, SESSION),
725 read($, LENS),
726 read($, GROUPS),
727 ])
728 const stamp = new Date(now).toISOString().replace(/[:.]/g, '-')
729 const paths = exportPaths(target, cwd, stamp, wantsMarkdown)
730 if (!paths.ok) return `Export refused: ${paths.error}.`
731 const built = buildExport({
732 version: VERSION,
733 exportedAt: new Date(now).toISOString(),
734 sessionId: session.sessionId,
735 redaction: options.redaction,
736 rawCapture: options.captureRaw,
737 mode: settings.mode,
738 stats,
739 breakpoints: settings.breakpoints,
740 events: trace,
741 errors,
742 errorGroups,
743 })
744 const text = serializeExport(built)
745 await $.fs.write(paths.json, text)
746 const written = [paths.json]
747 if (paths.markdown !== undefined) {
748 await $.fs.write(paths.markdown, exportToMarkdown(JSON.parse(text)))
749 written.push(paths.markdown)
750 }
751 const privacy = options.redaction ? 'secrets redacted' : 'REDACTION OFF'
752 return `Exported ${trace.length} events and ${errors.length} Error Lens record${errors.length === 1 ? '' : 's'} (${privacy}${options.captureRaw ? ', raw capture included' : ''}) to:\n${written.map(path => ` ${path}`).join('\n')}`
753}
754
755/** Registers the slash commands, each by its literal name; registering again on a reload replaces them. */
756async function registerCommands($: Dollar): Promise<void> {
757 await $.command.register({ name: 'devtools', description: 'Claude DevTools: open the debugger pane (overview, timeline, inspector, breakpoints)', immediate: true })
758 await $.command.register({ name: 'devtools-status', description: 'Claude DevTools: show the debugger state as text', immediate: true })
759 await $.command.register({
760 name: 'devtools-break',
761 description: 'Claude DevTools: add a breakpoint, or delete/toggle one by id',
762 argumentHint: 'tool Bash | command npm install | file .env* | error | when tool=Edit path=src/** | delete <id>',
763 immediate: true,
764 })
765 await $.command.register({ name: 'devtools-list', description: 'Claude DevTools: list breakpoint rules', immediate: true })
766 await $.command.register({ name: 'devtools-pause', description: 'Claude DevTools: pause on the next tool call', immediate: true })
767 await $.command.register({ name: 'devtools-continue', description: 'Claude DevTools: disarm pause-next and stepping so calls run freely', immediate: true })
768 await $.command.register({
769 name: 'devtools-disable',
770 description: 'Claude DevTools: stop pausing (observe), or turn the debugger off',
771 argumentHint: '[observe|off]',
772 immediate: true,
773 })
774 await $.command.register({ name: 'devtools-enable', description: 'Claude DevTools: pause on breakpoints again', immediate: true })
775 await $.command.register({ name: 'devtools-record', description: 'Claude DevTools: turn timeline recording on or off', argumentHint: '[on|off]', immediate: true })
776 await $.command.register({
777 name: 'devtools-export',
778 description: 'Claude DevTools: export a sanitized trace as JSON (and Markdown)',
779 argumentHint: '[path.json|path.md] [--md]',
780 immediate: true,
781 })
782 await $.command.register({
783 name: 'devtools-errors',
784 description: 'Claude DevTools Error Lens: why recent tool calls failed, with evidence and fixes',
785 argumentHint: '[clear]',
786 immediate: true,
787 })
788 await $.command.register({ name: 'devtools-help', description: 'Claude DevTools: usage and the breakpoint rule language', immediate: true })
789 // Short aliases.
790 await $.command.register({
791 name: 'bp',
792 description: 'DevTools: add a breakpoint (= /devtools-break); no rule lists them',
793 argumentHint: 'command npm install | file .env* | tool Bash | delete <id>',
794 immediate: true,
795 })
796 await $.command.register({ name: 'bpl', description: 'DevTools: list breakpoints (= /devtools-list)', immediate: true })
797 await $.command.register({ name: 'bpn', description: 'DevTools: pause on the next tool call (= /devtools-pause)', immediate: true })
798 await $.command.register({ name: 'bpc', description: 'DevTools: continue, disarm pause-next and stepping (= /devtools-continue)', immediate: true })
799 await $.command.register({ name: 'bpe', description: 'DevTools: Error Lens, why recent calls failed (= /devtools-errors)', argumentHint: '[clear]', immediate: true })
800}
801
802const ALIASES: Readonly<Record<string, string>> = { bp: 'devtools-break', bpl: 'devtools-list', bpn: 'devtools-pause', bpc: 'devtools-continue', bpe: 'devtools-errors' }
803
804async function runCommand($: Dollar, command: string, rawArgs: string): Promise<{ text: string }> {
805 const args = rawArgs.trim()
806 switch (ALIASES[command] ?? command) {
807 case 'devtools': {
808 const session = await read($, SESSION)
809 if (!session.isInteractive) return { text: await statusText($) }
810 const waiting = await openPane($, true)
811 if (waiting !== undefined) return { text: `${await statusText($)}\n\nThe pane is waiting: ${waiting}` }
812 return { text: 'Claude DevTools is open. Keys: d t i b e switch tabs; Tab walks the controls; ctrl+x tab focuses it.' }
813 }
814 case 'devtools-status':
815 return { text: await statusText($) }
816 case 'devtools-list':
817 return { text: listText(await liveSettings($)) }
818 case 'devtools-break': {
819 const [first = '', id = ''] = args.split(/\s+/)
820 const verb = first.toLowerCase()
821 if (['delete', 'rm', 'remove', 'toggle', 'enable', 'disable'].includes(verb) && id !== '') return { text: await editBreakpoint($, verb, id) }
822 if (verb === 'clear') {
823 await changeSettings($, current => ({ ...current, breakpoints: [] }))
824 await forgetHits($, 'all')
825 return { text: 'Deleted every breakpoint.' }
826 }
827 if (args === '') return { text: `${listText(await liveSettings($))}\n\nUsage: /bp <rule> (or /devtools-break <rule>)\n${SPEC_HELP.map(line => ` ${line}`).join('\n')}` }
828 const added = await addBreakpoint($, args)
829 if (!added.ok) return { text: `Breakpoint not added: ${added.error}.` }
830 const settings = await loadSettings($)
831 const note = settings.mode === 'active' ? '' : ` Mode is ${settings.mode}: run /devtools-enable for it to pause.`
832 return { text: `Added ${added.breakpoint.id} ${added.breakpoint.name}: ${describeBreakpoint(added.breakpoint)}.${note}` }
833 }
834 case 'devtools-pause':
835 await setArm($, { pauseNext: true, step: false, reason: 'armed by /devtools-pause' })
836 return { text: 'The next tool call will pause.' }
837 case 'devtools-continue':
838 await setArm($, DISARMED)
839 return { text: 'Disarmed: calls run freely until a breakpoint matches. A call already held is answered in its dialog.' }
840 case 'devtools-disable': {
841 const mode: DevtoolsMode = args === 'off' ? 'off' : 'observe'
842 await setMode($, mode)
843 return { text: mode === 'off' ? 'Claude DevTools is off: no interception, no recording.' : 'Breakpoints no longer pause; matches are still recorded (observe mode).' }
844 }
845 case 'devtools-enable':
846 await setMode($, 'active')
847 return { text: 'Breakpoints pause again (active mode).' }
848 case 'devtools-record': {
849 const settings = await loadSettings($)
850 const recording = args === 'on' ? true : args === 'off' ? false : !settings.recording
851 await changeSettings($, current => ({ ...current, recording }))
852 return { text: recording ? 'Recording every tool call.' : 'Recording off: only breakpoint matches are kept.' }
853 }
854 case 'devtools-errors': {
855 if (args === 'clear') {
856 await clearErrors($)
857 return { text: 'Error Lens cleared.' }
858 }
859 const text = await errorsText($)
860 const session = await read($, SESSION)
861 if (!session.isInteractive || options.errorLens === 'off') return { text }
862 await setView($, { tab: 'errors', lensId: undefined })
863 const waiting = await openPane($, true)
864 return { text: waiting === undefined ? text : `${text}\n\nThe pane is waiting: ${waiting}` }
865 }
866 case 'devtools-export':
867 try {
868 return { text: await exportTrace($, args) }
869 } catch (error) {
870 return { text: `Export failed: ${message(error)}` }
871 }
872 default:
873 return { text: helpText() }
874 }
875}
876
877function settle($: Dollar, work: Promise<unknown>): void {
878 work.catch(error => debug($, `pane action failed: ${message(error)}`))
879}
880
881// ------------------------------------------------------------------ register
882
883export const register: Register = (on, rawOptions) => {
884 const parsed = parseOptions(rawOptions)
885 options = parsed.options
886 optionWarnings = parsed.warnings
887 summary = { maxChars: options.maxSummaryChars, redaction: options.redaction, captureRaw: options.captureRaw }
888
889 on('tool.call', async ($, e, next) => {
890 const settings = await liveSettings($)
891 if (settings.mode === 'off') return next(e)
892 const session = await read($, SESSION)
893 const cwd = session.cwd === '' ? undefined : session.cwd
894 mirror.cwd = cwd
895 const call = normalizeCall(e as ToolCallLike)
896 const { plan } = await claimPlan($, settings, call, cwd)
897 const hits = plan.kind === 'pass' ? [] : plan.hits.map(bp => bp.id)
898 if (hits.length > 0) await bumpHits($, hits)
899
900 const startedMs = await $.clock.now()
901 localSeq += 1
902 const id = call.toolUseId ?? `call-${startedMs.toString(36)}-${localSeq}`
903 const stats = await update($, STATS, current => ({ ...current, observed: current.observed + 1 }))
904 const isRecorded = settings.recording || plan.kind !== 'pass'
905 const event: TraceEvent = {
906 id,
907 seq: stats.observed,
908 sessionId: session.sessionId,
909 ...(call.toolUseId !== undefined ? { toolUseId: call.toolUseId } : {}),
910 tool: call.tool,
911 ...(call.agentId !== undefined ? { agentId: call.agentId } : {}),
912 scope: call.scope,
913 status: plan.kind === 'pause' ? 'pending' : 'running',
914 startedAt: new Date(startedMs).toISOString(),
915 startedAtMs: startedMs,
916 risk: call.risk,
917 inputSummary: summarizeInput(call, summary),
918 input: sanitizeInput(call, summary),
919 paths: call.paths.map(path => redactIf(path)),
920 ...(hits.length > 0 ? { breakpointIds: hits } : {}),
921 simulated: false,
922 ...(options.captureRaw ? { raw: { input: rawOf(call, undefined, summary).input } } : {}),
923 }
924 if (isRecorded) await addEvent($, event)
925 if (plan.kind === 'warn') {
926 $.ui.toast(`DevTools: ${plan.warned.map(bp => bp.name).join(', ')} matched ${call.tool}: ${event.inputSummary}`, { timeoutMs: 6000 })
927 }
928
929 let preview: PermissionInfo | undefined
930 let runStartedMs = startedMs
931 if (plan.kind === 'pause') {
932 const held = await hold($, next.signal, call, event, plan.reason, plan.at, session.isInteractive)
933 if ('answer' in held) return held.answer
934 preview = held.permission
935 runStartedMs = await $.clock.now()
936 // The turn may have been interrupted between the answer and here: a paused call never runs then.
937 if (next.signal.aborted) {
938 await patch($, id, { status: 'failed', outcome: 'aborted', durationMs: 0, resultSummary: 'not run: aborted' })
939 return { deny: refusalText('aborted', call.tool, plan.reason) }
940 }
941 await patch($, id, { status: 'running', decision: held.decision })
942 }
943
944 // The one call into the rest of the chain: permissions, then the tool.
945 const result = await next(e)
946 try {
947 await afterRun($, call, event, isRecorded, runStartedMs, result as ResultLike, preview, cwd)
948 } catch (error) {
949 debug($, `recording ${call.tool} failed: ${message(error)}`)
950 }
951 return result
952 }).catch(($, e, next) => {
953 // Fail closed only where a breakpoint would have held the call; never run anything twice.
954 if (next.error.kind === 're-entry') {
955 // The pause dialog itself ($.ui.ask) is an AskUserQuestion call raised beneath this hook.
956 if (e.tool === 'AskUserQuestion') return next(e)
957 const nested = normalizeCall(e as ToolCallLike)
958 return wouldPause(mirror.settings, mirror.arm, nested, mirror.cwd) ? { deny: refusalText('guard', e.tool, 'a nested call during a pause') } : next(e)
959 }
960 if (next.called) return next(e)
961 const call = normalizeCall(e as ToolCallLike)
962 if (wouldPause(mirror.settings, mirror.arm, call, mirror.cwd)) {
963 const detail = next.error.message === undefined ? next.error.kind : `${next.error.kind}: ${next.error.message.slice(0, 160)}`
964 return { deny: refusalText('guard', e.tool, detail) }
965 }
966 return next(e)
967 })
968
969 // Observes the permission verdict each real call reaches; changes nothing.
970 on('tool.check', async ($, e, next) => {
971 const verdict = await next(e)
972 if (e.tool_use_id !== undefined) {
973 if (verdicts.size > 200) verdicts.clear()
974 verdicts.set(e.tool_use_id, { decision: verdict.decision, rule: verdict.rule, reason: verdict.reason, source: 'observed' })
975 }
976 return verdict
977 })
978
979 on('session.start', async ($, e, next) => {
980 let sessionId = ''
981 try {
982 sessionId = await $.session.id()
983 } catch (error) {
984 debug($, `no session id: ${message(error)}`)
985 }
986 await update($, SESSION, () => ({ sessionId, isInteractive: e.isInteractive, cwd: e.cwd, surface: e.surface }))
987 mirror.cwd = e.cwd
988 await loadSettings($)
989 // After a hot reload nothing still holds a call from the old module.
990 await update($, TRACE, closeStale)
991 await update($, PENDING, () => [])
992 await registerCommands($)
993 for (const warning of optionWarnings) debug($, warning)
994 await refreshStatus($)
995 // Unasked, the engine seats the pane only where it docks beside the transcript (144+ columns); narrower, /devtools opens it.
996 if (e.isInteractive && options.openOnStart) {
997 try {
998 await openPane($, false)
999 } catch (error) {
1000 debug($, `could not open the pane: ${message(error)}`)
1001 }
1002 }
1003 return next(e)
1004 })
1005
1006 // /clear resets $.state and fires no session.start: keep the session's id and cwd current.
1007 on('classic.SessionStart', async ($, e, next) => {
1008 await update($, SESSION, current => ({ ...current, sessionId: e.session_id || current.sessionId, cwd: e.cwd || current.cwd }))
1009 return next(e)
1010 })
1011
1012 on(
1013 'command.run',
1014 {
1015 command: [
1016 'devtools',
1017 'devtools-status',
1018 'devtools-break',
1019 'devtools-list',
1020 'devtools-pause',
1021 'devtools-continue',
1022 'devtools-disable',
1023 'devtools-enable',
1024 'devtools-record',
1025 'devtools-export',
1026 'devtools-errors',
1027 'devtools-help',
1028 'bp',
1029 'bpl',
1030 'bpn',
1031 'bpc',
1032 'bpe',
1033 ],
1034 },
1035 async ($, e) => runCommand($, e.command, e.args),
1036 )
1037
1038 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1039 const els = $.ui.resolve(e)
1040 const [held, hits, arm, trace, pending, view, session, stats, lens, groups] = await Promise.all([
1041 $.state.get(SETTINGS),
1042 read($, HITS),
1043 read($, ARM_ATOM),
1044 read($, TRACE),
1045 read($, PENDING),
1046 read($, VIEW),
1047 read($, SESSION),
1048 read($, STATS),
1049 read($, LENS),
1050 read($, GROUPS),
1051 ])
1052 const settings = withHits(held.value ?? defaultSettings(options), hits)
1053 const rows = e.props.placement === 'dock' ? e.props.scroll.bodyRows : Math.min(e.props.scroll.bodyRows, 24)
1054 const layout: Layout = e.props.placement === 'inline' ? 'mini' : e.props.bodyColumns >= 110 ? 'wide' : 'compact'
1055 const actions: PaneActions = {
1056 setTab: tab => settle($, setView($, { tab })),
1057 inspect: id => settle($, setView($, { tab: 'inspector', selectedId: id })),
1058 page: delta => settle($, movePage($, delta)),
1059 toggleBreakpoint: id => settle($, editBreakpoint($, 'toggle', id)),
1060 deleteBreakpoint: id => settle($, editBreakpoint($, 'delete', id)),
1061 addBreakpoint: spec => settle($, addFromPane($, spec)),
1062 toggleCategory: category => settle($, toggleCategory($, category)),
1063 toggleSuggestion: suggestion => settle($, toggleSuggestion($, suggestion)),
1064 setMode: mode => settle($, setMode($, mode)),
1065 toggleRecording: () => settle($, toggleRecording($)),
1066 togglePauseNext: () => settle($, toggleArm($)),
1067 clearTimeline: () => settle($, clearTimeline($)),
1068 exportTrace: () => settle($, exportFromPane($)),
1069 openLens: id => settle($, setView($, { tab: 'errors', lensId: id })),
1070 clearErrors: () => settle($, clearErrors($)),
1071 }
1072 return renderPane(
1073 els,
1074 {
1075 settings,
1076 arm,
1077 trace,
1078 pending,
1079 view,
1080 session,
1081 stats,
1082 lens,
1083 groups,
1084 options,
1085 columns: e.props.bodyColumns,
1086 rows: Math.max(6, rows),
1087 layout,
1088 hasFields: e.surface !== 'mobile',
1089 },
1090 actions,
1091 )
1092 })
1093
1094 // The gutter on each tool row: "break on" this tool, command or path, and a red mark where a rule covers the call.
1095 // It reads the rules and the cwd only, never the hit counts or the trace, so a tool call redraws no row.
1096 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
1097 const drawn = await next(e)
1098 if (options.inlineControls === 'off' || e.props.tool === 'AskUserQuestion') return drawn
1099 const [held, session] = await Promise.all([$.state.get(SETTINGS), read($, SESSION)])
1100 const settings = held.value ?? defaultSettings(options)
1101 if (settings.mode === 'off') return drawn
1102 const cwd = session.cwd === '' ? undefined : session.cwd
1103 const input = e.props.input !== null && typeof e.props.input === 'object' ? (e.props.input as Record<string, unknown>) : {}
1104 const call = normalizeCall({ ...input, tool: e.props.tool, tool_use_id: e.props.tool_use_id })
1105 const matched = settings.breakpoints.filter(bp => bp.enabled && bp.kind !== 'error' && criteriaMatch(bp, call, cwd))
1106 const offers: Offer[] = suggestBreakpoints(e.props.tool, input, cwd).map(s => ({ ...s, key: `dt-${s.id}`, rule: findRule(settings.breakpoints, s.kind, s.match) }))
1107 return renderGutter($.ui.resolve(e), drawn, matched, offers, options.inlineControls, offer => settle($, toggleSuggestion($, offer)))
1108 })
1109
1110 // A folded run of reads and searches: one "break on" per tool in it.
1111 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
1112 const drawn = await next(e)
1113 if (options.inlineControls === 'off' || e.props.isExpanded) return drawn
1114 const [held, session] = await Promise.all([$.state.get(SETTINGS), read($, SESSION)])
1115 const settings = held.value ?? defaultSettings(options)
1116 if (settings.mode === 'off') return drawn
1117 const cwd = session.cwd === '' ? undefined : session.cwd
1118 const calls = e.props.calls.map(one => {
1119 const input = one.input !== null && typeof one.input === 'object' ? (one.input as Record<string, unknown>) : {}
1120 return normalizeCall({ ...input, tool: one.tool })
1121 })
1122 const matched = settings.breakpoints.filter(bp => bp.enabled && bp.kind !== 'error' && calls.some(call => criteriaMatch(bp, call, cwd)))
1123 const tools = [...new Set(calls.map(call => call.tool))]
1124 const offers: Offer[] = tools.map(tool => {
1125 const s = suggestBreakpoints(tool, {}, cwd)[0] as Suggestion
1126 return { ...s, key: `dt-tool-${tool}`, rule: findRule(settings.breakpoints, s.kind, s.match) }
1127 })
1128 return renderGutter($.ui.resolve(e), drawn, matched, offers, options.inlineControls, offer => settle($, toggleSuggestion($, offer)))
1129 })
1130
1131 // The bar above the prompt: the latest call, and one key to break on its tool, command or path.
1132 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1133 const below = await next(e)
1134 if (options.inlineControls === 'off' || e.props.hasSurvey) return below
1135 const [held, view, trace, session] = await Promise.all([$.state.get(SETTINGS), read($, VIEW), read($, TRACE), read($, SESSION)])
1136 const settings = held.value ?? defaultSettings(options)
1137 const last = trace.at(-1)
1138 if (settings.mode === 'off' || view.barHidden === true) return below
1139 const cwd = session.cwd === '' ? undefined : session.cwd
1140 const offers: Offer[] =
1141 last === undefined ? [] : suggestBreakpoints(last.tool, last.input, cwd).map(s => ({ ...s, key: `bar-${s.id}`, rule: findRule(settings.breakpoints, s.kind, s.match) }))
1142 return renderBar($.ui.resolve(e), below, last, offers, e.props.bodyColumns, {
1143 toggle: offer => settle($, toggleSuggestion($, offer)),
1144 open: () => settle($, openPane($, true)),
1145 hide: () => settle($, hideBar($)),
1146 lens: () => last !== undefined && settle($, openLens($, last.id)),
1147 })
1148 })
1149}
1150src/config/schema.ts 144 lines1// Configuration: the manifest's userConfig options, validated at load, and
2// the persisted settings (mode, recording, breakpoint rules) in $.store.
3
4import type { BreakpointScope, DevtoolsMode, DevtoolsSettings } from '../../types'
5import { SCOPES, validateBreakpoint } from '../core/breakpoints.ts'
6
7export type HeadlessPolicy = 'reject' | 'record-only'
8export type InlineControls = 'hover' | 'always' | 'off'
9/** Error Lens: diagnose failures with read-only file checks, from the result alone, or not at all. */
10export type ErrorLensMode = 'probe' | 'classify' | 'off'
11
12export type DevtoolsOptions = {
13 recording: boolean
14 maxTimelineEntries: number
15 maxSummaryChars: number
16 defaultScope: BreakpointScope
17 headlessPause: HeadlessPolicy
18 simulation: boolean
19 redaction: boolean
20 captureRaw: boolean
21 persistBreakpoints: boolean
22 inlineControls: InlineControls
23 openOnStart: boolean
24 errorLens: ErrorLensMode
25 errorToasts: boolean
26}
27
28export const DEFAULT_OPTIONS: DevtoolsOptions = {
29 recording: true,
30 maxTimelineEntries: 500,
31 maxSummaryChars: 160,
32 defaultScope: 'all',
33 headlessPause: 'reject',
34 simulation: false,
35 redaction: true,
36 captureRaw: false,
37 persistBreakpoints: true,
38 inlineControls: 'always',
39 openOnStart: true,
40 errorLens: 'probe',
41 errorToasts: true,
42}
43
44export type ParsedOptions = { options: DevtoolsOptions; warnings: string[] }
45
46/**
47 * The engine validates userConfig against the manifest before `register`
48 * runs; this second pass keeps the module safe when it is handed something
49 * else (a test, an older manifest) and reports what it replaced.
50 */
51export function parseOptions(raw: Readonly<Record<string, unknown>> | undefined): ParsedOptions {
52 const source = raw ?? {}
53 const warnings: string[] = []
54 const options: DevtoolsOptions = { ...DEFAULT_OPTIONS }
55 const bool = (key: keyof DevtoolsOptions & string): void => {
56 const value = source[key]
57 if (value === undefined) return
58 if (typeof value === 'boolean') (options as Record<string, unknown>)[key] = value
59 else warnings.push(`option ${key} must be true or false; using ${String(DEFAULT_OPTIONS[key])}`)
60 }
61 const number = (key: 'maxTimelineEntries' | 'maxSummaryChars', min: number, max: number): void => {
62 const value = source[key]
63 if (value === undefined) return
64 if (typeof value === 'number' && Number.isFinite(value)) options[key] = Math.min(max, Math.max(min, Math.round(value)))
65 else warnings.push(`option ${key} must be a number; using ${DEFAULT_OPTIONS[key]}`)
66 if (typeof value === 'number' && (value < min || value > max)) warnings.push(`option ${key} clamped to ${min}-${max}`)
67 }
68 bool('recording')
69 bool('simulation')
70 bool('redaction')
71 bool('captureRaw')
72 bool('persistBreakpoints')
73 bool('openOnStart')
74 bool('errorToasts')
75 number('maxTimelineEntries', 10, 5000)
76 number('maxSummaryChars', 40, 2000)
77 if (source.defaultScope !== undefined) {
78 if (SCOPES.includes(source.defaultScope as BreakpointScope)) options.defaultScope = source.defaultScope as BreakpointScope
79 else warnings.push(`option defaultScope must be all, main or subagents; using ${DEFAULT_OPTIONS.defaultScope}`)
80 }
81 if (source.headlessPause !== undefined) {
82 if (source.headlessPause === 'reject' || source.headlessPause === 'record-only') options.headlessPause = source.headlessPause
83 else warnings.push('option headlessPause must be reject or record-only; using reject')
84 }
85 if (source.inlineControls !== undefined) {
86 if (source.inlineControls === 'hover' || source.inlineControls === 'always' || source.inlineControls === 'off') options.inlineControls = source.inlineControls
87 else warnings.push('option inlineControls must be always, hover or off; using always')
88 }
89 if (source.errorLens !== undefined) {
90 if (source.errorLens === 'probe' || source.errorLens === 'classify' || source.errorLens === 'off') options.errorLens = source.errorLens
91 else warnings.push('option errorLens must be probe, classify or off; using probe')
92 }
93 return { options, warnings }
94}
95
96export const STORE_KEY = 'settings.v1'
97export const SETTINGS_SCHEMA_VERSION = 1
98const MODES: readonly DevtoolsMode[] = ['active', 'observe', 'off']
99const MAX_BREAKPOINTS = 200
100
101export type PersistedSettings = {
102 schemaVersion: typeof SETTINGS_SCHEMA_VERSION
103 mode: DevtoolsMode
104 recording: boolean
105 breakpoints: unknown[]
106}
107
108export function defaultSettings(options: DevtoolsOptions): DevtoolsSettings {
109 return { mode: 'active', recording: options.recording, breakpoints: [] }
110}
111
112/** Reads what the store held: anything invalid is dropped with a warning, never trusted. */
113export function parsePersisted(raw: unknown, options: DevtoolsOptions): { settings: DevtoolsSettings; warnings: string[] } {
114 const settings = defaultSettings(options)
115 if (raw === undefined || raw === null) return { settings, warnings: [] }
116 if (typeof raw !== 'object') return { settings, warnings: ['saved settings were not an object; starting fresh'] }
117 const r = raw as Record<string, unknown>
118 if (r.schemaVersion !== SETTINGS_SCHEMA_VERSION) return { settings, warnings: [`saved settings have schema ${String(r.schemaVersion)}; starting fresh`] }
119 const warnings: string[] = []
120 if (MODES.includes(r.mode as DevtoolsMode)) settings.mode = r.mode as DevtoolsMode
121 if (typeof r.recording === 'boolean') settings.recording = r.recording
122 const seen = new Set<string>()
123 for (const item of Array.isArray(r.breakpoints) ? r.breakpoints.slice(0, MAX_BREAKPOINTS) : []) {
124 const checked = validateBreakpoint(item)
125 if (!checked.ok) {
126 warnings.push(`dropped a saved breakpoint: ${checked.error}`)
127 continue
128 }
129 if (seen.has(checked.breakpoint.id)) continue
130 seen.add(checked.breakpoint.id)
131 settings.breakpoints.push({ ...checked.breakpoint, hitCount: 0 })
132 }
133 return { settings, warnings }
134}
135
136export function toPersisted(settings: DevtoolsSettings): PersistedSettings {
137 return {
138 schemaVersion: SETTINGS_SCHEMA_VERSION,
139 mode: settings.mode,
140 recording: settings.recording,
141 breakpoints: settings.breakpoints.map(({ hitCount: _hits, ...rest }) => rest),
142 }
143}
144src/core/breakpoints.ts 421 lines1// Breakpoint rules: parsing, validation and matching. Pure functions; the
2// pattern matchers are bounded so a rule cannot stall a tool call.
3
4import type { Breakpoint, BreakpointAction, BreakpointKind, BreakpointMatch, BreakpointScope, SimulationKind } from '../../types'
5import type { NormalizedCall } from './events.ts'
6
7export const KINDS: readonly BreakpointKind[] = ['tool', 'command', 'file', 'error', 'conditional']
8export const ACTIONS: readonly BreakpointAction[] = ['pause', 'record', 'warn']
9export const SCOPES: readonly BreakpointScope[] = ['all', 'main', 'subagents']
10const SIMULATION_KINDS: readonly SimulationKind[] = ['fail', 'stub']
11
12const MAX_PATTERN = 512
13const MAX_REGEX = 200
14const MAX_COMMAND_SCAN = 8192
15const MAX_REGEX_SCAN = 2048
16const MAX_PATH = 1024
17const MAX_GLOB_WILDCARDS = 12
18
19type Failed = { ok: false; error: string }
20type Compiled = { ok: true; test: (text: string) => boolean } | Failed
21type CompiledGlob = { ok: true; glob: NormalPath; sensitive: RegExp; insensitive: RegExp } | Failed
22
23// ponycave: plain Map memos, cleared past 256 entries; an LRU only if rule sets get large.
24const commandCache = new Map<string, Compiled>()
25const globCache = new Map<string, CompiledGlob>()
26function memo<T>(cache: Map<string, T>, key: string, build: () => T): T {
27 const hit = cache.get(key)
28 if (hit !== undefined) return hit
29 if (cache.size > 256) cache.clear()
30 const made = build()
31 cache.set(key, made)
32 return made
33}
34
35function squash(text: string): string {
36 return text.replace(/\s+/g, ' ').trim().toLowerCase()
37}
38
39/**
40 * A command pattern: words with `*` wildcards, matched case-insensitively
41 * anywhere in the command (`npm install` matches `cd web && npm install x`),
42 * or `re:<regex>` under limits that keep it from backtracking for long.
43 */
44export function compileCommandPattern(pattern: string): Compiled {
45 return memo(commandCache, pattern, () => {
46 if (pattern.trim() === '') return { ok: false, error: 'the command pattern is empty' }
47 if (pattern.length > MAX_PATTERN) return { ok: false, error: `the command pattern is longer than ${MAX_PATTERN} characters` }
48 if (pattern.startsWith('re:')) return compileGuardedRegex(pattern.slice(3))
49 const segments = squash(pattern).split('*').filter(segment => segment !== '')
50 if (segments.length === 0) return { ok: true, test: () => true }
51 return {
52 ok: true,
53 test: command => {
54 const text = squash(command.slice(0, MAX_COMMAND_SCAN))
55 let from = 0
56 for (const segment of segments) {
57 const at = text.indexOf(segment, from)
58 if (at < 0) return false
59 from = at + segment.length
60 }
61 return true
62 },
63 }
64 })
65}
66
67/** Rejects the regex features that make backtracking blow up; the input is capped too. */
68export function compileGuardedRegex(source: string): Compiled {
69 if (source.length === 0) return { ok: false, error: 'the regex is empty' }
70 if (source.length > MAX_REGEX) return { ok: false, error: `the regex is longer than ${MAX_REGEX} characters` }
71 if (/\\[1-9]|\\k</.test(source)) return { ok: false, error: 'backreferences are not allowed in breakpoint regexes' }
72 if (/\)[*+{]/.test(source)) return { ok: false, error: 'quantified groups such as (a+)+ are not allowed in breakpoint regexes' }
73 const unbounded = source.match(/(?<!\\)[*+]|\{\d+,\}/g)?.length ?? 0
74 if (unbounded > 3) return { ok: false, error: 'a breakpoint regex may hold at most 3 unbounded quantifiers' }
75 let regex: RegExp
76 try {
77 regex = new RegExp(source, 'i')
78 } catch (error) {
79 return { ok: false, error: `invalid regex: ${(error as Error).message}` }
80 }
81 return { ok: true, test: text => regex.test(text.slice(0, MAX_REGEX_SCAN)) }
82}
83
84type NormalPath = { path: string; isWindows: boolean }
85
86/** Forward slashes, a lowercase drive letter, no `./`, no repeated or trailing slash. */
87export function normalizePath(raw: string): NormalPath {
88 const isWindows = raw.includes('\\') || /^[a-zA-Z]:/.test(raw)
89 let path = raw.trim().replace(/\\/g, '/')
90 const isUnc = path.startsWith('//')
91 path = path.replace(/\/{2,}/g, '/').replace(/(^|\/)\.(?=\/|$)/g, '$1').replace(/\/{2,}/g, '/')
92 path = path.replace(/^([a-zA-Z]):/, (_, drive: string) => `${drive.toLowerCase()}:`)
93 if (path.length > 1 && path.endsWith('/') && !/^[a-z]:\/$/.test(path)) path = path.slice(0, -1)
94 if (path.startsWith('./')) path = path.slice(2)
95 return { path: isUnc ? `/${path}` : path, isWindows }
96}
97
98function isAbsolute(path: string): boolean {
99 return path.startsWith('/') || /^[a-z]:\//.test(path)
100}
101
102function globSource(glob: string): string {
103 let out = ''
104 for (let i = 0; i < glob.length; i += 1) {
105 const char = glob[i] as string
106 if (char === '*') {
107 if (glob[i + 1] === '*') {
108 if (glob[i + 2] === '/') {
109 out += '(?:.*/)?'
110 i += 2
111 } else {
112 out += '.*'
113 i += 1
114 }
115 } else {
116 out += '[^/]*'
117 }
118 } else if (char === '?') {
119 out += '[^/]'
120 } else {
121 out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
122 }
123 }
124 // `dir/**` also names `dir` itself, so a search rooted at it matches.
125 return out.endsWith('/.*') ? `${out.slice(0, -3)}(?:/.*)?` : out
126}
127
128/**
129 * Glob over a path: `**` any depth, `*` and `?` within one segment. A glob
130 * with no slash matches any segment (`.env*` matches `/repo/.env.local`); a
131 * relative glob matches below the working directory or at any segment
132 * boundary; an absolute one matches the whole path. Case-insensitive when
133 * either side is a Windows path.
134 */
135export function compileGlob(glob: string): CompiledGlob {
136 return memo(globCache, glob, () => {
137 if (glob.trim() === '') return { ok: false, error: 'the path glob is empty' }
138 if (glob.length > MAX_PATTERN) return { ok: false, error: `the path glob is longer than ${MAX_PATTERN} characters` }
139 if ((glob.match(/[*?]/g)?.length ?? 0) > MAX_GLOB_WILDCARDS) return { ok: false, error: `a path glob may hold at most ${MAX_GLOB_WILDCARDS} wildcards` }
140 const normal = normalizePath(glob)
141 const source = `^${globSource(normal.path)}$`
142 return { ok: true, glob: normal, sensitive: new RegExp(source), insensitive: new RegExp(source, 'i') }
143 })
144}
145
146export function matchPath(glob: string, rawPath: string, cwd?: string): boolean {
147 const compiled = compileGlob(glob)
148 if (!compiled.ok || rawPath.length > MAX_PATH) return false
149 const { glob: pattern, sensitive, insensitive } = compiled
150 const target = normalizePath(rawPath)
151 const regex = pattern.isWindows || target.isWindows ? insensitive : sensitive
152 const segments = target.path.split('/').filter(segment => segment !== '')
153
154 if (!pattern.path.includes('/')) return segments.some(segment => regex.test(segment))
155
156 if (isAbsolute(pattern.path)) {
157 if (isAbsolute(target.path)) return regex.test(target.path)
158 return cwd !== undefined && regex.test(normalizePath(`${cwd}/${target.path}`).path)
159 }
160
161 if (cwd !== undefined) {
162 const root = normalizePath(cwd).path
163 const fold = (text: string): string => (regex === insensitive ? text.toLowerCase() : text)
164 if (fold(target.path).startsWith(`${fold(root)}/`) && regex.test(target.path.slice(root.length + 1))) return true
165 }
166 for (let i = 0; i < segments.length; i += 1) if (regex.test(segments.slice(i).join('/'))) return true
167 return false
168}
169
170function scopeAdmits(scope: BreakpointScope, call: NormalizedCall): boolean {
171 return scope === 'all' || (scope === 'main') === (call.scope === 'main')
172}
173
174/** Whether a breakpoint's criteria match a call (ignores enabled, kind and threshold). */
175export function criteriaMatch(bp: Breakpoint, call: NormalizedCall, cwd?: string): boolean {
176 if (!scopeAdmits(bp.scope, call)) return false
177 const { tools, command, path } = bp.match
178 if (tools !== undefined && tools.length > 0 && !tools.includes(call.tool)) return false
179 if (command !== undefined) {
180 const compiled = compileCommandPattern(command)
181 if (call.command === undefined || !compiled.ok || !compiled.test(call.command)) return false
182 }
183 if (path !== undefined && !call.paths.some(one => matchPath(path, one, cwd))) return false
184 return true
185}
186
187export type Evaluation = {
188 /** The rules with hit counts advanced. */
189 breakpoints: Breakpoint[]
190 /** Rules that matched, as advanced. */
191 hits: Breakpoint[]
192 /** Hits whose threshold is reached: their action applies. */
193 effective: Breakpoint[]
194}
195
196function evaluate(breakpoints: readonly Breakpoint[], isCandidate: (bp: Breakpoint) => boolean): Evaluation {
197 const hits: Breakpoint[] = []
198 const next = breakpoints.map(bp => {
199 if (!isCandidate(bp)) return bp
200 const hit = { ...bp, hitCount: bp.hitCount + 1 }
201 hits.push(hit)
202 return hit
203 })
204 const effective = hits.filter(bp => bp.hitThreshold === undefined || bp.hitCount >= bp.hitThreshold)
205 return { breakpoints: next, hits, effective }
206}
207
208/** Before a call runs: every enabled rule other than error rules. */
209export function evaluateCall(breakpoints: readonly Breakpoint[], call: NormalizedCall, cwd?: string): Evaluation {
210 return evaluate(breakpoints, bp => bp.enabled && bp.kind !== 'error' && criteriaMatch(bp, call, cwd))
211}
212
213/** After a call failed: the enabled error rules. */
214export function evaluateError(breakpoints: readonly Breakpoint[], call: NormalizedCall, cwd?: string): Evaluation {
215 return evaluate(breakpoints, bp => bp.enabled && bp.kind === 'error' && criteriaMatch(bp, call, cwd))
216}
217
218export function nextBreakpointId(breakpoints: readonly Breakpoint[]): string {
219 const taken = breakpoints.map(bp => Number(/^bp(\d+)$/.exec(bp.id)?.[1] ?? 0))
220 return `bp${Math.max(0, ...taken) + 1}`
221}
222
223export type Validated = { ok: true; breakpoint: Breakpoint } | { ok: false; error: string }
224
225const isString = (value: unknown): value is string => typeof value === 'string'
226
227/** Validates a rule from the store, a command or the pane; the one gate every rule passes. */
228export function validateBreakpoint(raw: unknown): Validated {
229 if (raw === null || typeof raw !== 'object') return { ok: false, error: 'a breakpoint must be an object' }
230 const r = raw as Record<string, unknown>
231 if (!isString(r.id) || !/^[A-Za-z0-9_-]{1,32}$/.test(r.id)) return { ok: false, error: 'a breakpoint id is 1-32 letters, digits, _ or -' }
232 if (!KINDS.includes(r.kind as BreakpointKind)) return { ok: false, error: `kind must be one of ${KINDS.join(', ')}` }
233 if (!ACTIONS.includes(r.action as BreakpointAction)) return { ok: false, error: `action must be one of ${ACTIONS.join(', ')}` }
234 if (!SCOPES.includes(r.scope as BreakpointScope)) return { ok: false, error: `scope must be one of ${SCOPES.join(', ')}` }
235 const m = (r.match ?? {}) as Record<string, unknown>
236 if (typeof m !== 'object') return { ok: false, error: 'match must be an object' }
237 const match: BreakpointMatch = {}
238 if (m.tools !== undefined) {
239 if (!Array.isArray(m.tools) || !m.tools.every(tool => isString(tool) && /^[A-Za-z0-9_.:-]{1,128}$/.test(tool)) || m.tools.length > 32) {
240 return { ok: false, error: 'tools must be a list of tool names' }
241 }
242 if (m.tools.length > 0) match.tools = [...(m.tools as string[])]
243 }
244 if (m.command !== undefined) {
245 if (!isString(m.command)) return { ok: false, error: 'command must be a string' }
246 const compiled = compileCommandPattern(m.command)
247 if (!compiled.ok) return { ok: false, error: compiled.error }
248 match.command = m.command
249 }
250 if (m.path !== undefined) {
251 if (!isString(m.path)) return { ok: false, error: 'path must be a string' }
252 const compiled = compileGlob(m.path)
253 if (!compiled.ok) return { ok: false, error: compiled.error }
254 match.path = m.path
255 }
256 const kind = r.kind as BreakpointKind
257 if (kind === 'tool' && match.tools === undefined) return { ok: false, error: 'a tool breakpoint names at least one tool' }
258 if (kind === 'command' && match.command === undefined) return { ok: false, error: 'a command breakpoint needs a command pattern' }
259 if (kind === 'file' && match.path === undefined) return { ok: false, error: 'a file breakpoint needs a path glob' }
260 if (kind === 'conditional' && Object.keys(match).length === 0) return { ok: false, error: 'a conditional breakpoint needs at least one of tool, command or path' }
261 let hitThreshold: number | undefined
262 if (r.hitThreshold !== undefined) {
263 if (typeof r.hitThreshold !== 'number' || !Number.isInteger(r.hitThreshold) || r.hitThreshold < 1 || r.hitThreshold > 1_000_000) {
264 return { ok: false, error: 'the hit threshold is a whole number from 1' }
265 }
266 hitThreshold = r.hitThreshold
267 }
268 let simulate: Breakpoint['simulate']
269 if (r.simulate !== undefined) {
270 const s = r.simulate as Record<string, unknown>
271 if (s === null || typeof s !== 'object' || !SIMULATION_KINDS.includes(s.kind as SimulationKind)) return { ok: false, error: 'simulate.kind must be fail or stub' }
272 if (s.text !== undefined && (!isString(s.text) || s.text.length > 2000)) return { ok: false, error: 'simulate.text is a string of at most 2000 characters' }
273 simulate = { kind: s.kind as SimulationKind, ...(isString(s.text) ? { text: s.text } : {}) }
274 }
275 const name = isString(r.name) && r.name.trim() !== '' ? r.name.trim().slice(0, 80) : `${kind} ${r.id}`
276 const hitCount = typeof r.hitCount === 'number' && Number.isInteger(r.hitCount) && r.hitCount >= 0 ? r.hitCount : 0
277 return {
278 ok: true,
279 breakpoint: {
280 id: r.id,
281 name,
282 enabled: r.enabled !== false,
283 kind,
284 match,
285 scope: r.scope as BreakpointScope,
286 action: r.action as BreakpointAction,
287 hitCount,
288 ...(hitThreshold !== undefined ? { hitThreshold } : {}),
289 ...(simulate !== undefined ? { simulate } : {}),
290 },
291 }
292}
293
294/**
295 * Splits a command line into words, honoring "double" and 'single' quotes.
296 * Inside double quotes only \" is unescaped, so regexes (\s) and Windows
297 * paths (C:\repo) keep their backslashes.
298 */
299export function tokenize(input: string): string[] {
300 const out: string[] = []
301 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
302 for (let m = re.exec(input); m !== null; m = re.exec(input)) {
303 out.push(m[1] !== undefined ? m[1].replace(/\\"/g, '"') : (m[2] ?? m[3] ?? ''))
304 }
305 return out
306}
307
308export const SPEC_HELP = [
309 'tool <Name>[,<Name>...] pause on every call to these tools',
310 'command <pattern> Bash command contains the words (* wildcard, re:<regex>)',
311 'file <glob> a call names a matching path (src/auth/**, .env*)',
312 'error [<Tool>,...] after a failed call: surface it, arm a pause on the next call',
313 'when tool=A,B command=".." path=.. all given conditions must match',
314 'flags: --action pause|record|warn --scope all|main|subagents --name ".."',
315 ' --after <N> (act from the Nth hit) --simulate fail|stub --text ".." --disabled',
316]
317
318export type Parsed = { ok: true; breakpoint: Breakpoint } | { ok: false; error: string }
319
320/** Parses the rule language `/devtools-break` and the pane's input share. */
321export function parseBreakpointSpec(input: string, id: string, defaultScope: BreakpointScope): Parsed {
322 const words = tokenize(input.trim())
323 const head = words.shift()?.toLowerCase()
324 if (head === undefined) return { ok: false, error: 'empty rule: start with tool, command, file, error or when' }
325 const positional: string[] = []
326 const flags: Record<string, string | true> = {}
327 for (let i = 0; i < words.length; i += 1) {
328 const word = words[i] as string
329 if (/^--[a-z]+$/.test(word)) {
330 const name = word.slice(2)
331 if (name === 'disabled') {
332 flags[name] = true
333 continue
334 }
335 const value = words[i + 1]
336 if (value === undefined) return { ok: false, error: `${word} needs a value` }
337 flags[name] = value
338 i += 1
339 } else {
340 positional.push(word)
341 }
342 }
343 const known = ['action', 'scope', 'name', 'after', 'simulate', 'text', 'disabled']
344 const unknown = Object.keys(flags).find(name => !known.includes(name))
345 if (unknown !== undefined) return { ok: false, error: `unknown flag --${unknown}` }
346
347 const list = (text: string): string[] => text.split(/[\s,]+/).filter(item => item !== '')
348 let kind: BreakpointKind
349 const match: BreakpointMatch = {}
350 switch (head) {
351 case 'tool':
352 case 'tools':
353 kind = 'tool'
354 match.tools = list(positional.join(','))
355 break
356 case 'command':
357 case 'cmd':
358 case 'bash':
359 kind = 'command'
360 match.command = positional.join(' ')
361 break
362 case 'file':
363 case 'path':
364 kind = 'file'
365 match.path = positional.join(' ')
366 break
367 case 'error':
368 case 'errors':
369 kind = 'error'
370 if (positional.length > 0) match.tools = list(positional.join(','))
371 break
372 case 'when':
373 case 'if':
374 kind = 'conditional'
375 for (const pair of positional) {
376 const at = pair.indexOf('=')
377 if (at < 1) return { ok: false, error: `expected key=value, got "${pair}"` }
378 const key = pair.slice(0, at).toLowerCase()
379 const value = pair.slice(at + 1)
380 if (key === 'tool' || key === 'tools') match.tools = list(value)
381 else if (key === 'command' || key === 'cmd') match.command = value
382 else if (key === 'path' || key === 'file') match.path = value
383 else return { ok: false, error: `unknown condition "${key}": use tool, command or path` }
384 }
385 break
386 default:
387 return { ok: false, error: `unknown rule "${head}": start with tool, command, file, error or when` }
388 }
389 const threshold = flags.after === undefined ? undefined : Number(flags.after)
390 const describe = (match.command ?? match.path ?? match.tools?.join(',') ?? 'any tool').slice(0, 48)
391 const draft: Record<string, unknown> = {
392 id,
393 name: typeof flags.name === 'string' ? flags.name : `${kind} ${describe}`,
394 enabled: flags.disabled !== true,
395 kind,
396 match,
397 scope: typeof flags.scope === 'string' ? flags.scope : defaultScope,
398 action: typeof flags.action === 'string' ? flags.action : kind === 'error' ? 'warn' : 'pause',
399 hitCount: 0,
400 ...(threshold !== undefined ? { hitThreshold: threshold } : {}),
401 ...(typeof flags.simulate === 'string'
402 ? { simulate: { kind: flags.simulate, ...(typeof flags.text === 'string' ? { text: flags.text } : {}) } }
403 : {}),
404 }
405 return validateBreakpoint(draft)
406}
407
408export function describeBreakpoint(bp: Breakpoint): string {
409 const parts: string[] = []
410 if (bp.match.tools !== undefined) parts.push(`tool=${bp.match.tools.join(',')}`)
411 if (bp.match.command !== undefined) parts.push(`command="${bp.match.command}"`)
412 if (bp.match.path !== undefined) parts.push(`path=${bp.match.path}`)
413 if (parts.length === 0) parts.push('any tool')
414 const extra = [
415 bp.scope === 'all' ? undefined : `scope ${bp.scope}`,
416 bp.hitThreshold === undefined ? undefined : `from hit ${bp.hitThreshold}`,
417 bp.simulate === undefined ? undefined : `simulate ${bp.simulate.kind}`,
418 ].filter(Boolean)
419 return `${bp.kind} ${parts.join(' ')} → ${bp.action}${extra.length > 0 ? ` (${extra.join(', ')})` : ''}`
420}
421src/core/controller.ts 158 lines1// The debugger's decisions as pure functions: whether a call pauses, what the
2// pause dialog offers, what an answer means, and how a finished call is
3// classified. The native layer executes these decisions; it decides nothing.
4
5import type { ArmState, Breakpoint, DevtoolsSettings, PauseDecision, PermissionInfo, RiskLevel, TraceOutcome, TraceStatus } from '../../types'
6import { evaluateCall, evaluateError } from './breakpoints.ts'
7import type { NormalizedCall, ResultLike } from './events.ts'
8
9export const DISARMED: ArmState = { pauseNext: false, step: false }
10
11/** Calls stepping and pause-next never stop on: a question already holds the person. */
12const NOT_STEPPABLE: readonly string[] = ['AskUserQuestion']
13
14export type CallPlan =
15 | { kind: 'pass' }
16 | { kind: 'record'; hits: Breakpoint[] }
17 | { kind: 'warn'; hits: Breakpoint[]; warned: Breakpoint[] }
18 | { kind: 'pause'; hits: Breakpoint[]; at?: Breakpoint; reason: string }
19
20export type Planned = { plan: CallPlan; settings: DevtoolsSettings; arm: ArmState }
21
22/**
23 * Decides what happens to a call before it runs. `active` pauses on pause
24 * rules and on an armed step; `observe` downgrades every pause to a record
25 * and leaves the arm alone; `off` passes everything untouched.
26 */
27export function planCall(settings: DevtoolsSettings, arm: ArmState, call: NormalizedCall, cwd?: string): Planned {
28 if (settings.mode === 'off') return { plan: { kind: 'pass' }, settings, arm }
29 const evaluation = evaluateCall(settings.breakpoints, call, cwd)
30 const next = evaluation.hits.length > 0 ? { ...settings, breakpoints: evaluation.breakpoints } : settings
31 const hits = evaluation.hits
32 if (settings.mode === 'active') {
33 const at = evaluation.effective.find(bp => bp.action === 'pause')
34 // A pause at a breakpoint also satisfies an armed step; an unarmed arm keeps its identity (no write).
35 if (at !== undefined) return { plan: { kind: 'pause', hits, at, reason: `breakpoint "${at.name}"` }, settings: next, arm: arm.pauseNext || arm.step ? DISARMED : arm }
36 if ((arm.pauseNext || arm.step) && !NOT_STEPPABLE.includes(call.tool)) {
37 const reason = arm.step ? 'step' : arm.reason !== undefined ? `pause-next (${arm.reason})` : 'pause-next'
38 return { plan: { kind: 'pause', hits, reason }, settings: next, arm: DISARMED }
39 }
40 }
41 const warned = evaluation.effective.filter(bp => bp.action === 'warn')
42 if (warned.length > 0) return { plan: { kind: 'warn', hits, warned }, settings: next, arm }
43 if (hits.length > 0) return { plan: { kind: 'record', hits }, settings: next, arm }
44 return { plan: { kind: 'pass' }, settings: next, arm }
45}
46
47export type ErrorPlan = { settings: DevtoolsSettings; arm: ArmState; triggered: Breakpoint[] }
48
49/**
50 * After a failed call: error rules surface the failure, and an error rule
51 * whose action is pause arms a pause on the next eligible call (a call that
52 * already ran is never paused after the fact).
53 */
54export function planAfterError(settings: DevtoolsSettings, arm: ArmState, call: NormalizedCall, cwd?: string): ErrorPlan {
55 if (settings.mode === 'off') return { settings, arm, triggered: [] }
56 const evaluation = evaluateError(settings.breakpoints, call, cwd)
57 if (evaluation.hits.length === 0) return { settings, arm, triggered: [] }
58 const next = { ...settings, breakpoints: evaluation.breakpoints }
59 const pauses = settings.mode === 'active' && evaluation.effective.some(bp => bp.action === 'pause')
60 return {
61 settings: next,
62 arm: pauses ? { pauseNext: true, step: false, reason: `after ${call.tool} failed` } : arm,
63 triggered: evaluation.effective,
64 }
65}
66
67export const LABELS = { continue: 'Continue', step: 'Step', reject: 'Reject', simulate: 'Simulate' } as const
68
69export type PauseQuestion = { question: string; options: string[]; header: string }
70
71export function pauseQuestion(args: {
72 tool: string
73 summary: string
74 reason: string
75 risk: RiskLevel
76 agentId?: string
77 permission?: PermissionInfo
78 canSimulate: boolean
79}): PauseQuestion {
80 const permission = args.permission === undefined ? '' : `, permission ${args.permission.decision}${args.permission.rule !== undefined ? ` by ${args.permission.rule}` : ''}`
81 const agent = args.agentId === undefined ? '' : ` in subagent ${args.agentId}`
82 const options: string[] = [LABELS.continue, LABELS.step, LABELS.reject]
83 if (args.canSimulate) options.push(LABELS.simulate)
84 return {
85 header: 'DevTools',
86 options,
87 question: `Paused at ${args.reason}: ${args.tool}${agent} wants to run ${args.summary} (risk ${args.risk}${permission}). Continue runs it through the normal permission checks; Step runs it and pauses on the next call. What should happen?`,
88 }
89}
90
91export type Interpreted = { decision: PauseDecision; note?: string }
92
93/** Maps the dialog's answer to a decision. Anything unrecognized rejects, keeping the typed text as a note. */
94export function interpretAnswer(answer: string, simulateOffered: boolean): Interpreted {
95 const text = answer.trim()
96 if (text === LABELS.continue) return { decision: 'continue' }
97 if (text === LABELS.step) return { decision: 'step' }
98 if (text === LABELS.reject) return { decision: 'reject' }
99 if (text === LABELS.simulate && simulateOffered) return { decision: 'simulate' }
100 return { decision: 'reject', note: text.slice(0, 500) }
101}
102
103export type RefusalKind = 'debugger' | 'cancelled' | 'headless' | 'aborted' | 'guard'
104
105/** What Claude reads when the debugger keeps a call from running: what happened and what to do next. */
106export function refusalText(kind: RefusalKind, tool: string, reason: string, note?: string): string {
107 switch (kind) {
108 case 'debugger':
109 return `Claude DevTools: the developer rejected this ${tool} call at ${reason}, so it did not run.${note !== undefined && note !== '' ? ` Their note: "${note}".` : ''} Choose a different approach or ask the user how to proceed.`
110 case 'cancelled':
111 return `Claude DevTools: this ${tool} call was held at ${reason} and the question was dismissed, so it did not run. Ask the user how to proceed before retrying it.`
112 case 'headless':
113 return `Claude DevTools: ${reason} requires an interactive decision, but this session has nobody to ask (headless), so the ${tool} call did not run. To let such calls through in headless runs, set the devtools option headlessPause to "record-only" or disable the breakpoint.`
114 case 'aborted':
115 return `Claude DevTools: the turn was interrupted while this ${tool} call was paused at ${reason}; it did not run.`
116 case 'guard':
117 return `Claude DevTools: its breakpoint guard failed (${reason}), so this ${tool} call was not run to keep the breakpoint's promise. Retry, or turn the debugger off with /devtools-disable off.`
118 }
119}
120
121const INTERRUPTED = /\[Request interrupted|interrupted by (the )?user|was interrupted|operation was aborted/i
122const PERMISSION_REJECTED =
123 /doesn't want to proceed|tool use was rejected|permission to use .{1,120} (?:has been|was) denied|denied by (?:a |the )?(?:permission )?(?:rule|policy|settings)|blocked by (?:a |the )?(?:permission|policy)/i
124
125export type Classified = { status: TraceStatus; outcome: TraceOutcome }
126
127/**
128 * Tells a tool failure from a permission denial, a hook's refusal and an
129 * interrupted call. Permission verdicts come from tool.check when observed;
130 * the text patterns are a documented fallback.
131 */
132export function classifyResult(result: ResultLike, permission?: PermissionInfo): Classified {
133 if (typeof result.deny === 'string') {
134 return { status: 'denied', outcome: permission?.decision === 'deny' || PERMISSION_REJECTED.test(result.deny) ? 'permission-denied' : 'blocked-by-hook' }
135 }
136 if (result.isError === true) {
137 const text = typeof result.text === 'string' ? result.text : String(result.result ?? '')
138 const record = result.result !== null && typeof result.result === 'object' ? (result.result as Record<string, unknown>) : {}
139 if (record.interrupted === true || INTERRUPTED.test(text)) return { status: 'failed', outcome: 'aborted' }
140 if (permission?.decision === 'deny' || PERMISSION_REJECTED.test(text)) return { status: 'denied', outcome: 'permission-denied' }
141 return { status: 'failed', outcome: 'tool-error' }
142 }
143 const record = result.result !== null && typeof result.result === 'object' ? (result.result as Record<string, unknown>) : {}
144 if (record.interrupted === true) return { status: 'failed', outcome: 'aborted' }
145 return { status: 'completed', outcome: 'ok' }
146}
147
148/**
149 * Whether a call would have been held, judged from a settings snapshot alone
150 * (no state reads): the fail-closed check a `.catch` handler runs when the
151 * guard itself failed before deciding.
152 */
153export function wouldPause(settings: DevtoolsSettings | undefined, arm: ArmState | undefined, call: NormalizedCall, cwd?: string): boolean {
154 if (settings === undefined || settings.mode !== 'active') return false
155 const planned = planCall(settings, arm ?? DISARMED, call, cwd)
156 return planned.plan.kind === 'pause'
157}
158src/core/events.ts 293 lines1// Event normalization: one shape for every tool call, whatever the tool's own
2// argument and result shapes, plus risk classification and the summaries the
3// timeline keeps. Pure: no Claude Code API here.
4
5import type { RiskLevel } from '../../types'
6import { redactString, redactValue } from '../security/redaction.ts'
7
8/** Tools whose `command` argument is a shell command line. */
9export const SHELL_TOOLS: readonly string[] = ['Bash', 'PowerShell']
10
11/** Arguments that carry file contents: never kept unless raw capture is on. */
12const CONTENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
13 Write: ['content'],
14 Edit: ['old_string', 'new_string'],
15 MultiEdit: ['edits'],
16 NotebookEdit: ['new_source'],
17}
18
19/** Argument names that hold a path, across built-in and MCP tools. */
20const PATH_FIELDS: readonly string[] = ['file_path', 'notebook_path', 'path', 'filePath', 'filepath', 'directory', 'dir']
21
22const RESERVED_KEYS = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
23
24export type ToolCallLike = {
25 tool: string
26 tool_use_id?: string
27 agentId?: string
28 [argument: string]: unknown
29}
30
31export type NormalizedCall = {
32 tool: string
33 toolUseId?: string
34 agentId?: string
35 scope: 'main' | 'subagent'
36 /** The tool's own arguments, without the envelope keys. */
37 args: Readonly<Record<string, unknown>>
38 /** The shell command line, for shell tools only. */
39 command?: string
40 /** Every path the call names (best effort for shell commands). */
41 paths: string[]
42 risk: RiskLevel
43}
44
45export type SummaryOptions = {
46 maxChars: number
47 redaction: boolean
48 captureRaw: boolean
49}
50
51export function normalizeCall(e: ToolCallLike): NormalizedCall {
52 const args: Record<string, unknown> = {}
53 for (const [key, value] of Object.entries(e)) if (!RESERVED_KEYS.has(key)) args[key] = value
54 const command = SHELL_TOOLS.includes(e.tool) && typeof args.command === 'string' ? args.command : undefined
55 const paths = collectPaths(args, command)
56 return {
57 tool: e.tool,
58 toolUseId: e.tool_use_id,
59 agentId: e.agentId,
60 scope: e.agentId === undefined ? 'main' : 'subagent',
61 args,
62 command,
63 paths,
64 risk: classifyRisk(e.tool, command),
65 }
66}
67
68function collectPaths(args: Record<string, unknown>, command: string | undefined): string[] {
69 const found: string[] = []
70 for (const field of PATH_FIELDS) {
71 const value = args[field]
72 if (typeof value === 'string' && value !== '') found.push(value)
73 }
74 if (Array.isArray(args.paths)) for (const value of args.paths) if (typeof value === 'string') found.push(value)
75 if (command !== undefined) found.push(...shellPathTokens(command))
76 return [...new Set(found)].slice(0, 32)
77}
78
79/** Words of a command line that look like paths: best effort, no full shell parsing. */
80export function shellPathTokens(command: string): string[] {
81 const tokens = command.slice(0, 4096).match(/"[^"]*"|'[^']*'|[^\s;&|<>()]+/g) ?? []
82 return tokens
83 .map(token => token.replace(/^["']|["']$/g, ''))
84 .filter(token => !token.startsWith('-') && /[\\/.]/.test(token) && !/^[a-z]+:\/\//i.test(token) && !token.includes('='))
85}
86
87const DESTRUCTIVE_COMMAND = new RegExp(
88 [
89 String.raw`\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)`,
90 String.raw`\bRemove-Item\b.*-Recurse`,
91 String.raw`\bgit\s+push\b.*(\s--force\b|\s-f\b|\s--force-with-lease\b|\s\+)`,
92 String.raw`\bgit\s+reset\s+--hard\b`,
93 String.raw`\bgit\s+clean\s+-[a-zA-Z]*f`,
94 String.raw`\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)`,
95 String.raw`\b(drop|truncate)\s+(table|database|schema)\b`,
96 String.raw`\bdelete\s+from\b`,
97 String.raw`\b(prisma\s+migrate|db:migrate|alembic\s+upgrade|manage\.py\s+migrate|knex\s+migrate|sequelize\s+db:migrate|flyway\s+migrate)\b`,
98 String.raw`\b(npm|pnpm|yarn)\s+publish\b`,
99 String.raw`\bterraform\s+(apply|destroy)\b`,
100 String.raw`\bkubectl\s+(delete|apply|replace)\b`,
101 String.raw`\b(mkfs|fdisk|diskpart)\b`,
102 String.raw`\bdd\s+.*\bof=`,
103 String.raw`\bchmod\s+-R\b`,
104 String.raw`\b(deploy|vercel\s+--prod|fly\s+deploy|heroku\s+releases)\b`,
105 ].join('|'),
106 'i',
107)
108
109const NETWORK_COMMAND =
110 /\b(curl|wget|ssh|scp|rsync|ftp|nc|Invoke-WebRequest|iwr|git\s+(push|pull|fetch|clone)|(npm|pnpm|yarn|bun)\s+(install|add|i|ci)|pip3?\s+install|cargo\s+(install|add)|go\s+get|docker\s+(pull|push))\b/i
111
112const READ_ONLY_COMMAND =
113 /^\s*(ls|dir|pwd|cat|head|tail|less|wc|echo|which|where|type|file|stat|du|df|tree|grep|rg|find|git\s+(status|diff|log|show|branch|rev-parse|remote\s+-v|blame)|node\s+(-v|--version)|npm\s+(ls|list|view|-v|--version)|Get-ChildItem|Get-Content|Get-Location)\b/i
114
115const COMMAND_SEPARATOR = /&&|\|\||;|\|/
116
117/** A coarse label for how consequential a call can be. Not a security boundary. */
118export function classifyRisk(tool: string, command?: string): RiskLevel {
119 if (command !== undefined) {
120 if (DESTRUCTIVE_COMMAND.test(command)) return 'destructive'
121 if (NETWORK_COMMAND.test(command)) return 'network'
122 const parts = command.split(COMMAND_SEPARATOR)
123 const isReadOnly =
124 !/[^2]>|>>|\bsudo\b|-delete\b|-exec\b/.test(command) && parts.every(part => READ_ONLY_COMMAND.test(part))
125 return isReadOnly ? 'read' : 'exec'
126 }
127 switch (tool) {
128 case 'Read':
129 case 'Glob':
130 case 'Grep':
131 case 'LS':
132 case 'NotebookRead':
133 case 'LSP':
134 return 'read'
135 case 'Edit':
136 case 'Write':
137 case 'MultiEdit':
138 case 'NotebookEdit':
139 return 'write'
140 case 'WebFetch':
141 case 'WebSearch':
142 return 'network'
143 case 'Agent':
144 case 'Task':
145 return 'exec'
146 default:
147 return 'unknown'
148 }
149}
150
151export function truncate(text: string, max: number): string {
152 return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
153}
154
155function oneLine(text: string): string {
156 return text.replace(/\s*\n\s*/g, ' ⏎ ').trim()
157}
158
159function clean(text: string, options: SummaryOptions): string {
160 return options.redaction ? redactString(text) : text
161}
162
163/** The tool's arguments as the inspector shows them: redacted, truncated, file contents omitted. */
164export function sanitizeInput(call: NormalizedCall, options: SummaryOptions): Record<string, unknown> {
165 const omitted = options.captureRaw ? [] : (CONTENT_FIELDS[call.tool] ?? [])
166 const out: Record<string, unknown> = {}
167 for (const [key, value] of Object.entries(call.args)) {
168 if (omitted.includes(key)) {
169 out[key] = `<${describeSize(value)} omitted>`
170 continue
171 }
172 out[key] = shrink(value, options.maxChars * 4, 0)
173 }
174 return (options.redaction ? redactValue(out) : out) as Record<string, unknown>
175}
176
177function describeSize(value: unknown): string {
178 if (typeof value === 'string') return `${value.length} chars`
179 if (Array.isArray(value)) return `${value.length} items`
180 return 'value'
181}
182
183function shrink(value: unknown, max: number, depth: number): unknown {
184 if (typeof value === 'string') return truncate(value, max)
185 if (value === null || typeof value !== 'object') return value
186 if (depth >= 4) return '[…]'
187 if (Array.isArray(value)) {
188 const items = value.slice(0, 20).map(item => shrink(item, max, depth + 1))
189 return value.length > 20 ? [...items, `… ${value.length - 20} more`] : items
190 }
191 const out: Record<string, unknown> = {}
192 for (const [key, item] of Object.entries(value).slice(0, 40)) out[key] = shrink(item, max, depth + 1)
193 return out
194}
195
196/** One line describing the call for the timeline. */
197export function summarizeInput(call: NormalizedCall, options: SummaryOptions): string {
198 const a = call.args
199 const str = (key: string): string => (typeof a[key] === 'string' ? (a[key] as string) : '')
200 let text: string
201 if (call.command !== undefined) {
202 text = call.command + (a.run_in_background === true ? ' (background)' : '')
203 } else {
204 switch (call.tool) {
205 case 'Read':
206 text = str('file_path') + (a.offset !== undefined || a.limit !== undefined ? ` [${String(a.offset ?? 0)}+${String(a.limit ?? '')}]` : '')
207 break
208 case 'Edit':
209 text = `${str('file_path')} (${str('old_string').length} → ${str('new_string').length} chars${a.replace_all === true ? ', all' : ''})`
210 break
211 case 'Write':
212 text = `${str('file_path')} (${str('content').length} chars)`
213 break
214 case 'NotebookEdit':
215 text = `${str('notebook_path')} (${str('edit_mode') || 'replace'})`
216 break
217 case 'Glob':
218 text = str('pattern') + (str('path') ? ` in ${str('path')}` : '')
219 break
220 case 'Grep':
221 text = `/${str('pattern')}/` + (str('path') ? ` in ${str('path')}` : '') + (str('glob') ? ` glob ${str('glob')}` : '')
222 break
223 case 'WebFetch':
224 text = str('url')
225 break
226 case 'WebSearch':
227 text = str('query')
228 break
229 case 'Agent':
230 case 'Task':
231 text = `${str('subagent_type') || 'agent'}: ${str('description')}`
232 break
233 default:
234 text = JSON.stringify(sanitizeInput(call, { ...options, redaction: false })) ?? ''
235 }
236 }
237 return truncate(oneLine(clean(text, options)), options.maxChars)
238}
239
240/** A loose view of `next(e)`'s answer: the ToolCallResult arms, read without trusting shapes. */
241export type ResultLike = {
242 deny?: string
243 result?: unknown
244 text?: string
245 isError?: true
246 isReadOnly?: true
247}
248
249function lineCount(text: string): number {
250 return text === '' ? 0 : text.replace(/\n$/, '').split('\n').length
251}
252
253/** One line describing what came back, with no file contents. */
254export function summarizeResult(tool: string, result: ResultLike, options: SummaryOptions): string {
255 if (typeof result.deny === 'string') return truncate(oneLine(`denied: ${clean(result.deny, options)}`), options.maxChars)
256 if (result.isError === true) {
257 const text = typeof result.text === 'string' ? result.text : String(result.result ?? '')
258 return truncate(oneLine(`error: ${clean(text, options)}`), options.maxChars)
259 }
260 const record = result.result !== null && typeof result.result === 'object' ? (result.result as Record<string, unknown>) : {}
261 const parts: string[] = []
262 if (SHELL_TOOLS.includes(tool) && typeof record.stdout === 'string') {
263 parts.push(`stdout ${lineCount(record.stdout)} lines`, `stderr ${lineCount(String(record.stderr ?? ''))} lines`)
264 if (record.interrupted === true) parts.push('interrupted')
265 if (typeof record.backgroundTaskId === 'string') parts.push(`background ${record.backgroundTaskId}`)
266 } else if (typeof record.numFiles === 'number') {
267 parts.push(`${record.numFiles} files`)
268 if (typeof record.numLines === 'number') parts.push(`${record.numLines} lines`)
269 } else if (typeof result.text === 'string') {
270 parts.push(`${lineCount(result.text)} lines`, `${result.text.length} chars`)
271 } else if (typeof result.result === 'string') {
272 parts.push(`${result.result.length} chars`)
273 } else {
274 parts.push('ok')
275 }
276 if (result.isReadOnly === true) parts.push('read-only')
277 return truncate(parts.join(', '), options.maxChars)
278}
279
280/** The error text the inspector shows: redacted, longer than a summary. */
281export function errorTextOf(result: ResultLike, options: SummaryOptions): string | undefined {
282 const text = typeof result.deny === 'string' ? result.deny : result.isError === true ? (result.text ?? String(result.result ?? '')) : undefined
283 return text === undefined ? undefined : truncate(clean(text, options), options.maxChars * 4)
284}
285
286/** Raw capture (opt-in): the input and the result text, redacted unless redaction is off. */
287export function rawOf(call: NormalizedCall, result: ResultLike | undefined, options: SummaryOptions): { input: string; result?: string } {
288 const limit = 20_000
289 const input = truncate(clean(JSON.stringify(call.args) ?? '', options), limit)
290 const text = result === undefined ? undefined : (result.deny ?? result.text ?? (typeof result.result === 'string' ? result.result : JSON.stringify(result.result)))
291 return { input, result: text === undefined ? undefined : truncate(clean(text, options), limit) }
292}
293src/core/lens.ts 716 lines1// Error Lens: why a tool call failed, from evidence. Deterministic and pure.
2// Every cause carries a certainty: `confirmed` when the result itself, a
3// permission verdict or a file system fact shows it; `possible` when it is a
4// known explanation the evidence does not prove; `unknown` for what no
5// observation here can settle. Nothing is guessed beyond the matched evidence.
6
7import type {
8 Certainty,
9 ErrorCategory,
10 ErrorGroup,
11 LensCause,
12 LensProbe,
13 LensRecord,
14 PermissionInfo,
15 TraceOutcome,
16 TraceStatus,
17} from '../../types'
18import { SHELL_TOOLS, truncate } from './events.ts'
19
20export const MAX_MESSAGE = 3000
21export const MAX_LENS = 80
22export const MAX_GROUPS = 50
23const MAX_GROUP_IDS = 20
24
25type Advice = { category: ErrorCategory; meaning: string; possible?: string[]; unknown?: string[]; fixes: string[] }
26
27const LOCKS_UNKNOWN = 'Permission bits, ownership, attributes and file locks are not visible to mods ($.fs.stat reports none), so they could not be checked.'
28
29/** errno-style codes: what the OS said, and what usually explains it. */
30const CODES: Readonly<Record<string, Advice>> = {
31 ENOENT: {
32 category: 'not-found',
33 meaning: 'a path does not exist',
34 possible: ['The path is misspelled, or relative to a different working directory than expected.', 'A parent directory has not been created yet.'],
35 fixes: ['Check the path and the working directory (pwd).', 'Create missing parent directories first (mkdir -p).'],
36 },
37 EACCES: {
38 category: 'access-denied',
39 meaning: 'access was refused',
40 possible: ['This user lacks permission on the file or a parent directory.', 'On Windows: the file is read-only, or another program (editor, antivirus, sync client) holds it.'],
41 unknown: [LOCKS_UNKNOWN],
42 fixes: ['Check permissions (ls -l, or icacls on Windows).', 'Close programs that may hold the file, then retry.', 'Clear a read-only attribute (chmod u+w, or attrib -r on Windows).'],
43 },
44 EPERM: {
45 category: 'not-permitted',
46 meaning: 'the operation is not permitted',
47 possible: ['The file is read-only, locked, or owned by another user.', 'On Windows: another program holds the file, or the location is protected.', "Claude Code's sandbox restricts this operation (sandboxed Bash only)."],
48 unknown: [LOCKS_UNKNOWN],
49 fixes: ['Check permissions and attributes; close programs holding the file.', 'For sandboxed Bash, allow the path in the sandbox settings or run outside the sandbox.'],
50 },
51 EISDIR: { category: 'is-directory', meaning: 'the path is a directory where a file was expected', fixes: ['Use a path to a file inside the directory.'] },
52 ENOTDIR: { category: 'not-directory', meaning: 'a component of the path is not a directory', fixes: ['Check each part of the path: one of them is a file.'] },
53 EEXIST: { category: 'already-exists', meaning: 'the path already exists', fixes: ['Use another name, or remove the existing path if that is intended.'] },
54 EBUSY: {
55 category: 'busy',
56 meaning: 'the file or resource is busy or locked',
57 possible: ['Another process holds it open (an editor, a dev server, antivirus, a sync client).'],
58 unknown: [LOCKS_UNKNOWN],
59 fixes: ['Close the program holding it, then retry.'],
60 },
61 ENOSPC: { category: 'no-space', meaning: 'the device has no space left', fixes: ['Free disk space, then retry.'] },
62 EROFS: { category: 'read-only-fs', meaning: 'the file system is read-only', fixes: ['Write somewhere writable, or remount the volume read-write.'] },
63 EMFILE: { category: 'too-many-files', meaning: 'the process has too many open files', fixes: ['Close file handles or raise the open-file limit (ulimit -n).'] },
64 ENFILE: { category: 'too-many-files', meaning: 'the system has too many open files', fixes: ['Close programs, or raise the system file limit.'] },
65 ENAMETOOLONG: { category: 'name-too-long', meaning: 'the path or a name in it is too long', fixes: ['Shorten the path; on Windows, enable long paths or move the project higher up.'] },
66 ETIMEDOUT: { category: 'timeout', meaning: 'an operation timed out', possible: ['A slow or unreachable network, or a host that does not answer.'], fixes: ['Check connectivity, then retry.'] },
67 ECONNREFUSED: { category: 'network', meaning: 'the connection was refused', possible: ['Nothing listens on that host and port (a server not started?).'], fixes: ['Start the server, or check the host and port.'] },
68 ECONNRESET: { category: 'network', meaning: 'the connection was reset by the other side', fixes: ['Retry; check proxies and the remote service.'] },
69 ENOTFOUND: { category: 'network', meaning: 'a host name did not resolve', fixes: ['Check the host name, DNS and connectivity.'] },
70 EAI_AGAIN: { category: 'network', meaning: 'a host name lookup failed temporarily', fixes: ['Check DNS and connectivity, then retry.'] },
71 EHOSTUNREACH: { category: 'network', meaning: 'the host is unreachable', fixes: ['Check the network route, VPN or firewall.'] },
72 ENETUNREACH: { category: 'network', meaning: 'the network is unreachable', fixes: ['Check the network connection, VPN or firewall.'] },
73}
74
75type TextRule = Advice & { pattern: RegExp; cause: string; mcpOnly?: boolean }
76
77/** Messages of Claude Code's tools and of common programs, most specific first. */
78const TEXT_RULES: readonly TextRule[] = [
79 {
80 category: 'stale-read',
81 pattern: /modified since (?:it was )?(?:last )?read/i,
82 meaning: '',
83 cause: 'The file changed on disk after Claude last read it, so Claude Code refused to overwrite it.',
84 possible: ['You, an editor, a formatter or linter, a file watcher, or another tool call changed the file in between.'],
85 unknown: ['Which process changed the file: mods cannot see other processes.'],
86 fixes: ['Read the file again, then retry the change.', 'If a formatter or watcher rewrites the file, wait for it or pause it while Claude edits.'],
87 },
88 {
89 category: 'not-read-yet',
90 pattern: /has not been read yet|read it first/i,
91 meaning: '',
92 cause: 'Claude Code requires a file to be read in this conversation before it is changed, and it had not been.',
93 fixes: ['Read the file first, then retry.'],
94 },
95 {
96 category: 'edit-mismatch',
97 pattern: /found \d+ matches of the string to replace|replace_all is false/i,
98 meaning: '',
99 cause: 'The text to replace occurs more than once, and replace_all was not set.',
100 fixes: ['Include more surrounding context so the text is unique, or set replace_all.'],
101 },
102 {
103 category: 'edit-mismatch',
104 pattern: /string to replace (?:was )?not found|old_string .{0,40}not found|could not find .{0,60}to replace/i,
105 meaning: '',
106 cause: 'The exact text to replace was not found in the file.',
107 possible: ['The file changed since it was read.', 'Whitespace, indentation or line endings differ from what Claude expected.'],
108 fixes: ['Read the file again and copy the exact text, including whitespace.'],
109 },
110 {
111 category: 'input-invalid',
112 pattern: /no changes to make|old_string and new_string are (?:exactly )?the same/i,
113 meaning: '',
114 cause: 'The edit would change nothing: the old and new text are the same.',
115 fixes: ['Nothing to fix in the file: the edit was a no-op.'],
116 },
117 {
118 category: 'too-large',
119 pattern: /exceeds? (?:the )?maximum (?:allowed )?(?:tokens|size|length)|too large to (?:read|process)|file (?:content|is) too (?:large|big)/i,
120 meaning: '',
121 cause: 'The file or input exceeds a size limit of the tool.',
122 fixes: ['Read it in parts (offset and limit), or search it with Grep.'],
123 },
124 {
125 category: 'input-invalid',
126 pattern: /InputValidationError|invalid (?:input|parameters?|arguments?)|required (?:parameter|property)|is not a valid|expected .{1,40} (?:but )?received/i,
127 meaning: '',
128 cause: 'The tool rejected its arguments as invalid.',
129 fixes: ["Check the arguments against the tool's schema."],
130 },
131 {
132 category: 'timeout',
133 pattern: /timed out|time ?out (?:after|exceeded)|deadline exceeded/i,
134 meaning: '',
135 cause: 'The operation ran longer than its time limit.',
136 possible: ['The command waited for input, a lock or a slow network, or the job is simply long.'],
137 fixes: ['Raise the timeout or run it in the background (run_in_background).', 'Make the command non-interactive (--yes, CI=1).'],
138 },
139 {
140 category: 'command-not-found',
141 pattern: /: command not found|is not recognized as an internal or external command|not recognized as the name of a cmdlet/i,
142 meaning: '',
143 cause: 'The shell could not find the program to run.',
144 possible: ['It is not installed, or not on PATH in the shell Claude Code uses.'],
145 fixes: ['Install the program, or call it by its full path.', 'Check PATH in the shell Claude Code runs (echo $PATH).'],
146 },
147 {
148 category: 'not-found',
149 pattern: /file does not exist|no such file or directory|cannot find the (?:file|path) specified|path not found|directory does not exist/i,
150 meaning: '',
151 cause: 'The tool reported that a path does not exist.',
152 possible: ['The path is misspelled, or relative to a different working directory than expected.'],
153 fixes: ['Check the path; Glob for the file name to find where it is.'],
154 },
155 {
156 category: 'access-denied',
157 pattern: /permission denied|access is denied|access denied/i,
158 meaning: '',
159 cause: 'The operating system or the program refused access.',
160 possible: ['This user lacks permission, or the file is read-only or held by another program.'],
161 unknown: [LOCKS_UNKNOWN],
162 fixes: ['Check permissions; close programs holding the file; retry.'],
163 },
164 {
165 category: 'not-permitted',
166 pattern: /operation not permitted/i,
167 meaning: '',
168 cause: 'The operating system did not permit the operation.',
169 possible: ["The file is read-only or locked, or Claude Code's sandbox restricts it."],
170 unknown: [LOCKS_UNKNOWN],
171 fixes: ['Check permissions and the sandbox settings.'],
172 },
173 {
174 category: 'busy',
175 pattern: /resource busy or locked|being used by another process|file is locked/i,
176 meaning: '',
177 cause: 'The file is in use by another process.',
178 unknown: [LOCKS_UNKNOWN],
179 fixes: ['Close the program holding it, then retry.'],
180 },
181 { category: 'no-space', pattern: /no space left on device|disk (?:is )?full/i, meaning: '', cause: 'The disk is full.', fixes: ['Free disk space, then retry.'] },
182 { category: 'read-only-fs', pattern: /read-only file system/i, meaning: '', cause: 'The file system is read-only.', fixes: ['Write somewhere writable.'] },
183 {
184 category: 'network',
185 pattern: /could not resolve host|connection refused|network is unreachable|getaddrinfo|socket hang up|fetch failed|\bssl\b|certificate (?:has expired|verify failed|is not trusted)|self[- ]signed certificate/i,
186 meaning: '',
187 cause: 'A network request failed.',
188 possible: ['No connectivity, a proxy or firewall, a wrong host, or a server that is down.'],
189 fixes: ['Check connectivity and the URL, then retry.'],
190 },
191 {
192 category: 'mcp',
193 pattern: /MCP error|MCP server|not connected|disconnected|server .{0,40}(?:unavailable|failed to start|not running)/i,
194 meaning: '',
195 cause: 'The MCP server behind this tool reported an error or is not connected.',
196 possible: ['The server crashed, failed to start, or lost its authentication.'],
197 fixes: ['Check the server in /mcp; reconnect or re-authenticate it.'],
198 mcpOnly: true,
199 },
200]
201
202const EXIT_MEANING: Readonly<Record<number, string>> = {
203 124: 'Exit code 124 usually means a `timeout` wrapper stopped the command.',
204 126: 'Exit code 126 usually means the program was found but is not executable.',
205 127: 'Exit code 127 usually means the shell could not find the program.',
206 130: 'Exit code 130 usually means the command was interrupted (Ctrl+C, SIGINT).',
207 137: 'Exit code 137 usually means the process was killed (SIGKILL), often for running out of memory.',
208 143: 'Exit code 143 usually means the process was terminated (SIGTERM).',
209}
210
211const USER_REJECTED = /doesn't want to proceed|tool use was rejected|user (?:rejected|denied|declined)/i
212
213export type DiagnoseInput = {
214 tool: string
215 outcome: TraceOutcome
216 /** The error text as Claude read it, already redacted. */
217 text: string
218 /** The tool's own result record, when there is one (Bash: interrupted). */
219 result?: unknown
220 args: Readonly<Record<string, unknown>>
221 permission?: PermissionInfo
222 durationMs?: number
223 suspected?: boolean
224}
225
226export type Diagnosis = {
227 category: ErrorCategory
228 code?: string
229 exitCode?: number
230 headline: string
231 causes: LensCause[]
232 fixes: string[]
233 mentionedPaths: string[]
234}
235
236/** The text a result carries as its error, whatever its shape. */
237export function errorTextOfResult(result: { deny?: string; text?: string; result?: unknown }): string {
238 if (typeof result.deny === 'string') return result.deny
239 if (typeof result.text === 'string' && result.text !== '') return result.text
240 if (typeof result.result === 'string') return result.result
241 try {
242 return JSON.stringify(result.result) ?? ''
243 } catch {
244 return ''
245 }
246}
247
248function lines(text: string): string[] {
249 return text
250 .replace(/<\/?tool_use_error>/g, '')
251 .split(/\r?\n/)
252 .map(line => line.trim())
253 .filter(line => line !== '')
254}
255
256/** The first meaningful line: not an `Exit code N` line, not a tag. */
257export function headlineOf(text: string): string {
258 const all = lines(text)
259 const line = all.find(one => !/^exit code:? \d+$/i.test(one)) ?? all[0] ?? '(no error text)'
260 return truncate(line, 160)
261}
262
263/** The line of `text` that matched, as a quoted excerpt. */
264function excerpt(text: string, pattern: RegExp): string | undefined {
265 const line = lines(text).find(one => pattern.test(one))
266 return line === undefined ? undefined : `"${truncate(line, 180)}"`
267}
268
269const PATH_QUOTED = /'([^'\n]{2,300})'|"([^"\n]{2,300})"/g
270const PATH_BARE = /(?:^|[\s(=])((?:[A-Za-z]:)?[\\/][^\s:'"(),]{1,300}|\.{1,2}[\\/][^\s:'"(),]{1,300})/g
271
272/** Paths an error names (quoted, absolute or ./relative), at most three. */
273export function pathsInText(text: string): string[] {
274 const found: string[] = []
275 const add = (candidate: string | undefined): void => {
276 if (candidate === undefined) return
277 const value = candidate.trim().replace(/[.,;:]+$/, '')
278 if (value.length < 2 || value.length > 1024 || /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || value.includes('[REDACTED]')) return
279 if (!/[\\/]/.test(value) && !/\.[a-z0-9]{1,8}$/i.test(value)) return
280 if (!found.includes(value)) found.push(value)
281 }
282 for (const match of text.matchAll(PATH_QUOTED)) add(match[1] ?? match[2])
283 for (const match of text.matchAll(PATH_BARE)) add(match[1])
284 return found.slice(0, 3)
285}
286
287function cause(certainty: Certainty, text: string, evidence: Array<string | undefined> = []): LensCause {
288 return { certainty, text, evidence: evidence.filter((item): item is string => item !== undefined && item !== '') }
289}
290
291function fromAdvice(advice: Advice, first: LensCause, out: { causes: LensCause[]; fixes: string[] }): void {
292 out.causes.push(first)
293 for (const text of advice.possible ?? []) out.causes.push(cause('possible', text))
294 for (const text of advice.unknown ?? []) out.causes.push(cause('unknown', text))
295 out.fixes.push(...advice.fixes)
296}
297
298function exitCodeOf(text: string): number | undefined {
299 const match = /(?:^|\n)\s*exit code:? (\d{1,3})\b|exited with (?:code|status) (\d{1,3})\b/i.exec(text)
300 const value = match?.[1] ?? match?.[2]
301 return value === undefined ? undefined : Number(value)
302}
303
304function isWindowsPath(value: unknown): boolean {
305 return typeof value === 'string' && (/^[a-zA-Z]:[\\/]/.test(value) || value.includes('\\'))
306}
307
308/**
309 * Classifies one failure. Order: what DevTools itself or the permission
310 * system decided (known for certain), errno codes in the text, the tools'
311 * own messages, shell exit codes, then unknown.
312 */
313export function diagnose(input: DiagnoseInput): Diagnosis {
314 const { text, tool, outcome } = input
315 const headline = headlineOf(text)
316 const mentionedPaths = pathsInText(text)
317 const quoted = text.trim() === '' ? undefined : `"${truncate(headline, 180)}"`
318 const out: { causes: LensCause[]; fixes: string[] } = { causes: [], fixes: [] }
319 const done = (category: ErrorCategory, extra: Partial<Diagnosis> = {}): Diagnosis => ({
320 category,
321 headline,
322 causes: out.causes,
323 fixes: [...new Set(out.fixes)],
324 mentionedPaths,
325 ...extra,
326 })
327
328 switch (outcome) {
329 case 'debugger-rejected':
330 out.causes.push(cause('confirmed', 'You rejected this call at a Claude DevTools breakpoint, so it never ran.', [quoted]))
331 out.fixes.push('Nothing failed: Claude was told the call was rejected. Disable the breakpoint if it should run.')
332 return done('debugger')
333 case 'user-cancelled':
334 out.causes.push(cause('confirmed', 'The Claude DevTools pause question was dismissed, so the call never ran.', [quoted]))
335 out.fixes.push('Answer the question with Continue to let such a call run.')
336 return done('debugger')
337 case 'headless-rejected':
338 out.causes.push(cause('confirmed', 'A Claude DevTools pause breakpoint matched in a headless session, where nobody can answer, so the call was refused.', [quoted]))
339 out.fixes.push('Set the devtools option headlessPause to "record-only", or disable the breakpoint for headless runs.')
340 return done('debugger')
341 case 'guard-failed':
342 out.causes.push(cause('confirmed', "Claude DevTools' own breakpoint guard failed, so it refused the call to keep the breakpoint's promise.", [quoted]))
343 out.fixes.push('Retry; if it repeats, run claude --debug and look for devtools lines.')
344 return done('debugger')
345 case 'simulated':
346 out.causes.push(cause('confirmed', 'Claude DevTools answered with a simulated failure; the real tool never ran.', [quoted]))
347 out.fixes.push('Nothing to fix: this failure was injected on purpose.')
348 return done('simulated')
349 case 'aborted':
350 out.causes.push(cause('confirmed', 'The call was interrupted (Esc, or an abort) before it finished.', [quoted]))
351 out.fixes.push('Rerun it if it is still needed.')
352 return done('interrupted')
353 case 'permission-denied': {
354 const p = input.permission
355 if (p?.decision === 'deny') {
356 out.causes.push(
357 cause('confirmed', `Claude Code's permission check denied this call${p.rule !== undefined ? ` by the rule ${p.rule}` : ''}.`, [
358 `tool.check verdict: deny (${p.source})`,
359 p.reason,
360 ]),
361 )
362 out.fixes.push(`If it should run, change the rule${p.rule !== undefined ? ` ${p.rule}` : ''} in your settings (/permissions).`)
363 } else if (USER_REJECTED.test(text)) {
364 out.causes.push(cause('confirmed', 'The permission prompt for this call was answered with a rejection.', [excerpt(text, USER_REJECTED)]))
365 out.fixes.push('Approve the call when prompted, or add an allow rule in /permissions.')
366 } else {
367 out.causes.push(cause('possible', 'A permission rule or the permission prompt refused the call; the message reads like a permission denial.', [quoted]))
368 out.causes.push(cause('unknown', 'Which rule refused it: no deny verdict was observed for this call.'))
369 out.fixes.push('Check /permissions for a deny or ask rule matching this tool.')
370 }
371 return done('permission-denied')
372 }
373 case 'blocked-by-hook':
374 out.causes.push(cause('confirmed', 'A settings hook or another mod beneath Claude DevTools refused the call before it ran.', [quoted]))
375 out.causes.push(cause('unknown', 'Which hook or mod refused it: the result does not name it (claude --debug logs the plugin that denied).'))
376 out.fixes.push('Read the refusal text; review /hooks and the mods in /plugin.')
377 return done('blocked-by-hook')
378 default:
379 break
380 }
381
382 const exitCode = SHELL_TOOLS.includes(tool) ? exitCodeOf(text) : undefined
383 const exitEvidence = exitCode === undefined ? undefined : `exit code ${exitCode}`
384
385 if (input.suspected === true) {
386 out.causes.push(cause('possible', 'The tool reported success, but its output begins like an error message.', [quoted]))
387 out.causes.push(cause('unknown', 'Whether the call really failed: the tool did not flag it as an error.'))
388 out.fixes.push('Read the output to decide whether the call did what was intended.')
389 return done('suspected')
390 }
391
392 const code = /\b(E[A-Z_]{2,14})\b/g
393 for (const match of text.matchAll(code)) {
394 const name = match[1] as string
395 const advice = CODES[name]
396 if (advice === undefined) continue
397 // The sandbox restricts Bash alone: the hint does not apply to the file tools.
398 const applicable = SHELL_TOOLS.includes(tool) ? advice : { ...advice, possible: (advice.possible ?? []).filter(text => !text.includes('sandbox')) }
399 fromAdvice(applicable, cause('confirmed', `The operating system reported ${name}: ${advice.meaning}.`, [excerpt(text, new RegExp(`\\b${name}\\b`)), exitEvidence]), out)
400 return done(advice.category, { code: name, ...(exitCode !== undefined ? { exitCode } : {}) })
401 }
402
403 for (const rule of TEXT_RULES) {
404 if (rule.mcpOnly === true && !tool.startsWith('mcp__')) continue
405 if (!rule.pattern.test(text)) continue
406 const possible = [...(rule.possible ?? [])]
407 if (rule.category === 'edit-mismatch' && isWindowsPath(input.args.file_path)) possible.push('The file uses Windows line endings (CRLF) where the text to replace has LF.')
408 fromAdvice({ ...rule, possible }, cause('confirmed', rule.cause, [excerpt(text, rule.pattern), exitEvidence]), out)
409 return done(rule.category, exitCode !== undefined ? { exitCode } : {})
410 }
411
412 if (exitCode !== undefined) {
413 out.causes.push(cause('confirmed', `The command exited with code ${exitCode}.`, [excerpt(text, /exit code|exited with/i)]))
414 const meaning = EXIT_MEANING[exitCode]
415 if (meaning !== undefined) out.causes.push(cause('possible', meaning))
416 else out.causes.push(cause('unknown', "Why: no known signature matched; the command's own output (below) is the evidence."))
417 addTimeoutHint(input, out)
418 out.fixes.push('Read the command output below; run the command yourself to reproduce it.')
419 return done('exit-code', { exitCode })
420 }
421
422 if (tool.startsWith('mcp__')) {
423 out.causes.push(cause('confirmed', 'The MCP server returned an error for this tool.', [quoted]))
424 out.causes.push(cause('unknown', 'Why: the message matches no known signature; it is the server\'s own.'))
425 out.fixes.push('Read the server\'s message below; check the server in /mcp.')
426 return done('mcp')
427 }
428
429 out.causes.push(cause('unknown', 'No known error signature matched, so the cause cannot be determined from the result alone.', [quoted]))
430 addTimeoutHint(input, out)
431 out.fixes.push('Read the original error below.', 'Reproduce the call by hand to see more detail.')
432 return done('unknown')
433}
434
435/** A shell call that failed after running about as long as its limit. */
436function addTimeoutHint(input: DiagnoseInput, out: { causes: LensCause[]; fixes: string[] }): void {
437 if (!SHELL_TOOLS.includes(input.tool) || input.durationMs === undefined) return
438 const limit = typeof input.args.timeout === 'number' ? input.args.timeout : 120_000
439 if (input.durationMs >= limit - 1000) {
440 out.causes.push(cause('possible', `It ran for ${Math.round(input.durationMs / 1000)}s, about its time limit of ${Math.round(limit / 1000)}s, so it may have been stopped for time.`))
441 }
442}
443
444/** Masks paths, quoted text and numbers so repeats of one failure share a signature. */
445export function signatureOf(tool: string, category: ErrorCategory, headline: string): string {
446 const masked = headline
447 .replace(/'[^']*'|"[^"]*"/g, '…')
448 .replace(/(?:[A-Za-z]:)?[\\/][^\s:]+|\S+[\\/]\S+/g, '<path>')
449 .replace(/\d+/g, '#')
450 .toLowerCase()
451 return truncate(`${tool}|${category}|${masked}`, 140)
452}
453
454/**
455 * Whether an MCP call the server reported as successful reads like a failure.
456 * Built-in tools flag their own errors, so only MCP servers, which sometimes
457 * answer an error as plain text, are judged this way.
458 */
459export function looksLikeFailure(tool: string, result: { deny?: string; isError?: true; text?: string; result?: unknown }): boolean {
460 if (typeof result.deny === 'string' || result.isError === true || !tool.startsWith('mcp__')) return false
461 const text = typeof result.text === 'string' ? result.text : typeof result.result === 'string' ? result.result : ''
462 return /^\s*(?:error|failed|failure|exception|traceback|fatal)\b[\s:!]/i.test(text.slice(0, 200))
463}
464
465const FILE_TOOLS: readonly string[] = ['Write', 'Edit', 'MultiEdit', 'Read', 'NotebookEdit']
466const DIR_TOOLS: readonly string[] = ['Grep', 'Glob']
467const NO_PROBE: readonly ErrorCategory[] = ['debugger', 'simulated', 'interrupted', 'permission-denied', 'blocked-by-hook', 'network', 'mcp', 'input-invalid', 'timeout']
468
469export type ProbeTarget = { path: string; role: LensProbe['role'] }
470
471export function parentOf(path: string): string | undefined {
472 const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
473 if (cut <= 0) return undefined
474 const parent = path.slice(0, cut)
475 return /^[A-Za-z]:$/.test(parent) ? `${parent}\\` : parent
476}
477
478/** The read-only checks worth making for a failure: its target, the target's parent, paths the error names. */
479export function probePlan(tool: string, category: ErrorCategory, args: Readonly<Record<string, unknown>>, mentioned: readonly string[]): ProbeTarget[] {
480 if (NO_PROBE.includes(category)) return []
481 const out: ProbeTarget[] = []
482 const add = (path: string | undefined, role: ProbeTarget['role']): boolean => {
483 // Skipped: network paths (a stat could reach out), globs, and spellings redaction or truncation changed (a stat would report a path nobody named).
484 if (path === undefined || path === '' || path.length > 1024 || /^[\\/]{2}/.test(path) || /[*?]/.test(path) || path.includes('[REDACTED]') || path.endsWith('…')) return false
485 if (!out.some(one => one.path === path)) out.push({ path, role })
486 return true
487 }
488 const field = (name: string): string | undefined => (typeof args[name] === 'string' ? (args[name] as string) : undefined)
489 const target = FILE_TOOLS.includes(tool) ? (field('file_path') ?? field('notebook_path')) : DIR_TOOLS.includes(tool) ? field('path') : undefined
490 // The parent of a path that could not be checked would be a guess.
491 if (add(target, 'target') && FILE_TOOLS.includes(tool)) add(parentOf(target as string), 'parent')
492 for (const path of mentioned) add(path, 'mentioned')
493 return out.slice(0, 5)
494}
495
496function clock(ms: number): string {
497 return new Date(ms).toISOString().slice(11, 19)
498}
499
500function size(bytes: number): string {
501 return bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
502}
503
504/** One line describing a probe, for the view and reports. */
505export function describeProbe(probe: LensProbe): string {
506 if (probe.exists === null) return `${probe.role} ${probe.path}: could not be checked (${probe.error ?? 'error'})`
507 if (!probe.exists) return `${probe.role} ${probe.path}: does not exist`
508 const parts = [probe.kind === 'dir' ? 'directory' : probe.kind ?? 'exists']
509 if (probe.kind === 'file' && probe.size !== undefined) parts.push(size(probe.size))
510 if (probe.mtimeMs !== undefined) parts.push(`modified ${clock(probe.mtimeMs)}`)
511 if (probe.isLink === true) parts.push(`link → ${probe.realPath ?? '?'}`)
512 return `${probe.role} ${probe.path}: ${parts.join(' · ')}`
513}
514
515/** Grace for file system clocks (FAT's 2 s granularity, a skew between the host clock and the file system's). */
516const MTIME_GRACE_MS = 2000
517
518/**
519 * What the read-only checks show. Facts about the files are `confirmed`
520 * (they were observed after the failure, and say so); what they suggest
521 * about the failure is `possible`.
522 */
523export function interpretProbes(
524 record: Pick<LensRecord, 'tool' | 'category' | 'startedAtMs' | 'endedAtMs' | 'contentBytes'>,
525 probes: readonly LensProbe[],
526): { causes: LensCause[]; fixes: string[] } {
527 const causes: LensCause[] = []
528 const fixes: string[] = []
529 const isWrite = record.tool === 'Write' || record.tool === 'NotebookEdit'
530 for (const probe of probes) {
531 const fact = describeProbe(probe)
532 if (probe.exists === null) {
533 causes.push(cause('confirmed', `Checking ${probe.path} after the failure also failed: ${probe.error ?? 'unknown error'}.`, [fact]))
534 continue
535 }
536 if (probe.role === 'target') {
537 if (!probe.exists) {
538 causes.push(cause('confirmed', isWrite ? `${probe.path} does not exist after the call: the write did not land.` : `${probe.path} does not exist.`, [fact]))
539 if (!isWrite) fixes.push('Check the path; Glob for the file name to find where it is.')
540 } else if (probe.kind === 'dir' && record.tool !== 'Grep' && record.tool !== 'Glob') {
541 causes.push(cause('confirmed', `${probe.path} is a directory, not a file.`, [fact]))
542 fixes.push('Use a path to a file.')
543 } else if (probe.kind === 'file' && probe.mtimeMs !== undefined) {
544 const changedDuring = probe.mtimeMs >= record.startedAtMs - MTIME_GRACE_MS
545 if (isWrite && changedDuring) {
546 causes.push(cause('confirmed', `${probe.path} exists and was modified at ${clock(probe.mtimeMs)}, during or right after this call.`, [fact]))
547 if (record.contentBytes !== undefined && probe.size === record.contentBytes) {
548 causes.push(cause('possible', `The write may have landed despite the reported error: the file's size (${probe.size} bytes) equals what Claude meant to write.`, [fact]))
549 fixes.push('Read the file to check its content before retrying the write.')
550 }
551 } else if (isWrite) {
552 causes.push(cause('confirmed', `${probe.path} exists but was last modified at ${clock(probe.mtimeMs)}, before this call: the call did not change it.`, [fact]))
553 } else if (record.category === 'stale-read') {
554 causes.push(cause('confirmed', `${probe.path} was last modified at ${clock(probe.mtimeMs)}${changedDuring ? ', during or after this call' : ''}.`, [fact]))
555 }
556 }
557 if (probe.isLink === true) causes.push(cause('possible', `${probe.path} is a symbolic link to ${probe.realPath ?? 'an unresolved target'}; the tool may have followed it somewhere unexpected.`, [fact]))
558 if (isWindowsPath(probe.path) && probe.path.length >= 260) {
559 causes.push(cause('possible', `The path is ${probe.path.length} characters, past Windows' classic 260-character limit (MAX_PATH).`, [fact]))
560 fixes.push('Enable long paths on Windows, or shorten the path.')
561 }
562 } else if (probe.role === 'parent') {
563 if (!probe.exists) {
564 causes.push(cause('confirmed', `The parent directory ${probe.path} does not exist.`, [fact]))
565 fixes.push(`Create it first: mkdir -p "${probe.path}"`)
566 } else if (probe.kind !== 'dir') {
567 causes.push(cause('confirmed', `${probe.path} is a file, so nothing can be created inside it.`, [fact]))
568 } else if (record.category === 'not-found') {
569 causes.push(cause('confirmed', 'The parent directory exists, so only the file itself is missing.', [fact]))
570 }
571 } else if (!probe.exists) {
572 causes.push(cause('confirmed', `${probe.path}, named in the error, does not exist now.`, [fact]))
573 } else {
574 causes.push(cause('possible', `${probe.path}, named in the error, exists now: it may have been created after the failure, or the error meant another location.`, [fact]))
575 }
576 }
577 return { causes, fixes }
578}
579
580/** Orders causes confirmed first, keeping each group's order, and drops exact repeats. */
581export function orderCauses(causes: readonly LensCause[]): LensCause[] {
582 const rank: Record<Certainty, number> = { confirmed: 0, possible: 1, unknown: 2 }
583 const seen = new Set<string>()
584 return causes
585 .filter(one => (seen.has(one.text) ? false : (seen.add(one.text), true)))
586 .map((one, index) => ({ one, index }))
587 .sort((a, b) => rank[a.one.certainty] - rank[b.one.certainty] || a.index - b.index)
588 .map(({ one }) => one)
589}
590
591const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g
592
593export type CaptureInput = {
594 id: string
595 seq: number
596 tool: string
597 agentId?: string
598 startedAtMs: number
599 endedAtMs: number
600 status: TraceStatus
601 outcome: TraceOutcome
602 suspected?: boolean
603 /** The error text, already redacted. */
604 text: string
605 result?: unknown
606 /** Sanitized arguments, as the timeline keeps them. */
607 args: Record<string, unknown>
608 paths: string[]
609 permission?: PermissionInfo
610 contentBytes?: number
611 probing: 'probe' | 'classify'
612}
613
614/** A diagnosed failure, ready to keep; its probes still to run when the plan has any. */
615export function buildLensRecord(input: CaptureInput): { record: LensRecord; plan: ProbeTarget[] } {
616 const durationMs = Math.max(0, input.endedAtMs - input.startedAtMs)
617 // Terminal color and cursor codes from shell output would garble the pane.
618 const text = input.text.replace(ANSI, '')
619 const diagnosis = diagnose({
620 tool: input.tool,
621 outcome: input.outcome,
622 text,
623 result: input.result,
624 args: input.args,
625 ...(input.permission !== undefined ? { permission: input.permission } : {}),
626 durationMs,
627 ...(input.suspected === true ? { suspected: true } : {}),
628 })
629 const plan = input.probing === 'probe' ? probePlan(input.tool, diagnosis.category, input.args, diagnosis.mentionedPaths) : []
630 const record: LensRecord = {
631 id: input.id,
632 seq: input.seq,
633 tool: input.tool,
634 ...(input.agentId !== undefined ? { agentId: input.agentId } : {}),
635 startedAtMs: input.startedAtMs,
636 endedAtMs: input.endedAtMs,
637 durationMs,
638 status: input.status,
639 outcome: input.outcome,
640 ...(input.suspected === true ? { suspected: true } : {}),
641 category: diagnosis.category,
642 ...(diagnosis.code !== undefined ? { code: diagnosis.code } : {}),
643 ...(diagnosis.exitCode !== undefined ? { exitCode: diagnosis.exitCode } : {}),
644 headline: diagnosis.headline,
645 message: truncate(text, MAX_MESSAGE),
646 signature: signatureOf(input.tool, diagnosis.category, diagnosis.headline),
647 args: input.args,
648 paths: input.paths,
649 ...(input.permission !== undefined ? { permission: input.permission } : {}),
650 ...(input.contentBytes !== undefined ? { contentBytes: input.contentBytes } : {}),
651 causes: orderCauses(diagnosis.causes),
652 fixes: diagnosis.fixes,
653 probes: [],
654 probeState: input.probing === 'classify' ? 'off' : plan.length === 0 ? 'none' : 'pending',
655 }
656 return { record, plan }
657}
658
659/** Folds probe results into a record: facts and what they suggest, causes reordered. */
660export function withProbes(record: LensRecord, probes: readonly LensProbe[]): LensRecord {
661 const found = interpretProbes(record, probes)
662 return {
663 ...record,
664 probes: [...probes],
665 probeState: 'done',
666 causes: orderCauses([...found.causes.filter(one => one.certainty === 'confirmed'), ...record.causes, ...found.causes.filter(one => one.certainty !== 'confirmed')]),
667 fixes: [...new Set([...found.fixes, ...record.fixes])],
668 }
669}
670
671/** Adds a failure to its group (by signature), newest group first; bounded. */
672export function addToGroups(groups: readonly ErrorGroup[], record: LensRecord, max = MAX_GROUPS): { groups: ErrorGroup[]; group: ErrorGroup } {
673 const existing = groups.find(group => group.signature === record.signature)
674 const group: ErrorGroup =
675 existing === undefined
676 ? { signature: record.signature, tool: record.tool, category: record.category, headline: record.headline, count: 1, firstMs: record.endedAtMs, lastMs: record.endedAtMs, ids: [record.id] }
677 : { ...existing, headline: record.headline, count: existing.count + 1, lastMs: record.endedAtMs, ids: [...existing.ids, record.id].slice(-MAX_GROUP_IDS) }
678 return { groups: [group, ...groups.filter(one => one.signature !== record.signature)].slice(0, max), group }
679}
680
681/** Whether a failure is worth a toast: the first of its group, then every fifth. */
682export function shouldNotify(group: ErrorGroup): boolean {
683 return group.count === 1 || group.count % 5 === 0
684}
685
686const CERTAINTY_LABEL: Record<Certainty, string> = { confirmed: 'CONFIRMED', possible: 'POSSIBLE', unknown: 'UNKNOWN' }
687
688/** A plain-text report of one failure: the commands' output and the Markdown export share it. */
689export function lensReport(record: LensRecord): string[] {
690 const out = [
691 `✗ ${record.tool} · ${record.category}${record.code !== undefined ? ` (${record.code})` : ''}${record.exitCode !== undefined ? ` · exit ${record.exitCode}` : ''} · ${new Date(record.endedAtMs).toISOString()}`,
692 ` error: ${record.headline}`,
693 ]
694 if (record.permission !== undefined) out.push(` permission: ${record.permission.decision}${record.permission.rule !== undefined ? ` by ${record.permission.rule}` : ''} (${record.permission.source})`)
695 for (const certainty of ['confirmed', 'possible', 'unknown'] as const) {
696 const items = record.causes.filter(one => one.certainty === certainty)
697 if (items.length === 0) continue
698 out.push(` ${CERTAINTY_LABEL[certainty]}`)
699 for (const item of items) {
700 out.push(` - ${item.text}`)
701 for (const evidence of item.evidence) out.push(` evidence: ${evidence}`)
702 }
703 }
704 if (record.probes.length > 0) {
705 out.push(' checks (read-only, after the failure)')
706 for (const probe of record.probes) out.push(` - ${describeProbe(probe)}`)
707 } else if (record.probeState === 'pending') {
708 out.push(' checks: running')
709 }
710 if (record.fixes.length > 0) {
711 out.push(' try')
712 record.fixes.forEach((fix, index) => out.push(` ${index + 1}. ${fix}`))
713 }
714 return out
715}
716src/core/recorder.ts 250 lines1// The bounded timeline and its exports: a ring buffer of trace events, a
2// versioned JSON export with a validator, and a readable Markdown report.
3
4import type { Breakpoint, Certainty, DevtoolsMode, DevtoolsStats, ErrorGroup, LensRecord, TraceEvent, TraceOutcome, TraceStatus } from '../../types'
5import { describeBreakpoint } from './breakpoints.ts'
6import { lensReport } from './lens.ts'
7
8export const TRACE_SCHEMA = 'claude-devtools.trace'
9/** 2: adds Error Lens (`errors`, `errorGroups`). */
10export const TRACE_SCHEMA_VERSION = 2
11/** Kept under $.fs.write's 4 MiB per file. */
12export const MAX_EXPORT_BYTES = 3_500_000
13
14/** Appends, dropping the oldest past `max`. */
15export function appendBounded<T>(list: readonly T[], item: T, max: number): T[] {
16 const next = [...list, item]
17 return next.length > max ? next.slice(next.length - max) : next
18}
19
20/** Applies a patch to one event by id; an id no longer in the ring is ignored. */
21export function patchEvent(list: readonly TraceEvent[], id: string, patch: Partial<TraceEvent>): TraceEvent[] {
22 return list.map(event => (event.id === id ? { ...event, ...patch } : event))
23}
24
25/** After a reload no hook still holds a call: what was pending or running is closed as aborted. */
26export function closeStale(list: readonly TraceEvent[]): TraceEvent[] {
27 return list.map(event =>
28 event.status === 'pending' || event.status === 'running'
29 ? { ...event, status: 'failed', outcome: 'aborted', errorText: 'Closed by Claude DevTools: the mod reloaded while this call was in flight.' }
30 : event,
31 )
32}
33
34export type TraceExport = {
35 schema: typeof TRACE_SCHEMA
36 schemaVersion: typeof TRACE_SCHEMA_VERSION
37 generator: { name: string; version: string }
38 exportedAt: string
39 sessionId: string
40 redaction: boolean
41 rawCapture: boolean
42 mode: DevtoolsMode
43 /** Events dropped to keep the file under the size limit, oldest first. */
44 droppedEvents: number
45 stats: DevtoolsStats
46 breakpoints: Array<Pick<Breakpoint, 'id' | 'name' | 'kind' | 'enabled' | 'action' | 'scope' | 'match' | 'hitCount'>>
47 events: TraceEvent[]
48 /** Error Lens: the diagnosed failures, oldest first. */
49 errors: LensRecord[]
50 /** Error Lens: repeated failures grouped by signature, most recent first. */
51 errorGroups: ErrorGroup[]
52}
53
54export function buildExport(args: {
55 version: string
56 exportedAt: string
57 sessionId: string
58 redaction: boolean
59 rawCapture: boolean
60 mode: DevtoolsMode
61 stats: DevtoolsStats
62 breakpoints: readonly Breakpoint[]
63 events: readonly TraceEvent[]
64 errors?: readonly LensRecord[]
65 errorGroups?: readonly ErrorGroup[]
66}): TraceExport {
67 return {
68 schema: TRACE_SCHEMA,
69 schemaVersion: TRACE_SCHEMA_VERSION,
70 generator: { name: 'devtools', version: args.version },
71 exportedAt: args.exportedAt,
72 sessionId: args.sessionId,
73 redaction: args.redaction,
74 rawCapture: args.rawCapture,
75 mode: args.mode,
76 droppedEvents: 0,
77 stats: args.stats,
78 breakpoints: args.breakpoints.map(({ id, name, kind, enabled, action, scope, match, hitCount }) => ({ id, name, kind, enabled, action, scope, match, hitCount })),
79 events: [...args.events],
80 errors: [...(args.errors ?? [])],
81 errorGroups: [...(args.errorGroups ?? [])],
82 }
83}
84
85export type ExportPaths = { ok: true; json: string; markdown?: string } | { ok: false; error: string }
86
87/**
88 * Where `/devtools-export [path] [--md]` writes. A path is the person's own
89 * input, so it is checked: a .json or .md name, no `..` segment, no control
90 * characters; a relative path lands under the session's working directory.
91 */
92export function exportPaths(arg: string | undefined, cwd: string, stamp: string, wantsMarkdown: boolean): ExportPaths {
93 const root = cwd.replace(/[\\/]+$/, '')
94 const target = arg === undefined || arg === '' ? `.claude-devtools/trace-${stamp}.json` : arg
95 if (/[\u0000-\u001f]/.test(target)) return { ok: false, error: 'the export path holds a control character' }
96 if (target.split(/[\\/]/).includes('..')) return { ok: false, error: 'the export path may not contain ".." segments' }
97 const isAbsolutePath = /^([a-zA-Z]:[\\/]|[\\/])/.test(target)
98 const full = isAbsolutePath ? target : `${root}/${target}`
99 const lower = full.toLowerCase()
100 if (lower.endsWith('.json')) return { ok: true, json: full, ...(wantsMarkdown ? { markdown: `${full.slice(0, -5)}.md` } : {}) }
101 if (lower.endsWith('.md')) return { ok: true, json: `${full.slice(0, -3)}.json`, markdown: full }
102 return { ok: false, error: 'the export path must end in .json or .md' }
103}
104
105function byteLength(text: string): number {
106 return new TextEncoder().encode(text).length
107}
108
109/** Serializes, dropping the oldest events until the text fits `maxBytes`. */
110export function serializeExport(trace: TraceExport, maxBytes = MAX_EXPORT_BYTES): string {
111 let current = trace
112 let text = JSON.stringify(current, null, 2)
113 while (byteLength(text) > maxBytes && current.events.length > 0) {
114 const drop = Math.max(1, Math.ceil(current.events.length / 10))
115 current = { ...current, events: current.events.slice(drop), droppedEvents: current.droppedEvents + drop }
116 text = JSON.stringify(current, null, 2)
117 }
118 return text
119}
120
121const CERTAINTIES: readonly Certainty[] = ['confirmed', 'possible', 'unknown']
122const STATUSES: readonly TraceStatus[] = ['pending', 'running', 'completed', 'failed', 'denied', 'simulated']
123const OUTCOMES: readonly TraceOutcome[] = [
124 'ok',
125 'tool-error',
126 'permission-denied',
127 'blocked-by-hook',
128 'debugger-rejected',
129 'user-cancelled',
130 'headless-rejected',
131 'simulated',
132 'timeout',
133 'aborted',
134 'guard-failed',
135]
136
137/** Checks a parsed export against the schema; returns the problems, empty when valid. */
138export function validateExport(value: unknown): string[] {
139 const problems: string[] = []
140 if (value === null || typeof value !== 'object') return ['the export is not an object']
141 const x = value as Record<string, unknown>
142 if (x.schema !== TRACE_SCHEMA) problems.push(`schema must be "${TRACE_SCHEMA}"`)
143 if (x.schemaVersion !== TRACE_SCHEMA_VERSION) problems.push(`schemaVersion must be ${TRACE_SCHEMA_VERSION}`)
144 if (typeof x.exportedAt !== 'string' || Number.isNaN(Date.parse(x.exportedAt))) problems.push('exportedAt must be an ISO time')
145 if (typeof x.sessionId !== 'string') problems.push('sessionId must be a string')
146 if (typeof x.redaction !== 'boolean') problems.push('redaction must be a boolean')
147 if (!Array.isArray(x.breakpoints)) problems.push('breakpoints must be an array')
148 if (!Array.isArray(x.errorGroups)) problems.push('errorGroups must be an array')
149 if (!Array.isArray(x.errors)) problems.push('errors must be an array')
150 else
151 x.errors.forEach((raw, i) => {
152 const r = raw as Record<string, unknown>
153 const at = `errors[${i}]`
154 if (typeof r.id !== 'string' || r.id === '') problems.push(`${at}.id must be a non-empty string`)
155 if (typeof r.tool !== 'string' || r.tool === '') problems.push(`${at}.tool must be a non-empty string`)
156 if (typeof r.category !== 'string' || r.category === '') problems.push(`${at}.category must be a non-empty string`)
157 if (typeof r.message !== 'string') problems.push(`${at}.message must be a string`)
158 if (!Array.isArray(r.causes) || r.causes.some(c => !CERTAINTIES.includes((c as { certainty?: Certainty }).certainty as Certainty))) {
159 problems.push(`${at}.causes must be an array of causes with a known certainty`)
160 }
161 if (!Array.isArray(r.probes)) problems.push(`${at}.probes must be an array`)
162 })
163 if (!Array.isArray(x.events)) return [...problems, 'events must be an array']
164 x.events.forEach((raw, i) => {
165 const e = raw as Record<string, unknown>
166 const at = `events[${i}]`
167 if (typeof e.id !== 'string' || e.id === '') problems.push(`${at}.id must be a non-empty string`)
168 if (typeof e.tool !== 'string' || e.tool === '') problems.push(`${at}.tool must be a non-empty string`)
169 if (!STATUSES.includes(e.status as TraceStatus)) problems.push(`${at}.status is not a known status`)
170 if (e.outcome !== undefined && !OUTCOMES.includes(e.outcome as TraceOutcome)) problems.push(`${at}.outcome is not a known outcome`)
171 if (typeof e.startedAt !== 'string' || Number.isNaN(Date.parse(e.startedAt))) problems.push(`${at}.startedAt must be an ISO time`)
172 if (typeof e.simulated !== 'boolean') problems.push(`${at}.simulated must be a boolean`)
173 if (e.simulated === true && e.status !== 'simulated') problems.push(`${at} is simulated but its status is ${String(e.status)}`)
174 if (e.durationMs !== undefined && (typeof e.durationMs !== 'number' || e.durationMs < 0)) problems.push(`${at}.durationMs must be a non-negative number`)
175 })
176 return problems
177}
178
179const ICON: Record<TraceStatus, string> = {
180 pending: '⏸',
181 running: '…',
182 completed: '✓',
183 failed: '✗',
184 denied: '⊘',
185 simulated: '◇',
186}
187
188export function statusIcon(status: TraceStatus): string {
189 return ICON[status]
190}
191
192export function formatDuration(ms: number | undefined): string {
193 if (ms === undefined) return '—'
194 if (ms < 1000) return `${Math.round(ms)}ms`
195 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
196 return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
197}
198
199function cell(text: string): string {
200 return text.replace(/\|/g, '\\|').replace(/\n/g, ' ')
201}
202
203/** A readable report of an export. */
204export function exportToMarkdown(trace: TraceExport): string {
205 const lines = [
206 '# Claude DevTools trace',
207 '',
208 `- Session: \`${trace.sessionId || 'unknown'}\``,
209 `- Exported: ${trace.exportedAt}`,
210 `- Mode: ${trace.mode} · redaction ${trace.redaction ? 'on' : 'OFF'} · raw capture ${trace.rawCapture ? 'ON' : 'off'}`,
211 `- Calls observed: ${trace.stats.observed} · failed ${trace.stats.failed} · denied ${trace.stats.denied} · simulated ${trace.stats.simulated} · paused ${trace.stats.paused}`,
212 ...(trace.droppedEvents > 0 ? [`- ${trace.droppedEvents} oldest events dropped to fit the file size limit`] : []),
213 '',
214 '## Breakpoints',
215 '',
216 ...(trace.breakpoints.length === 0
217 ? ['None.']
218 : trace.breakpoints.map(bp => `- \`${bp.id}\` ${bp.enabled ? '●' : '○'} **${cell(bp.name)}**: ${cell(describeBreakpoint(bp))} · hits ${bp.hitCount}`)),
219 '',
220 '## Timeline',
221 '',
222 '| # | Time | Status | Tool | Agent | Duration | Input | Result |',
223 '| - | - | - | - | - | - | - | - |',
224 ...trace.events.map(e =>
225 [
226 '',
227 String(e.seq),
228 e.startedAt,
229 `${ICON[e.status]} ${e.status}${e.outcome !== undefined && e.outcome !== 'ok' ? ` (${e.outcome})` : ''}${e.simulated ? ' **SIMULATED**' : ''}`,
230 cell(e.tool),
231 e.agentId ?? 'main',
232 formatDuration(e.durationMs),
233 `\`${cell(e.inputSummary)}\``,
234 cell(e.errorText ?? e.resultSummary ?? ''),
235 '',
236 ].join(' | ').trim(),
237 ),
238 '',
239 '## Error Lens',
240 '',
241 ...(trace.errors.length === 0 ? ['No failed tool calls.', ''] : []),
242 ...(trace.errorGroups.some(group => group.count > 1)
243 ? ['| Count | Tool | Kind | Error |', '| - | - | - | - |', ...trace.errorGroups.map(g => `| ${g.count} | ${cell(g.tool)} | ${g.category} | ${cell(g.headline)} |`), '']
244 : []),
245 // A fence inside quoted error text would end the block early.
246 ...trace.errors.flatMap(record => ['```text', ...lensReport(record).map(line => line.replace(/`{3,}/g, "'''")), '```', '']),
247 ]
248 return lines.join('\n')
249}
250src/core/simulation.ts 56 lines1// Synthetic results: opt-in, allowlisted, always labeled, and never claiming
2// that a side effect happened. A simulated call never reaches the real tool.
3
4import type { Breakpoint, SimulationKind } from '../../types'
5import { SHELL_TOOLS, type NormalizedCall } from './events.ts'
6
7/** Tools whose calls may be answered with a simulated failure. */
8export const SIMULATION_TOOLS: readonly string[] = [
9 'Bash',
10 'PowerShell',
11 'Read',
12 'Edit',
13 'Write',
14 'NotebookEdit',
15 'Glob',
16 'Grep',
17 'WebFetch',
18 'WebSearch',
19]
20
21export const SIMULATED_FAILURE_TAG = '[Claude DevTools · SIMULATED FAILURE]'
22export const SIMULATED_OUTPUT_TAG = '[Claude DevTools · SIMULATED OUTPUT: the command was NOT executed]'
23
24export type SimulationCheck = { ok: true; kind: SimulationKind; text?: string } | { ok: false; reason: string }
25
26/**
27 * Whether 'Simulate' may be offered for this call: simulation switched on,
28 * an allowlisted tool, and for a stubbed output a shell command classified
29 * read-only, so a canned output can never stand in for a write, migration,
30 * deployment or other consequential operation.
31 */
32export function checkSimulation(isEnabled: boolean, bp: Breakpoint | undefined, call: NormalizedCall): SimulationCheck {
33 if (!isEnabled) return { ok: false, reason: 'simulation is off (devtools option "simulation")' }
34 const kind = bp?.simulate?.kind ?? 'fail'
35 if (!SIMULATION_TOOLS.includes(call.tool) && !call.tool.startsWith('mcp__')) {
36 return { ok: false, reason: `${call.tool} is not on the simulation allowlist` }
37 }
38 if (kind === 'stub' && !(SHELL_TOOLS.includes(call.tool) && call.risk === 'read')) {
39 return { ok: false, reason: 'a stubbed output is allowed only for read-only shell commands' }
40 }
41 return { ok: true, kind, ...(bp?.simulate?.text !== undefined ? { text: bp.simulate.text } : {}) }
42}
43
44export type SyntheticResult =
45 | { deny: string }
46 | { result: { stdout: string; stderr: string; interrupted: false } }
47
48/** The answer a simulated call returns in place of running the tool. */
49export function synthesize(kind: SimulationKind, call: NormalizedCall, text?: string): SyntheticResult {
50 if (kind === 'stub') {
51 return { result: { stdout: `${SIMULATED_OUTPUT_TAG}\n${text ?? ''}`.trimEnd(), stderr: '', interrupted: false } }
52 }
53 const detail = text ?? 'A tool failure was injected by the developer to test recovery.'
54 return { deny: `${SIMULATED_FAILURE_TAG} ${detail} The ${call.tool} call was NOT executed, so nothing changed.` }
55}
56src/core/suggest.ts 103 lines1// One-press breakpoints, Chrome DevTools style: the rules a tool call suggests
2// (its tool, its command, its path) and the tool categories that can be ticked
3// like event-listener breakpoints. Pure.
4
5import type { Breakpoint, BreakpointKind, BreakpointMatch } from '../../types'
6import { normalizePath } from './breakpoints.ts'
7import { SHELL_TOOLS } from './events.ts'
8
9export type Category = { id: string; label: string; tools: readonly string[] }
10
11/** Tool families a person ticks to pause on, like Chrome's event-listener breakpoints. */
12export const CATEGORIES: readonly Category[] = [
13 { id: 'shell', label: 'Shell', tools: ['Bash', 'PowerShell'] },
14 { id: 'read', label: 'Read', tools: ['Read'] },
15 { id: 'search', label: 'Search', tools: ['Grep', 'Glob'] },
16 { id: 'edit', label: 'Edit', tools: ['Edit', 'Write', 'NotebookEdit'] },
17 { id: 'web', label: 'Web', tools: ['WebFetch', 'WebSearch'] },
18 { id: 'agent', label: 'Agents', tools: ['Agent'] },
19]
20
21/** The family a tool belongs to, for counts; MCP and unknown tools have their own. */
22export function familyOf(tool: string): string {
23 if (tool.startsWith('mcp__')) return 'mcp'
24 return CATEGORIES.find(category => category.tools.includes(tool))?.id ?? 'other'
25}
26
27export type Suggestion = {
28 id: 'tool' | 'command' | 'path'
29 /** What the button says. */
30 label: string
31 kind: BreakpointKind
32 match: BreakpointMatch
33 name: string
34}
35
36const PATH_FIELDS = ['file_path', 'notebook_path', 'path'] as const
37/** Tools whose `path` names a directory searched below. */
38const DIRECTORY_TOOLS: readonly string[] = ['Grep', 'Glob']
39
40/**
41 * The program and its subcommand (`npm install`, `git push`), skipping
42 * leading environment assignments and `sudo`; the part of a command that
43 * stays the same when its arguments change.
44 */
45export function commandHead(command: string): string | undefined {
46 const first = command.split(/&&|\|\||[;|\n]/)[0] ?? ''
47 const words = first.trim().split(/\s+/).filter(word => word !== '')
48 while (words.length > 0 && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] as string) || words[0] === 'sudo')) words.shift()
49 const program = words[0]
50 if (program === undefined || program === '') return undefined
51 const sub = words[1]
52 return sub !== undefined && /^[a-z][\w:.-]*$/i.test(sub) ? `${program} ${sub}` : program
53}
54
55/** A path shown and matched relative to the working directory when it lies below it. */
56export function relativePath(path: string, cwd?: string): string {
57 const target = normalizePath(path)
58 if (cwd === undefined || cwd === '') return target.path
59 const root = normalizePath(cwd).path
60 const fold = (text: string): string => (target.isWindows ? text.toLowerCase() : text)
61 return fold(target.path).startsWith(`${fold(root)}/`) ? target.path.slice(root.length + 1) : target.path
62}
63
64function shorten(text: string, max = 32): string {
65 return text.length <= max ? text : `…${text.slice(text.length - max + 1)}`
66}
67
68/** The breakpoints one tool call suggests: its tool, its command (shell), its path. */
69export function suggestBreakpoints(tool: string, input: unknown, cwd?: string): Suggestion[] {
70 const args = input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
71 const out: Suggestion[] = [{ id: 'tool', label: tool, kind: 'tool', match: { tools: [tool] }, name: `${tool} calls` }]
72 if (SHELL_TOOLS.includes(tool) && typeof args.command === 'string') {
73 const head = commandHead(args.command)
74 if (head !== undefined) out.push({ id: 'command', label: `"${shorten(head, 24)}"`, kind: 'command', match: { command: head }, name: head })
75 }
76 const raw = PATH_FIELDS.map(field => args[field]).find((value): value is string => typeof value === 'string' && value !== '')
77 if (raw !== undefined) {
78 const relative = relativePath(raw, cwd)
79 const glob = DIRECTORY_TOOLS.includes(tool) ? `${relative.replace(/\/$/, '')}/**` : relative
80 if (!/[*?]/.test(raw)) out.push({ id: 'path', label: shorten(glob), kind: 'file', match: { path: glob }, name: shorten(glob, 48) })
81 }
82 return out
83}
84
85function canonical(match: BreakpointMatch): string {
86 return JSON.stringify({ tools: match.tools === undefined ? undefined : [...match.tools].sort(), command: match.command, path: match.path })
87}
88
89/** The existing rule that a suggestion or category stands for, if any. */
90export function findRule(breakpoints: readonly Breakpoint[], kind: BreakpointKind, match: BreakpointMatch): Breakpoint | undefined {
91 const wanted = canonical(match)
92 return breakpoints.find(bp => bp.kind === kind && canonical(bp.match) === wanted)
93}
94
95export function findCategoryRule(breakpoints: readonly Breakpoint[], category: Category): Breakpoint | undefined {
96 return findRule(breakpoints, 'tool', { tools: [...category.tools] })
97}
98
99/** Merges the session's hit counts into the rules. */
100export function withHits<T extends { breakpoints: readonly Breakpoint[] }>(settings: T, hits: Readonly<Record<string, number>>): T {
101 return { ...settings, breakpoints: settings.breakpoints.map(bp => ({ ...bp, hitCount: hits[bp.id] ?? 0 })) }
102}
103src/security/redaction.ts 70 lines1// Secret redaction for everything the timeline keeps and every export writes.
2// Pattern-based and best effort: it catches the common credential shapes and
3// every inline environment assignment, it cannot prove a string holds no secret.
4
5export const REDACTED = '[REDACTED]'
6
7/** Object keys whose values are dropped whole, matched per word (`apiKey` is `api_key`). */
8const SENSITIVE_KEY =
9 /(^|_)(password|passwd|passphrase|pass|pwd|secret|token|api_?key|auth|authorization|cookie|credentials?|private_?key|session_?(id|key)|bearer|access_?key)(_|$)/
10
11/** Credential shapes replaced wherever they appear in a string. */
12const SECRET_PATTERNS: readonly RegExp[] = [
13 /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
14 /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
15 /\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}/g,
16 /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
17 /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
18 /\bglpat-[A-Za-z0-9_-]{20,}/g,
19 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
20 /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
21 /\bAIza[0-9A-Za-z_-]{35}\b/g,
22 /\bnpm_[A-Za-z0-9]{36}\b/g,
23 /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
24]
25
26/** `scheme://user:password@host` keeps the user, drops the password. */
27const URL_CREDENTIALS = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):[^\s@/]+@/gi
28
29/** `KEY=value` (shell env assignment, `export`, `set`, `env`): the value goes. */
30const ENV_ASSIGNMENT = /(^|[\s;&|(`])((?:export\s+|set\s+)?[A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|[^\s;&|)`]+)/g
31
32/** PowerShell `$env:NAME = value`. */
33const PS_ENV_ASSIGNMENT = /(\$env:[A-Za-z_][A-Za-z0-9_]*\s*=\s*)("[^"]*"|'[^']*'|\S+)/gi
34
35/** `--password x`, `--token=x`, `-p:x` style secret flags. */
36const SECRET_FLAG =
37 /(--?(?:password|passwd|pass|token|secret|api[-_]?key|auth(?:-token)?|access[-_]?key|client[-_]?secret)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi
38
39/** Header-style `Authorization: x`, `X-Api-Key: x`. */
40const SECRET_HEADER = /\b((?:authorization|x-api-key|api-key|cookie|set-cookie)\s*:\s*)([^\s"',;]+(?:\s+[^\s"',;]+)?)/gi
41
42export function redactString(text: string): string {
43 let out = text
44 for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, REDACTED)
45 return out
46 .replace(URL_CREDENTIALS, `$1:${REDACTED}@`)
47 .replace(ENV_ASSIGNMENT, `$1$2=${REDACTED}`)
48 .replace(PS_ENV_ASSIGNMENT, `$1${REDACTED}`)
49 .replace(SECRET_FLAG, `$1${REDACTED}`)
50 .replace(SECRET_HEADER, `$1${REDACTED}`)
51}
52
53export function isSensitiveKey(key: string): boolean {
54 const words = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[-\s.]+/g, '_').toLowerCase()
55 return SENSITIVE_KEY.test(words)
56}
57
58/** Deep copy of plain JSON data with sensitive keys and secret shapes redacted. */
59export function redactValue(value: unknown, depth = 0): unknown {
60 if (typeof value === 'string') return redactString(value)
61 if (value === null || typeof value !== 'object') return value
62 if (depth > 8) return '[…]'
63 if (Array.isArray(value)) return value.map(item => redactValue(item, depth + 1))
64 const out: Record<string, unknown> = {}
65 for (const [key, item] of Object.entries(value)) {
66 out[key] = isSensitiveKey(key) && item !== null && item !== undefined ? REDACTED : redactValue(item, depth + 1)
67 }
68 return out
69}
70src/ui/inline.tsx 153 lines1// Breakpoints set where the calls are, Chrome DevTools style: a gutter on
2// each tool row in the transcript (hover it, press "break on"), a red mark on
3// rows a breakpoint covers, and a bar above the prompt for the latest call
4// that the keyboard reaches (ctrl+x tab, then its hotkeys).
5
6import type { RenderElement } from 'claude-code'
7
8import type { Breakpoint, TraceEvent } from '../../types'
9import { truncate } from '../core/events.ts'
10import { statusIcon } from '../core/recorder.ts'
11import type { Suggestion } from '../core/suggest.ts'
12import type { Table } from './model.ts'
13import { C, STATUS_COLOR } from './theme.ts'
14
15/** A suggestion as drawn: its element key, and the rule it stands for when one is set. */
16export type Offer = Suggestion & { key: string; rule?: Breakpoint }
17
18export type InlineMode = 'hover' | 'always'
19
20function offerLabel(offer: Offer): string {
21 return `${offer.rule !== undefined ? '●' : '○'} ${offer.label}`
22}
23
24/**
25 * The transcript row with a gutter: the engine's own drawing first, a red
26 * line naming the breakpoints that cover the call, then the "break on"
27 * controls. `hover` lays them over the row's top right, shown only while the
28 * pointer is over the row, so nothing moves; `always` gives them a line.
29 */
30export function renderGutter(els: Table, drawn: RenderElement, matched: readonly Breakpoint[], offers: readonly Offer[], mode: InlineMode, onToggle: (offer: Offer) => void): RenderElement {
31 const { Box, Text, Button } = els
32 const buttons = offers.map(offer => <Button key={offer.key} plain dimColor={offer.rule === undefined} label={offerLabel(offer)} onPress={() => onToggle(offer)} />)
33 return (
34 <Box key="devtools-row" flexDirection="column">
35 {drawn}
36 {matched.length > 0 && (
37 <Text key="devtools-mark" color={C.breakpoints} wrap="truncate-end">
38 {` ● breakpoint ${matched.map(bp => `${bp.id} ${bp.name} → ${bp.action}`).join(' · ')}`}
39 </Text>
40 )}
41 {mode === 'hover' ? (
42 <Box position="absolute" top={0} right={0} flexDirection="row" columnGap={1} display="none" hover={{ display: 'flex' }}>
43 <Text color={C.breakpoints}>break on</Text>
44 {buttons}
45 </Box>
46 ) : (
47 <Box key="devtools-gutter" flexDirection="row" columnGap={1} marginLeft={2}>
48 <Text dimColor>break on</Text>
49 {buttons}
50 </Box>
51 )}
52 </Box>
53 )
54}
55
56const BAR_HOTKEY: Record<Suggestion['id'], string> = { tool: 't', command: 'c', path: 'f' }
57
58export const BAR_HINT = '/bp <rule> · /devtools-help'
59/** Longest first: the bar shows the longest that still fits on its line. */
60const HINTS = [BAR_HINT, '/bp <rule>']
61/** A plain Button with a hotkey draws `t: label`. */
62const BUTTON_EXTRA = 3
63/** Cells the engine keeps at the band's right edge (its `[−]` fold control). */
64const EDGE = 4
65const BREAK_ON = '· break on'
66
67/** What the bar shows, so that it always fits one line. */
68export type BarFit = { summary?: string; offers: number; lens: boolean; hide: boolean; open: boolean; hint?: string }
69
70/**
71 * Lays the bar out on one line, never wrapping: the brand and status first,
72 * then each part in order of importance (the breakpoint keys, the Error Lens
73 * key, hide, open, the call's summary, the hint), each only if the rest of the
74 * line still has room for it.
75 */
76export function fitBar(width: number, summary: string, offerLabels: readonly string[], lens: string | undefined): BarFit {
77 let room = width - EDGE - 'DevTools'.length - 2
78 const take = (cells: number): boolean => (cells <= room ? ((room -= cells), true) : false)
79 const button = (label: string): number => 1 + BUTTON_EXTRA + label.length
80 let offers = 0
81 if (offerLabels.length > 0 && take(1 + BREAK_ON.length)) {
82 for (const label of offerLabels) {
83 if (!take(button(label))) break
84 offers += 1
85 }
86 // "break on" with no key after it says nothing.
87 if (offers === 0) room += 1 + BREAK_ON.length
88 }
89 const showLens = lens !== undefined && take(button(lens))
90 const hide = take(button('hide'))
91 const open = take(button('DevTools'))
92 // The summary gets what is left, at most a third of the line; cut below 12 cells it tells nothing.
93 const cells = Math.min(summary.length, Math.floor(width / 3), room - 1)
94 const shown = cells > 0 && cells >= Math.min(12, summary.length) ? truncate(summary, cells) : undefined
95 if (shown !== undefined) room -= 1 + shown.length
96 const hint = HINTS.find(text => take(3 + text.length))
97 return { offers, lens: showLens, hide, open, ...(shown !== undefined ? { summary: shown } : {}), ...(hint !== undefined ? { hint } : {}) }
98}
99
100/**
101 * The bar above the prompt: the latest call, one-key breakpoints for it, its
102 * Error Lens when it failed, and a dim hint. Before the first call, the hint alone.
103 */
104export function renderBar(
105 els: Table,
106 below: RenderElement,
107 event: TraceEvent | undefined,
108 offers: readonly Offer[],
109 width: number,
110 on: { toggle: (offer: Offer) => void; open: () => void; hide: () => void; lens: () => void },
111): RenderElement {
112 const { Box, Text, Button } = els
113 const cells = Math.max(24, width)
114 if (event === undefined) {
115 return (
116 <Box flexDirection="column">
117 {below}
118 <Box key="devtools-bar" flexDirection="row" columnGap={1} width={cells}>
119 <Text color={C.brand} dimColor>
120 DevTools
121 </Text>
122 <Text dimColor wrap="truncate-end">
123 {truncate(`· press "break on" under any tool call, or ${BAR_HINT}`, cells - EDGE - 9)}
124 </Text>
125 </Box>
126 </Box>
127 )
128 }
129 const lens = event.errorCategory !== undefined ? `✗ why? (${event.errorCategory})` : undefined
130 const fit = fitBar(cells, `${event.tool} ${event.inputSummary}`, offers.map(offerLabel), lens)
131 const keys = offers.slice(0, fit.offers)
132 return (
133 <Box flexDirection="column">
134 {below}
135 <Box key="devtools-bar" flexDirection="row" columnGap={1} width={cells}>
136 <Text color={C.brand} bold>
137 DevTools
138 </Text>
139 <Text color={STATUS_COLOR[event.status]}>{statusIcon(event.status)}</Text>
140 {fit.summary !== undefined && <Text dimColor>{fit.summary}</Text>}
141 {keys.length > 0 && <Text dimColor>{BREAK_ON}</Text>}
142 {keys.map(offer => (
143 <Button key={`bar-${offer.id}`} hotkey={BAR_HOTKEY[offer.id]} plain dimColor={offer.rule === undefined} label={offerLabel(offer)} onPress={() => on.toggle(offer)} />
144 ))}
145 {fit.lens && lens !== undefined && <Button key="bar-lens" hotkey="e" plain label={lens} onPress={() => on.lens()} />}
146 {fit.open && <Button key="bar-open" hotkey="d" plain dimColor label="DevTools" onPress={() => on.open()} />}
147 {fit.hide && <Button key="bar-hide" hotkey="x" plain dimColor label="hide" onPress={() => on.hide()} />}
148 {fit.hint !== undefined && <Text dimColor>{`· ${fit.hint}`}</Text>}
149 </Box>
150 </Box>
151 )
152}
153src/ui/model.ts 70 lines1// What the pane draws from and what it can ask for.
2
3import type { Elements, RenderSurface } from 'claude-code'
4
5import type {
6 ArmState,
7 DevtoolsMode,
8 DevtoolsSettings,
9 DevtoolsStats,
10 DevtoolsTab,
11 ErrorGroup,
12 LensRecord,
13 PendingCall,
14 SessionInfo,
15 TraceEvent,
16 ViewState,
17} from '../../types'
18import type { DevtoolsOptions } from '../config/schema.ts'
19import type { Category, Suggestion } from '../core/suggest.ts'
20
21export type Table = Elements[RenderSurface]
22
23/** `wide`: two columns, docked from 110 cells; `compact`: stacked; `mini`: inline above the prompt. */
24export type Layout = 'wide' | 'compact' | 'mini'
25
26export type PaneModel = {
27 /** Rules with this session's hit counts merged in. */
28 settings: DevtoolsSettings
29 arm: ArmState
30 trace: readonly TraceEvent[]
31 pending: readonly PendingCall[]
32 view: ViewState
33 session: SessionInfo
34 stats: DevtoolsStats
35 /** Error Lens: diagnosed failures, oldest first. */
36 lens: readonly LensRecord[]
37 /** Error Lens: failures grouped by signature, most recent first. */
38 groups: readonly ErrorGroup[]
39 options: DevtoolsOptions
40 /** Cells across the pane body. */
41 columns: number
42 /** Rows the body may show at once. */
43 rows: number
44 layout: Layout
45 /**
46 * Whether the surface draws Input and Select. Element tables are completed
47 * across surfaces (mobile's Input draws nothing), so this comes from the surface.
48 */
49 hasFields: boolean
50}
51
52export type PaneActions = {
53 setTab: (tab: DevtoolsTab) => void
54 inspect: (id: string) => void
55 page: (delta: number) => void
56 toggleBreakpoint: (id: string) => void
57 deleteBreakpoint: (id: string) => void
58 addBreakpoint: (spec: string) => void
59 toggleCategory: (category: Category) => void
60 toggleSuggestion: (suggestion: Suggestion) => void
61 setMode: (mode: DevtoolsMode) => void
62 toggleRecording: () => void
63 togglePauseNext: () => void
64 clearTimeline: () => void
65 exportTrace: () => void
66 /** Shows one failure in the Errors tab. */
67 openLens: (id: string) => void
68 clearErrors: () => void
69}
70