feat(agent-setup): configure Zed - #437
Draft
Menci wants to merge 61 commits into
Draft
Conversation
Zed reaches Floway through its `anthropic_compatible` provider rather than `openai_compatible`. That provider gets the full JSON Schema for tool definitions where the OpenAI one is pinned to a lossy OpenAPI subset that rejects `if`/`then`/`$ref` outright and silently drops `format` and `additionalProperties`; it maps Anthropic errors into typed retry semantics instead of flattening them at the transport boundary; its SSE reader tolerates a stray `[DONE]` sentinel that gateways emit; and it reports cache token counts, which the Chat Completions mapper hardcodes to zero. That last one matters because Zed has no token counting at all, so streamed usage is its only source of truth for the context meter and auto-compaction. Both installers write `global_settings.json`, a settings layer Zed reads below the user's own file and never creates or writes itself. Owning that file outright is what keeps this on plain JSON: the user's settings.json is JSONC, and no portable tool edits it safely — jq and its kin cannot parse comments, and PowerShell's ConvertFrom-Json errors on 5.1 while 7.0-7.5 silently turn a comment inside an array into a string element. The merge touches only the one provider key, matching the Claude and Codex installers. Zed is configured but never installed: it ships outside any package manager these scripts drive, so a missing configuration directory is a hard stop rather than a directory to create. Model entries are snapshotted from `/v1/models` because `anthropic_compatible` has no discovery path, and are selected by `kind` rather than by `endpoints` — the endpoint map is the upstream wire surface, and translation lets any chat model serve a Messages request. The credential is written to the OS store ahead of the settings document. Without a key `is_authenticated()` is false and every model disappears from the picker with no error shown, so the failure mode to avoid is a registered provider with no credential; an unreferenced credential is harmless by comparison. Refs: zed-industries/zed#30444 PowerShell/PowerShell#14553
Adds the Zed tab to Agent Setup: a provider-name field, the setup command, and a config snippet pair for operators who configure by hand. Zed takes only a name because everything else is derived. The catalog projection lives beside the Claude and Codex model helpers and mirrors the installer's jq program — both write the same `available_models` array, and the suite pins the parts that would silently break Zed: models are selected by `kind` rather than by `endpoints`, all three capability flags are always written because Zed reads no per-field default and a partial object fails the whole provider, and reasoning maps onto adaptive, budgeted thinking, or no mode at all. The snippet is a whole `global_settings.json` rather than a fragment to merge, since Zed owns nothing in that file. Its credential half is a separate block because Zed reads the key from the OS credential store, which has no settings representation: macOS and Secret Service take a one-liner, Windows needs a CredWriteW P/Invoke because the blob must be UTF-8 and cmdkey writes UTF-16LE. lobe-icons carries no Zed mark, so the tab icon comes from simple-icons under CC0, normalized to the sizing and currentColor attributes the other marks use.
Ten cases run both installers for real: the catalog projection, the managed-key merge against a document holding a sibling provider, the credential record, a provider name carrying a quote, and the three refusal paths — absent configuration directory, unparseable settings, and a catalog with no chat models. One case runs the same scenario through PowerShell and asserts the same provider document, which is the guard that matters most here: the jq program and the PowerShell projection are two implementations of one mapping, and nothing else would catch them drifting. Both fragments gain a credential hook, since neither a keychain nor a Secret Service is something a test host can be asked to mutate. The harness serves `/v1/models` with one model per branch of the projection — adaptive reasoning with images, a budget ceiling, no limits at all, and a non-chat kind that must be dropped. Collapse the three per-agent configuration builders onto a shared base so the fourth does not repeat the whole schema a fourth time.
Four defects, each of which would have shipped a broken or unsafe setup. **A thinking mode with no budget.** Zed serializes `Thinking::Enabled.budget_tokens` with no skip_serializing_if, unlike the `Adaptive` variant beside it, so a mode carrying no budget puts `"budget_tokens": null` on every Messages request and Anthropic rejects it. The projection emitted exactly that for any model stating reasoning without a ceiling — every Codex model (effort levels only) and every Claude Code model (a floor, no ceiling). Such a model now stays in Default mode, which the picker still offers, and where a budget exists the floor is preferred: Zed sends it verbatim on every request and Anthropic requires it below max_tokens. **XDG honored on macOS.** Zed consults `XDG_CONFIG_HOME` on Linux and FreeBSD only; macOS falls through to an unconditional `~/.config`. An operator exporting XDG on macOS — routine for shared dotfiles — had the file written where Zed never reads it, and the run reported success. FLATPAK_XDG_CONFIG_HOME is honored on Linux, matching upstream's order. **A hardcoded app bundle.** `security -T` fails the entire call when the path does not exist, so anyone running Zed Preview, Nightly, or a `~/Applications` install had setup abort with the reason discarded by a stderr redirect. Only bundles present on the host are named now, and naming none still writes the item. **A test hook that exfiltrated the key.** `AGENT_SETUP_TEST_CREDENTIAL_ RECORD` wrote the live credential in cleartext to an env-var-chosen path, replacing the hardened store. Every other hook in the family adjusts a timeout, a colour, or where code is fetched from, and the two that hand control to a chosen path strip `SETUP_API_KEY` first. Since these scripts are piped into a shell that inherits the caller's environment, one `.envrc` or profile line was enough. It is gone; the harness shims `secret-tool` and `security` on PATH instead, which also brings their real argument vectors under test for the first time. Also from the same pass: the catalog request carries its credential in a curl config file rather than argv; model projection moved ahead of both the credential write and the backup, so no failure can strand an orphan backup or an unreferenced keychain entry; PowerShell uses File.Replace on Windows rather than a delete-then-create Move-Item, guards its Add-Type against a second run in the same console, validates through the property bag so a provider named `Count` resolves, compares against $null so a catalog 0 survives, and surfaces the underlying fetch error. The `secret-tool clear` call and its rationale were both wrong — libsecret replaces a matching item — so both are gone, and the config-dir override is renamed to the `AGENT_SETUP_TEST_` convention.
The dashboard fetches `/api/models?include_unlisted=true` to populate the alias combobox, and nothing downstream dropped those rows. The installer snapshots `/v1/models`, which by definition excludes them, so an operator with `modelPrefix.addressable` wider than `listed` got a snippet naming both ids — visually identical in Zed's picker, since the unlisted row copies its display name — while the setup script wrote only one. The projection now drops them, and the id dedupe it carried is gone: the catalog collapses on a Map keyed by public id, so duplicates cannot arrive, and the guard only masked the divergence from the jq program, which never had one. Reasoning maps the way the installers now map it — a budget or no mode at all, floor preferred over ceiling — so the two implementations write the same document again. An empty projection no longer renders a copyable document. The installer refuses that catalog rather than register a provider with no models, and the panel offering one anyway would have handed the operator a config that fails silently. It shows the same refusal instead. The config hint said "Merge into", but the snippet is a whole document whose paste would drop any other provider in the file; it now says to save it and merge by hand when one exists. The credential snippet drops the `secret-tool clear` that the installer also dropped.
**The curl config file corrupted the key.** Moving the credential off argv looked like a hardening, but curl's quoted config value parses `\` escapes and terminates at `"`, and the unquoted form is discarded for containing whitespace. Verified against a listener: a key holding a quote arrives truncated, and one holding a backslash arrives with the escape expanded — the gateway 401s and the operator is told only that the catalog fetch failed. An API key is an arbitrary string, so this is reachable. Reverted to `-H`, which passes the key byte for byte, with the tradeoff stated where it is made. **PowerShell stored the key with a trailing newline.** `$key | & secret- tool` terminates the piped object with a newline and secret-tool stores every byte it reads, so Zed read back `sk-…\n` and sent a malformed Authorization header on every request — a 401 loop behind a settings file that looks correct, and a divergence from Bash on the same host. Now written through a redirected stdin. The shim could not have caught the second one: command substitution eats a trailing newline, so `key` and `key\n` recorded identically. It records the byte count instead, and the PowerShell test asserts it — the Bash test could not, because it takes the `security` branch on macOS while CI takes `secret-tool` on Linux. Confirmed by reintroducing the pipeline and watching the test fail. Also: the dashboard's macOS credential snippet still named a hardcoded `/Applications/Zed.app`, which fails the whole call on a Preview, Nightly, or `~/Applications` install — the installers were fixed for this in the previous commit but the pasted form was not. `max_output_tokens` moves after `capabilities` so the web projection matches the installers byte for byte rather than only semantically, and `== null` there drops an explicit JSON null the way both installers already do. `--agent` accepts every registered agent instead of the original two. A ceiling-only catalog row covers the budget fallback the installer harness never exercised. PowerShell reports its own message when a backup cannot be made, and an unverified claim about PSObject intrinsics is removed rather than left asserting something I did not confirm.
…licitly The redirected-stdin write leaked a Process handle on every run. Only stdin is redirected, so there is no output pipe to deadlock on, but the handle still needs releasing. The stdin assertion compared against a JS string length, which equals the UTF-8 byte count only because the sentinel is ASCII. It now says so and measures bytes, so a non-ASCII sentinel would not quietly turn the check into a tautology.
The strict schema parses stored rows as well as request bodies, so a configuration written before this branch — one with no `zed` key — throws on the next acquire. It throws permanently: `latestByUserId` has no `expires_at` predicate, so the stale row stays latest, and the parse happens in `restorableConfiguration` before the insert that would replace it, so the sweep trigger never fires and retrying re-enters the same 500. Verified by parsing a pre-branch blob against the current schema, then by running the migration against SQLite and re-parsing: the legacy row backfills and parses, an already-migrated row keeps its own name, and unrelated fields survive. Same shape as 0061 and 0068, which backfilled for the same reason. The pasted macOS credential snippet now runs its Darwin arm in a subshell. Unlike the installer's function, where `set --` is scoped, a pasted script sets positional parameters in the operator's own shell — leaving the API key in `$@` after the paste, where a later `echo "$@"` would surface it. The credential shim records the secret as hex rather than a byte count. The count proved a trailing newline was absent but would have accepted any same-length content; confirmed by substituting an equal-length wrong secret and watching the hex assertion fail where the count had passed.
The shim piped stdin through `od` and `tr`, neither of which is in the harness's hermetic tool list — the installer PATH is exactly binDir plus SHIM_BIN, with no system directories. Both stages die with "command not found", the substitution yields empty, and `case` swallows the status, so the shim still exits 0 and the assertion compares '' against the key. That is red on CI, which runs Linux and therefore takes the secret-tool branch; my macOS runs take `security` and never reach it. Reproduced under `env -i PATH=…` before fixing, and confirmed afterwards by forcing the secret-tool branch on this host. The secret now goes to its own file via `cat`, the one byte-preserving tool already on that PATH. Still byte-exact, still fails on the trailing newline a piping shell would add, and no longer licenses the installers to reach for tools the harness does not provide. PowerShell 6+ routes -Headers through HttpClient, which parses Authorization as a typed header and rejects a parameter containing `,` or `"` before the request leaves the host — verified: a key with a comma throws "The format of value is invalid" with no request sent, and succeeds with -SkipHeaderValidation. 5.1 has neither the validation nor the switch. The Bash installer already passes the same key to curl verbatim and says so in a comment; this makes the two agree. The projection is also handed to jq through --slurpfile rather than --argjson. It is already a file, and a single argument is capped at 128 KiB, which a few hundred chat models would reach.
It is the one free-text field in Agent Setup, so the only one whose draft the gateway can reject — every other control is a dropdown over a closed set. Typing a name and pausing after a space PUT a padded value, and a 400 is not retryable: the save was abandoned, the copy button stayed disabled, and the untranslated Zod issue landed in the page-level message bar with nothing pointing at the field. The name is now held locally while invalid and only patched into the draft once it is acceptable, with the reason shown on the field itself — the same shape as `d2cd73ab3`, which moved model validation to its fields. Confirmed load-bearing by removing the predicate and watching the test fail. The length bound is restated rather than imported, since `apps/web` may not runtime-import the gateway package; it only stops typing early, and the gateway still enforces the rule.
The local hold that keeps an invalid name out of the draft outlived the configuration it was typed against: once set it shadowed the incoming value permanently, so a lease arriving for another key rendered behind a half-typed name from the previous one. That is the invariant the two existing card tests protect — the form shows the lease and nothing else. It is now keyed on the configuration's api key id, so the hold applies only while that configuration is the one on screen. Confirmed by writing the test first, watching it fail, and re-running it against the unkeyed form afterwards. My first attempt at that test asserted the field reverts the moment another key is picked. It does not, and should not: the card deliberately keeps showing the current configuration until a lease answers, which the neighbouring test asserts. The test now stubs the arriving lease, which is the point at which the hold must yield. Also renames a lease-projection test that still said "both agents"; the same phrasing was corrected in its sibling when Zed landed.
The dashboard's Windows credential snippet emitted a bare Add-Type under the type name ZedCred, while the installer defines the same writer as FlowayZedCredential behind an existence guard. Two surfaces writing the same credential now agree on both. Add-Type returns its cached type for a byte-identical re-add and rejects a differing source under a name already in the AppDomain, so the guard makes a second paste — after rotating the key or renaming the provider — a no-op outright instead of resting on that cache. The emitted snippet was parsed with the PowerShell parser and run twice in one session to confirm the here-string survives the enclosing block. The installers' /v1/models fixture served the catalog to any caller, so removing the Authorization header from either installer left the suite green while every real install would have failed. It now answers 401 without the sentinel key; removing the header fails four Bash cases and the PowerShell parity case.
The comment asserted how VS Code keys its own provider group, which nothing on this branch implements and no reviewer here can check against code. The constraint it documents — an opaque label neither editor derives behavior from — is stated from the one consumer that exists.
The installer fetched /v1/models and projected it into Zed's available_models itself, which meant the same mapping existed three times: a jq program, a PowerShell loop, and the dashboard's preview builder. They had to agree byte for byte and repeatedly did not — a stated limit of 0 became a fallback under PowerShell truthiness, an empty effort list was dropped, and key order drifted. Each divergence was found by a test written specifically to compare the three. The gateway now projects the catalog once and embeds the result in the script it serves. The installers keep only the merge: write the embedded list to a file and hand it to jq, or decode it and hand it to the document writer. Nothing compares three implementations any more because there is one, in packages/agent-setup/src/models.ts, which the dashboard imports through a new /models subpath export so its preview is what a run will write. Two values stay client-side. The endpoint URL, because the gateway does not render its own public origin — the dashboard injects it into the executing shell. And the API key, because it already appears once in the script and copying it into every model entry would multiply the credential for no gain. Listing now happens while serving the script, so it can fail there. A listing failure renders a script that says so and exits non-zero: an opaque 404 would read as a dead setup link and a 500 as a gateway fault, and the upstream detail stays in the operator's log. The upstream scope is resolved through the same intersect rule the data plane uses, extracted so a setup script cannot advertise a model the key cannot reach. zed.sh drops from 307 to 248 lines and zed.ps1 from 344 to 278.
`budget_tokens.min` is a lower bound an operator may legitimately record as 0, meaning "no lower bound stated", and the projection preferred the floor unconditionally. Zed sends the budget verbatim on every Messages request and Anthropic rejects anything under 1024, so such a model 400'd on every call with nothing in Zed's UI to explain it — and a stated ceiling that would have worked was discarded. A floor too small to use now falls through to the ceiling, and a model with no usable budget stays in Default mode, which the picker still offers. This is the same class as the zero-limits case: a stated 0 is a value, not an absent bound. Two failures around the new gateway seam: The PowerShell failure script ended in `exit 1`. Its documented invocation is `irm … | iex` in the operator's own console, which `exit` closes — taking the message the script exists to show with it. Reproduced; it now sets $global:LASTEXITCODE and returns, matching what the installers already do. The listing failure told the operator to check the gateway log, while the diagnostics helper deliberately drops the error message because a generic failure may carry a secret. Both secrets are in hand at that call site, so the reason is redacted and kept rather than discarded. And a transaction boundary: the PowerShell document mutation sat outside the try that owns rollback. A provider name PowerShell reserves on every object — PSObject, PSBase, PSTypeNames, all accepted by the schema because Zed treats the key as opaque text — makes Add-Member throw there and leaves the backup beside the operator's settings forever. Moving it inside restores the "no orphan" property the comment claimed; a test asserts the directory is clean after such a run, and fails when the block moves back out.
`PowerShell writes the same provider document as Bash` never read the Bash document — it re-asserted a hand-maintained copy of that test's expectations, which is the duplicated-expectations mechanism the server-side projection was built to remove: a restated expectation can drift from both implementations at once. It now runs both halves over the same catalog and prior document and compares the two results, with one anchored assertion so the comparison cannot pass by both halves being wrong the same way. Making the PowerShell half drop a model fails it. A single-model catalog had no coverage in either half, though it is where ConvertTo-Json unwraps a one-element array into an object — which fails Zed's Vec deserialization and takes the whole provider down — and where jq's --slurpfile/$models[0] is pinned. The catalog fixture gained the two rows whose projection branches nothing exercised: a model stating limits of 0, and an addressable-but-unlisted row that must not reach the settings document. The model server's /v1/models handler and its two catalog modes are gone. No installer requests that endpoint any more, so the handler was unreachable and its comment — that rejecting an unauthenticated request makes every success case assert the header — had become false.
`umask 077` made every Zed settings write come back 0600, including a run that refused the document and reported leaving it untouched — the backup was created under the umask and moved back over the original. This file holds no credential (Zed reads the key from the keychain), so the operator's own mode is theirs to keep: the backup is copied with `-p` and the staged replacement inherits the mode of the document it replaces. A new file still takes the umask default. The pre-backup gate also passed on a truncated file, because jq runs a filter zero times on empty input and still exits 0. Such a file was backed up, staged, and only caught at the staged check — reporting a fault in our own list rather than naming the document the operator has to fix. `-e` answers that before any backup exists. The ESLint rule forbidding apps/web from runtime-importing @floway-dev/agent-setup matched only ImportDeclaration, so the branch's own `export … from` re-export of the /models subpath satisfied it by syntax rather than by intent — and a future re-export of the root would have too. It now matches exports as well and names /models as the one allowed surface, which is what makes the dashboard preview and the embedded projection the same code.
…alves Zed is the first agent to key a map by operator-chosen text, which reaches a case-insensitivity the shared PowerShell helper had never been exposed to: `-contains` matched `floway` for a chosen `Floway`, and the dotted assignment then wrote the new value under the OLD key. Bash added a second key instead. So one operator renaming only the case got a picker still showing the old name and an exit 0 on Windows, and a stale broken provider beside the new one elsewhere. The PowerShell property bag cannot hold both keys at once — adding `Floway` beside `floway` replaces it — so keeping both is not a behavior the two halves can share. Both now drop any key differing only by case before writing the chosen one, which is also the better outcome: a case-only rename stops leaving an entry whose stored credential no longer matches its name. ASCII case is what both fold, stated where they fold it. The Zed fragment does this itself rather than through Set-SetupProp, whose case-insensitive lookup is correct for the fixed key names Claude and Codex pass it.
Zed reads global_settings.json with serde_json_lenient, so a JSONC comment is
the operator's own content. jq refuses such a document and PowerShell 7 accepts
it and drops the comments on the way out — data loss reported as success, and a
third behavior on the 5.1 baseline, which errors. Both halves refuse it now. The
check walks strings rather than matching a pattern, so a `//` inside a URL or a
model id is not mistaken for a comment.
ConvertTo-Json emits a subtree deeper than its limit as the literal string
"@{k=}" with only a warning, and the staged check cannot see it because that
inspects the provider entry alone — an unrelated setting nested deeply enough
was replaced by a string under exit 0. The warning is promoted to an error.
Three of the previous round's repairs were incomplete, and the gaps are the
same shape each time — a rule stated in one place and enforced in another.
The budget fix bounded the floor at Anthropic's minimum and restated "must stay
below max_tokens" without enforcing it. Zed sends max_tokens as the model's own
output limit or 4096 when it declares none, and passes the budget through
unclamped, so a model announcing a 32000 ceiling and no output limit produced a
provider that 400s on every request — enshrined as expected in two fixtures.
The budget is now held under that ceiling too, and a model with no qualifying
budget stays in Default mode.
Mode preservation reached only the Bash half: the PowerShell stage was written
under the process umask, so a settings file the operator had tightened to 0600
came back 0644. The replacement now carries the mode of the file it replaces,
as Bash does. Windows needs nothing — File.Replace keeps the destination ACL.
The root-shape check read the decoded value, and ConvertFrom-Json unwraps a
top-level one-element array into a bare object — so `[{...}]` was rewritten as
an object with the array silently discarded, exit 0, while jq refused it. The
VS Code half already decided this from the text; the Zed half does now.
`stat` was missing from the harness's hermetic tool list, so the BSD fallback
in the new mode helper was never exercised and the suite failed on a stock
macOS PATH. Verified there with Homebrew's coreutils off the PATH.
The ESLint export hole was closed on the agent-setup rule and left open on the
gateway rule directly above it, which states the same rationale.
… comment claims
Four corrections, two of them to statements that were simply false.
The 200000 context fallback was documented as "the window Zed's own
Anthropic-compatible provider assumes for an unknown model", citing lines that
hold two unrelated structs. Zed has no such fallback: `max_tokens` is a
required u64 consumed verbatim, so a model without one fails deserialization
and takes the whole provider down. The number is ours, and the comment now says
so, citing the field and its consumer. Three further references were off by a
file or a line — the 4096 default, the inline-chat predicate, and the lenient
parse, which lives in fallible_options.rs and not in settings_store.rs.
ProviderNameField's comment said it withholds control-character names; the
predicate only checked emptiness and padding. A text input strips CR and LF but
not a tab, so a name pasted from a spreadsheet reached the draft and drew a 400
that is not retryable — leaving the lease with a disabled copy button and an
untranslated Zod issue, exactly what the field exists to prevent. The check now
matches the gateway rule, and the message names what it rejects.
The PowerShell root-shape check moved ahead of the decode and into a shared
helper. `Get-Content -Raw` yields $null for an empty file, so the Zed half was
dereferencing null and reporting a PowerShell internal where Bash named the
document — the VS Code half already had that guard. Asking the shared helper is
what stops this landing on one half again; the empty-file case now runs against
both.
The Bash gate accepted a stream of JSON documents, so `{"a":1}{"b":2}` passed
and was rewritten as a two-document file reporting success. Slurping asks for
exactly one.
The control-character check reached the predicate that renders the error and not the gate that decides whether to patch the draft, which still asked only for non-empty and untrimmed. `"Ops\tbox".trim()` equals itself, so an interior tab was shown as invalid and sent anyway — drawing the unretryable 400 the field exists to prevent, with the error already on screen. Both now ask one predicate, because two conditions here mean a value can be invalid and sent at the same time. The test was named for withholding but asserted only that the message rendered, so it passed against the broken gate. It now flushes the save debounce and asserts no request body carries the value; restoring the weak gate fails it. The JSONC refusal reached only the PowerShell halves. zed.sh had no comment check at all and vscode.sh matched a line-leading `//`, so a block comment or a trailing one was refused by jq while naming the wrong cause. The scan is now a shared Bash helper mirroring the PowerShell one — string-aware, so a `//` in a URL is not a false positive — and both fragments call it. Verified against seven documents including escaped quotes; both Zed halves now name the comment, asserted by the parity test. A settings file the run creates also landed 0600 under Bash and 0644 under PowerShell, which took the ambient umask. A file we create is ours to set: both state owner-only now, and the Bash comment stops claiming a new file "keeps the umask default" when main has already set 077.
`reports a padded name at the field and withholds it from the draft` built a fetch mock, never inspected it, and never waited past the save debounce — so it asserted the error message and nothing about withholding. Removing the draft gate entirely left it green while its control-character sibling failed, which is the same defect the previous round fixed on that sibling and left here. Both now read the request bodies through one helper, because a field that shows an error while the value is already on its way is precisely what these two exist to catch. Removing the gate now fails both. Also: the ConvertFrom-Json paragraph explaining the array-root refusal had been separated from its test by the owner-only case inserted between them, so it sat above a test it does not describe; the note on PROVIDER_NAME_MAX_LENGTH still said the dashboard may not runtime-import @floway-dev/agent-setup, which this stack narrowed to "only through /models"; and three comments used a backtick as an apostrophe.
`listModels` returned an empty catalog when the API key or its owner was deleted between `resolveApiKey` proving they exist and the listing itself. The installer then told the operator their gateway advertises no chat models, for what is a deleted key. It throws now, so the listing-failure script says something went wrong. The harness's Windows-replacement rewrite reached only Claude and Codex, so Zed's `File.Replace` — the branch where a `$null` PowerShell binds as String.Empty aborts the whole install — ran nowhere off Windows. The rewrite is keyed per agent and throws when a guard stops matching, rather than being a chain of .replace calls that silently no-op, and a Zed case now executes that branch: reverting NullString::Value fails it.
`max_tokens` on a Zed model entry is copied straight into `max_input_tokens` and returned by `max_token_count()`, so it is the budget Zed compacts against — not the full window. The projection filled it from `max_context_window_tokens` first, so a model stating both, which Copilot routinely does, told Zed a 128k-prompt model accepts 216k: Zed never compacts and every long request 400s upstream with nothing in its UI to explain it. The comment directly above the constant already said "consumed verbatim as the input window" while the code below it read the other field. The prompt limit comes first now, with the window as a stand-in when none is stated — the precedence the Gemini catalog projection already uses for an input limit. Nothing pinned this: no fixture stated both limits, and the test locked the inverted order in by name. It now covers a model stating both, and restoring the old order fails it. The dashboard's Windows credential snippet also skipped the loop that zeroes the unmanaged buffer before freeing it. Both bodies define FlowayZedCredential and both skip Add-Type when the name already exists, so pasting the snippet first in a console left the installer's own scrubbing unreachable.
The previous commit projected Claude Code's `[1m]` suffix into both editor catalogs. That suffix is a discovery-protocol representation the CLI itself unwinds — it strips `[1m]` and pairs the request with the `context-1m-2025-08-07` beta — and the gateway's own resolution has no notion of it. Editors send the id back verbatim, so every 1M-capable model would have addressed a model that does not exist. The comment stating this is in data-plane/models/http.ts, which I had not read before assuming the mechanism generalised from the Claude Code half. The suffix returns to where it was: the Claude Code model picker alone, with its own comment saying why it cannot travel. The merged Copilot row's window stays optimistic for the editors, which is the lesser of the two problems and is inherited from mergeClaudeVariants rather than introduced here. Also: the last commit was pushed with two red tests in packages/agent-setup/__tests__/routes_test.ts — the suffix changed the ids they assert, and I ran the installer harness, typecheck and lint but not the package suite. And a scratch migration script had been swept into a commit by `git add -A`; it is removed.
… 8 API `[System.IO.File]::SetUnixFileMode` and its getter arrived in .NET 8, so they are missing on pwsh 7.0-7.2, which run on .NET 6. On those hosts the call threw MethodNotFound inside the staging transaction — and `Set-SetupZedCredential` runs before `Write-SetupZedSettings`, so the run died after the credential was already in the keychain, leaving an orphan entry and no provider. It fired on the fresh-install path too. Claude, Codex and VS Code all succeed on that same host, because every other permission change in this installer set goes through chmod; this was the only .NET 7+ API anywhere in it. It goes through chmod now as well, reading the existing mode with the same GNU/BSD `stat` pair the Bash helper uses and leaving the mode alone when neither dialect answers. Removing either call fails the owner-only and mode-preservation tests.
A mutation sweep found the context-window fallback and the `max_output_tokens` presence check unobserved: swapping `??` for `||` or `!== undefined` for truthiness left the package suite green. The difference reaches Zed as a 200k window on a model that announced none, or a silently dropped output limit, so the test is written against that property rather than a value.
The pre-backup gate's own comment said a refusal must not land "after a backup
already existed", but the `language_models` and `anthropic_compatible` checks
were not in it — they lived in the merge program, which does not run until the
copy is done. So `{"language_models": null}` was refused with "failed to
construct updated Zed global settings", naming Floway's list rather than the
operator's file, printed a raw jq error to stderr, and left the backup behind.
The PowerShell half and the VS Code installer both check everything their merge
can abort on before copying; this half now does too, and the merge program only
merges.
That path had no coverage either: every Zed refusal was caught pre-backup, so
`zed_rollback_settings` never ran and `cp -p` — which exists so a rollback
cannot narrow the operator's mode — could be deleted with the suite green. A
read-only config directory now drives a failure after the backup exists, and
asserts the document and its mode both come back.
Both halves also give one sentence for one document now, rather than two
wordings for the same refusal.
And the PowerShell mode read passes `-L`. `stat` without it is lstat, so a
symlinked settings file — chezmoi and stow both create one — reported the
link's 755 instead of the target's 600, while the Bash half's
`chmod --reference` dereferences. Verified both ways on this host.
The `-L` fix landed only on PowerShell. Bash reads the mode through `chmod --reference` first — which does dereference — but that option does not exist on BSD, so on macOS every run falls through to `_stat_mode`, where neither dialect passed `-L`. A chezmoi- or stow-placed symlink therefore reported its own 0755 and the operator's deliberately 0600 settings file came back world-readable, on the platform most likely to be running Zed. Measured both ways on this host, and CI could never have caught it: Linux has GNU coreutils, so the fallback never runs there. Three tests could not fail on what they name, all added by the round that fixed these paths: The refusal-after-backup test used a read-only config directory, which stops `cp` from creating the backup at all — so the run aborted before the copy and all three assertions held trivially. Both `zed_rollback_settings` and `cp -p` were deletable with the suite green. A stale backup that is a directory makes the prune fail instead, which happens after the write is already in place. The mode-preservation test asserted 0600 after a successful write, but main sets `umask 077`, so a stage carrying no mode at all is already 0600 — the assertion restated the umask. It uses 0644 now, a mode only the operator's own file can supply. And nothing used a symlink, so the `-L` this commit adds had no observer either.
The symlink mode test only ever ran on the host's own toolchain. On GNU that means `chmod --reference` succeeds and the mode probe is never reached at all, so dropping `-L` from every `stat` call in both the Bash helper and the PowerShell installer left the suite fully green while the installers had gone back to reading the link's own mode. Shim `chmod` to refuse `--reference` and `stat` to refuse `-c`, and run the test under both dialects. The `stat` shim answers `-f '%Lp'` by translating it to the host's `-c '%a'` — refusing the GNU call without serving the BSD one would have made the fallback look broken when it was the shim that could not answer. Verified by mutation: removing `-L` from the Bash helper, the PowerShell GNU branch, or the PowerShell BSD branch each turns the suite red.
…replacing it chezmoi and stow both place a symlink where Zed expects its global settings. Both halves staged the merged document beside that path and renamed it into place, which replaces the link itself: the operator's dotfile silently stops being what Zed reads, and their next edit there has no effect. The backup and the backup prune landed beside the link too, so a hand recovery would not find them next to the real file. Resolve the managed path before anything touches it, in a shared helper per half — the hop loop is written out rather than delegated to `readlink -f`, `realpath`, or `ResolveLinkTarget`, none of which exist across the BSD and pwsh versions this installer set targets. Reading the mode now goes through _stat_mode on both halves. `chmod --reference` did it in one step but only on GNU, so its BSD fallback was never executed on a GNU host and the PowerShell half had to read the mode itself regardless; one path replaces two that agreed by inspection. The test covers both stat dialects and both link forms — stow writes a relative link, chezmoi an absolute one — against a 0640 fixture, which is a mode neither half produces on its own: an uninherited staged file lands on 0600 under the Bash half's `umask 077` and on 0644 under the PowerShell half's inherited umask, so either would have passed one half without observing it. Verified by mutation: removing the resolution, the relative-target branch, the absolute-target branch, or either stat dialect turns the suite red on the half that lost it.
… check The band — compaction off with the small-context warning suppressed — was closed only for catalogs that state a prompt limit. A catalog that states a window and an output limit and nothing else went out verbatim, so any row whose window clears 80_000 while window minus output does not landed exactly where the comment above it said no row may land. Reachable from Ollama and Codex, which state only a window, and from any OpenAI-compatible upstream through provider-custom. Which lever closes the band depends on whether the prompt limit is stated, because that decides whether the budget is ours to move. Stated: the reservation is dropped so the raw value falls under the threshold and the callout fires — raising the budget instead would have Zed plan against headroom the upstream refuses. Not stated: the window is the only bound and its split is ours, so the reservation shrinks until the budget reaches the threshold, which turns compaction on and leaves the total untouched. Under 4096 left over there is no such split, and the window drops instead. The thinking ceiling now reads the reservation that will actually be sent. A budget between the catalog's output limit and a shrunk one is under the stated ceiling and over the wire value, which Anthropic rejects on every request. Verified by mutation: removing the band check, either of its two outcomes, the stated-prompt branch, or the ceiling's link to the shrunk reservation each turns the suite red.
The symlink resolver landed in the shared helper with prose about what editors' documents an operator puts under chezmoi or stow, and then only Zed called it. `~/.claude/settings.json` is the likeliest of the set to be a dotfile, and it still had its link replaced by a regular file. Codex reaches the same rename through backup and rollback even though `codex login` writes config.toml itself, so both of its paths resolve too — the provider token because this installer renames it into place, the config because restoring a backup over the link would strand the operator's dotfile while the CLI kept editing the real file. A helper in `common/` that one caller uses is a claim the directory does not keep; every managed path now goes through it. Verified by mutation: reverting the resolution on either half of Claude or of the Codex provider token turns the suite red.
…efuse alike
Every conjunct of the shape gate shipped unobserved: removing the
`length == 1` slurp, the nested type checks, the text-level root check,
or the PowerShell parse catch each left the suite green. The cases the
comments name — a `language_models` of 5, an `anthropic_compatible` of
5, `{"a":1}{"b":2}`, a truncated object — now run through both halves
and assert the same exit, the same untouched bytes, no leftovers, and a
refusal that names the operator's document.
Writing them surfaced three disagreements between the halves:
- A parse failure said "is not valid JSON" on the PowerShell half and
"is not a valid Zed settings document" on the Bash half. Its jq gate
cannot report which conjunct refused either, and to the operator a
JSON stream and a truncated object carry the same instruction, so
both now say the latter.
- A stale backup shaped like a directory was skipped by
`Get-ChildItem -File` and reported as a successful prune, while the
Bash half's `rm -f` failed on it and rolled the write back. Both now
refuse it: this installer creates only files there, so a directory is
a state nobody here produced, and removing it recursively could take
something of the operator's along. The rollback test runs both halves.
- The mode fixture was 0644, which is exactly what the PowerShell half's
inherited umask produces, so that half's mode inheritance could be
deleted with the suite still green. 0640 is a mode neither half
reaches on its own.
Also removes a PowerShell root-type check that cannot fire — the brace
root is established from the text first, and every brace-rooted document
decodes to PSCustomObject — and a null guard the parameter binder makes
unreachable. The `language_models` conjunct stays despite being
redundant by outcome, with a comment saying why: refusing by type is
what the gate means, and leaning on the raised jq error from the
conjunct after it would silently take this case with the next edit.
…ree claims The Windows target name and the macOS server/account mapping were stated without a reference while the Linux label carried one; both are correct against Zed at cc053a4a, and both now say where. The dashboard's pasted snippets restate the same two claims and get the same permalinks. Three claims were wrong or short of what they cited: - The config-dir comment presented its citation as if the range held only the Windows, XDG, and macOS branches. The first branch in it is `--user-data-dir`, which relocates the configuration wholesale; the comment now names it and points at the override that serves it. - `zedConfigHint` gave `~/.config/zed` unconditionally, while both installers and Zed itself honour `XDG_CONFIG_HOME` on Linux. - The listing-failure test asserted the `sh` variant while its comment spoke for both. The `ps1` arm — whose whole reason for existing is that `exit` would close the console this script is piped into — was never executed. Verified by mutation: making it `exit 1`, or dropping the message, each turns the suite red.
Two defects in the token plan, both in the band it exists to close. A stated zero reached Zed verbatim, and two tests asserted that as correct. Zed's fields are required u64s it sends as given, with no encoding for "unknown": a zero window is a zero-token context whose callout the usage ratio suppresses outright — neither compaction nor warning, the band entered from the other side — and a zero output limit becomes a Messages `max_tokens` of 0, which Anthropic rejects on every request. The catalog's schema bounds none of this, so an operator can state it, and a negative or fractional value would fail Zed's u64 deserialization and take the whole settings document with it. A stated zero stays a value inside the gateway and becomes no bound at this boundary. The stated-prompt branch dropped the reservation for every prompt limit under the threshold, while the band is only the window between the threshold and the reservation below it. Under that, the callout fires either way, so dropping bought nothing and cost the operator the whole reservation: a 32k prompt limit beside a 16k output limit reached Zed as a 15,616-token budget. It also put a bare prompt limit where this file says the context window goes. The band test's model of Zed now includes the ratio guard — without that conjunct it called a zero row "warned" and the band stayed open under a test named for closing it. Verified by mutation: restoring either the wide condition, the verbatim zero, or the zero-blind predicate turns the suite red.
The comment claimed the dashboard's Windows credential snippet was byte-identical to the installer's body, and rested a safety argument on it: both define `FlowayZedCredential` and both guard on the type already being in the AppDomain, so in one console whichever runs first defines it for the other. The two had drifted. Identifiers differed, and the snippet zeroed the freed blob inside `try` after a successful `CredWriteW` while the installer zeroes it in `finally` — so an operator who pasted the snippet and then ran the installer got the version that leaves the key in the block it frees when the write fails. That is the outcome the comment says the guard prevents. The C# now lives in one fragment the installer assembles and the dashboard imports, so identity is structural rather than asserted. Its own subpath export keeps the browser boundary honest: the dashboard reaches this text and nothing else of the gateway-side package. Nothing observed any of the three Zed snippet builders. Two cases now do: the emitted snippet contains the installer's body verbatim, and the scrubbing sits on the failure path.
…le links Four ways the two halves disagreed, all of them reached through a file an operator legitimately has. A trailing comma. Zed reads this document with serde_json_lenient, so a comma before a closing brace is the operator's content just as a comment is — and jq refuses it while ConvertFrom-Json takes it and writes the document back without it. The comment gate was symmetrized; this was not, so one operator was blocked and another silently rewritten. The scanner now reports either construct, and the refusal names JSONC syntax rather than comments alone. A non-canonical managed path. The resolver returned an absolute symlink target verbatim, and the backup prune compares that string against Get-ChildItem's canonical FullName — so a target written through a `..` segment matched nothing and the prune deleted the backup it was told to keep, leaving the run with no way back. Both branches canonicalize now, and the test's absolute link is joined by hand, because `join` would normalize the segment the case exists for. A stale backup that is a symlink. It unlinks like any other entry, which is what the Bash half's `rm -f` does with it, while the PowerShell half threw on the wrapper type and rolled back a successful write. Only a real directory is refused now. In the other direction Bash's `-e` followed the link before deciding, so a dangling leftover was skipped forever; it asks about the entry itself now. A hard link. `Target` is non-empty for one on the Windows PowerShell 5.1 build, where it enumerates hard-link names — so a document with a second link would resolve to itself until the hop bound tripped and the run stopped having configured nothing. `LinkType` asks what `[ -L ]` asks. This one is stated without a test: the harness runs on Unix pwsh, where `Target` is empty for a hard link and the defect cannot be reproduced. Also fixes the BSD stat shim, which implemented `-f` by calling the host's `-c`. On a macOS runner without coreutils that is the system stat, which has no `-c`, so the leg modelling BSD would have failed there as a mode regression. It now asks in whichever dialect the host speaks; pinning it to /usr/bin/stat keeps the suite green.
jq's `ascii_downcase` folds A-Z; `-ieq` folds Unicode case as well. So a rename from `flowäy` to `FLOWÄY` dropped the old entry on the PowerShell half and kept it on the Bash half — two picker entries or one, from the same operator action. The comment acknowledged the split and dismissed it as unreachable; a provider named in German or Swedish reaches it. Both halves fold A-Z now. That exposes the real constraint underneath: a PowerShell property bag is Unicode case-insensitive, so it cannot hold `flowäy` beside `FLOWÄY` at all, while jq writes that document happily. The halves cannot agree on an outcome there, so the PowerShell half refuses and names the provider in the way — better than deleting someone's provider to make room, and better than the Add-Member failure it was heading for. Also states why the three Bash backups differ on `cp -p`: the Zed document and the Codex config carry no credential, so they come back at the mode the operator set; the Claude document and the Codex provider token do, so they come back owner-only. Only the Codex config was on the wrong side of that line. The symlink hop bound was 40 on one half and 41 on the other, and the 4096 reservation is now documented for both the call sites it serves — the request `max_tokens` and the compaction subtraction, which are different lines in Zed and only agree because the provider resolves the default before the thread reads it.
`accepts a valid name` asserted that no error rendered and that the input echoed what was typed. Deleting the draft gate outright left it green: a name held back never reaches the served script or the pasted snippet, and nothing on screen says so. It now asserts the request body the way the two withholding cases assert its absence. `editorProviderName`'s `.max(120)` had no case on either side. It is the one condition the dashboard's own check leaves to the input's maxLength attribute, so the schema is where it is enforced for anything that did not come through that field. The prefix test built its expectation with `JSON.stringify`, the same call the production code makes, so it followed the serializer through any change of shape — including one that would give jq's `--slurpfile` a different document to read. It spells out what it expects now. Verified by mutation: removing the draft gate, the length bound, or the compact serialization each turns its own test red.
Three paths in the PowerShell half never execute under the Unix harness: the ACL arm of Protect-SetupFile, File.Replace, and the symlink resolver's treatment of a hard link. All three now carry what was measured on Windows PowerShell 5.1.26100.8875 rather than what was reasoned about. The ACL arm leaves inheritance blocked and one rule for the running user. File.Replace carries the destination's ACL through unchanged — same SDDL before and after — which is why nothing re-applies it, and `$null` for the backup path fails where NullString succeeds, exactly as the comment beside it claimed. The hard-link finding is the one that mattered: `Target` is empty for a file with only its own name and non-empty once `mklink /H` gives it a second, so the resolver as written before this branch would have walked such a document to the hop bound and stopped the run having configured nothing. Writing a credential could not be checked this way — an SSH logon session cannot reach the credential manager, and `cmdkey` fails there identically, so the failure says nothing about the P/Invoke.
…ersions
On Windows PowerShell 5.1 the Zed installer wrote its model list as
"available_models": [{"value": [ …the models… ], "Count": 2}]
and reported "Configured 1 model(s)". Zed's schema requires a list there,
so the provider fails to load outright — the installer reported success
and configured nothing usable, on every Windows run.
`ConvertFrom-Json` hands a top-level array to the pipeline as one object
on 5.1 and as its elements on 7, so `@(text | ConvertFrom-Json)` is a
one-element array holding the real one there and the real one here.
`-InputObject` does not change it; only enumerating does. ConvertTo-Json
then writes the inner collection with its .NET shape, which is where the
`value`/`Count` pair comes from.
Found by running the served script on Windows PowerShell 5.1.26100.8875
rather than by reading it: the harness runs on pwsh 7, where every one of
these expressions already gives the flat array, so nothing in the suite
could have shown it. Verified the same way — after the fix the same run
reports 2 models and writes a flat array.
The staged check now compares the model count instead of asking whether
the list is non-empty. A nested list has a count of 1 and passed an
emptiness check, which is how this reached a written document at all.
The array reader enumerated with `foreach`, which skips a `$null` — so `[null]` came back as an empty list rather than as one element, and the VS Code half stopped refusing a document that is not a provider list at all. Deciding by type keeps every shape: both versions now return 2, 1, 1, 2, 0 and 2 elements for a two-object array, a one-object array, `[null]`, `[null,null]`, `[]` and `[1,2]`, measured on 5.1.26100.8875 and pwsh 7.7.
…s as UTF-8 Two defects, one of them on a live Copilot shape. In the band the projection sent the prompt limit as the window with the reservation still attached, and Zed derives the budget by subtracting one from the other — so any row whose output limit meets or exceeds its prompt limit ended up with no room to prompt at all. Copilot's o3-mini states a 64k prompt limit beside a 100k output limit and reached Zed with a budget of zero; o1 the same. The window now goes out one token under the threshold with the reservation shrunk to fit beneath it, which leaves the budget at exactly the stated limit and still raises the callout. Where too little room remains under the threshold to state a reservation worth stating, the limit goes alone and Zed's own 4096 applies. `Get-Content -Raw` decodes with the system ANSI code page on Windows PowerShell 5.1 and with UTF-8 on 6+. A settings document holding a font name, a path, or a localized string therefore came back mis-decoded on a stock Windows box, was written out again as UTF-8, and stayed mojibake — with nothing to refuse it, because mis-decoded text is still valid JSON. The Bash half passes the bytes through untouched, so the halves disagreed about one file. Both readers go through the same encoding the writer uses now. Claude's installer had it too and is fixed with it. Measured on 5.1.26100.8875: the same bytes decode differently under CP1252 than under UTF-8, and the new reader is unaffected by either. The box has the UTF-8 beta flag on, so the mojibake itself could not be reproduced there — the finding rests on 5.1's documented default plus that divergence. The band test now asserts a non-zero budget for every projected row, not only that each one compacts or warns.
The JSONC scanners classified whitespace differently — `[char]::IsWhiteSpace`
against awk's space/tab/CR — so `{"a":1,<FF>}` and three more shapes were
refused by both halves under two different names. Neither writes the
file, so nothing was lost; the operator was sent after an error that is
not there, which is exactly what the refusal test says must not happen.
Two guards that had no equivalent on the other half:
- The macOS credential arm reached for `security` without checking it is
there, while its Secret Service sibling and the whole Bash half do. A
missing tool surfaced as a raw CommandNotFoundException through the
top-level catch instead of the installer's own sentence.
- A property bag refuses names the schema accepts and the Bash half
writes, so those now stop with a sentence naming what to change. The
set is narrower than it looks: members the object already has, plus
anything `-NotePropertyName` converts to a PSMemberTypes value, which
is why "2" throws and "2024" does not — measured identical on
5.1.26100.8875 and pwsh 7.6.
The band test's zero guard is documented for what it is: no projected row
reaches `max_tokens: 0` any more, so the conjunct is what keeps the
predicate a model of Zed rather than of what the projection emits.
…pting `Remove-Item -Force` unlinks a symlink-to-directory on pwsh 7 and asks for confirmation on Windows PowerShell 5.1 regardless of the flag — measured: the call simply blocks. In an `irm | iex` console that is a prompt nobody expects mid-install; in a non-interactive host it hangs the run. `Directory.Delete` with recurse:$false unlinks on both and leaves what the link pointed at alone. Four comments corrected against what the code does: - `-WarningAction Stop` promotes nothing on 5.1, which emits no warning for an over-deep object at all. What refuses such a document there is ConvertFrom-Json's own recursion limit, before the merge runs. - The Bash staged check is an assertion on the merge, not a gate on input, and now says so the way the VS Code half does. - The array helper's `[null]` handling is a property of the helper, not a case Zed's caller reaches — its input is the gateway's own projection. - `-contains` and dotted access are case-insensitive where jq's `has` is not, so a differently-cased `Language_Models` would split the halves. No document Zed produces can have one, and it is noted rather than worked around. `PowerShell replaces existing settings atomically` is renamed to what it observes: swapping File.Replace for Move-Item leaves it green, while the unrelated settings and the catalog it does check are the point.
…comment The `..`-segment symlink leg claimed to observe that a non-canonical keep-path takes the backup it was told to keep, but the run it set up had no stale backup for the prune to act on — the assertion only counted what was left. One is placed beside the real document now, and the leg asserts both that the stale one goes and that this run's stays. Doing so showed the canonicalization is doubled: the resolver and the prune's keep-path each close the gap alone, and only removing both turns the leg red. Both stay, each with its own reason stated, and the comments say the leg observes the pair rather than either one. Also moves a comment about the array-root check, which had drifted onto the File.Replace test with no test of its own between them.
…ciding Two defects, one of them mine from the last round. `zedTokenPlan`'s window branch never asked whether anything was left after Zed subtracts its reservation. An Ollama row states a context length and no output limit, so a 4096-token local model reserved all of it and a 2048-token one reserved twice its window — reaching the picker with a budget of zero and of minus 2048. A stated output limit at or over the window did the same. The reservation is bounded against the window it shares now, exactly as the prompt branch and the VS Code projection already were: a stated one to half, an unstated one only where Zed's own 4096 would take more than a quarter. Aligning the JSONC scanners on JSON's whitespace last round removed the only thing refusing a form feed between members, and PowerShell 6+ decodes through Newtonsoft, which takes it — so the Bash half stopped and the PowerShell half rewrote the operator's file. That was a regression, and the wider gap under it was not: single-quoted strings and unquoted keys were always accepted here and refused by jq. The scanner returns a verdict now — jsonc, invalid, or ok — and anything outside JSON's grammar is refused under the sentence the Bash half prints, so which decoder the host happens to have no longer decides what is a valid document. Four comments corrected against measurement: the band bullet described the pre-fix code; the reader's justification is that the encoding is an argument rather than a host setting, not the truncated-sequence detail it cited; the trailing-comma claim holds on PowerShell 6+ and not on 5.1, which refuses one itself; and the names a property bag refuses are the members it already has plus the name or number of a member type Add-Member can create — "4" and "64" are neither, and go through. ConvertFrom-Json's recursion limit is 102, not 100. `_stat_mode` is one half's helper, not a shared path. And the rollback test's header comment had drifted onto the symlink test.
The two per-agent sites moved to `[string]::Equals(…, Ordinal)` when the group identity did; the shared top-level handler still used `-ne`, which is the culture-aware comparison that change was made to avoid. A message that only collates as the sentinel would have been swallowed as a reported failure and the run would have exited without saying anything.
The strict arm let a character through if it appeared anywhere in `true`, `false` or `null`, so an unquoted key spelled only from those letters — `sane`, `test`, `nu` — passed the gate. Newtonsoft then accepts the unquoted key and writes the document back with it quoted, where jq refuses the file: the split this gate exists to close, reachable through a key an operator could plausibly type. The literals are matched whole now, and the character set left over is structure plus what a number is spelled with. Verified against the scanner directly: escapes, a surrogate pair, every JSON number form and 30-deep nesting all read `ok`; a truncated literal, a single-quoted string, a form feed and both unquoted keys read `invalid`.
…is zero
The bound added last round computes a quotient, and a window of one to
three tokens makes it 0 — the value the limit filter refuses from the
catalog, for the stated reason that Zed sends it as a Messages
`max_tokens` of 0 and the upstream rejects every request. The reservation
is clamped to at least one token now, and a window that cannot carry both
a reservation and a prompt drops out of the projection entirely: better
absent from the picker than listed and refused on use.
The verdict function no longer answers on whichever offence the scan
reaches first. A document carrying both a lenient construct and a comment
named the strict cause on one half and the JSONC cause on the other,
which is the disagreement the gate exists to prevent; the strict verdict
is carried to the end so `jsonc` always wins. A raw control character
inside a string joins the strict arm — jq refuses one and both decoders
take it.
Four comments corrected against measurement on 5.1.26100.8875: both
decoders take a single-quoted string and an unquoted key, so 5.1 is not
the stricter backstop the text implied; `ReadAllText` honours a UTF-16
BOM over its encoding argument, so UTF-8 is the fallback rather than the
rule; the Bash scanner mirrors one arm of a function that has since been
renamed and gained a second; and the band's fall-through is taken because
a reservation under 4096 is not worth stating, not because no split
exists — for an 82_000 window one does.
The strict arm is RFC 8259, which jq implements with leniencies of its
own — `NaN`, `Infinity`, a leading `+` — and the reverse case `{"":1}` is
valid JSON ConvertFrom-Json rejects. Both are documents no editor writes,
and the comment says so rather than claiming the two halves agree
everywhere.
…talog
Returning the decoded array comma-wrapped keeps its element structure but
wraps it in a PSObject, and Windows PowerShell 5.1's ConvertTo-Json
writes a wrapped collection as `{"value":[…],"Count":n}` instead of an
array. The model list reached `available_models` in that shape, the
staged check caught it, and every run on 5.1 rolled back having
configured nothing.
`@()` sheds the wrapper but flattens a nested element on pwsh 7 — the
shape `-NoEnumerate` was added to preserve — so the callers cast to
`[object[]]`, which sheds it without touching the elements. Measured on
5.1.26100.8875 and pwsh 7.7: a two-object array, a one-object array,
`[]`, `[null]`, `[[{…}]]` and `[[],{…}]` all serialize identically on
both after the cast, and none of them does before it.
Confirmed end to end by running the served script on the real 5.1: exit
0, no `value`/`Count` wrapper in the written document, both models
present. Nothing in the suite can observe this — it spawns pwsh 7, where
a wrapped collection serializes correctly.
Consuming the token was not enough: `{true:1}` and `{123:1}` spell their
key as a value the scan walks past, so both decoders accepted them and
rewrote the operator's file with the key quoted, where jq refuses it. The
scan now asks what follows a literal or a number — a `:` there means the
value was used as a key, which JSON does not allow.
A leading `+` stays accepted. jq takes one and rewrites it as a plain
number, so the Bash half configures such a file and repairs it; refusing
here would make this half the stricter one for a document the other half
fixes. The decoders already disagree about it — pwsh 7 refuses, 5.1
accepts and canonicalizes — and that is theirs to own. `NaN` and
`Infinity`, which jq passes through unchanged, stay refused.
The empty-catalog refusal runs through both halves now; it was Bash-only
while its name read as a property of the installer. And since the
projection can drop every row of a catalog that does advertise chat
models, all three copies of that message say no model can be configured
rather than that none is advertised.
Also removes two comment paragraphs describing the pre-clamp reservation,
which the paragraph after them already states correctly.
The dashboard wrapped the shared credential body in `@"`, which interpolates. The installer uses `@'`, which does not. A `$` or a backtick reaching that C# would therefore expand on one side and not the other, leaving one type name with two different programs in a console where both have run — the outcome the sharing exists to prevent. The body carries neither today, so this was latent; the test asserted the strings match, which they do, because interpolation happens when PowerShell runs the paste rather than when the snippet is built. The setup pane now says what the snippet pane already said. The dashboard knows the installer will refuse a catalog it cannot configure before the operator runs anything, and the setup pane is the one they see first — handing over a command that fails there is worse than saying so. The Bash resolver canonicalizes the path it walked to, so both halves report the same file for a link whose target carries a `..` segment. They already wrote the same file; only the sentence differed, and that is the first thing an operator compares. Two doc blocks orphaned by helpers inserted beneath them are moved back above the functions they describe.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Zed as a third Agent Setup target, beside Claude Code and Codex.
Why
anthropic_compatibleZed reaches Floway through its
anthropic_compatibleprovider rather thanopenai_compatible. Protocol choice is not the reason —PublicModel.endpointsis the upstream wire surface, and any of chatCompletions/messages/responses
makes a model reachable from all four inbound chat routes, so every model works
on either provider. The reason is client-side fidelity:
openai_compatiblehardcodesJsonSchemaSubsetwith noescape hatch;
anthropic_compatiblehas no override and gets fullJsonSchema. The subset rejectsif/then/$refoutright and silentlydrops
format,additionalProperties, andexclusiveMinimum/Maximum.mode: Thinking { budget_tokens } | Adaptivemaps 1:1 ontochat.reasoning.budget_tokensandchat.reasoning.adaptive, which have nohome at all in the OpenAI dialect.
429/529,retry_after, and theerror.typetaxonomy into typed retry semantics; the OpenAI path flattenseverything at the transport boundary.
[DONE]sentinel,with a comment naming gateways as the reason.
0. This matters because Zed does no token counting anywhere — notiktoken, no
count_tokenson the trait, no/v1/messages/count_tokenscall— so streamed
usageis its only truth source for the context meter andauto-compaction.
parallel_tool_callsis not a loss: on the Chat Completions path afalseomits the field entirely, and Zed never emits
disable_parallel_tool_use, soboth surfaces land on the protocol default.
Why
global_settings.jsonBoth installers write
global_settings.json, a settings layer Zed reads belowthe user's own file and never creates or writes
itself — added upstream for
"enterprises with automation … without interfering with user's settings files".
Owning that file outright is what keeps this on plain JSON. The user's
settings.jsonis JSONC, and no portable tool edits it safely: jq and its kincannot parse comments and reserialize on write, and PowerShell's
ConvertFrom-Jsonerrors on 5.1 while 7.0–7.5 (including 7.4 LTS and 7.5, wherethe fix was never backported) silently turn a comment inside an array into a
string element — data
corruption, not a formatting nuisance.
The merge touches only the one provider key, matching the Claude and Codex
installers.
Shape
Zed is configured but never installed: it ships outside any package manager
these scripts drive, so a missing configuration directory is a hard stop rather
than a directory to create. Model entries are snapshotted from
/v1/modelsbecause
anthropic_compatiblehas no discovery path, and are selected bykindrather than by
endpoints.The credential is written to the OS store ahead of the settings document.
Without a key
is_authenticated()is false and every model disappears from thepicker with no error shown, so a registered provider with no credential is the
failure mode worth avoiding; an unreferenced credential is harmless.
Verification
116 passed, 0 failedin the real installer harness, ten of them Zed. One runsthe same scenario through PowerShell and asserts the same provider document —
the jq program and the PowerShell projection are two implementations of one
mapping, and nothing else would catch them drifting. Full repo: 5471 tests,
typecheck, and lint all green.
Notes
user file, so a Floway entry in
global_settings.jsonsurvives it.