Skip to content

feat(agent-setup): configure Zed - #437

Draft
Menci wants to merge 61 commits into
mainfrom
zed-agent-setup
Draft

feat(agent-setup): configure Zed#437
Menci wants to merge 61 commits into
mainfrom
zed-agent-setup

Conversation

@Menci

@Menci Menci commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Adds Zed as a third Agent Setup target, beside Claude Code and Codex.

Why anthropic_compatible

Zed reaches Floway through its anthropic_compatible provider rather than
openai_compatible. Protocol choice is not the reason — PublicModel.endpoints
is 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:

  • Tool schemas. openai_compatible hardcodes JsonSchemaSubset with no
    escape hatch; anthropic_compatible has no override and gets full
    JsonSchema. The subset rejects if/then/$ref outright and silently
    drops format, additionalProperties, and exclusiveMinimum/Maximum.
  • Reasoning. mode: Thinking { budget_tokens } | Adaptive maps 1:1 onto
    chat.reasoning.budget_tokens and chat.reasoning.adaptive, which have no
    home at all in the OpenAI dialect.
  • Errors. The Anthropic path maps 429/529, retry_after, and the
    error.type taxonomy into typed retry semantics; the OpenAI path flattens
    everything at the transport boundary.
  • SSE. The Anthropic reader explicitly skips a stray [DONE] sentinel,
    with a comment naming gateways as the reason.
  • Usage. The OpenAI Chat Completions mapper hardcodes both cache counters to
    0. This matters because Zed does no token counting anywhere — no
    tiktoken, no count_tokens on the trait, no /v1/messages/count_tokens call
    — so streamed usage is its only truth source for the context meter and
    auto-compaction.

parallel_tool_calls is not a loss: on the Chat Completions path a false
omits the field entirely, and Zed never emits disable_parallel_tool_use, so
both surfaces land on the protocol default.

Why global_settings.json

Both installers write global_settings.json, a settings layer Zed reads below
the 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.json is JSONC, and no portable tool edits it safely: jq and its kin
cannot parse comments and reserialize on write, and PowerShell's
ConvertFrom-Json errors on 5.1 while 7.0–7.5 (including 7.4 LTS and 7.5, where
the 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/models
because anthropic_compatible has no discovery path, and are selected by kind
rather 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 the
picker 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 failed in the real installer harness, ten of them Zed. One runs
the 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

  • The tab icon comes from simple-icons under CC0; lobe-icons carries no Zed mark.
  • No uninstall path. Zed's own "Remove Provider" only deletes the key from the
    user file, so a Floway entry in global_settings.json survives it.

Menci added 4 commits August 8, 2026 05:26
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.
Menci added 6 commits August 8, 2026 07:55
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.
@Menci
Menci force-pushed the zed-agent-setup branch from 4db744f to 0313237 Compare August 8, 2026 08:24
Menci added 18 commits August 8, 2026 17:13
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.
Menci added 30 commits August 9, 2026 17:57
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant