SLOPSHOPPER

Claude DevTools

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

newpanebandrowsguardcommand
v0.1.2MITupdated 2026-10-09NMenzel/claude-devtools-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · devtools
│ ┃ Claude DevTools ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ CLAUDE DEVTOOLS · ● active · ○ not recording │ devtools │ │ ┃ ■ ok ■ failed ■ denied ■ simulated ■ pau ⏺ Read(src/auth.ts) │ DevTools ✗ Bash failed (unknown): │ │ ┃ d: Dashboard t: Timeline i: Inspector b: ⎿ Read 6 lines │ src/auth.test.ts: · /devtools-errors to │ │ ┃ ⟨Claude Code's own dra│ inspect │ │ ┃ ✗ Bash failed · unknown · 08:53:20 · 20ms break on ○ Edit ○ sr╰────────────────────────────────────────────╯ │ ┃ #6 toolu_06 · 1 of 1 ⏺ Bash(bun test) │ ┃ Original error ⎿ 3 pass, 1 fail │ ┃ src/auth.test.ts: │ ┃ ✓ refreshes expired token ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ rejects a bad signature │ ┃ ✓ issues a new token ✻ Worked for 42s · done 4:20 PM │ ┃ ✗ revokes on logout │ ┃ error: expected 401, got 200 › /devtools │ ┃ ⎿ devtools: Claude DevTools is open. Keys: d t i b e switch tabs; │ ┃ 3 pass │ ┃ … 2 more lines │ ┃ · UNKNOWN │ ┃ • No known error signature matched, so the │ ┃ cannot be determined from the result alone. │ ┃ evidence: "src/auth.test.ts:" │ ┃ Checks (read-only, after the failure) │ ┃ none apply to this failure │ ┃ Try │ ┃ 1. Read the original error below. │ ┃ 2. Reproduce the call by hand to see more ⟨Claude Code's own drawing⟩ DevTools ✓ Bash cat .env · break on t: ○ Bash c: ○ "cat" d: DevTools x: hide · /bp <rule> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ DevTools ✓ Bash cat .env · break on t: ○ Bash c: ○ "cat" d: DevTools x: hide · /bp <rule>
Pane · Claude DevTools
CLAUDE DEVTOOLS · ● active · ○ not recording · 0 breakpoints ■ ok ■ failed ■ denied ■ simulated ■ paused d: Dashboard t: Timeline i: Inspector b: Breakpoints e: ✗ Bash failed · unknown · 08:53:20 · 20ms #6 toolu_06 · 1 of 1 Original error src/auth.test.ts: ✓ refreshes expired token ✓ rejects a bad signature ✓ issues a new token ✗ revokes on logout error: expected 401, got 200 3 pass … 2 more lines · UNKNOWN • No known error signature matched, so the cause cannot be determined from the result alone. evidence: "src/auth.test.ts:" Checks (read-only, after the failure) none apply to this failure Try 1. Read the original error below. 2. Reproduce the call by hand to see more detail. permission — · outcome tool-error arguments {"command":"bun test","description":"Run the… h: Older failure l: Newer failure o: Open in Inspector c:
Tool row
⟨Claude Code's own drawing⟩ break on ○ Edit ○ src/auth.ts
README

<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>&nbsp; <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>&nbsp; <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License: MIT"></a>&nbsp; <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>&nbsp; <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">


The problem

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.

  • No breakpoints. You can't say "stop before any 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.
  • Failures are one line. 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.
  • No record. What ran, in what order, which permission rule applied, and what failed is spread across the scrollback.

The solution

Claude DevTools is a debugger for the agent's tool level, built on Claude Code's native Mods API.

In Claude Code todayWith Claude DevTools
A permission prompt per call, allow or denyBreakpoints 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 transcriptA "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 permittedError 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 againRepeats grouped and counted. A notification for the first, then every fifth
ScrollbackA timeline and inspector: status, duration, agent, permission verdict and breakpoint of every call
Copying from the terminalSanitized 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 push or 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.

Requirements

  • Claude Code with Mods support. Built and tested on 2.1.294 (Windows 10). Mods need 2.1.287 or later, and builds older than 2.1.294 are untested.
  • The pane draws in the terminal and in the Code tab of Claude Desktop. In the VS Code chat panel and in claude -p the hooks run but nothing is drawn; use the /devtools-* commands there.

Install

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.

Install with an AI agent

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.
Instructions for agents

If you are an AI agent installing Claude DevTools for a user, follow these steps.

  1. Check the version. Run 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).
  2. Install, without prompts: ``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.
  3. Options are optional. The defaults suit most users. Set an option only if the user asked for it, with --config <option>=<value> on the install command. Examples: --config errorLens=classify (no file checks), --config openOnStart=false. All options are listed under Options.
  4. Verify: claude plugin list --json includes devtools@claude-devtools-mod.
  5. Hand over to the user. An agent cannot activate a mod inside the user's running session. Tell the user to run /reload-plugins, then /devtools. A newly started session loads the mod by itself.

Notes for agents:

  • Nothing needs to be built, and it needs no npm packages, global installs or sudo. Do not clone the repository to install it.
  • The mod makes no network or model calls, and it never approves a tool call. It does not change the user's permission settings.
  • To test it headlessly: 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.

Use it

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

Set breakpoints where the calls are (Chrome DevTools style)

There are three ways to set a breakpoint without typing a rule.

  • The transcript gutter. Under every tool row (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.
  • The bar above the prompt. It shows the latest call: 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.
  • Category toggles, like Chrome's event-listener breakpoints: 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.

The dashboard

/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).

  • Header: CLAUDE DEVTOOLS · ● active · ⏺ recording · 3 breakpoints · ⏸ 1 paused, and a color legend (ok, failed, denied, simulated, paused).
  • PAUSED / ARMED: each held call with its tool, summary, breakpoint and time held. When pause-next or step is armed, a Disarm button.
  • BREAKPOINTS: the category toggles, every rule with its state, match, action and hit count (press a rule to enable or disable it), and the totals.
  • CALLS: one colored cell per call (green ok, red failed, amber denied, purple simulated), the totals, and counts per family (shell, read, search, edit, web, mcp).
  • ERRORS (only after a failure): the latest kinds of failure, repeats counted (✗ 3× Bash · exit-code · npm ERR! …). Press one to open its Error Lens.
  • TIMELINE: the newest calls. Press one to inspect it.
  • Actions: 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):

  • Dashboard: as above.
  • Timeline: newest-first tool calls with status, tool, duration and summary. A ● 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.
  • Inspector: the selected call's id, session, agent, timestamps, risk, permission verdict, breakpoint and decision, synthetic flag, result, error, and redacted input (JSON). break on buttons on 1/2/3, h/l older/newer, and on a failed call w "Why it failed".
  • Breakpoints: the mode picker, the category toggles, each rule with Enable/Disable and Delete, and a field that adds a rule in the same language as /devtools-break.
  • Errors: Error Lens (below). The tab label counts the failures kept.

Error Lens: why a tool call failed

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:

  • the original error, as Claude read it (redacted, up to 3,000 characters), with its code (ENOENT, EACCES, EPERM, ...) or shell exit code;
  • the call: the arguments (sanitized, file contents omitted), the agent, the duration and the permission verdict;
  • a category: not-found, access-denied, not-permitted, busy, stale-read (the file changed since Claude read it), not-read-yet, edit-mismatch, timeout, command-not-found, exit-code, network, mcp, permission-denied, blocked-by-hook, input-invalid, too-large, unknown, and more;
  • causes, sorted by certainty:
  • ✔ 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.
  • read-only checks (the 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.
  • fixes to try, as a numbered list.

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.

Commands

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

Rule language

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 (opt-in)

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.

Headless (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.

Options

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 |

Develop and test

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.

License

MIT

Source 17 files
hooks/register.tsx 1150 lines
1// 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}
1150
src/config/schema.ts 144 lines
1// 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}
144
src/core/breakpoints.ts 421 lines
1// 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}
421
src/core/controller.ts 158 lines
1// 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}
158
src/core/events.ts 293 lines
1// 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}
293
src/core/lens.ts 716 lines
1// 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}
716
src/core/recorder.ts 250 lines
1// 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}
250
src/core/simulation.ts 56 lines
1// 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}
56
src/core/suggest.ts 103 lines
1// 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}
103
src/security/redaction.ts 70 lines
1// 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}
70
src/ui/inline.tsx 153 lines
1// 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}
153
src/ui/model.ts 70 lines
1// 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