An agent made 225 edits and the ledger said none

cursorinstrumentationcost

Cursor renamed edit_file to edit_file_v2, the adapter kept exact-match lookup, and 274 sessions were filed as never having tried to write anything.

One session in this corpus ran for 117 minutes, made 528 tool calls, and issued 225 edits across 29 distinct paths — 28 files in one repository, plus a Cursor plan file. The run ledger reported its delivery state as no_intent, which in our vocabulary means "never tried to write anything".

274 Cursor sessions were filed that way in error. The cause was a rename.

What broke

session-viz reads three harnesses and normalises them into one shape. Claude Code's tool vocabulary is the target: everything downstream tests set membership against it.

const WRITE_TOOLS = new Set<string | undefined>(['Write', 'Edit', 'NotebookEdit'])

function deliveryState(r: RunScan): DeliveryState {
  if (r.wroteOk > 0) return 'wrote_ok'
  if (r.writeDenied > 0) return 'denied'
  if (r.intentWrite > 0) return 'unverified'
  return 'no_intent'
}

intentWrite increments when a tool call's mapped name is in WRITE_TOOLS. The Cursor adapter held a table: edit_file → Edit, read_file → Read, run_terminal_cmd → Bash, and so on. Cursor then versioned its tools by suffix. edit_file became edit_file_v2. run_terminal_cmd became run_terminal_command_v2. The lookup was exact-match — return TOOL_NAMES[raw] || raw || 'UnknownTool' — so every versioned name fell straight through and kept its Cursor spelling.

A tool called edit_file_v2 is not in WRITE_TOOLS. So it was not an edit. So the session had no write intent. So it never tried.

Here is the census over the current corpus, by raw name, for the eight most common names the old table missed:

raw namecallsmaps to
read_file_v27,045Read
edit_file_v26,439Edit
run_terminal_command_v22,917Bash
ripgrep_raw_search2,131Grep
glob_file_search927Glob
semantic_search_full728Grep
apply_patch383Edit
list_dir_v2295Glob

Four of the missed names were write-relevant: edit_file_v2 6,439, apply_patch 383, MultiEdit 30, edit_notebook 2. That is 6,854 real file edits that never counted as writes. Only the first was the rename. The other 415 were plain gaps in the table, one of them a case mismatch — MultiEdit against a lower-case key.

The numbers, and where each one comes from

node scripts/runs.mjs --json reports 2,367 runs across three harnesses: claude-code 1,288, codex 438, cursor 641. Summing the tools field over the Cursor slice gives 66,392 tool calls.

9,828 of those carry no tool name at all. Every one of them holds a single key — {additionalData:{status:'error'}} — a call that failed before Cursor recorded what it was. Nothing could have mapped those, so the honest denominator is the 56,564 named calls.

Of those 56,564, 21,756 (38.5%) passed through unmapped under the old table. After the fix, 756 (1.3%).

A correction while I am here. The commit that fixed this (00f93b1) claims 47.6%. That figure counts the 9,828 nameless calls as unmapped, which they were, and divides by all 66,392. A call with no name is not a mapping defect, so it is not a number anyone can act on. Among calls that had a name, the miss rate was 38.5%. The same commit calls the misses "almost all _v2"; versioned names are 16,698 of the 21,756, so about three-quarters.

Why it mattered more than a wrong tool count

no_intent is not a neutral label. It removes a run from the cost-per-delivered denominator, on the reasoning that a run which never tried to write should not be scored on whether it wrote. Its output tokens stay in the numerator, because they were spent. So the metric was divided by a number that shrank while the number on top did not move.

Replaying the Cursor slice under both tables: 9,996,342 output tokens, 551 runs that wrote a file, 276 that would have counted before.

Overstated by very close to 2×. The metric said each shipped change cost roughly twice what it did.

Two caveats on that pair. Cursor's token data is partial — the CLI prints cursor: counted, but token data is partial — its spend is a floor, not a total, and only 311 of the 641 runs record any output tokens at all. The 225-edit session records zero, which is why 117 minutes of work contributes nothing to the numerator. Second, the cost-per-delivered column in the ledger is computed per recurring task, and no Cursor run carries a task label, so this ratio is my arithmetic over the JSON rather than a figure the tool prints. Treat it as the shape of the error, not a price.

The delivery split now reads wrote_ok 551, no_intent 88, denied 2, against wrote_ok 276, no_intent 362, denied 3 before. The 88 are real. Plenty of Cursor sessions are reading sessions.

The fix, and the part worth stealing

const VERSION_SUFFIX = /_v\d+$/

const toolName = (t: ToolFormer): string => {
  const raw = (t.name || t.tool || '').trim()
  if (!raw) return 'UnknownTool'
  const base = raw.replace(VERSION_SUFFIX, '').toLowerCase()
  return TOOL_NAMES[base] || raw
}

The interesting part is not the regex. It is that nothing failed.

Any measurement that maps a vendor's vocabulary onto your own has this shape. The map is a dictionary, the miss path is || raw, and a miss produces a plausible string rather than an error. The pipeline keeps running. The report keeps rendering. The only visible effect is that the fleet looks quieter than it is: fewer writes, fewer deliveries, more sessions that apparently just read things.

That direction is the trap. A silent unmapping never invents work, it only ever loses it. Losing work makes agents look cheaper per unit of nothing and busier per unit of output, and neither reads as a bug. Nobody opens an investigation because delivery went down.

The cheap defence is to count your own miss rate and publish it. 1.3% unmapped, of which create_plan 270, ask_question 90 is a line you can look at and accept. 38.5% is a line that would have caught this the week Cursor shipped the rename, without anyone knowing a rename had happened.

Everything above is reproducible from the code and the corpus:

node plugins/session-viz/scripts/runs.mjs
node plugins/session-viz/scripts/runs.mjs --json
git show 00f93b1 -- plugins/session-viz/src/cursor.mts