* build: omit peer deps from agent SDK tarballs, bump claude to 0.3.258 npm 7+ installs peerDependencies automatically, so the claude tarball has been carrying 100 packages the agent host never loads — @modelcontextprotocol/sdk, zod, ajv and their transitive graph. The SDK inlines all of that into sdk.mjs at publish time: sdk.mjs statically imports node builtins and nothing else, and the one external module it resolves at runtime is its own native binary package. Every reference to those packages on the VS Code side is an `import type`, which TypeScript erases. Adding --omit=peer to the packaging install leaves exactly two packages in the tarball, both pinned to the SDK version. That makes the bytes a function of (SDK version, target) and nothing else, so a transitive peer bump can no longer change the content at a CDN path that is already published — the failure that took #333870 and its revert #334094. Unlike --omit=optional, this doesn't touch the native binary package. codex declares no peers, so its tarball is byte-identical either way. That the SDK inlines its peers is an implementation detail Anthropic never promised, so package.ts now runs a load probe before tarring: in a child process it imports sdk.mjs out of the staged tree and builds an MCP server from it, peers absent. If a future version starts importing a peer for real, the build fails there rather than on a user's machine against a tarball that is already immutable on the CDN. Bumping claude in the same change since the CDN path moves regardless. 0.3.258 adds a required Query.updateSettings, hence the three test fakes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * build: verify the staged tree for every agent SDK, not just claude `--omit=peer` applies to every SDK, and `Sdk` is an open string type, so adding one is a single folder under `agents/`. The load probe that justified the flag only ran for claude, which left any other SDK — codex today, anything added later — inheriting the flag with nothing checking it. Replace the `if (sdk === 'claude')` guard with a `verifyStagedTree` dispatcher whose `default` branch fails the build. The per-SDK checks stay different on purpose: claude's tarball is dynamic-imported by the agent host, so the build imports it too; codex's never is, since the host spawns the vendored binary directly, so the binary layout is what's worth asserting. codex gets a structural check — the platform package vendors exactly one rust triple, holding a non-empty executable binary. It deliberately does not copy `codexAgent.ts`'s `sdkTarget → triple` table; a second copy could drift and then validate a path nothing uses. Also fixes a latent bug in `chmodPlatformBinaries`: the claude branch looked for `claude` on every target, so it silently skipped win32's `claude.exe`. Nothing shipped broken — the registry already publishes that binary 0755 and Windows ignores POSIX modes on extract — but the loop's filename assumption was wrong, and the new assertion checks the same path it chmods. Verified by fault injection against a real extracted tree: all seven codex checks and the unknown-SDK branch fire, and an untouched tree passes. Five real builds (claude darwin-arm64/win32-x64/linux-x64-musl, codex darwin-arm64/win32-arm64) succeed; the claude darwin-arm64 sha is byte-identical to one built before these checks existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * build: exercise the real tool() path in the SDK load probe, bound it with a timeout Three fixes from PR review. The probe checked that `tool` was a function but never called it. The shipped path is `buildClientToolMcpServer`, which passes a zod raw shape into `sdk.tool()` and the result into `createSdkMcpServer()`. A future SDK that resolved zod lazily inside `tool()` would sail past the old check and break at runtime. The probe now makes that exact call, using VS Code's own zod, which is what the agent host hands across the boundary. Verified by substituting a `tool()` that resolves a peer from disk: exit 1 with ERR_MODULE_NOT_FOUND. `spawnSync` without a timeout blocks forever, so the old comment claiming a child process kept a stray handle from wedging the build was wrong. Added a 2 minute timeout and a `result.signal` check, since a timeout surfaces as SIGTERM with a null status and would otherwise report a confusing exit code. The README claimed every reference to the peers in non-test `src/` was `import type`. That is true of `@modelcontextprotocol/sdk` but not of zod: `claudeJsonSchemaToZod.ts` imports `z` at runtime. The invariant that `--omit=peer` actually needs is narrower, that zod comes from VS Code's own dependency rather than the downloaded tree, so the README says that instead. Tarball sha is unchanged, since the probe file sits outside `node_modules`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * build: trim comments in agent-sdk package.ts Review feedback: the comments on the new verification code ran far longer than the code they described. Cut them roughly in half, and point at README.md for the rationale instead of restating it in three places. Also drops `nativeBinaryName` for a one-line `exeName(base, sdkTarget)`. It took an `Sdk` parameter every call site already knew statically, and `sdk === 'claude' ? 'claude' : 'codex'` would have silently returned 'codex' for any SDK added later. The only rule the two share is the `.exe` suffix on win32. No behavior change: claude darwin-arm64 still builds to sha256 1050d42b5e86f1d5b0c3a910e5325894d7b1dcfb684fe08ff4ffbf09dcfe0cda and codex darwin-arm64 to a32d7afd7f088e8e4fb9f237283bfb93f656ac1da5c78fb879d31e48422a241d. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * build: correct which SDK call the load probe leans on createSdkMcpServer() is what validates and converts the zod raw shape; tool() is a plain constructor that never touches zod. Verified by passing a non-zod shape: tool() returns fine, createSdkMcpServer() throws "inputSchema must be a Zod schema or raw shape". Comment and README said tool() was the load-bearing call. The sequence was already right, only the explanation was wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * build: drop SDK-specific logic from the staged-tree check verifyStagedTree was a switch with a claude case that imported sdk.mjs and replayed buildClientToolMcpServer's call shape (zod raw shape into tool(), result into createSdkMcpServer()) and a codex case that asserted the vendor/<triple>/bin layout. That put one SDK's API into the packaging step for a small gain: a peer that stops being inlined will come back as a static import, which a plain import of the entry point already catches. Now nothing in the check is conditioned on which SDK is building: - The entry point comes from the installed manifest's `main`, which is the same path claudeAgentSdkService.ts imports at runtime. codex declares no `main`, so it is skipped without a special case. - Every native binary must be present, non-empty and executable. The per-SDK binary layouts move into listPlatformBinaries, which chmodPlatformBinaries now shares, so the chmod and the assertion can no longer disagree about where the binaries are. That is also the new-SDK guard: no layout entry means no binaries found, and the build fails naming the function to edit. Removes the zod dependency from the build script and ~50 lines. Fault-injected, all caught: binary missing / empty / not executable, codex vendor/ removed, sdk.mjs importing an uninstalled peer (inserted after the shebang so it is a real ERR_MODULE_NOT_FOUND), and listPlatformBinaries returning [] for an unknown SDK. Tarball bytes unchanged: claude darwin-arm64 1050d42b…, claude win32-x64 b4e00f75…, codex darwin-arm64 a32d7afd…. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: re-record /model stdout for claude 0.3.258 The bumped CLI now backticks the model name in its `/model` slash command output, so the recorded request no longer matched the live one and the E2E replay failed on Linux and macOS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
build/agent-sdk
Per-platform agent SDK production. Each VS Code build (darwin-arm64,
linux-x64, Alpine REH, etc.) uploads its own platform's SDK tarballs
to main.vscode-cdn.net and stamps agentSdks into the shipped
product.json with a {version, urlTemplate} per SDK. Every platform
job emits the same urlTemplate per SDK — the runtime substitutes
{sdkTarget} per launch via resolveSdkTarget(), which is what lets
macOS Universal bundles share one product.json across arm64 + x64.
The runtime side (src/vs/platform/agentHost/) downloads and caches
the SDK tarball at first use. See IAgentSdkProductConfig in
src/vs/base/common/product.ts for the contract.
How the pipeline uses this
The platform packaging jobs (Linux, macOS, Windows, Alpine) each include
the shared template build/azure-pipelines/common/agent-sdk-produce.yml
before the existing gulp vscode-<platform>-<arch>-min-ci step:
- template: ../../common/agent-sdk-produce.yml@self
parameters:
vscodePlatform: linux
The template runs node build/agent-sdk/produce.ts --vscode-platform=<x> --arch=$(VSCODE_ARCH), which iterates the SDKs (SDKS = ['claude', 'codex']), figures out the matching sdkTarget for (vscode-platform, arch, sdk) via getSdkTargetForBuild, runs buildOne for each in
parallel, and drops the tarballs in
$(Build.SourcesDirectory)/.build/agent-sdk/tarballs/.
Publish vs test runs
produce.ts reads the pipeline variable VSCODE_PUBLISH from env (Azure
auto-injects all non-secret pipeline variables) to decide whether to
hit the CDN:
-
VSCODE_PUBLISH=true(real release builds) — the AzureCLI@2 step inside the template fetches CDN credentials,produce.tscallsuploadOnefor every tarball (HEAD-then-decide idempotent), writes the results JSON, and emits##vso[task.setvariable variable=AGENT_SDK_RESULTS_FILE]<path>. The downstream gulp packaging step then stampsproduct.agentSdksviareadAgentSdkResults(). -
VSCODE_PUBLISHunset or not'true'(PR runs, CI runs, manual test runs with the publish toggle off) — the AzureCLI credential step is skipped, the upload is skipped, no results file is written, andtask.setvariableis not emitted. The tarballs are still produced and published as a pipeline artifact namedagent_sdk_<vscodePlatform>_<arch>_tarballsso you can download and inspect them. product.json ships withoutagentSdks— same shape as a local dev build, so the runtime falls back to the per-provider env-var override.
Where the agentSdks gating lives
Inside packageTask's jsonEditor callback (the same one that injects
commit / date / checksums / version), readAgentSdkResults() loads
the results file (returns {} when the env var is unset) and merges
agentSdks into product.json. The REH gulpfile only writes agentSdks
for type === 'reh'; the REH-web variant skips it because the agent host
is node-only and the SDK config has no consumer in a browser-served
server.
Local gulp vscode-darwin-arm64 invocations don't set
AGENT_SDK_RESULTS_FILE and don't have VSCODE_PUBLISH=true, so
readAgentSdkResults() returns {} and product.json ships without
agentSdks — same UX as today's no-config build.
Why two steps, not inline-in-gulp
The agent SDK work is a distinct concern from the VS Code packaging gulp graph. As its own pipeline step:
- Visible in the build log — operators see a discrete "Agent SDK: build
- upload" step they can click into instead of grepping inside "Build client" output.
- Independently re-triggerable — if the SDK step fails, the operator can re-run just the platform job; if it succeeds but the gulp step fails, the SDK upload is already idempotent (HEAD-then-skip).
- Doesn't add async-stream complexity to the gulpfile.
packageTaskstays a sync stream-returning function; the only change is one synchronousreadAgentSdkResults()call inside the existingjsonEditorcallback.
Files
agents/<sdk>/— one folder per SDK we ship. Each contains apackage.json(single dependency: the SDK's own npm package, pinned to an exact version) and apackage-lock.json(full transitive graph). Folder name = SDK id = key underproduct.agentSdks= path segment in the CDN URL. The set of folders IS the SDK list — no parallel array to keep in sync.common.ts— types,getSdks()(discovers SDKs fromagents/),getAgentMeta()/getSdkVersion()(reads fromagents/<sdk>/package.json, rejects^/~ranges),getSdkTargetForBuild()((vscodePlatform, arch, sdk) → npm-suffix),buildCdnUrl()/buildCdnUrlTemplate(),sha256OfFile(),parseFlags()for CLI flag parsing, andreadAgentSdkResults()for the gulpfile-side reader.package.ts—buildOne({ sdk, sdkTarget, outDir }). Runs on any OS: copiesagents/<sdk>/{package.json,package-lock.json}into a scratch dir,npm ciwithnpm_config_libc/os/cpufetches the foreign platform binary verbatim from the locked graph, then node-tar+gzip with reproducible flags. Has a thin CLI at bottom.upload.ts—uploadOne(...). HEAD-then-decide: absent → upload; matching sha → skip (idempotent re-runs); different / no-metadata sha → fail loud, refusing to overwrite content-addressed history. Thin CLI.produce.ts— pipeline-step entry. For one(vscode-platform, arch), iterates the SDKs in parallel, callsbuildOne+uploadOnefor each that applies, writes results toAGENT_SDK_RESULTS_FILE, and emits##vso[task.setvariable]so downstream pipeline steps see the path.
What ends up in a tarball
npm ci --ignore-scripts --omit=peer, then the whole node_modules/ tarred.
--omit=peer is the load-bearing flag.
npm 7+ installs peerDependencies automatically, so claude's lockfile carries
100 packages the agent host never loads: @modelcontextprotocol/sdk, zod,
ajv and their transitive graph. The SDK inlines all of that into sdk.mjs at
publish time. sdk.mjs statically imports node builtins and nothing else, and
the one external module it resolves at runtime is its own native binary
package.
On the VS Code side, @modelcontextprotocol/sdk is only ever import type, so
TypeScript erases it. zod is not: claudeJsonSchemaToZod.ts imports z at
runtime to build the raw shapes it hands to sdk.tool(). That zod is VS Code's
own dependency (root package.json, shipped in the product), and the objects
flow into the SDK. Nothing resolves zod out of the downloaded tree. That is
the invariant --omit=peer needs, and it is weaker than "unused".
With the peers omitted, a claude tarball is exactly two packages,
@anthropic-ai/claude-agent-sdk and the one claude-agent-sdk-<target> binary
package, both pinned to the SDK version.
The point isn't size (the peers are ~4% of a ~90MB tarball). It's that the
tarball becomes a function of (SDK version, target) and nothing else. Before
this, a transitive peer bump could change the bytes without changing the
version, and since the CDN path is content-addressed and immutable, the upload
then failed against the already-published blob. That is
#333870 /
#334094.
--omit=optional would be a very different flag: the native binary ships as an
optional dependency, and findMissingNativeOptionalDep exists to catch it
going missing.
codex declares no peers at all, so the flag is inert there — its tarball bytes are unchanged.
Keeping the assumption honest
That the SDK inlines its peers is an implementation detail Anthropic never
promised; the peerDependencies block says the opposite. And --omit=peer
applies to every SDK, including any added later, so the check that justifies it
can't be special-cased to one.
verifyStagedTree in package.ts runs against the finished tree, just before
it is tarred, and nothing in it is conditioned on which SDK is being built:
- It imports the package's entry point in a child process, under a
timeout, with the peers absent. The entry is
<package>/<manifest.main>, which is the literal pathclaudeAgentSdkService.tsloads at runtime. Packages that declare nomainare skipped, which is how codex opts out without a special case: it ships only abin, and the agent host never loads JS from that tarball. If a future SDK starts importing a peer for real, the build fails with ERR_MODULE_NOT_FOUND instead of failing on a user's machine months later, against a tarball already immutable on the CDN. - It stats every native binary and requires each to be present, non-empty and executable.
Step 2 needs the one piece of per-SDK knowledge in the file, since no manifest
field describes it: claude ships a single binary at the root of its platform
package, codex fills a vendor/<rust-triple>/bin/ directory.
listPlatformBinaries is the only place that encodes those layouts, and
chmodPlatformBinaries reads from the same function, so the chmod and the
assertion cannot disagree about where the binaries are.
An SDK added under agents/ with no entry in listPlatformBinaries yields no
binaries, and step 2 fails the build naming the function to edit. That is the
mandatory-per-SDK guard: a new folder cannot inherit --omit=peer unchecked.
The import probe deliberately does not exercise SDK-specific APIs. An earlier
version called tool() and createSdkMcpServer() with a zod shape to catch a
peer resolved lazily inside those calls, but that meant hardcoding one SDK's
call shape into the build, and the packaging step is the wrong place for it.
A peer that comes back will almost certainly come back as a static import,
which the plain import catches. The lazy resolution that does exist in
sdk.mjs today is for the native binary, and step 2 covers that.
Both steps are cross-target safe: sdk.mjs is platform-independent JS and
importing it does not spawn the native binary.
Bumping an SDK version
- Edit the
dependenciesversion inbuild/agent-sdk/agents/<sdk>/package.jsonto the new exact version. - From that directory:
npm install --package-lock-only --ignore-scriptsto refreshpackage-lock.json. - Also bump the matching
devDependenciesentry in repo-rootpackage.json(the runtime imports types from that copy) so the shipped types and the build-time pin stay in lockstep. npm installat repo root to refresh the root lockfile.- Commit all four edits together.
The test/versionSync.test.ts build test (run by cd build && npm run test in PR CI) enforces step 3: it fails if an SDK's agents/<sdk>
package.json pin, its package-lock.json, and the repo-root
devDependencies pin ever fall out of lockstep.
The next pipeline run rebuilds + uploads each platform tarball at the
new content-addressed CDN path and re-stamps each product.json with
the new urlTemplate pointing at the bumped version.
No human-paste step into vscode-distro. No coordination between jobs.
Local dev
Build one tarball locally:
node build/agent-sdk/package.ts --sdk=claude --target=darwin-arm64 --out=/tmp/out
For OSS contributors who want to drive the agent host without going through the CDN, point the dev override env vars at a local SDK install:
VSCODE_AGENT_HOST_CLAUDE_SDK_ROOT=/path/to/anthropic-claude-sdk-install \
./scripts/code.sh
(See src/vs/platform/agentHost/common/agentService.ts for env var names.)