Bisecting a Claude Agent SDK behaviour change: why grepping the bundle dates it wrong
A grep bisect tells you when today's code landed, not when the behaviour changed. Here is the argv-capture harness that dates it properly, and the worked example where grepping made a maintainer look wrong and running the versions proved him right.
On this page
Quick Answer (September 2026). To find which version of @anthropic-ai/claude-agent-sdk changed a behaviour, do not grep the published bundles for the current implementation. A grep bisect tells you when today's code landed, not when the behaviour changed, and those are routinely different releases. Install each candidate version and measure what it actually does. In the worked example below, a grep bisect dated a fix to 0.2.47 and made an Anthropic maintainer look like he had closed a bug seven releases early. Running the versions proved the maintainer right: the behaviour was fixed in 0.2.40, and 0.2.47 was a later rewrite of an already-fixed path.
Everything here was measured against published tarballs on 16 September 2026, with @anthropic-ai/claude-agent-sdk at 0.3.273 and Node 24.8.0.
The problem with reading the bundle
You hit an SDK behaviour you did not expect. The natural next question is "when did this start?", because the answer decides whether you pin a version, file a regression, or accept it as by-design.
The tempting method is to download a spread of published tarballs, grep each one for the code you just read in the current release, and report the first version where it appears. It is fast, it needs no API key, and it produces a confident-looking table.
It is also the wrong instrument, and the failure is not subtle. A vendor can fix a behaviour in one release with a small patch, then rewrite that whole code path months later. Your grep is keyed on the rewrite. It will date the fix to the rewrite and silently skip the release that actually fixed it.
The fix is to stop grepping for code and start measuring behaviour.
turn the question into something observable
The Claude Agent SDK does not do the work itself. It builds an argument vector and spawns the Claude Code CLI as a subprocess. That boundary is the observable: for any set of options, the SDK produces a specific argv, and the argv is the SDK's entire decision.
The SDK lets you point that subprocess anywhere, via pathToClaudeCodeExecutable. So you can hand it a script of your own that records its arguments and exits. No API key, no network, no billing.
build the capture harness
Two files. The first is the fake CLI:
#!/usr/bin/env node
// fakecli.js
const fs = require('fs');
const out = process.env.ARGV_OUT || '/tmp/argv.json';
fs.writeFileSync(out, JSON.stringify(process.argv.slice(2)));
process.exit(0);
The second drives the SDK and drains the (empty) result stream:
// probe.mjs
import fs from 'node:fs';
import { query } from '@anthropic-ai/claude-agent-sdk';
const CLI = '/tmp/fakecli.js';
async function argvFor(opts, label) {
const out = `/tmp/argv-${label}.json`;
try { fs.unlinkSync(out); } catch {}
process.env.ARGV_OUT = out;
try {
const q = query({
prompt: 'hi',
options: { ...opts, pathToClaudeCodeExecutable: CLI, executable: 'node' },
});
for await (const _m of q) { /* drain */ }
} catch (e) { /* the fake CLI exits 0 with no stream; ignore */ }
try { return JSON.parse(fs.readFileSync(out, 'utf8')); } catch { return null; }
}
Two details that cost me time. The flag you are usually looking for is --allowedTools in camelCase, not kebab-case, so a probe grepping for --allowed-tools reports "flag absent" on every row and reads exactly like a feature that does nothing. And subagent definitions and toolAliases do not travel as CLI flags at all, they go over the control protocol on stdin, so an argv-only probe is structurally blind to them. If you need those, have the fake CLI read stdin too, which is how I captured subagent definitions when checking how the SDK validates skill names.
get the real version list, with dates
Do not guess version numbers. Ask the registry. (If you would rather inspect tarballs than install them, npm pack downloads one without touching node_modules.)
npm view @anthropic-ai/claude-agent-sdk time --json
This matters more than it looks. When I bisected this package, 0.2.46 came back as a pack failure on every attempt. It was not a network problem: that version was never published. Sparse version lines are normal, and a bisect that treats a missing version as a failed measurement will stall on it.
install each candidate in its own directory
for V in 0.2.38 0.2.40 0.2.44 0.2.45 0.2.47; do
mkdir -p /tmp/ver/$V
(cd /tmp/ver/$V && npm init -y >/dev/null \
&& npm install --save-exact "@anthropic-ai/claude-agent-sdk@$V" >/dev/null)
done
Use --save-exact and a separate directory per version. Do not reach for --no-save: if you install package A with --no-save and later install package B with --no-save in the same directory, the second install prunes A, because neither is in package.json. I lost the SDK out of my own scratch directory that way, mid-run, and the symptom is a bare "Cannot find module" on a package you watched install successfully.
Then import by path rather than by bare specifier, so one script can drive every version:
const { query } = await import(
`/tmp/ver/${V}/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`
);
measure, then read the code
Here is the worked example. The question: how does the SDK translate its thinking options into CLI flags, and when did that change?
Five option shapes, five versions, argv captured for each:
Scroll to see more
| Version | {type:'adaptive'} | {type:'adaptive'} + maxThinkingTokens:10000 | {type:'enabled', budgetTokens:2000} | {type:'enabled'} | maxThinkingTokens:10000 |
|---|---|---|---|---|---|
| 0.2.38 | (no thinking flag) | (no thinking flag) | --max-thinking-tokens 2000 | (no thinking flag) | --max-thinking-tokens 10000 |
| 0.2.40 | --max-thinking-tokens 32000 | --max-thinking-tokens 10000 | --max-thinking-tokens 2000 | (no thinking flag) | --max-thinking-tokens 10000 |
| 0.2.44 | --max-thinking-tokens 32000 | --max-thinking-tokens 10000 | --max-thinking-tokens 2000 | (no thinking flag) | --max-thinking-tokens 10000 |
| 0.2.45 | --max-thinking-tokens 32000 | --max-thinking-tokens 10000 | --max-thinking-tokens 2000 | (no thinking flag) | --max-thinking-tokens 10000 |
| 0.2.47 | --thinking adaptive | --thinking adaptive | --max-thinking-tokens 2000 | --thinking adaptive | --max-thinking-tokens 10000 |
Now, and only now, read the bundles to explain what you measured.
At 0.2.38 the normalisation is, deobfuscated:
let thinkingTokens = options.maxThinkingTokens;
if (options.thinking) switch (options.thinking.type) {
case 'adaptive': thinkingTokens = void 0; break;
case 'enabled': thinkingTokens = options.thinking.budgetTokens; break;
case 'disabled': thinkingTokens = 0; break;
}
The adaptive branch nullifies the value the flag depends on, so asking for adaptive thinking produced no thinking flag at all. That is a real bug and it was reported as issue 168 on 10 February 2026.
At 0.2.40 exactly one line differs:
switch (options.thinking.type) {
case 'adaptive': if (!thinkingTokens) thinkingTokens = 32000; break;
case 'enabled': thinkingTokens = options.thinking.budgetTokens; break;
case 'disabled': thinkingTokens = 0; break;
}
A hardcoded default instead of a nullification. That is the fix, and it matches the maintainer's reply on the issue: "Yes, this should be fixed as of 0.2.40."
At 0.2.47 the whole path is rewritten. A dedicated --thinking flag appears, and the normalisation becomes a two-stage thing: options are folded into a thinking config, then the config is turned into flags.
if (thinkingConfig) {
switch (thinkingConfig.type) {
case 'enabled':
if (thinkingConfig.budgetTokens === undefined) push('--thinking', 'adaptive');
else push('--max-thinking-tokens', String(thinkingConfig.budgetTokens));
break;
case 'disabled': push('--thinking', 'disabled'); break;
case 'adaptive': push('--thinking', 'adaptive'); break;
}
}
That expression is what a grep bisect finds. It first appears at 0.2.47 and is still there at 0.3.273.
the trap, stated plainly
My grep bisect searched every published tarball for the current release's guard, the budgetTokens === void 0 test and the --thinking flag string. It reported, correctly, that both appear first at 0.2.47 on 18 February 2026.
From that I drew a conclusion that felt solid and was false: that the maintainer had closed issue 168 as fixed at 0.2.40 while the fix did not actually land until 0.2.47, seven releases later.
The behavioural bisect killed it in one table. At 0.2.40 the adaptive case already produces a thinking flag. The bug was fixed exactly where the maintainer said it was. What landed at 0.2.47 was a rewrite of a path that had already been correct for six days.
A grep bisect answers "when did this code appear". A behavioural bisect answers "when did this behaviour change". Those are different questions, and when a vendor rewrites a fixed path the two answers diverge by however long the gap between patch and rewrite happens to be.
Note the direction of the error. The grep bisect did not merely give a vaguer answer, it produced a specific, publishable, accusatory claim about a named engineer. If you are about to write "the maintainer closed this too early", that sentence is a signal to go and run the versions.
what the measurement found that neither instrument alone would
Read the table again along the {type:'enabled'} column, the case with no budgetTokens.
At 0.2.38 through 0.2.45 it produces no thinking flag. At 0.2.47 through 0.3.273 it produces --thinking adaptive. It has never produced a fixed budget. The failure mode changed, silently, and the option has never once done what its name suggests.
This matters because it typechecks. budgetTokens is optional in the ThinkingEnabled type, as the TypeScript reference shows, so under tsc --strict this compiles with no error at all:
const opts: Options = { thinking: { type: 'enabled' } };
I checked: the only shape in that file tsc rejects is an invalid variant such as { type: 'auto' }, which fails with TS2322: Type '"auto"' is not assignable to type '"adaptive" | "disabled" | "enabled"'. The no-budget case passes clean.
Worth crediting: this conversion is not my discovery. The Cherry Studio project documented the exact mechanism in March 2026, including the deobfuscated branch, after it produced a 400 from a non-Anthropic proxy that accepts only enabled and disabled. What the version archaeology adds is when it started and why: it arrived with the 0.2.47 rewrite, as a side effect of re-doing a fix that was already working.
One practical consequence. Issue 168 is closed, and it still recommends dropping thinking entirely and using only maxThinkingTokens. On 0.3.273 that advice is unnecessary. Measured on the full argument vector, not just the thinking flags:
maxThinkingTokens: 2000produces a 9-element argvthinking: { type: 'enabled', budgetTokens: 2000 }produces a 9-element argv- the two arrays are identical, element for element
So the workaround points readers at a deprecated option for no benefit. Pass budgetTokens and use the supported one.
The current translation table
For reference, every thinking option shape at 0.3.273, measured:
Scroll to see more
| Options | argv |
|---|---|
thinking: {type:'adaptive'} | --thinking adaptive |
thinking: {type:'enabled', budgetTokens:2000} | --max-thinking-tokens 2000 |
thinking: {type:'enabled'} | --thinking adaptive |
thinking: {type:'disabled'} | --thinking disabled |
maxThinkingTokens: 2000 | --max-thinking-tokens 2000 |
maxThinkingTokens: 0 | --thinking disabled |
thinking: {type:'adaptive'} + maxThinkingTokens: 2000 | --thinking adaptive |
thinking: {type:'auto'} + maxThinkingTokens: 2000 | (no thinking flag) |
| neither option set | (no thinking flag) |
Two rows deserve a second look. {type:'enabled'} with no budget is byte-identical to asking for adaptive. And an invalid variant is worse than useless: it produces no thinking flag and suppresses the maxThinkingTokens fallback, because the normalisation is an if (thinking) ... else if (maxThinkingTokens !== undefined) ... and an unrecognised type falls through the inner switch without setting anything. Its argv is identical to passing no thinking options at all. TypeScript catches that one, so it is a hazard for JavaScript consumers, config loaded from JSON, or anything that reaches the option through a cast.
Scope and limits
Steps 1 through 7 are first-party measurements: I installed each version, ran it, and captured the argv. The version dates come from the npm registry.
What is not measured here is what the CLI and the model do with those flags. The deprecation note on maxThinkingTokens says that on Opus 4.6 the value is treated as on/off rather than as a budget. That happens downstream of the SDK, and this article cannot confirm it, because confirming it needs a real Claude Code install, an API key, and a billed run. What I can say precisely is that the SDK forwards your number as --max-thinking-tokens N; whether a 2000 you pass is honoured as 2000 is a question for a different harness.
The same limit applies to toolAliases. I confirmed it is transmitted verbatim over the control protocol with no SDK-side validation at all, including chains, self-references and unknown tool names, but the documented single-hop resolution happens in the CLI and is outside what this harness can see.
The method, condensed
- Find the boundary where the thing you are measuring becomes observable. For this SDK it is the spawned subprocess.
- Replace whatever is on the other side of that boundary with a recorder.
- Get the real version list from the registry, with dates.
- Install candidates in isolated directories with exact versions.
- Measure first. Coarse bisect, then narrow, then pin.
- Read the source only to explain a result you already have.
- If your explanation implies someone made a mistake, run one more measurement before you write it down.
The same harness, pointed at a different option, is what showed that an unknown hook type is silently stripped rather than rejected. Once the recorder exists, most "when did this change" questions are a twenty-minute job.
Written by
Ren OkabeRen builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.
Frequently asked questions
Why not just grep the published bundles to find when a behaviour changed?
Because a grep bisect keyed on the current release's code tells you when that code landed, not when the behaviour changed. A vendor can fix a behaviour with a small patch and then rewrite the whole path months later. Measured here: grepping for the current thinking-flag guard dates it to 0.2.47, while running the versions shows the behaviour was already correct at 0.2.40. Install the candidates and measure what they do.
How do I capture the arguments the Claude Agent SDK passes to the CLI?
Set pathToClaudeCodeExecutable to a script of your own that writes process.argv to a file and exits 0, and set executable to node. The SDK spawns your script instead of the real CLI, so you get the full argument vector with no API key, no network and no billing. Subagent definitions and toolAliases travel over the control protocol on stdin rather than as flags, so read stdin too if you need those.
Does thinking type enabled without budgetTokens give me a fixed thinking budget?
No. On 0.3.273 the SDK converts it to --thinking adaptive, byte-identical to asking for adaptive. budgetTokens is optional in the ThinkingEnabled type, so it compiles clean under tsc --strict and you get no warning. It has never produced a fixed budget: from 0.2.38 to 0.2.45 it produced no thinking flag at all, and from 0.2.47 onward it produces adaptive. Always pass budgetTokens.
Is the deprecated maxThinkingTokens option still safe to migrate away from?
Yes, and the migration is a no-op at the argv layer. Measured on 0.3.273, maxThinkingTokens 2000 and thinking type enabled with budgetTokens 2000 produce identical nine-element argument vectors, element for element. The workaround still recommended in the closed issue 168, which is to drop thinking and use only maxThinkingTokens, therefore buys nothing and points you at a deprecated option.
Why did npm pack fail on one version during the bisect?
Because that version was never published. Version 0.2.46 does not exist in the registry, and npm returns a 404 for it. Sparse version lines are normal. Pull the real list with npm view PACKAGE time --json before bisecting, and treat a missing version as a gap to skip rather than as a failed measurement.
Why did installing a second package with --no-save break my bisect?
Because --no-save installs are not recorded in package.json, so a later --no-save install prunes the earlier one as an extraneous package. If you install the SDK with --no-save and then install TypeScript the same way in that directory, the SDK disappears and you get a Cannot find module error for a package you watched install. Use --save-exact and one directory per version.
Related tutorials
Claude Agent SDK skills: "docs:*" throws, "pdf-*" does not
The skills option validates every name before the session starts, and its wildcard guard tests for exactly two endings. Eight of eleven wildcard shapes get through, become a Skill(name) rule that names no skill, and say nothing. Measured first-party on 0.3.272, with the bisect.
Unknown hook type: Claude Agent SDK 0.3.257 Drops the Hook, Not the File (2026)
Since @anthropic-ai/claude-agent-sdk 0.3.257 a malformed hook entry is deleted from settings.json and the rest of the file loads normally, so the hook just never runs. PreToolUse and PermissionRequest are the two exceptions, where the whole file is voided. Bisected against 0.3.252, with a runnable detector.
Claude Agent SDK Subagents: Isolate Context and Run Tasks in Parallel (2026)
Define subagents in the Claude Agent SDK with the agents parameter and AgentDefinition: isolate context, run focused subtasks in parallel, restrict each one's tools, and confirm delegation. Runnable Python and TypeScript, August 2026.