Commit Graph
121 Commits
Author SHA1 Message Date
yetone d1fca72f69 library: the Library's MCP servers go to WorkBuddy and CodeBuddy Code (#1266)
WorkBuddy had skills only, and CodeBuddy Code had no library target at all,
so their MCP chips could not be switched on.

WorkBuddy 5.5.6 (app.asar) reads ConnectorService.customMcpConfigPath:
$WORKBUDDY_CONFIG_DIR, then $CODEBUDDY_CONFIG_DIR, then ~/.workbuddy, plus
mcp.json, as {"mcpServers": {...}} in Claude's shape. It connects a server
only once the user has switched it on in its MCP settings
(mcp-approvals.json, module.app-server.js). magpie doesn't write that
approval. Instead, the servers page tells the user to switch it on once,
in en/zh/zh-TW/ja/de.

CodeBuddy Code 2.162.0 (PathUtils.resolveMcpFilePath) reads the first of
<dir>/.mcp.json, <dir>/mcp.json and <base>/.codebuddy.json that exists.
<dir> is $CODEBUDDY_CONFIG_DIR or ~/.codebuddy. The file holds mcpServers
in Claude's shape, and ${NAME} is expanded (McpConfigEnvExpandService).
User scope needs no approval. Skills go in <dir>/skills.

Verified: the real CodeBuddy Code 2.162.0 CLI, in a sandbox HOME, read
SaveServer's file back with `mcp get` and `mcp list`. That covered stdio
with ${MY_TOKEN} expanded, http and sse, and the user's own entry and
disabledMcpServers were kept. WorkBuddy.app was not run; its side is
from its code.

Tests: TestAgentMCPFiles (4 new cases) and TestCodeBuddyMCPFile fail
without targets.go. library-workbuddy-trust fails without library.js.
Run: go test ./internal/library ./internal/gui; go vet for darwin, linux
and windows; in Chromium and WebKit: gui-ja, gui-de, gui-zh-tw, i18n-keys,
click-scroll and all library-* GUI tests.
2026-10-08 17:12:07 +08:00
wizzy-yangandwizzy-yang e5e87d980a 支持 Ante:把 magpie 作为 provider 写进 ~/.ante/catalog.json (#1210)
* agent: Ante takes magpie as a provider in ~/.ante/catalog.json

Ante (antigma.ai, AntigmaLabs/ante) is asked for in #1179. Its catalog is
~/.ante/catalog.json ($ANTE_HOME moves the folder), merged on top of the
providers it ships: a providers map keyed by the id --provider takes, each
with base_url, wire_style (AnthropicMessage, OpenAiCompatible, OpenAiResponse
or Gemini), an optional auth, and preferred_models, the ids its model picker
shows (docs.antigma.ai, reference/catalog-reference). magpie adds the entry
"magpie" at the gateway's /v1 on OpenAiCompatible, which is the wire style
Ante speaks Chat Completions with and what the gateway takes every model on,
and lists magpie's models in its preferred_models. The user's own providers
and the models section are left as they are, and magpie's entry goes again
when magpie steps out; a file magpie made goes with it.

No auth block is written. The gateway listens on loopback and lets any token
in from this machine (gateway.Token's own comment, lan.go's identifyCaller:
"Loopback takes any token"), and Ante counts a provider with no auth as
authenticated. A bearer env_key would mean putting the gateway's key into
Ante's environment, which the user would have to do themselves -- the same
decision Empryo's entry makes, which sends no key of the user's either. A
gateway shared on the local network is another matter, and the same one every
agent magpie wires has.

Two things are deliberately left off a model's entry. A routing group is not
told what it reasons at: which member answers, and what that member takes, is
the group's own to decide per turn, and magpieModels sets no APIs for a group
for the same reason. And support_vision is never written: Ante takes a model
as seeing images unless told otherwise, and what magpie knows is only as good
as a provider's own list -- a relay's models are often not described at all,
so writing false would have Ante drop the reader's images before the gateway,
which describes them to a model that can't see them itself
(provider.Described). No other agent magpie wires declares it either.

Ante ships macOS and Linux builds alone and suggests WSL on Windows, so the
Windows machine's Ante is usually the one in a distro: wslKinds gains it
beside Empryo, which also means the gateway address Ante is pointed at is the
one that distro reaches magpie on (place.base), and a distro not known to be
in mirrored networking gets the notice it already gets for the others.

TestAnte covers a user's own providers and models section kept as they are,
magpie's entry added and taken away, Sync repointing it, and a file magpie
made going again. TestAnteKeepsTheUsersFile covers a catalog magpie can't
parse costing magpie's provider and not the user's. TestAnteGroupSaysNoCapab
ilities covers a group declaring neither effort nor support_vision.
TestAnteEfforts covers the ladder: Ante's has no "none" or "minimal", its two
lowest, so both are its "min", and the rungs come out in Ante's order
whatever order magpie lists them in.

Tried with a real Ante 0.2.9 in WSL Ubuntu: magpie wrote the catalog, and
`ante catalog` then read 16 providers with magpie's among them, 39 models,
and Ante filled in support_vision: true for each itself. Ante then ran a real
turn through the gateway — `ante --provider magpie --model
group/auto-deepseek-v4-1-flash -p …` answered, and magpie's own usage log has
the request (ep /v1/chat/completions, req the group's id, served by one of its
members, out 10) — and so did a second model on another provider. The gateway
was reached over the Windows host's address from the distro (172.17.128.1),
this machine's own being on 127.0.0.1 alone, which is what notMirrored tells
the user about.

go vet, gofmt -l and go build (linux nogui, windows) pass. go test -tags
nogui ./internal/agent: 12 failures here, the same 12 on unmodified main (the
Windows ones AGENTS.md lists). ./internal/gui and ./internal/provider:
TestGatewayFixQuitsOnlyAMagpie, TestPurposeAssetMatchesUsage,
TestUpdateThatWontStart and TestLoginUsageClaudeAskedMeanwhile, all four on
unmodified main here too. docs/reference.md's adapter table gains Ante.

* agent: Ante keeps the user's own keys, reads ANTE_HOME, sends no effort it wasn't told to, and starts Ante on magpie (#1210)

Seven points from the review, each with a test that fails without it.

1. ANTE_HOME was in the file's own comment and in docs/reference.md but never read:
the path was fixed to $HOME/.ante, so a user who set it had magpie write a catalog
Ante does not look at. anteDir reads it through place.getenv, the way MiniMax Code
reads MINIMAX_DATA_DIR, so a distro's own ANTE_HOME - which magpie can't read -
still means ~/.ante there. "ANTE_HOME" joins agentenv.Vars so a sandbox clears it.

2. providers.magpie was written whole, so every key of the user's that magpie
doesn't write - the auth block a gateway shared on the LAN or reached from WSL
needs to send a key, http_headers, extra_body, stream_idle_timeout_secs,
thinking_display - went at the next sync, and an unchanged catalog was rewritten
every round. It goes through theirsKept and syncJSON now, as #1103 did for the
others: the user's keys in magpie's entry stay, preferred_models is magpie's list
alone, and a file that says the same as it does is left as it is.

3. The two files were written in separate steps, so a change that failed halfway
left Ante's catalog without magpie's entry and its settings still naming magpie.
Each field's Set and its Sync are wrapped in atomic(), as empryo, minimax and kimi
do.

4. The comment said Ante's "min" was the rung it sends no effort at, and folded
both magpie's "none" and its "minimal" into it. Ante's reference says what min is:
"OpenAI-compatible providers send the selected level as the same-named
reasoning_effort (min is sent as minimal)" - so min is a sent level, and magpie's
"none" has no rung of Ante's to fold into. Ante's "min" is magpie's "minimal"; a
model magpie knows only "none" of comes out as the empty list, which is Ante's own
way of saying it takes no effort setting at all.

5. The default was the highest rung the model takes, so every model magpie listed
ran at Ante's slowest and dearest level. No effort is written: "Omit for models
with no reasoning knob (Ante then sends no effort parameter and hides the effort
slider)".

6. Having magpie in the catalog never meant Ante used it. What a session starts on
is the provider and the model of ~/.ante/settings.json; with neither set Ante
"uses the first authenticated provider in catalog order", and Ante's own presets
come before magpie's entry - so while the user has any of those signed in, that is
the one picked and magpie is not. (Measured: nothing named in settings, two keyless
providers ordered aaa_other then magpie, and the request went to aaa_other's port;
settings provider=magpie and it went to magpie's.) magpie sets that pair now and
saves what it found there, so Disconnect hands it back; a provider the user picks in
Ante's own /providers while magpie is wired is theirs and stands, and Check says so
instead of keeping quiet. Ante's settings name the model apart from the provider, so
magpie writes the catalog id it put in preferred_models, un-prefixed, and reads a
bare id as magpie's only beside magpie's provider.

Tests lean on the package's own testenv, not a real ~/.ante.
TestAnteSyncKeepsTheUsersKeys is modelled on ad3b7fe5's
TestAirProviderWritesKeepCustomFields and goes both ways - sync and pick - and
wants an unchanged file to keep its mtime.

Tried with a real Ante 0.2.9 in WSL Ubuntu on this machine (Ante ships macOS and
Linux builds alone; there is no Windows Ante here). The strongest of these is the
whole of it: magpie's own Go code - ante(home).Connect(), then the model field -
wrote a catalog.json and settings.json into a temp folder, that folder was handed
to the real Ante as ANTE_HOME, and Ante ran a turn through it with no --provider,
no --model and nothing typed by hand.

- `ante doctor` read `home ok /tmp/ante-e2e` and `16 providers (1 authed), 108
  models` from ANTE_HOME, and magpie's entry came back with the base_url it was
  given: ANTE_HOME really moves the folder, and a base_url already ending in /v1
  is not doubled (the request went to /v1/chat/completions).
- What a session starts on is settings.json's provider, measured against two
  keyless providers each answering on its own port, so the logged port names the
  one Ante asked. Catalog ordered aaa_other then magpie, nothing named in
  settings: Ante asked 44331, aaa_other - the first authenticated in catalog
  order, exactly the review's "很可能选不到 magpie". settings provider=magpie: it
  asked 44220, magpie. settings provider=aaa_other: 44331 again, so a pick the
  user makes in Ante's own /providers overrules what magpie wrote, which is why
  stepping out leaves it rather than handing back the one magpie found. And with
  magpie the only authenticated provider, auto-detection does reach it - so the
  catalog entry is not useless, it is simply not enough while the user has any
  other provider signed in, which is every real install.
- What Ante sends for effort, from the same files: no reasoning_effort at all for
  a model with no effort written (item 5, as its reference says omitting it sends
  none); `--effort min` on a model sent `reasoning_effort: "minimal"` - Ante's min
  is magpie's minimal, exactly item 4; and a model Ante was told
  `"supported_efforts": []` for sent no reasoning_effort even when asked `--effort
  low`, which is what a none-only model now gets instead of a level that refuses
  it.
- The user's own keys inside magpie's entry are ones Ante reads, not decoration:
  `ante catalog` hands back the entry's auth, http_headers, extra_body,
  stream_idle_timeout_secs and thinking_display unchanged, and a bearer env_key
  there makes `ante doctor` count the provider as the one authed. That is item 2.
- The no-auth decision holds on Ante's side, isolated by doctor's counts: Ante's
  own presets alone read `15 providers (0 authed)`; a magpie with no auth block
  reads `(1 authed)`; the same magpie given a bearer env_key that isn't set drops
  back to `(0 authed)`; with the key set, `(1 authed)`. So an entry with no auth is
  authenticated and one that asks for a key the user doesn't have is not - writing
  an env_key, which the first version's comment considered, would have made magpie
  the one provider Ante refuses to auto-detect.
- Ante fills `support_vision: true` in for itself, as the PR's own note said, and
  a routing group declared with no capabilities at all still worked.

Not covered by that run: the stand-in server answered inside the distro, so this
proves Ante's config handling end to end, not a live magpie gateway relay (the
first commit's message records a real turn through one). Every probe used
ANTE_HOME into a temp folder; this machine's real ~/.ante gained no catalog.json
or settings.json.

What each fix failed without it, one test each: taking ANTE_HOME out ->
TestAnteHome "$ANTE_HOME ignored"; theirsKept out -> "the user's auth lost or
changed"; syncJSON out -> "an unchanged catalog was rewritten"; atomic out ->
"settings.json kept half of a change that failed"; the old min mapping ->
"anteEfforts([minimal high]) = [high], want [min high]"; a written default ->
"deepseek/pro: effort written"; settings never pointed at magpie -> TestAnte; a
model never written -> TestAnteModelField; Check left quiet -> "moved off magpie:
\"\""; handing magpie back over a pick the user made while wired ->
TestAnteKeepsWhatTheUserPickedWhileWired; never restoring the model ->
TestAnteConnectDisconnect "not put back"; Connect picking the model field instead
of magpie as provider -> "Connect left the user's model beside magpie's provider";
ANTE_HOME kept out of agentenv.Vars -> TestFolderVarsAreListed. Taking
preferred_models out of the whole list is the one mutation no test turns red on:
mergeTheirs cannot merge a JSON array (sync.go returns want for it), so the file
behaves the same either way and the argument is there for intent and for the other
keys, not for behaviour.

go vet ./... and go build ./... pass, as do GOOS=linux go build -tags nogui ./...
and GOOS=windows go build ./..., and gofmt is clean on LF content (this clone has
core.autocrlf and no .gitattributes, so gofmt -l reports every file).
go test -tags nogui ./internal/agent fails twelve tests, and an untouched worktree
of origin/main run the same command on this machine fails the same twelve, test for
test; ./internal/agentenv, ./internal/appdir and ./internal/edit pass, and every
Ante test passes at -count=5. The whole ./... was run on both: the failing sets are
alike, origin/main having one more, TestLoginUsageClaudeAskedMeanwhile, already
filed as #1228. None of those twelve is this change's, and none is fixed here.

* docs: agent-wiring records that Ante keeps the user's keys in magpie's entry and starts on settings.json's provider (#1210)

The two rules the review asked for, written where the repo keeps them - the same lines ad3b7fe5 (#1239) added for Air and 2d9db1b6 (#1242) for malformed configs. The Verification block gains the package's own Ante run, as #1239 added Air's.

---------

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-08 16:58:41 +08:00
yetone e52d1cb820 docs: README rewritten for what magpie does now — stat line, flow diagram, warm-up, context, image MCP, keep awake, agent updates, all 47 agents; "coding agent" is now just "agent"; per-agent notes moved to the reference 2026-10-08 16:36:33 +08:00
yetone b6d88ce66e provider, gui: each of a provider's keys can go through a proxy of its own, as a subscription's accounts can (Beyfish_Wang on X: 不同 key 走不同的代理)
A provider of keys kept one proxy for all of them: Save dropped
AccountProxies for it. Now AccountProxies holds a key's proxy by its
KeyID, ProxyChoice reads it for the key a try uses (WithKey), and Save
keeps only the keys the provider still holds. The key editor's Proxy
row has a line per key (Provider's proxy | Direct | Custom), named as
the Accounts list names it, when the provider has two or more.

Run:
- go test ./internal/provider ./internal/gui ./internal/gateway: ok;
  TestKeyProxy fails without the provider change ("kept map[]").
- account-proxy.test.cjs, Chromium + WebKit, en + zh: 16/16; the new
  key cases fail without the app.js change. Also at 440px: 16/16.
- gui-ja, gui-de, i18n-keys, account-models, provider-many-keys,
  provider-typed, provider-proxy: all pass.
- Built binary, `magpie serve` in a sandbox HOME, three keys, four
  local proxies: k-1 (429) through its own proxy, k-2 (429) through its
  own, k-3 through the provider's; the global proxy got nothing.
2026-10-08 16:17:23 +08:00
Zhu Wang f00ed2901c routing: filter by several purposes with checkboxes (#1247)
* routing: filter by several purposes with checkboxes

Ticking a purpose immediately shows the union of the selected purposes while keeping the menu open. All purposes, Clear filter and removing the last selection restore every purpose. Counts, the story and history follow the same list.

Live menus ignore renderer-induced scrolling while deliberate outside scrolling still dismisses them. Update the GUI shell reference and browser coverage for multi-purpose counts, keyboard toggling, history, aliases and narrow windows in five languages.

Verified 115 isolated Playwright checks in Chromium and WebKit, JavaScript syntax and git diff --check. The new checkbox case fails against the old assets with 0 checkbox choices instead of 5; restoring the old menu scroll handler separately detaches the menu during selection in WebKit.

* routing: All purposes returns keyboard focus to its button

Address the keyboard-focus regression reported in PR #1247. Choosing All
purposes closes its menu and focuses the purpose button without scrolling.
Unticking the last purpose keeps the live menu and its checkbox focused.

The expanded checkbox test fails without the focus restoration in Chromium
and WebKit: All purposes returns keyboard focus to the purpose button
(false !== true). With the fix, 162 isolated Playwright checks pass across
both engines, including five locales, narrow widths, purpose state,
history, request/session grouping, Usage and scroll/menu regressions.
JavaScript syntax and git diff --check pass. The full GUI/Go suites and the
packaged desktop app were not run.
2026-10-08 16:02:17 +08:00
yetone 01e66e0f4a gateway: a reply a Claude subscription account answered carries its anthropic-ratelimit-unified-5h/7d headers, so a Claude Code signed in to claude.ai shows the 5-hour and weekly limits in its status line (claude-hud, #1257)
Claude Code tells the allowance in its rate_limit_event, not as headers, so
magpie says what it keeps of the account again as Anthropic does: a share
0-1 and a Unix-seconds reset per window, set as the request comes in and
again as the reply begins, after the turn's own event.

Claude Code 2.1.293 reads them only when it is signed in to claude.ai (the
sign-in field's claudeai); with magpie's key it ignores them.

Run: go test -tags nogui ./... (temp HOME); the two new tests fail without
limitHeaders; GOOS=linux/windows go vet of gateway and provider; real
Claude Code 2.1.293 in a sandbox HOME against a fake server sending these
headers: its statusline stdin had rate_limits.five_hour/seven_day with
used_percentage 42/17 when OAuth-signed in, none with ANTHROPIC_AUTH_TOKEN.
Not run: a real Claude account through a running magpie.
2026-10-08 15:28:52 +08:00
yetone db0feb3615 library, gui: a remote MCP server's headers and a command's env can name ${NAME}: the library keeps the placeholder and each agent gets its own syntax, so the token is never written into an agent's settings (#1250)
The library keeps `${NAME}` as typed. refsOf (internal/library/envref.go)
gives each MCP format the agent's own syntax, with the source beside each:
${NAME} for Claude Code, Droid, Grok, Gemini, Crush, Copilot, Pi, OmO, omp,
Qoder, AtomCode, Hermes and Command Code; {env:NAME} for OpenCode and MiMo;
${env:NAME} for Cursor; Codex's bearer_token_env_var, env_http_headers and
env_vars; Goose's ${NAME} plus env_keys. An agent with no syntax (Claude
Desktop, Antigravity, Kimi, Cline, ZCode, dsh, Devin, Pi with
pi-mcp-extension) is never given such a server: supports() refuses it with
the reason, an entry magpie wrote before is taken out, and its chip is
greyed with the reason on the row and in the editor. Codex's and Goose's
extra keys are magpie's only while the server names a variable, so the
user's own env_vars / env_keys stay. The editor says how to write one under
Headers and Environment; a check whose variable isn't set where magpie runs
says which one ("GH_TOKEN not set").

Verified:
- go test -tags nogui ./... (sandbox HOME): all ok.
- New TestEnvRefs* (per-agent encode/read-back, left-out agents, library
  keeps the placeholder, user values kept, health check novar) fail with
  the behaviour turned off (refsOf -> ${NAME} for all, no refsProblem /
  refKeys, no expansion).
- library-env-refs.test.cjs (Chromium + WebKit, en/zh/ja/de at 440px)
  fails on the old library.js (4/4 subtests); gui-ja, gui-de, i18n-keys,
  library-servers-bulk, library-all, by-agent, project-servers,
  library-mcp-health, library-null-agents, library-items: 109/109 pass.
- Real: magpie built from this tree synced a library server with
  Authorization: Bearer ${FAKE_MCP_TOKEN} and X-Api-Key: ${FAKE_KEY} into a
  sandbox HOME, then against a fake MCP on 127.0.0.1:3561 that answers only
  the right bearer: Claude Code 2.1.293 `mcp list` Connected, Gemini CLI
  0.62.0 Connected, Codex 0.160.1 app-server listed the tool (authStatus
  bearerToken), cursor-agent sent both headers expanded; no agent file held
  the token. Antigravity got the Problem and no entry. Not run for real:
  OpenCode (its mcp list goes through the user's background service),
  Goose, Crush, Copilot and the rest; their syntax is from their source.
- go vet (darwin, GOOS=linux, GOOS=windows) for internal/library and
  internal/gui.
2026-10-08 14:04:21 +08:00
如是 1b8c33b5e2 provider: a routing group reasons when any member does, not only when all of them do (#950)
* provider: a routing group reasons when any member does, not only when all of them do

groupEntries started a group at Reasoning: true and intersected it with
each member's (e.Reasoning = e.Reasoning && thinks), so one member that
doesn't think made the whole group 不支持推理. Pi reads that off /v1/models
and turns /thinking off for the model: a routing group of
deepseek-v4-flash-0731-max (a Trae plugin model magpie read as one that
doesn't reason) with a DeepSeek that does lost reasoning for both.

The group now reasons when any member does. Images already worked this way
(#756: a text-only member doesn't turn every image away), and the gateway
fits a request's effort to the member that gets it (fitFor), failing over
when a member refuses the level (failEffort). What the group offers as
levels is not changed: a member known to take none still leaves the group
none (#597), so such a group is Levelless — a shape magpie serves — rather
than one that doesn't reason.

group_reasoning_test.go (6 tests) fails without the change:
TestGroupReasoningFromAMemberThatThinks, TestGroupReasoningLevels and
TestGroupReasoningQuietMemberKeepsTheRest report reasoning off.
TestModelsListReasoning, the /v1/models contract test, fails the same way
and is updated to the new semantics. go vet, go build, GOOS=linux
GOARCH=amd64 go build -tags nogui and GOOS=windows go build pass; go test
-tags nogui ./internal/provider ./internal/plugin pass under a temp HOME.
internal/gateway passes except TestPluginStreamStalledClientKeepsOther
RequestsMoving, which fails on this machine with and without the change.

* gateway, provider: a group's member that can't think is sent no reasoning, rather than a 400

A routing group reasons when any member does (#950, 533887c2), so an agent
asks a group for reasoning that a member of it may not think at all. The
effort reached every member, and some vendors turn a reasoning ask away
with a 400 on a model that can't think — the review's second point: the
comment said the gateway routes a reasoning request to the members that
take it, and nothing did.

The gateway now sends such a member no reasoning ask, in the agent's own
protocol: Chat's reasoning_effort and reasoning, Responses' reasoning (with
reasoning_effort, which some relays take), Anthropic's thinking when it
asks to think — thinking disabled is no ask and stays — and output_config's
effort alone, the rest kept. Everything else goes as the agent sent it, the
Responses effort-update history is skipped for it, and the trace's Effort is
"" (nothing was sent) with Fixed kept, so the user's own pin is still read.
Which member goes first is unchanged: the group routes as it did, and only
the body changes.

provider.Entry.Quiet is the judgment: magpie has a word on the model — its
vendor's or its maker's list, or its provider's own account list — and that
word says no reasoning. A model nothing speaks for isn't quiet: it is sent
the effort the agent asked for, as #597 leaves it. Nor is one that takes
levels, which includes a model the user gave levels of their own: those
take the effort the gateway fits to them. Direct requests outside a group
are untouched, as TestPluginThinkingOff pins.

TestModelsReasoningWithoutLevels (thinks_test.go) still expected
group/mixed false; the review's first point. It is updated with a comment
saying why the #402 expectation changes, and a group of two quiet members
keeps the "none of them thinks" case.

TestGroupNonThinkingMemberNoEffort (group_reasoning_test.go) fails without
the change: with the withoutReasoningAsk call disabled it reports "B: the
quiet member was sent map[... reasoning_effort:high]". TestEntryQuiet
(provider) pins the judgment, and TestWithoutReasoningAsk the field set per
protocol.

go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./... and
GOOS=windows go build ./... pass. go test ./internal/provider,
./internal/catalog and ./internal/plugin pass whole; internal/gateway
passes in three chunks covering every test (TestGroup|TestFixed|TestFast|
TestModels|TestPluginThink|TestWithout|TestMember, TestEffort|TestResponse|
TestReasoning|TestQuiet|TestTrace|TestRoute|TestRules|TestRule, and the
rest), 43s + 36s + 790s. The whole suite in one process times out on this
machine in an unrelated test (TestRuleDecidesAtTurnStartAndHolds here,
TestRequestBodyProgressRenewsIdleDeadline on another run) — the base commit
533887c2 times out the same way, at 800s — so the chunks are what was run.
The GUI is untouched, so no Playwright run.

* gateway, provider: a member the user fixed at an effort keeps it, quiet or not

The review's remaining point: Quiet() rests on magpie's catalog or an
account's own model list, and what this PR is about is that word being
wrong — the Trae plugin gave no capabilities, and magpie read a thinking
model as one that doesn't. A member the user fixed at an effort
("provider/model:high", #189) is the user saying this model does think, so
magpie's word doesn't override theirs: the gateway sends the fixed member
its effort and strips the reasoning ask from the others as before.

quiet := isGroup && c.effort == "" && quietTo(c.p.ID, c.model). c.effort is
only ever the member's own fixed effort (Member.Effort, planLevel), never
the agent's, a group's Group.Levels, the turn's pick or the Settings' — so
those are stripped as they were.

TestGroupNonThinkingMemberNoEffort's E is now the other way: the fixed quiet
member is sent reasoning_effort "high", and the trace's Effort is "high"
with Fixed "high". Dropping the c.effort == "" guard makes it fail with
"E: the fixed member was sent map[... model:plain]". quiet.go and
Entry.Quiet say why.

gofmt, go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./...
and GOOS=windows go build ./... pass. The review's own set passes —
go test -count=1 -run 'TestGroup|TestWithoutReasoningAsk|TestEntryQuiet|
TestModelsReasoning|TestModelsListReasoning|Thinks|TestPluginThinking'
./internal/provider ./internal/gateway — and so does internal/provider whole
(208s). internal/gateway passes in chunks (Test[A-D] 340s, Test[S-Z] 216s,
Test[^A-DS-Z] split over TestEffort|TestResponse|TestReasoning|TestQuiet|
TestTrace|TestRoute|TestRules|TestRule|TestFixed|TestFast 38s and the rest
earlier): the whole suite in one process still hangs here on an unrelated
test (TestCodexBodyReadsPastOldLimit this time, TestRuleDecidesAtTurnStart
AndHolds and TestRequestBodyProgressRenewsIdleDeadline before), which the
base commit does too and which passes alone in 0.9s.
2026-10-08 13:47:13 +08:00
yetone 5d0c1dcad7 agent, library, sessions: a Claude Desktop installed as an MSIX package on Windows is found, wired, given MCP servers and skills, and its Cowork calls read, in the package's folders (Kilig on Discord, Windows 10 LTSC 21H2)
Desktop from its setup.exe there runs packaged, so Windows keeps what it
writes under %APPDATA% and %LOCALAPPDATA% in
%LOCALAPPDATA%\Packages\Claude_<publisher>\LocalCache\{Roaming,Local}.
Nothing was in %APPDATA%\Claude or %LOCALAPPDATA%\Claude*, so magpie didn't
recognise Desktop at all. The packaged app reads its own copy before the
real folder's, so writing the usual folders wouldn't reach it either.

New internal/desktopdir is the one resolver for Desktop's folders: Data
(its own userData: %APPDATA%\Claude, the package's LocalCache\Roaming\Claude),
Mode (the Claude folder deploymentMode goes into, %LOCALAPPDATA%\Claude as
before) and ThreeP (Claude-3p). The package is found by globbing
Packages\Claude_* (the publisher id isn't assumed) and used unless Desktop
runs in the usual folders: it has run there (Chromium's "Local State"; an
%APPDATA%\Claude holding only another tool's claude_desktop_config.json
doesn't count) and not later in the package's. Every place that built
these paths goes through it:

- agent: desktopDirs (its 3p switch's writes, DesktopConfig3p, Claude
  Code's desktop check in claude.go) and detection, which also looks at
  Data (replacing the separate %APPDATA%\Claude check). windowsDesktopDir
  moved into desktopdir unchanged.
- library: apps() (Desktop's MCP file, was os.UserConfigDir) and
  desktopSkillRoots (Cowork's skills-plugin).
- sessions: desktopDataDirs, a copy of agent's resolver, now desktopdir's
  All(); it also adds %APPDATA%\Claude on a usual Windows install, where
  Desktop keeps 1p Cowork sessions (only %LOCALAPPDATA%'s were read). usage's
  request_page and session_provider read it through sessions.DesktopDataDirs.

Tests: TestFindMSIX/TestFindWindowsClassic/TestFindOthers (desktopdir),
TestClaudeDesktopMSIX (agent: detected, switch writes LocalCache\Local\Claude
and Claude-3p, nothing in the usual folders), TestClaudeDesktopMSIXLibrary
(MCP file and skills-plugin in LocalCache\Roaming\Claude),
TestCallsCoworkMSIX (sessions). With the call sites put back to origin/main
the last three fail: "an MSIX Claude Desktop isn't detected", "no Claude
Desktop target: <nil>", "dirs: [.../Claude .../Claude-3p]".

Run: go test -tags nogui ./... under a temp HOME with pinned caches (all
ok); GOOS=darwin|linux|windows go vet -tags nogui on desktopdir, agent,
library, sessions, usage; GOOS=windows go build, GOOS=linux go build -tags
nogui. On the Windows 11 box (Squirrel install, no MSIX package): the
windows test binaries pass every Desktop/MSIX/Cowork test; the full agent
and sessions binaries fail the same 16 and 76 tests (run outside their
testdata) as origin/main's; a read-only probe of desktopdir.Here() gives
the usual %APPDATA%\Claude, %LOCALAPPDATA%\Claude and Claude-3p, Package "".
Not tried against a real MSIX Desktop: none on the box, and Claude-3p's
place in the package (LocalCache\Local, as Local\Claude is in Kilig's
listing) is inferred, not seen.
2026-10-08 12:47:48 +08:00
Strelizia 69b6772794 provider: Qoder check-ins follow the campaign window, so a campaign open past Beijing midnight is claimed and the next one isn't missed (#1173)
Qoder and Qoder CN campaigns run on [startAt, endAt) windows that cross Beijing midnight, so a campaign claimed yesterday could stand in for the whole new day and the next campaign was never claimed.

- A campaign is active by its upstream window, so one still open after Beijing midnight can be claimed.
- A saved result records the campaign's end in `until`, and is reused only on the same Beijing day, within 30 minutes and before that end; after that the list is read again (about 48 list reads per account per day, `inactive` included).
- Manual check-in always verifies upstream; failures and older records without `until` keep the old retry behaviour.
- Only Qoder sets `recheck`; WorkBuddy, Trae CN and MiniMax keep their once-a-day settlement.
2026-10-08 12:46:41 +08:00
yetone 3aeb7d1bf8 gateway: a model a key's plan doesn't serve rests that model alone, not the key (#1235)
SenseNova answers a Token Plan key asked for deepseek-v4.1-flash with
403 "model is not available in the current token plan" while the same
key serves deepseek-v4-flash. That refusal was a plain failure: it
rested the whole key, its backoff counted per key and doubling up to
10 minutes, so an agent that kept asking for the model left every key
of the provider resting for every model (Sun1090: both keys "失败 · 9
分钟后恢复").

A 400/403/404/422 whose body says the model isn't served
(unservedWords), with no quota, credit, rate-limit or channel words,
now rests only that key's or account's model (modelRefused, keyed by
restID, which is now restKey/model for every candidate), its backoff
counted by the model and cleared when the model answers. The key's
other models stay first in order, and its row shows no rest.

TestModelNotInPlanRestsTheModelNotTheKey (two keys, four requests for
the refused model, SenseNova's error shape) fails without the change:
"key One rests for a model its plan lacks: {Why:other Status:403 ...
By:backoff Failures:3}" and "deepseek-v4.1-flash doesn't rest". With
it: go test -tags nogui ./... under a temp HOME passes; -race -count=5
over TestModelNotInPlan|TestSeveralKeys|TestFallback|TestRouting|
TestPool|TestGroup passes; go vet (darwin, GOOS=linux, GOOS=windows,
nogui) passes. Not tried against a real SenseNova key: the body's
"code" field in the fixture is a guess; the message is the reporter's.
2026-10-08 12:26:53 +08:00
Alex be1ce5d4d2 davsync: incomplete S3 listings keep cached usage days (#1243)
Reject truncated listings with missing or repeated tokens and listings that exceed the page limit. On the old code the regression reports successful partial listings and shareWith removes a cached day. Focused tests pass in a temporary HOME.
2026-10-08 11:52:26 +08:00
Alex 2d9db1b60d edit: malformed JSON configs stay untouched when agents connect (#1242)
Validate the complete comment-stripped document before object and array edits, including previews. Regression tests fail on the old implementation because edits and VS Code Connect succeed and alter malformed files. Targeted edit and VS Code tests pass in an isolated HOME.
2026-10-08 11:52:20 +08:00
yetone 814dca9390 gateway: the ChatGPT backend's 502 "response protection is unavailable" is the request's, not the account's: asked once, no account rests, and a magpie-model compaction refused so is asked again as text (vs on Discord)
A long Codex conversation's compaction refused so went to every ChatGPT
account in turn, each rested on a growing backoff, then the last was asked
twice more: 5 upstream tries per Codex request with 3 accounts, times
Codex's own retries. Now the provider's other accounts and models are
not asked it, nor the same one after a moment; another vendor left in a
group is asked once. A reply broken off with it rests nobody either.
codexCompact falls back to plainCompact on it, as vs's control experiment
(the same history as text) was answered.

TestProtectionRefusalAskedOnceNobodyRests fails on the old code with
"asked 5 times ([acct-1 acct-3 acct-2 acct-2 acct-2]), not once" and two
accounts resting; TestProtectionRefusedCompactionAsText with status 502.
The error body's exact bytes are not the reporter's; no real ChatGPT
account was tried.
2026-10-08 11:34:33 +08:00
Kenoia ad3b7fe5c2 agents: keep Air provider fields on sync and model picks (#1239) 2026-10-08 11:20:35 +08:00
yetone 70ec514872 provider: a Copilot editor token GitHub refuses gives way to the Copilot CLI's sign-in of the same account, and a sign-in from magpie of that account is kept (#1238, eightHundreds)
A stale oauth_token for octocat left in ~/.config/github-copilot/apps.json
was read before everything else, so its 401 Bad credentials left Copilot
with no models although the Copilot CLI was signed in to octocat and
worked; signing in again from magpie didn't help, since the new token was
let go as the editors' own account.

- copilotToken remembers an editor token whose trade GitHub refuses (401 or
  403; tried again after an hour, or as soon as one trade works).
- copilotApp.session then uses the CLI's sign-in of the same login on the
  same host, sent as the CLI sends it, and copilotLogin reads that sign-in
  for the account from then on. A CLI signed in to another account is never
  used in its place; the error names the editors' file, and that the CLI is
  another account.
- The Usage card (loginQuota) reads the CLI's sign-in the same way when the
  editor token gets 401.
- The device sign-in asks GitHub about the editors' token of the same account
  first (copilotProbeEditor); refused, addCopilotLogin keeps magpie's token,
  standing for the account. With a working editor token it is let go as
  before.
- TestMain stubs copilotCLISecret, so no test reads the runner's keychain.

Tests: copilot_stale_editor_test.go (fake exchange, user and API servers,
temp HOME). Without the fix TestCopilotStaleEditorFallsBackToCLI and
TestCopilotStaleEditorNotAnotherAccount fail "Copilot is signed out (Bad
credentials); sign in to Copilot again", TestCopilotSignInKeptOverStaleEditor
lists octocat with the editors' gho_x, TestCopilotStaleEditorUsageReadsCLI
reads "Unauthorized". Not tried with a real stale apps.json or a real
Copilot CLI login.
2026-10-08 11:17:39 +08:00
yetone 1efa4c642f gateway: a Codex lead's turn carrying its subagent's sealed reply goes back to the Responses provider that answered the lead, instead of being refused as a subagent task "sealed by the ChatGPT backend" (#1237)
SuperrNaruto (#1237), and PennyVibe on #1109 with v0.1.1108: Codex
with the lead and its subagents all on one custom Responses provider
serving gpt-6-astra. #1109's fix sends the subagent's sealed task back
to that provider, and the subagents ran. But when a subagent answers
the lead with send_message, Codex hands the lead an agent_message to
/root whose payload the provider sealed, and hasSealedAgentMessage
marks the lead's next turn as a sealed task. The lead has no parent
thread, so leadProvider found nobody, sealedReader accepted only a
ChatGPT account, and the lead was refused before anyone was asked with
"This subagent's task was sealed by the ChatGPT backend that answered
its lead ... is neither". The main conversation stopped while the
subagents finished.

The providers that can read a sealed message are now those that
answered the lead (the parent thread, as before) and the one that
answered the request's own conversation (leadProvider of its
conversationID): a subagent sent a task sealed by the lead's provider
had to be on that provider, so its reply was sealed there too. A lead
moved to another provider, one magpie never answered, and a subagent
whose lead a ChatGPT account answered are still refused before anyone
is asked, with the same messages.

Verified: TestSealedReplyGoesBackToTheProviderThatAnsweredTheLead
(lead, subagent, then the lead with the subagent's sealed FINAL_ANSWER
reply, all on third/gpt-6-astra with Codex's web_search tool) fails on
origin/main with the reporter's exact 400 and passes with the change;
the reply's ciphertext reaches the provider as it came.
TestSealedReplyRefusedWhereTheLeadWasNotAnswered and
TestSealedTaskFromChatGPTLeadStillRefused (lead on gpt-5.5 through the
ChatGPT sign-in, subagent on the custom provider) pass on both. go test
-tags nogui ./internal/gateway passes; the sealed tests pass under
-race -count=5; go vet -tags nogui ./internal/gateway passes for
darwin, linux and windows. Not tried with real Codex and a real relay
of the ChatGPT backend: the reply's item is built from the
"Message Type: FINAL_ANSWER / Task name / Sender / Payload" layout in
the codex 0.160.1 binary and PennyVibe's description, not from a
captured request.
2026-10-08 11:13:11 +08:00
yetone 40c5b92a93 gateway: structured output crosses every translation: a Responses text.format or a Chat response_format reaches a Chat, Responses, Messages or Gemini upstream in its own words (frostming on Discord: responses.parse through magpie to OpenRouter came back as plain text)
The IR held a schema only from Anthropic's output_config.format, and only
Messages (and Claude Code's --json-schema) were asked for one. A Responses
request to a provider magpie speaks Chat or Messages to (OpenRouter has no
Responses URL) lost text.format; a Chat request translated to Responses or
Messages lost response_format; an Anthropic schema translated to Chat or
Responses was lost too.

Format (format.go) now carries json_schema (name, description, schema,
strict) or json_object from Responses, Chat, Anthropic and Gemini, and each
builder asks for it in its upstream's field. Where a target can't say it
(json_object on Messages or Claude Code; the vendor bridges Cursor, Devin,
Kiro, Qoder, Command Code, Zed) it is told in the system prompt. An upstream
answering 400/422 naming the field (DeepSeek's "This response_format type is
unavailable now") is asked again with it told in words, and so from then on.
Relayed requests are unchanged.
2026-10-08 10:37:02 +08:00
forever ac142bb1d2 agent, gateway: Claude Code through magpie can keep its claude.ai sign-in (#1218)
* gateway: Claude Code signed in to claude.ai is told a provider's 401 as a 502, not as its own sign-in refused

Claude Code signed in to claude.ai, its base URL magpie's and no key set,
sends its own sign-in as its key (Authorization: Bearer sk-ant-oat…),
which magpie never passes on. A 401 it reads as that sign-in refused: it
renews the sign-in before each of its retries (ten for one request) and
then asks to /login; a 403 saying "OAuth token has been revoked" the
same. Whatever magpie answers such a request with is never about that
sign-in: a provider refused magpie's credential, or one of magpie's own
Claude accounts lapsed (the bridge's 401 or revoked 403). keepsSignIn,
on /v1/messages, /messages and /v1/messages/count_tokens, tells such a
request's 401 as a 502, and a 403 too when its body says the token was
revoked; the body is the provider's message as it was. Recent calls and
the usage log keep the provider's status. Any other 403, and a request
with magpie's key or an API key, is answered as before; no
x-should-retry is added, so Claude Code retries a 502 as it does a 5xx.

Verified: TestClaudeSignInNotToldItsSignInFailed (401 on /v1/messages,
/messages and count_tokens; revoked 403; another 403; magpie's key; an
API key; a reply and a stream untouched; Recent calls' status) fails
without the route change ("401: 401 … want 502", "revoked: 403 … want
502", "401 counting: 401 … want 502"); TestSignInWriterReleasesA403. go
test -tags nogui ./internal/gateway, go vet and gofmt are clean.

* gateway: a provider never gets the oauth beta of Claude Code's claude.ai sign-in

Claude Code signed in to claude.ai asks oauth-2025-04-20 in
anthropic-beta with its sign-in. magpie relayed the agent's betas to
every Anthropic-protocol provider, so that one went too, naming a
credential that never leaves magpie: a provider got a request claiming
an OAuth sign-in it was not sent, beside magpie's key for it. askedBetas
leaves the agent's oauth- betas out of what forwardOnce asks, so a
provider gets the request as Claude Code with magpie's key sends it. A
beta the user set on the provider (header.anthropic-beta, fitUserBetas)
still goes, oauth- or not.

Verified: TestSignInBetaStaysWithMagpie (the oauth beta among others,
alone, and the user's own on a provider) fails without the forwardOnce
change (sent ["claude-code-20250219,oauth-2025-04-20,…"], and
["oauth-2025-04-20"] where none was wanted); TestUserBetas* and
TestClaudeCodeHeadersReachAnthropicRelay pass. go test -tags nogui
./internal/gateway, go vet and gofmt are clean.

* agent, gui: Claude Code through magpie can keep its claude.ai sign-in

Claude Code's new sign-in field is magpie's key (the default) or claude.ai.
claude.ai writes ANTHROPIC_AUTH_TOKEN empty, which Claude Code reads as no
key: it stays signed in to claude.ai (/usage plan limits, connectors, voice,
/teleport) and sends that sign-in to magpie, which never passes it on. Every
later write (a model, a tier, the subagents, a /model pick followed, Reapply,
a port move, Disconnect and Connect) keeps it so, and a key put there by hand
is reported by Check rather than taken for magpie's. A token emptied or taken
out by hand in key mode is reported with the way to keep it so.

It is offered only where the gateway takes any key (loopback, a WSL distro
mirrored), not from a distro under NAT while magpie is shared on the network.
The GUI draws no square for a Claude Code with no other sign-in to take.

Verified: go test -tags nogui ./internal/agent (new claude_login_test.go,
each test fails with its change reverted) and -run 'SignIn|Claude|Beta'
./internal/gateway; go vet; GOOS=linux/windows builds; claude-signin.test.cjs
(fails without the app.js change) plus i18n-keys, agent-field-names,
agent-layout, claude-direct, claude-effort, codex-signin-always,
subagent-effort and menu-scroll, Chromium and WebKit, en/zh/ja/de, 1100 and
440px. Real Claude Code 2.1.292 with the token empty sent Bearer sk-ant-oat
and the oauth beta to the base URL, took a 502 as a server error, and stayed
signed in to claude.ai.

* agent: Claude Code given a magpie model at an older gateway address keeps the endpoint it had before magpie

When the gateway moved while magpie wasn't running to follow it, a magpie
model picked next found Claude Code at the older address and stashed what
was there as the user's own: the dead gateway URL, magpie's key or the
empty one of a claude.ai sign-in kept, and magpie's own tier models. That
overwrote the endpoint and token stashed when magpie was first wired in, so
switching off put the dead address back, and with the claude.ai sign-in
kept "model default" left it and five magpie tiers too.

What's there at an older address is now taken for magpie's (keyOurs: its
key, or an empty token with the claude.ai sign-in kept), and the stash made
when magpie was wired in stays. The same test replaces "model default"'s
guess from the model's name, which could take out a user's own endpoint
with an empty token.

Verified: TestClaudeAtAnOlderGatewayAddress (both sign-ins, Disconnect and
model default; fails on the previous commit, the key-mode case on main
too); go test -tags nogui ./internal/agent; -race -count=5 on the Claude
and WSL Claude tests; go vet; GOOS=windows build.

* gateway: Claude Code signed in to claude.ai is told a provider's refusal in magpie's words, never mentioning x-api-key

Claude Code 2.1.293 tells any error whose message mentions x-api-key as
"Not logged in · Please run /login", whatever its status, so the 502 alone
still signed it out (yetone on #1218). A 401, or a 403 saying the OAuth
token was revoked, now reaches it as an api_error "<provider> refused
magpie's credential (HTTP 401): invalid API key", and a stream's error
after its 200 says "API key" too. Recent calls keep the provider's status
and message.

Checked with Claude Code 2.1.292 signed in to claude.ai, through the
gateway to a provider answering 401 invalid x-api-key: it shows "API
Error: 502 Fake refused magpie's credential (HTTP 401): invalid API key.
This is a server-side issue" and stays signed in.

* agent: an empty token at an endpoint of the user's own stays theirs, though Claude Code once kept its claude.ai sign-in through magpie

keyOurs took any empty ANTHROPIC_AUTH_TOKEN for magpie's once the sign-in
was kept, so after Disconnect a user's own https://corp.example with an
empty token lost both to the model default (yetone on #1218). The empty
token is magpie's only beside the address magpie last wrote, stashed as
claude.wired_at, which Rewire carries to the gateway's new port.

* gateway: no error Claude Code signed in to claude.ai is told mentions x-api-key, whatever its status

Claude Code reads any error naming x-api-key as its own sign-in refused,
so a provider's 403 "invalid x-api-key" or 400 "x-api-key header is
required" still told it "Not logged in · Please run /login" (yetone on
#1218, Claude Code 2.1.293). Every error such a request is told is held
until its body and says "API key" there, at the status it had; a 401 and
a revoked 403 stay the 502 in magpie's words.
2026-10-08 08:58:21 +08:00
forever 202031642a provider: an account refused while its allowance is being read has it read again once that reading is back, and keeps the share it had meanwhile (#1142)
* provider: an account refused while its allowance is being read has it read again once that reading is back, and keeps the share it had meanwhile

StaleAllowance zeroed the time an agent's allowances were read, and
dropped the account's own reading, but a reading Allowances already had
out, asked before the refusal, came back after it, set that time to now
and was trusted for a minute. For that minute the Routing page and the
trace showed the share from before the refusal, the tray's account in
use kept the refused account, and a second refusal of the account found
its window not full: a quota refusal with no reset time rested 15
minutes by "quota" instead of until the window renews, and the account
was tried and refused again after it. Every caller of StaleAllowance had
it: the gateway after a refusal, Claude's limit headers, a Codex reset
spent and both warm-ups.

StaleAllowance now marks the agent stale when a reading is out, as
StaleKeyAllowance does a key (ee2588c2). Once that reading is back what
it read is kept, but as not read, so the next ask reads again at once.
The values are kept rather than dropped because weigh counts an account
it doesn't know as unused, which would put the refused account first.
The tests' resets of the allowances clear the mark.

TestAllowancesStaleMidRead (provider: the reading held until after
StaleAllowance, answered 97%; later readings 100%) fails without the
change, "first ask 97%, 2s later 97%, readings 1", 3 of 3.
TestRefusedMidReadRestsByWindow (gateway: provider.Allowances behind
restAfter, as a plugin's agent is kept) fails without it, "second
refusal rests 15m0s by quota; the share read 97%, readings 2", 3 of 3,
and now rests by "window". Both pass -race -count=20, as do
TestAllowances, TestResetRunsOut, TestKeyAllowance and TestLoginRoom.
The verification commands of gateway-routing.md and
providers-accounts.md, go test -tags nogui ./..., go vet and gofmt -l
pass. docs/reference.md says it.

* docs: providers-accounts says a reading out when StaleAllowance is called is kept and read again (from #1161)

* provider: a built-in Grok account refused while its allowance is being read is read again on the next ask, not served the share read before the refusal for a minute

grokLoginUsage wrote every per-home reading back with a fresh time, so one
out when StaleAllowance("grok", user) dropped the home's entry was kept and
served for up to a minute. Each home's reading now carries a pending
generation that StaleAllowance drops; a reading whose generation no longer
matches goes to its caller but isn't cached, as loginReading does for the
other agents. A Grok moved to its plugin goes through loginReading and was
not affected.
2026-10-08 08:23:20 +08:00
yetone 84f37ac977 provider: a Copilot account renamed with its host keeps its proxy, models, cap and concurrency, moved from its bare login before the new name is written (#1220)
Before #1220 a bare Copilot login named one account (a same-named one
wrote over it), so the per-account settings in providers.json kept under
it are that account's. When an account is renamed login -> login@host
(an old enterprise record read by nameAlike, or the editors' own
enterprise account renamed in sideLogins), renameTo remembers the old
name (savedLogin.was, never written) and pins the account's stable id
from it. writeLogins then moves accountProxies, accountModels,
accountCaps and accountConcurrency of the copilot provider from the old
key to the new (moveAccountSettings), and writes the new name only once
that has worked (renameSettled):
- a setting the new name has already stays, and the old with it;
- an old name another account still bears is left to that account;
- providers.json that can't be read or written stops the move, and the
  account is written under its old name, its settings with it, retried
  a minute later (renameDue) rather than at every listing.
sideLogins writes a pending rename the first time copilot is listed,
on a copy so the listing keeps its order.

Not moved: usage history, quota history, usage-alert marks and the
GUI's expanded/brief card state (localStorage) - records and view
state, not settings. Gateway keys' account lists go by the stable
login id, which is kept.

Tests (each FAILs with moveAccountSettings stubbed to do nothing):
- TestCopilotRenameKeepsSettings: proxy, cap, models, concurrency under
  "mona" are mona@a.ghe.com's after the upgrade; a github.com mona added
  after has neither proxy nor cap.
- TestCopilotOwnGHERenameKeepsSettings: the editors' own GHE account
  keeps cap and proxy.
- TestCopilotRenameWaitsForSettings: an unreadable providers.json is
  left as it is and logins.json keeps "mona"; once readable, the
  settings move and the new name is written.
- TestCopilotRenameKeepsNewNamesSetting.

Ran: rebased onto origin/main (b6f33753); gofmt -l clean; go vet -tags
nogui . ./internal/provider ./internal/gui ./internal/gateway for
GOOS=darwin, linux, windows; go build ./...; GOOS=linux/windows go
build -tags nogui .; go test -tags nogui -count=1 ./internal/provider
./internal/gui ./internal/gateway . under a temp HOME with pinned
caches and pipefail, all ok; -race -count=5 -run TestCopilot
./internal/provider ok.
2026-10-08 07:27:03 +08:00
yetone 74a809dcdf provider, cli: a Copilot account is told apart by its host as well as its login, so one GitHub login on github.com and on enterprises' *.ghe.com is kept as separate accounts, none written over or dropped as the editors' own (#1220)
A Copilot account is now named "<login>" on github.com and
"<login>@<name>.ghe.com" on an enterprise's host (CopilotAccountName).
That name is its logins.json user, so addSideLogin, the own-account dup
check, switch, on/off, forget, the account lists, usage cards, routing
ids and per-account settings all go by login and host. The device-code
sign-in reports the account under that name, so the window opens the one
just added. Plan refresh for the editors' own account on an enterprise's
host matches it under the same name.

logins.json written before keeps working: a saved account on an
enterprise's host stored under its login alone (host only in its auth) is
read as login@host, keeping its id and token (copilotSavedName in
nameAlike). The editors' own account remembered by its login alone is
renamed in place, keeping its plan and its hidden mark (copilotRenamed).
github.com accounts are unchanged.

CLI: `magpie accounts switch|forget copilot` now works. It takes
login@<name>.ghe.com, the login with --host <name>.ghe.com (or
--host=), or the login alone when it names one account. An ambiguous
login lists the matches and asks for one. Switching says "magpie now
uses X", since the editors' own sign-in isn't changed.

Tests (new, each FAILs on the old code):
- TestCopilotSameLoginOnTwoHosts: mona on a.ghe.com and b.ghe.com,
  through the device flow. Old: the sign-in reports "mona" and the second
  overwrites the first.
- TestCopilotOwnAndSameNamedGHE: the editors' github.com mona plus
  a.ghe.com's mona.
- TestCopilotLoginsBeforeHosts: an old-format logins.json. Old: listed
  "mona* hubot".
- TestCopilotOwnGHERenamed. Old: own row still "mona".
- TestAccountsCopilotHosts. Old: "copilot": only Claude Code, Codex, ...
  can be added and switched.
TestCopilotGHESignIn and TestCopilotCLIGHELogin now expect
mona_acme@acme.ghe.com.

Ran gofmt; go vet -tags nogui . ./internal/provider for GOOS=darwin,
linux and windows; go build ./...; GOOS=linux and windows go build
-tags nogui; go test -tags nogui -count=1 ./internal/provider
./internal/gui ./internal/gateway . under a temp HOME with pinned
caches, all ok; -race -count=5 on the Copilot account tests, ok. No GUI
asset changed, so no Playwright run. Not tried with a real GHE.com
account.
2026-10-08 07:27:03 +08:00
yetone b6f33753ce gateway: a gzip or zstd request body is read on every route, so Codex with a custom provider on /v1/responses no longer gets "expected a JSON object" (#1223)
Only the Codex backend path decoded a Content-Encoding (codexReader);
/v1/responses, /v1/chat/completions, /v1/messages, count_tokens, Gemini's
routes, embeddings, images, videos and System One read the compressed
bytes as JSON. Codex sends zstd to a custom provider's /v1/responses by
default. readBoundedRequestBody now always decodes (decodedBody, moved
from codexReader): identity, gzip and zstd are read, the header is
dropped so the provider gets plain JSON, the size limit holds for the
decoded bytes (413), and any other encoding is refused 415 naming it.
keyLimited reads a limited key's compressed body the same way, so its
reservation is the decoded size, not the compressed one.

Verified: request_encoding_test.go posts real gzip and zstd bodies to a
running httptest server of New().Handler() on every route. Without the
change TestEncodedBodiesOnEveryRoute and TestEncodedEmbeddingsBody fail
with 400 "expected a JSON object" / "the body isn't a JSON object",
TestEncodedBodyInflatedPastLimit gets 400 instead of 413,
TestUnknownBodyEncodingRefused gets 400/502 instead of 415, and with only
key_limit.go reverted TestKeyLimitReservesDecodedBody fails "held at 19
tokens for a 40061-byte body sent as 79 bytes". gofmt; go vet -tags nogui
./internal/gateway for GOOS linux, windows, darwin; go test -tags nogui
./internal/gateway (temp HOME, pinned caches) ok; the new tests -race
-count=5 ok. Not tried with the real Codex CLI.
2026-10-08 07:12:44 +08:00
forever d151c10839 gateway: a whole reply after keepalives reaches the agent as its stream, not as an "OK" error (#1175)
Sometimes a held try's vendor says nothing for longer than keepHeldAfter.
keepAlive then sends the agent the stream's 200 and SSE comments. On the
passthrough path the vendor could then ignore stream:true and answer 200
with its reply whole. The try wasn't a failure, so release() told it to
the agent as the stream's error, "OK: {...the reply...}", and the agent
lost a reply that was billed. Found by reading the code, then reproduced
for Chat, Responses and Anthropic, in a group and for a lone provider.

Once the agent has a stream's headers, release() now sends a whole 200
reply as that protocol's stream (wholeAsStream). Each choice, output item
and content block is sent in the events that stream it, as the vendor
wrote it:
- text, with its citations (Anthropic citations_delta, Responses
  output_text.annotation.added)
- reasoning summaries, and thinking with its signature
- tool calls, refusals and stop details
- usage

A 200 under any type other than JSON or SSE is held too. Before, a
text/plain reply was written raw into the SSE stream. Its first bytes
decide (sseStart). If its first line begins with an SSE field, it was a
stream all along under the wrong type, and it goes through as it comes,
as before. While those bytes have yet to come, the agent is still kept
alive.

A 200 that is no reply of the protocol's now fails in settle(), the way
a translated reply that didn't stream does (502, "... did not stream").
The next member answers. On the last member the agent gets that error
and the request is logged as a 502.

Gemini gets no keepalives, so this changes nothing for it.

Verified:
- Each new test fails on the code before its fix and passes with it:
  - TestQuietWholeReplyStreamed covers Chat, Chat as text/plain,
    Responses, Anthropic, "not a reply" and "a stream as octet-stream".
    Before the fix the first four get "error:OK: {...}", or nothing for
    text/plain. The octet-stream case is the regression found on
    review: its stream was buffered and failed over.
  - TestQuietNotAReplyLastFails: before the fix the agent got the OK
    error, logged as 200.
  - TestWholeAsStreamClientReads and TestWholeAsStreamResponsesCitations.
  - TestHeldUnsureStreamPasses covers prefixes split across writes,
    id:/retry: first, and a whole reply.
  - TestHeldUnsureKeptAlive.
- go test -tags nogui -race -count=20 on these tests together with
  TestQuiet|TestSilentHeld|TestKeepAlive.
- go test -tags nogui ./internal/gateway, and the prebuilt test binary
  with GOMAXPROCS=2.
- go vet ./....
- go build, GOOS=linux go build -tags nogui, and GOOS=windows go build.
- go test -tags nogui ./... under a temp HOME.
- The synthesized streams were also fed to openai-node 7.30.0
  (chat.completions.stream, responses.stream) and @anthropic-ai/sdk
  0.131.0 (messages.stream, beta.messages.stream). Each accumulated the
  whole body's reply field for field.
- Not tried against a real vendor that ignores stream:true.
2026-10-08 06:48:48 +08:00
a2c3905368 provider, gateway: a usage window whose reset has passed is renewed on every path, and an account resting out of it is back once a reading finds it renewed (#1200)
* provider: a usage window whose reset has passed is renewed on every path, so a fresh Claude /usage reading at 100% a minute after its reset no longer counts the account spent or moves Claude Code off it

Claude Code's /usage gives a reset to the minute and can lag it, so a run
just after a reset can still say "100% used · resets <a minute ago>".
claudeWindows handed that fresh reading back raw while its cached and
header paths already went through elapsed, and usedPast judged Used
without looking at ResetsAt. NextLogin dropped passed windows only for a
Claude reading kept from before (AsOf) and only for the account the agent
is on, not for the account to go back to or the spares.

- claudeWindows: the fresh /usage path returns its windows as of now
  (elapsed), as the cached and header paths do. What is kept in
  claudeUsage stays as read.
- resetPassed is the one test for a window that has started again.
  usedPast, usedUp, UsedUp and BackAt take now and leave such a window out,
  for Codex and Claude Code alike; NextLogin's Claude-and-AsOf special case
  goes, since every account it looks at (on, back, spares) is now judged
  that way. capReached already was.
- quota_last's reading still decides whether a Codex hold came with a
  used-up window from the reading as it was read (usedUp at the zero time),
  so the hold ends when that window starts again, as before.
- magpie quota wait and the Codex expiring resets pass their own now, so
  quota wait no longer waits out a window that has already reset.
- docs/subsystems/providers-accounts.md says so, and that a reset /usage
  gives as a time alone ("resets 3pm") is read as its next one by
  claudeResetTime, which this change can't see as passed.

Verified under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE
pinned: go vet ./..., go build, GOOS=linux go build -tags nogui and
GOOS=windows go build pass. go test -tags nogui ./... passes except
internal/gui's TestSetAnswersWhileDevinAndProvidersHang once in the full
run (set slow/slow-1 took 1.67s against its 1.5s limit, with other
sessions' suites running); it passed -race -count=20 on this branch and on
upstream/main, and internal/gui passed on its own. go test -race -count=20
on the new tests passes.

New tests (window_renewed_test.go) and what fails without the change:
- TestFreshClaudeReadingPastItsResetIsNotSpent: with the fresh path
  returning raw ws, "the session reads 100% (reset ... 14:20:00 UTC)". The
  control with its reset an hour away is spent and moves to the spare.
- TestNextLoginTakesAPassedResetAsRenewed (codex and claude): with usedPast
  and BackAt ignoring ResetsAt and the old AsOf drop back, NextLogin moves
  off an account whose full window reset a minute ago (fresh, and for Codex
  kept), won't move to a spare or back to the first whose full window has
  reset; the controls with resets an hour away are unchanged.
- TestUsedUpLeavesOutARenewedWindow: with BackAt and usedUp ignoring
  ResetsAt, "a window reset a minute ago stops the account".
TestClaudeUsageExpiredReadingDoesNotSwitchAccounts and TestCodexHeld* pass
unchanged. Not tried against a real Claude or Codex account here.

* provider, gateway, docs: a subscription account resting out of its window is back once a reading finds it renewed

A Claude account rested for a full five hours or week waited out the whole
rest (the time Claude Code's refusal named, or c.full), even when a new
/usage reading or the reset passing showed the window started again: only
a Codex reset spent and a key's reading ever told OnRenewed.

provider: Allowances, the reading routing goes by for every account,
keeps each account's last reading and when it was taken (usedCache.seen,
kept through a failed reading). A new reading that finds the account full
till sooner than the last (renewedFrom, the rule keyAllowances' keep uses
for keys) tells OnRenewed as the agent its usage is read under
(Account.UsageAgent: "claude", or "plugin:<id>" for a plugin-served
subscription) and the user. Scopes are the whole account, and each pool of
some models with the whole account's windows over it. A window used below
SpentShareOf its routing, or whose reset passed, is full no more; one full
with its reset unknown is full for good. Only windows in both readings
are compared. So a first, failed, alike or partial reading tells nothing,
nor does a five hours renewed inside a used-up week, nor two windows of
one name. Hooks are told after usedCache is unlocked and before the
reading's waiters are released. renewedNow drops the account's seen
reading so a spent reset is told once.

gateway: renewed lifts only an account's rests out of quota or by
"window"; a rest for a rate limit, credit, verification or a failure
stays, as a key's already did. Before, it only ran for a Codex reset spent;
now readings reach it for every subscription.

docs: providers-accounts (Allowance step, test list), gateway-routing
(account rests lifted by a reading, test lists), reference (when an
account runs out).

Verified: TestClaudeAccountToldRenewed (a fake /usage: 100% tells
nothing, 99% tells nothing, 3% tells once, more use doesn't tell again; a
kept window whose reset passes tells once), TestPluginAccountToldRenewed
(plugin:fakeco; a failed reading in between, a reset spent, and a reset
passed while the vendor still says 100%), TestRenewedFrom (table, including
the five hours inside a full week and same-named windows), and
TestClaudeAccountBackOnceItsWindowRenews (gateway: a Claude account resting
by "resets" is back once a reading shows 4%, another still full keeps its
rest). TestQuotaRestsUntilItsReset now checks a rate-limit and a backoff
rest survive renewed. Without the tell, all three renewal tests fail (told
[], still rests by resets); mutating each branch of renewedFrom/fullTill
fails TestRenewedFrom or the plugin test. Under a temporary HOME: go vet
./..., go build, GOOS=linux go build -tags nogui, GOOS=windows go build,
go test -tags nogui ./... and go test -race -count=20 (the gateway test also -count=50) on the new and
changed tests pass. Not tried with a real Claude account.

Not covered here: once a /usage run prints Claude's limit refusal,
claudeWindows returns that error until /usage or a header succeeds again,
so Allowances skips the account and the reset passing tells nothing. That
path is claudeWindows' own; the change making passed windows count as
renewed there lets this tell reach it.

* gateway: a Codex subagent's sealed task goes back to the Responses provider that answered its lead (a Sub2API relay of the ChatGPT backend), relayed as it came, instead of being refused before it is asked; the refusal names who sealed it (#1109)

Codex on Ultra with s2a/gpt-6-astra for both the lead and the subagent,
s2a a Sub2API relay: the relay's ChatGPT backend sealed the lead's
spawn_agent task, and magpie turned the subagent away because only a
Codex account counted as able to read it, telling a user already on a
magpie model to "use a Magpie-served model for the lead".

The provider that answered the lead (leadProvider, from the lead
thread's affinity in any scope) now reads it too when it is a key
provider serving the model on Responses; the task is relayed, even
when Codex offers web_search (it was translated, which dropped the
agent_message's encrypted_content). A subagent on another provider, or
whose lead magpie never answered, is still refused before anyone is
asked, with a message naming the lead's provider when known.

Verified: TestSealedTaskGoesBackToTheRelayThatSealedIt (lead and
subagent on s2a/gpt-6-astra, with Codex's web_search tool) fails without
the change with the old 400; without only the relay part it fails with
the ciphertext dropped from the upstream request.
TestSealedTaskRefusedByAnotherRelay covers a lead on s2b and an unknown
lead. go test -race ./internal/gateway, go vet ./..., GOOS=linux and
GOOS=windows builds pass.

* codexcat: a third-party entry for a model Codex has its own entry for (s2a/gpt-6-astra) says the same multi_agent_reasoning_effort, so Codex's Ultra sends xhigh for it as for OpenAI's gpt-6-astra instead of falling back to max (#1108)

Codex resolves Ultra to the entry's multi_agent_reasoning_effort, and to
max when the entry has none and lists max. magpie's entries never carried
the field, so the same model ran at max through a relay and at xhigh
through the ChatGPT account. The value is taken from Codex's own entry in
models_cache.json for the model's slug (after the provider prefix and a
dated snapshot suffix), only when the third-party entry offers Ultra and
the effort itself; models whose own entry says nothing (gpt-6-sol today)
keep Codex's max. The gateway's literal "ultra" -> max mapping is
unchanged: Codex sends the resolved effort, not "ultra".

TestUltraEffortAsCodexOwnEntry, built from the reporter's entries, fails
without the change ("s2a/gpt-6-astra: multi_agent_reasoning_effort <nil>,
want xhigh"). go test -race ./internal/codexcat ./internal/gateway
./internal/agent and go vet ./... pass in a sandbox HOME.

* gateway: a DeepSeek model whose Codex request is passed through as it is (OpenCode Go's deepseek-v4.1-flash) gets its reasoning back as reasoning_text, and is never asked without it (#1104)

Codex hands a reasoning item back as it got it; one from a turn magpie
translated, or another provider in a mixed group served, has only its
summary. Passed through to OpenCode Go's /responses, DeepSeek refused the
request with "The `reasoning_text` in the thinking mode must be passed back
to the API". That refusal names the input, so passthrough asked once more
without every unsealed reasoning item (withoutBareReasoning) and marked the
model as refusing them, which left the reasoning out of every later request.
For a model that wants its reasoning back (replaysReasoning, the predicate
#388's buildResponses uses), a reasoning item without reasoning_text now
gets its summary as one, and withoutBareReasoning is never applied to it.

TestDeepSeekReasoningPassedThrough plays OpenCode Go with the reporter's
error word for word. Without the change it fails ("reasoning_text sent =
\"\", want the summary"); with only the strip guards taken out it fails ("a
reasoning item with nothing in it was answered"). go test -race
./internal/gateway, -race -count=10 on the new test, and go vet ./... pass.

* agent: a catalog sync or a model pick keeps the keys a user or another tool added to magpie's provider block (Pi's providers.magpie.compat.sendSessionAffinityHeaders from pi-cache-optimizer) instead of writing the block whole (#1103)

syncJSON and Pi's writeMagpie set providers.magpie to a freshly built
object, so a key magpie doesn't write there was dropped on every sync.
theirsKept now merges magpie's block into what the file holds: keys magpie
writes are its own, objects in both are merged key by key, and anything
else (compat, an options key, Crush's extra_headers) stays. The model list
is still magpie's whole, so a model gone from the catalog leaves it, and a
sync with nothing new writes nothing.

The same goes for every agent that writes the block whole: Pi and OmO,
Aside, Pencil, OpenCode (and the agents built on it), OpenChamber, Crush
and Command Code.

Verified: TestPiKeepsTheirKeysInMagpiesProvider (the reporter's
compat.sendSessionAffinityHeaders: true in Pi's models.json, through
SyncCatalog and a pick) and TestOpenCodeAndCrushKeepTheirKeysInMagpiesProvider
fail without the change ("compat.sendSessionAffinityHeaders dropped by the
sync", "opencode sync") and pass with it. go test -race ./internal/agent
and go vet ./... pass in a sandbox HOME.

* providers: a SiliconFlow key's card says SiliconFlow retired its balance query and has no API in its place yet, and that the balance and vouchers are on its console, instead of "410 Gone: {...}" (#1094)

SiliconFlow stopped /v1/user/info on 2026-08-14 (release notes, 2026.08.11)
and answers it with 410 {"code":20092,"message":"This endpoint is
deprecated and is no longer available.","data":null}. Its docs and release
notes name no replacement yet ("new interfaces will be announced
separately"), and the balance given before 2025-12 became vouchers, seen
under 余额充值 → 代金券 on the console. So the card now says that and links
cloud.siliconflow.cn (cloud.siliconflow.com for the international site). A
key SiliconFlow refuses (401 30014 "Token is invalid.") is still said as it
came, and a 200 is still read.

TestSiliconFlowRetiredBalanceQuery replays the reporter's literal 410 body
for both hosts, plus the real 401 body and the documented 200; without the
change it fails with `410: true 410 Gone: {"code":20092,...}, want it
retired and https://cloud.siliconflow.cn named`. go test -race
./internal/provider and go vet ./... pass. Not tried with a real
SiliconFlow key (none on this machine); the 401 body was read from the
live endpoint with a dummy key.

* gui tests: routing-failed-open measures the 400's row once the live page has followed the newest request and drawn its story

In WebKit the live cases read the row's top as soon as the list was there,
before the page had followed the newest request and drawn its story above
the list (.rt-log grows 17 → 104px), so the row was 87px lower by the click
and "picking the request moved the page" failed — with the test's own
commit's assets too (d6096e2f), so it is the test's timing, not a
regression. The test now waits for the newest row to be pressed and the
story's steps, then for the row's top to hold still, before clicking.

Before: webkit en/zh live failed 2 of 2 (row 524 → 611). After: 8 of 8
(chromium and webkit, en and zh, live and kept day), three runs.

* gui tests: profile-confirm finds and measures the home chip in one call, so a list drawn again in between doesn't read as a chip at 0

Under load "closing them leaves the chip where it is: 0 → 259" failed in
both engines: the chip's top before opening its details read 0 while a probe
right after read it at 259. The locator found the chip, the profiles list
was drawn again from the state that came back (renderProfiles replaces the
chips), and the evaluate then measured the detached old chip, whose
bounding rect is all zeros. The chip never moved; the test's two-step
lookup raced the redraw. top() now finds the chip and measures it inside
one page.evaluate.

Eight copies at once (chromium and webkit, en and zh, window and panel):
before 9 and 15 of 80 failed in two runs; after 80 of 80 pass.

* usage: in a narrow window the Requests table's account column keeps room for both source badges, where it cut off "Subscription" (since fdcdb5af, #860)

fdcdb5af's tighter fit capped .led .where at 110px. The account cell's
source badges ("✓ Official" and "Subscription" in English, ~149px) don't
wrap and the cell hides its overflow, so at 440px the second badge was cut
off at the cell's edge. The 110px cap now goes on the account's name
(.where-name, still cut with an ellipsis); the column keeps the tight
fit's 160px cap, enough for the badges.

session-identity's "both badges share one row below the account at 440"
caught this only when the machine was loaded: it measured right after
setViewportSize, before ledFit's frame had applied the tighter fit, so on
an idle machine it saw the 1200px layout and passed. It now waits two
frames and 100ms first. With that wait, the test fails on fdcdb5af's
assets (en, chromium and webkit: badge right 457 > cell right 410) and
passes on fdcdb5af^ and with this change. ledger-*, usage-ledger* (incl.
usage-ledger-fit, #860's) and session-identity pass in chromium and
webkit: 90 of 90.

* gui tests: purpose-filter doesn't count WebKit's "ResizeObserver loop completed with undelivered notifications" as a page error

webkit en and zh failed in the full suite and under load with errors ==
['ResizeObserver loop completed with undelivered notifications.']. Traced
with every ResizeObserver callback logged per frame: picking a purpose
shrinks the Usage page from 100 rows, the click's hold (heldSizes in
app.js) gives the view room again before the paint, and the view's 8px
scrollbar coming back narrows #view-usage 1068 → 1060, so #ledWrap,
#ledChart, table.led and the Usage head are told a second time in the
same frame, past the loop's depth. The browser delivers that round on the
next frame; nothing is painted wrong. WebKit reports it as a page error,
Chromium doesn't. The test now ignores that one message, with the reason
beside it.

Eight WebKit copies at once: before, 1 to 3 of 6–8 failed with it; after,
16 of 16 pass.

* providers: an OpenCode Go or Zen provider saved with its chat URL alone sends GPT, Grok, Muse Spark and MiniMax on the API OpenCode serves them on, not chat, and "Model does not support this protocol" says which URL was asked (#1215)

The reporter's "OpenCode Go 1x" had only chat=https://opencode.ai/zen/go/v1
and no models.dev catalog (its native_endpoints were /v1/chat/completions
for every model). Go serves each model on one API of three, as models.dev's
per-model provider.npm says and OpenCode itself asks it (@ai-sdk/openai ->
/responses, @ai-sdk/anthropic -> /messages, the rest chat), so with chat the
only URL, gpt-6-luna, gpt-5.6-luna, grok-4.6/4.7, minimax-m2.7 and
muse-spark-1.2/1.3-contributor had nowhere else to go and Go answered 400
on all three of magpie's entry points.

- normalize fills in the other two of Zen's or Go's APIs (/v1 for chat and
  Responses, the root for Messages) and its catalog, only where the
  provider has none of its own (openCodeEndpoints); other hosts unchanged.
- ListedAPIs reads the per-model API from the list of the gateway the URL
  is at (opencode-go for /zen/go, opencode for /zen), whatever catalog was
  saved: Go and Zen serve MiniMax M2.7 on different APIs, and an import at
  /zen/v1 could be saved with opencode-go's.
- A wrong-endpoint error with no other API left now ends "(asked at <url>;
  <model> is served on /v1/responses, which <provider> has no URL for)".

Verified: TestOpenCodeGoModelsGoOnTheirOwnAPI (gateway, all 3 client APIs x
5 models through the handler) and TestOpenCodeEndpointsFilledIn fail without
the change with the reporter's exact "OpenCode Go 1x: Model does not support
this protocol."; TestOtherRelayKeepsItsOneAPI and the relay cases keep
other providers as they were. go test -tags nogui ./... green under a temp
HOME; go vet, go build, GOOS=linux/windows builds. Real upstream: probed
https://opencode.ai/zen/go/v1 with no key only (grok-4.7 is "not supported
for format oa-compat" on chat and "format anthropic" on messages, passes to
the key check on responses); no keyed request was made.

* gui: a routing group's rule by intent has its own entry, "Route by what it asks for" beside Add a rule, and a group whose effort Jev picks offers it under the classifier (CherryL1quor on X: 模型路由能不能用 jev,现在只有思考强度能用 jev)

An intent was one of a rule's seven conditions, so a reader who had Jev
picking the effort took it for that alone. "Route by what it asks for"
adds a rule with its intent box focused. Jev is picked as the classifier
when there is one, as the effort's Picked per turn already did, and the
classifier row shows at once, before anything is typed. Typing an intent
into any rule picks Jev too when no classifier is set. A group whose
effort Jev picks, with no intent yet, says under the classifier "It can
pick the model too: add a rule with an intent", which adds the same rule.
Translated in zh, ja and de.

routing-intent-entry.test.cjs (Chromium and WebKit, en and zh, 1100 and
440 wide) checks that both buttons stay inside the editor and that the
clicked one stays under the pointer as the rule comes in above it. It
also checks the focus, that Jev is picked, the save as
{use: b/flash, intent} with classifier typesafe/jev-latest, and the offer
under an effort-only Jev. It fails on the old assets (no such button).
gui-ja, gui-de, i18n-keys, click-scroll, routing-compact, -time, -save,
-save-blank, -effort-row, -effort-change, -levels, -days-fit and every
group-* suite pass (159 tests). Not tried with a real TypeSafe key.

* docs: Intent routing's setup names Route by what it asks for, Jev as the classifier with a TypeSafe provider, and the offer under a group whose effort Jev picks (en, zh, ja)

* proc: the login shell magpie asks for its PATH runs in a session of its own, so it can't take the terminal from magpie (DD on Discord: magpie web → zsh: suspended (tty input))

askShell ran `$SHELL -ilc` (and ReadCLI's zsh, bash and fish, for Settings › Command line; LoginPath for Library › RTK) in magpie's own session and process group. An interactive zsh puts itself in the terminal's foreground, so while it ran, magpie's group was in the background of the terminal it was started from: Ctrl-C no longer reached `magpie web`, and anything left in magpie's group that touched the terminal — the bash asked beside it, whose profile runs in magpie's group without job control, or another child — had the kernel send SIGTTIN to the whole group, stopping magpie ("zsh: suspended (tty input)"). Run with `&`, the user's own shell then got EIO reading the terminal and exited. A timeout also killed only the shell, leaving what its profile started holding the terminal.

The shell is now a probe (ProbeContext): it leads a session with no controlling terminal, and its timeout and EndProbes end its whole group. It still answers with its PATH and variables.

TestAskShellOwnSession (a fake shell answering with its process group; a real zsh still gives a PATH) fails without the change: "the login shell ran in magpie's process group …". Reproduced on a pty under an interactive zsh -f, temp HOME, MAGPIE_ADDR 127.0.0.1:47811, magpie web on 47812, ~/.zshrc `sleep 2`, ~/.bash_profile reading /dev/tty, GET /api/cli: the old build prints "zsh: suspended (tty input)  magpie web" in the foreground and "[1] + suspended (tty input)" with `&` (then "zsh: error on TTY read"); with a plain profile it loses the foreground (ps S+ → S). The fixed build stays S+ / running in both, and /api/cli still says zsh and bash are known. go vet -tags nogui ./..., GOOS=linux go build -tags nogui, GOOS=windows go build, and go test -tags nogui of internal/proc, internal/update, internal/library and internal/gui under a temp HOME pass; -race -count=10 of the new test passes. Not tried with DD's own profile, or on Linux.

* library: the skills market offers magpie's own magpie-quota skill first, marked official, so an agent can read what is left of every subscription, plan and key (magpie quota --json) and wait for allowance (magpie quota wait); it lives in magpie-community/plugins, and its card shows no installs or skills.sh link until skills.sh lists it (ttmouse on X: magpie quota works, suggested an official skill to go with it)

Verified: TestFeaturedSkills; market-featured-skill.test.cjs, Chromium and
WebKit, en/zh/ja/de at 520px, 8 pass (4 fail on the old library.js);
market-have and skill-online-local pass (34); a livemarket-tagged run of
InstallMarketSkill in a sandbox HOME fetched the real skill from GitHub
into the library; npx skills add magpie-community/plugins -l lists it.
go test ./...: all pass but TestSetAnswersWhileDevinAndProvidersHang
(2.8s under two other -count=5 suites); alone -count=5 passes.

* proc tests: TestAskShellOwnSession reads magpie's session with x/sys/unix, so the package's tests build on Linux (#1219)

bbf99143's test called syscall.Getsid, which Go's syscall package has on
macOS and the BSDs but not on Linux: go vet ./... (and every test run) on
Linux stopped at "undefined: syscall.Getsid", so the ubuntu Test job failed
at Vet on main and on every PR. golang.org/x/sys/unix, already required,
has Getsid on every Unix.

Verified: GOOS=linux, darwin, freebsd and android go vet ./internal/proc;
go test -run TestAskShellOwnSession ./internal/proc on macOS. Linux is run
by this PR's ubuntu job; there is no Linux machine here.

* library: a skill or server switched off for every agent is saved with "agents": [] not null, a check's finds beside skills on no agent come with [] too, and the Library page draws a find without its agents and says why when it can't draw a check's reply, where it threw on n.agents and went blank after Check for updates (#1217)

* library: installing a set of skills skips the ones in the way and installs the rest; the toast names the ones already in the library and the ones skipped (lc on Discord: 安装skills的时候,没有跳过已经安装的)

installFrom stopped the whole set at the first skill whose name was taken,
and returned before change saved library.json. The folders it had already
copied for the skills before that one stayed in the library's folder,
unlisted. Every later try then failed on the first of them with "the
library's folder already has a caveman-commit in it".

Now each skill is installed on its own (InstallSkills, InstallMarketSkill
and AddNewSkills, through installChange):
- the library lists it from the same source: had already (Result.Had),
  nothing written, so the user's edits stay;
- a folder in the library's folder holding exactly its files (not a link,
  sameTree, which counts a failed read as different): listed where it is,
  as had;
- another skill by that name, listed or only a folder, or a folder that
  can't be read: left as it is, in Result.Skipped with why;
- the rest are installed (Result.Installed). Only when nothing was
  installed or had does the change fail, with the first reason.
The GUI's toast and the TUI's line say how many were installed, the ones
already in the library and each one skipped with why.

Verified (temp HOME, pinned caches):
- go vet -tags nogui ./internal/library ./internal/tui ./internal/gui .;
  GOOS=windows go build and GOOS=linux go build -tags nogui of
  internal/library and internal/tui: clean
- go test -tags nogui ./internal/library ./internal/tui ./internal/gui: ok
- with the old skills.go, market.go and skillnew.go, TestInstallSetSkipsOneInTheWay,
  TestInstallListsItsOwnLeftoverFolder (fails with lc's exact "the library's
  folder already has a caveman-commit in it") and
  TestInstallSetSkipsAnotherByTheSameName FAIL; TestSkillsFromGitHub's
  reinstall now asserts Had
- skill-install-skip.test.cjs (Chromium + WebKit, en and zh at 440px) fails
  on the old library.js ("status ok", no names) and passes now; with
  gui-ja, gui-de and 11 library GUI files, 99/100 passed, and the one that
  failed (by-agent, Chromium, at load average ~150) passes 3 times in a row
  in both engines when run alone
- not tried in the real app, or with lc's library

* agent tests: Claude Code's haiku tier follows Claude Haiku 5.5 where a provider serves it beside Haiku 4.5 (Discord: using Haiku 5.5)

magpie keeps no list of Anthropic's models: the Claude subscription's list
is models.dev's "anthropic" provider (catalog.Provider, cached and
refetched daily), other providers' come from their own /v1/models, and the
code that knows Claude models (claudeLight, claudeEffortsFor,
claudeReadsTop, the gateway's adaptiveOnly) reads versions, not ids. So
claude-haiku-5-5 needs no entry: once a provider lists it, claudeLight
picks it over claude-haiku-4-5 and its dated id, and Claude Code is sent
every effort level for it, read from modelSettings only.

TestClaudeLightHaiku55 holds that, on an Anthropic-style provider and on a
router's dotted ids (anthropic/claude-haiku-5.5, as Vercel lists it). With
claudeLight's newest-first compare reversed it fails: claudeLight
("anth/claude-opus-5-5") = "anth/claude-haiku-4-5", want
"anth/claude-haiku-5-5". go vet -tags nogui and go test -tags nogui
./internal/agent pass under a temp HOME.

Not added: models.dev's anthropic provider doesn't list claude-haiku-5-5
yet (only Vercel's gateway does, released 2026-10-07), so the Claude
subscription won't offer it until models.dev does. No Bedrock id was given
or listed, so the Bedrock preset is unchanged. Not tried with a real
account.

* gui, tray, alerts, settings: magpie speaks Traditional Chinese (繁體中文) with Taiwan wording, picked on its own for a zh-TW, zh-HK or zh-Hant system

Settings → Language offers 繁體中文 beside 简体中文. On System, a
browser or OS locale of zh-TW, zh-HK, zh-MO or zh-Hant (zh_TW.UTF-8 too)
shows zh-TW; zh-CN, zh-SG and zh-Hans-HK keep Simplified. The tray menu
and quota alerts follow the same choice. Release notes fall back to
Simplified.

The zh-TW strings are generated from the zh strings with opencc
(cn→twp), then corrected to Taiwan wording: 設定, 檔案, 帳號, 伺服器,
預設, 閘道, 連接埠, 參數, 選單, 透過, 取得, 用戶端, 存取, 終端機, 「」
quotes. A quota window is 時段 and a context window stays 上下文視窗.
Number units use 萬/億, and #unitsRow is shown for zh-TW.

Verified:
- New gui-zh-tw.test.cjs, in Chromium and WebKit:
  - key parity, placeholders and tags against zh;
  - no Simplified characters;
  - the Taiwan words;
  - locale detection for each system tag;
  - <html lang>, nav labels, fmtN units, the picker;
  - Settings Language at 1000px and 440px.
  With the sysLocale zh-TW line removed, it fails. On the base assets,
  all 3 fail.
- gui-ja, gui-de and i18n-keys pass.
- go vet, and go test -tags nogui for ./internal/gui and
  ./internal/settings, pass. That includes the new zh-TW cases in
  TestTrayLang / TestTrayLabels / quota_alert_test, which fail without
  the zh-TW branches.

* gui: zh-TW gets the four strings added beside it — Route by what it asks for, its hint, and the skill install's already-in-the-library and skipped notes

* provider: moved Devin users get the plugin's 0.1.9, which sends a tool whose parameters have a root anyOf, oneOf or allOf as a plain object, so Devin's Claude models no longer answer 502 to Codex desktop's automation_update (#1196)

* gateway: the built-in Devin sends a tool whose parameters have a root anyOf, oneOf or allOf as a plain object, as the plugin's 0.1.9 does, so Devin's Claude models no longer answer 502 to Codex desktop's automation_update for users still on the built-in (#1196); provider.ObjectRoot keeps the root's and allOf's own required fields beside anyOf/oneOf branches, which only add the fields every branch requires (it used to intersect them away)

buildDevin folds every offered tool's parameters through objectSchema
(provider.ObjectRoot, as anthropic.go does for #646); a schema with no root
combinator goes byte for byte as it came.

TestBuildDevinObjectRoot builds from #1196's literal parameters with oneOf,
anyOf and allOf; without the devin.go change it fails ("oneOf: sent {...
oneOf ..."). TestObjectRootKeepsOwnRequired fails on the old ObjectRoot with
"required = [id n]" (allOf's "kind" dropped). Run: go test -tags nogui
./internal/gateway ./internal/provider (temp HOME, pinned caches), both ok;
GOOS=linux and GOOS=windows go vet -tags nogui on both packages, ok. Not
tried against a real Devin account.

* gui: the subscription windows' fit (stacked under the name or beside it) is decided on the next frame, not in its ResizeObserver's own call, so WebKit no longer reports a ResizeObserver loop as a page error (account-mask)

* context: magpie inspects the context window of every request, session and agent

The gateway reads each request's prompt into its parts (system prompt,
tools and MCP servers, memory, files, tool results, chat), measured
against the model's window and calibrated to the tokens the vendor
counted. Routing shows a request's window as a card: a 400-cell grid by
part, a hover naming what is largest in a cell, and the contents by part.
The Usage page's Context tab scores each agent out of 100 (cache reuse,
how lean it starts, how fast it grows, how reliably it answers), tags it
(lean, cache-friendly, mcp-heavy, compacts, near-full…), and draws each
session's growth with its latest request's card.

Older Safari (15) gets media-query and solid-colour fallbacks for the
card's container queries and color-mix.

* provider: Volcengine plans use their preset models instead of /models (xyenon) (#1216)

Coding Plan's /models returns Ark's general catalog, while Agent Plan's
returns 404. Skip their model-list requests and ignore old fetched lists,
including their protocol and key restrictions. Pay-as-you-go and an
explicit list URL still fetch normally; saved model choices stay intact.

Both plan endpoints were checked with real keys. TestVolcengineModelLists
fails without the no-list behavior and passes with it. Vet (gtk3) and
Linux GUI/CLI and Windows builds pass under a temporary HOME. The full
nogui suite passes except TestCodexArchivedSameSizeRewriteInvalidatesDiscovery
in sessions, also reproduced separately with -race -count=20.

Amp-Thread-ID: https://ampcode.com/threads/T-01a11791-db8b-7229-ade8-5e3600b835fc

Co-authored-by: Amp <amp@ampcode.com>

* gui tests: fold-show-grip measures Show and the picker once the fold's rows have slid in, in one read, and reads the grips from the page as drawn now

The fold's rows open with translateY(-10px) scale(.985) over .56s after a
staggered delay. The test read Show's box and the picker's box in two round
trips while the row still moved, so the second read landed lower (Show
355.7 vs picker 361.3): it failed in 3 of 4 runs alone, in either engine.
It now waits for the fold's animations to finish and reads both boxes in one
evaluate. The Usage page's cards are drawn again as its reads come in
(Chromium: three grips within 35ms of the first), so the grip's style and
the card, grip and logo boxes are read from the page in one call rather
than through a handle that may already be replaced. The hover-only grip is
unchanged.

* gui tests: api-key-theme finds where Settings' sharing On is in one read, so a #lanList drawn again in between doesn't read as no box

Settings opened on Network reads the settings twice (show("settings") and
load() after /api/state), and each draws #lanList again, 20-50ms apart.
The test waited for On, then asked its box in a second call; under load the
button it had found was already replaced, boundingBox() was null and the
test threw "Cannot read properties of null (reading 'y')". With #lanList
drawn again every 1ms for 600ms after it first shows, the old test fails
that way in Chromium and WebKit; this one passes.

* usage: a GLM Coding Plan key's card on bigmodel.cn or Z.ai shows its reset cards, five-hour and weekly, counted from customer-package-reset/list (targetType=PERSONAL) as the vendor's usage page counts them: only those still available; a list that can't be read shows none, never 0. Read only, never spent from magpie (#1191)

The list is asked with the plan's key, bare in Authorization, at the host its
term (subscription/list) is asked at, so open.bigmodel.cn and api.z.ai alike;
a ZCode account on the built-in gets the same. The tooltip says "The plan's
resets" for a person's own, "The team plan's" for a team's (ResetCredits.Team).

Tested: the reporter's literal reply (ids and times filled in, same shape)
in TestZhipuPersonalResets, TestPlanQuotas and TestZCodeAccounts, each
failing with the change reverted; reset-each-title in Chromium and WebKit,
en/zh/ja/de; a real Z.ai key through zhipuPersonalResets (1 five-hour,
1 weekly). Not tried: a real bigmodel.cn key.

* ci: the GTK and WebKitGTK install on Linux gives up after 15 minutes, where a stuck apt held main's Test run for 2.5 hours (b0d456c3) and every later push queued and was replaced behind it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* library tests: skillnew_test.go's imports are gofmt'd (Gofmt red on main since bb8a3e30, run 37693090857)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* davsync tests: the slow transfer's line is narrow both ways, so a download on macOS doesn't burst through the tuned-up buffers past the 300ms watchdog (TestSlowTransferIsNotCutShort red on macos-latest in main's run 37693090857: "took nothing for 300ms (GET)"; b1cba654 narrowed only the upload's way)

* plugin: a failed write to the plugin host ends the host before its caller hears of it, so a call freed by the failure finds the host already gone (TestHostWriterFailureWakesWaiters, red on macOS CI) (#1214)

* provider, gateway: a Claude /usage reset with no year is read within its window, so a reading a few days old no longer benches an account for a year (#1212)

* provider: a Claude reset read in the hour the clocks repeat, or the night they skip it, is the coming one, and a Dec 31 with no year read just after New Year is last year's

claudeResetTime turns /usage's reset text into the ResetsAt of the
"5 hours" and "7 days" windows, which smart routing orders accounts by and
rests a full account until.

- Where the clocks go back and read a time twice, time.Date gives either
  instant: the first west of UTC, the second east of it. Each reading is
  now considered (nextReading), and the one not yet past is taken.
  Before, at 2026-11-01 01:20 EST (the second 01:20), "Nov 1, 1:30am
  (America/New_York)" was 01:30 EDT, 50 minutes past, so a full window
  read as renewed; "1:30am" was 24h10m away instead of 10m. In Berlin at
  02:20 CEST on 2026-10-25, "2:30am" was 02:30 CET, 1h10m away instead of
  10m (Sydney and Lord Howe alike).
- A date with no year is in the first of last year, this year and next
  that is less than a day past, as the doc comment already said. Before,
  only this year and next were tried, so at 2027-01-01 00:00:05
  "Dec 31, 11:59pm" was 2027-12-31, 8760h away, and a 7-day window that
  had just renewed read as renewing in a year.
- A time alone the clocks skip today is the next day that has it,
  rebuilt from the clock read rather than from the normalized time.
  Before, the evening after New York skipped 02:00-02:59, "2:30am" was
  tomorrow's 1:30am; at 23:31 before Santiago skips midnight, "12:30am"
  was 23:30 that night, a minute past, instead of 00:30 the day after
  tomorrow.

What Claude Code prints, from the /usage formatter in the 2.1.290 binary:
every line of `claude -p /usage` carries a date, with the year only when
it isn't this one, so the New Year case needs /usage run just before
midnight and parsed after, and the repeated hour shows in the dated form.
Whether "at" or a comma joins date and time follows the platform's ICU,
and both are parsed. A time alone is the older form, which the TUI still
prints for the session within a day.

No other parser in internal/provider fills in a missing date or year:
zhipuTime and mimoTime read full dates in UTC+8, and the rest read
RFC3339 or Unix times.

Verified:
- TestClaudeResetTimeAroundClockChanges and TestClaudeResetTimeAtNewYear
  are new. With upstream's claudeResetTime put back, 15 rows fail, e.g.
  `"Nov 1, 1:30am (America/New_York)" at 2026-11-01 01:20:00 -0500 EST:
  2026-11-01 01:30:00 -0400 EDT true (in -50m0s)` and `"Dec 31, 11:59pm
  (America/New_York)" at 2027-01-01 00:00:05 -0500 EST: 2027-12-31
  23:59:00 -0500 EST true (in 8759h58m55s)`; the 16 controls pass. With
  the change, all pass, as does TestParseClaudeUsage, also with
  -race -count=20.
- An oracle that lists every instant a zone shows a clock at was swept
  over 518 zones, every transition 2025-2030, 85M calls: no deviation in
  anything Claude Code can print. What remains is Feb 29 in a non-leap
  year and dated times the clocks skip, both unchanged from upstream.
- Under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE pinned:
  go vet ./..., go build, GOOS=linux go build -tags nogui,
  GOOS=windows go build, go test -tags nogui ./... all pass.
- Not run: claude -p /usage on a real account across a clock change or
  New Year.

* provider: a Claude reset further ahead than its window can reach is the one already past, so a /usage reading days old no longer rests an account for a year

claudeResetTime now gets the window's length (5 hours for "Current
session", 7 days for "Current week ...") and never reads a reset further
ahead than that plus claudeResetSlack (2h, for this machine's clock being
behind Anthropic's; Claude Code prints the reset from Anthropic's instant
with seconds dropped, so never later).

- A date with no year is in the first of last year, this year and next
  that is less than a day past and within the window. Before, it was the
  first less than a day past: read on Oct 5, a week's "Oct 1, 3pm" was
  2027-10-01, 8667h away, and routing (candidate.full, Allowance.Full)
  rested a full account that long. Now it is 2026-10-01.
- Where no year fits, the latest year where it is already past is taken,
  rather than dropping ResetsAt. The date read fine, and the window's
  length rules out every later year, so the reset has passed and the
  window has renewed, which is what elapsed() does with a past reset.
  Dropping it would keep a 100% window with no time to clear it: Pooled
  counts a full model window with no reset as out, so Opus on the account
  stayed refused until the next read, and the card showed 100% with no
  reset.
- A 5-hour window's date 6 days ahead ("Oct 11, 3pm" on Oct 5) is likewise
  last year's and reads as renewed; a week's keeps Oct 11.
- A time alone (the older form) is the next one within the window, else
  the latest day where it is already past: at noon, "11am" is today's for
  the session (was tomorrow's, 23h on) and tomorrow's for the week.
- A fresh /usage read now goes through elapsed() as a kept one does, so a
  reset it gives as past reads as renewed at once. Before, only the next
  look did, and the switch (NextLogin's usedPast) and quota alerts saw the
  fresh 100%.
- Dates with a year are unchanged.

Tests: TestClaudeResetTimeWithinItsWindow covers the cases above, the
inclusive bound (exactly span+slack is kept, a minute more is not, for a
date and a time alone), a week across New York's fall-back, and
claudeWindows' fresh and kept reads of a 4-day-old reset (Used 0, no
reset, Full zero). The DST rows and the other New Year rows pass
unchanged, at the week's length. Changed existing rows, each of which
asserted a reset further ahead than any window reaches: TestParseClaudeUsage's
"Current week (Opus) ... Jan 2 at 2pm" read Oct 1 (now 2026-01-02), the
#631 "Jan 2, 9:05am (UTC)" row (2026-01-02), and the New Year "Dec 30,
11pm" read Jan 1 01:00, 26h past (2026-12-30, not 2027-12-30). The #631
format rows are read on Oct 3 instead of Oct 1, so "Oct 9, 2:59pm" stays
within its week. Fixtures that gave a 5-hour session a reset the next day
(soon(1) at 3:30pm in claude_usage, claude_seats, claude_usage_switched;
claude_made_first in gateway; "Jul 20 at 10am" at midnight in
claude_scoped) now give one 3 hours on, as /usage would.

Fails without: with the bound removed and fresh reads not through
elapsed, TestClaudeResetTimeWithinItsWindow fails ("Oct 1, 3pm (UTC)" for
168h at 2026-10-05: 2027-10-01 15:00 (in 8667h), want 2026-10-01 15:00;
"Oct 11, 3pm (UTC)" for 5h: 2026-10-11 (in 147h), want 2025-10-11;
"read true: held until 2027-10-03 15:00"), as do TestParseClaudeUsage and
TestClaudeResetTimeAtNewYear on the changed rows. With the bound but
fresh reads not through elapsed, it fails with "read true: 5 hours
Used:100 ResetsAt:<4 days ago>".

Verified, under a temp HOME with GOPATH, GOMODCACHE and GOCACHE pinned:
go vet ./..., go build, GOOS=linux go build -tags nogui, GOOS=windows go
build, go test -tags nogui ./... (all pass), and go test -race -count=20
-run 'TestClaudeResetTime|TestParseClaudeUsage' ./internal/provider.
Not run: claude /usage on a real account read days late. Unchanged: a
week's time alone read minutes after its reset still reads as tomorrow's
(at most a day).

* gateway: a subscription account that fails with a window full rests until it renews, but no more than 8 days, so a reset time read a year off no longer benches it for that year

restAfter's default branch (a failure that isn't credit, quota, a rate
limit or verification: a 502, a sign-in error) rests a subscription
account until its fullest window renews, by c.full(now). That time is
the vendor's reading, not the failure's: Claude Code's /usage text, read
by claudeResetTime, takes a date with no year for next year when it reads
it wrong, and the account then sat out ~365 days until the user noticed
and lifted it by hand. The failQuota branch of the same function already
holds every rest, window included, to longestQuota (a week's window and a
day over); the window rest here now takes the same bound:
min(t.Sub(now), longestQuota). A window renewing within 8 days rests
exactly as before.

Why longestQuota and not the full window's own Span: Allowance.Full
returns only the renewal time, so the call site doesn't know which window
was full or how long it runs; Span is zero where a reading doesn't say
(plugin windows may not); and a failure that isn't about quota must not
sit out longer than one that is, which the quota branch already caps at
8 days. Bounding by Span would only shorten a misread 5-hour window from
8 days to 5 hours, and would have to change the quota branch with it, so
it is left for a change of its own.

Siblings: restAfterMarked is the only place that stores a rest
(restingUntil). Its other branches are bounded already: failQuota by
longestQuota (resets in the body, the noted header, the window and
Retry-After alike), failRate by longestWait and longestRateRest, credit
and verify by constants; a key never takes the default branch's window
rest (c.p.Account != nil). Every other use of a window's reset (capHeld,
Allowance.Full/Renewal/Restarts/Pace in weighing, the sink condition) is
computed again from the latest reading on each request and stores
nothing. Not changed here: cappedError's soonest renewal still becomes
the agent's Retry-After uncapped, and a stored window rest isn't lifted
when a later reading corrects the time (only OnRenewed or a manual
Unrest does), so a bad reading can still bench an account for up to 8
days. Claude's parsing is fixed in the commit before this one, and a
Claude account's window rest is lifted by a renewed reading in #1200.

docs/subsystems/gateway-routing.md says so in the runtime path; its
gateway test list already takes the new test by its TestRouting prefix.

Verified under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE
pinned: go vet ./..., go build, GOOS=linux go build -tags nogui and
GOOS=windows go build pass. TestRoutingHoldsAWindowRestToLongestQuota (new): a
Claude account whose window is full with ResetsAt a year out, failing
502, rests at most longestQuota; a control renewing in 3h rests 3h.
Without the change it fails: "window read a year off: rests
8759h59m59.999682292s by window (other), want at most 192h0m0s". It
passes with -race -count=20, with TestOrderedKeepsAnAccountAt98 and
TestQuotaRestsUntilItsReset. The routing reference's gateway test set
passes.

go test -tags nogui ./... ran with the machine at a load average of
~125 on 12 cores, and three packages failed there, none on this path:
internal/davsync timed out at 10m (it passes alone in 353s);
TestSetAnswersWhileDevinAndProvidersHang fails on e95852e3 too (4 of 20
with -race); TestQuietStreamsKeptAliveEveryProtocol/chat_mid-reply (a
keepalive written before the reply already passed) failed 6, then 1,
then 0 of 20 here with -race and 0 of 60 on e95852e3. Its provider is a
key that answers, so restAfter, the only code changed, never runs in it;
it is left for a change of its own, not called a flake.

* gui: a click made while the page is drawn again is kept: a Settings option pressed through a redraw is still picked, and a menu still loading opens at the redrawn button (#1211)

* gui: a Settings option pressed while the page is drawn again is still picked when let go on it

Every save's answer runs renderSettings(), which replaces each option
control. A second option pressed while the first one's save came back was
let go on a new button: down and up were on two nodes, the browser sent the
click to neither, and nothing was saved. Two quick clicks lost the second
one silently.

A capture-phase pointerdown/pointerup pair now clicks the new button when the
pressed one was taken out of the page and the pointer is let go on the same
option of the same control. The same control means the same place under the
nearest element with an id, among as many controls of its kind, in rows
alike by their data-*; a list drawn again with a row gone, come in or moved
above the pressed one is another control, and nothing is picked. Let go
anywhere else (dragged off to another option, or to another control) picks
nothing, as for any button. A click the browser sends after all is not
doubled. A tap is left to the browser, which clicks what is under the finger
as it lifts. It covers every segs() control, so every option on Settings.

segs-press-redraw.test.cjs, Chromium and WebKit, en/zh/ja/de, 900 and 440px
(42 tests): the probe (press Lightweight → On, renderSettings(), let go)
posts once; drag-offs post nothing; a plain click posts once; every option
control on every Settings tab (49 in English) pressed through a redraw
clicks its new self once; a Chromium tap through a redraw posts once; a
list drawn again with its rows changed picks nothing. Without the fix the
probe and the sweep get no click at all in both engines; without the touch
skip a tap posts twice; matching by place alone picks the row below.

* gui: a menu still loading when its button is drawn again opens at the redrawn button

openAgentModels kept the clicked button across its fetch and gave up when
a redraw had taken it off the page, so a click on Pick, Claude Desktop's
model menu or the models line was silently dropped whenever Agents was
drawn again while the list loaded: a periodic refresh, or the /api/state
re-read that closing a changed list queues (agent-models.test.cjs's
Escape-then-Pick was racing it). The loading record now holds the button,
the builders hold it to the button they draw (holdAgentModels, beside the
existing re-anchor of an open list), and the list opens at the current one.
A click on the redrawn button while it loads takes the click back, as one
on the first did.

The same await-then-open gap was in the two other menus that wait on a
fetch before opening:
- a gateway key's models badge dropped the click when the keys were drawn
  again during GET /api/caller-keys/models;
- the menu bar's allowances pill, opened while the allowances loaded and
  Settings was drawn again (another setting saved), came up in the window's
  corner at the dead pill and saved the other setting back as it was.
Both now open from the button drawn last, with what that drawing keeps.

Reproduced on the unfixed code with the new tests, which delay the fetch
and redraw during it: agent-models-redraw, gateway-key-models-redraw and
tray-usage-redraw, in Chromium and WebKit at a wide and a 560px width.
They pass after the fix, as do the agent-models, gateway-key and
tray-usages tests. Full GUI suite in both engines: 3503 of 3518 pass; the
15 failures are in 8 files outside these paths (account-mask,
panel-arrange, session-identity fail on upstream/main too; profile-confirm
is flaky there too; azure-endpoint, lightweight, purpose-filter and
routing-time failed only under the full run's load and pass alone on both).

---------

Co-authored-by: yetone <yetoneful@gmail.com>
Co-authored-by: XYenon <i@xyenon.bid>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:24:44 +08:00
forever dfde3acafc provider, gateway: a Claude /usage reset with no year is read within its window, so a reading a few days old no longer benches an account for a year (#1212)
* provider: a Claude reset read in the hour the clocks repeat, or the night they skip it, is the coming one, and a Dec 31 with no year read just after New Year is last year's

claudeResetTime turns /usage's reset text into the ResetsAt of the
"5 hours" and "7 days" windows, which smart routing orders accounts by and
rests a full account until.

- Where the clocks go back and read a time twice, time.Date gives either
  instant: the first west of UTC, the second east of it. Each reading is
  now considered (nextReading), and the one not yet past is taken.
  Before, at 2026-11-01 01:20 EST (the second 01:20), "Nov 1, 1:30am
  (America/New_York)" was 01:30 EDT, 50 minutes past, so a full window
  read as renewed; "1:30am" was 24h10m away instead of 10m. In Berlin at
  02:20 CEST on 2026-10-25, "2:30am" was 02:30 CET, 1h10m away instead of
  10m (Sydney and Lord Howe alike).
- A date with no year is in the first of last year, this year and next
  that is less than a day past, as the doc comment already said. Before,
  only this year and next were tried, so at 2027-01-01 00:00:05
  "Dec 31, 11:59pm" was 2027-12-31, 8760h away, and a 7-day window that
  had just renewed read as renewing in a year.
- A time alone the clocks skip today is the next day that has it,
  rebuilt from the clock read rather than from the normalized time.
  Before, the evening after New York skipped 02:00-02:59, "2:30am" was
  tomorrow's 1:30am; at 23:31 before Santiago skips midnight, "12:30am"
  was 23:30 that night, a minute past, instead of 00:30 the day after
  tomorrow.

What Claude Code prints, from the /usage formatter in the 2.1.290 binary:
every line of `claude -p /usage` carries a date, with the year only when
it isn't this one, so the New Year case needs /usage run just before
midnight and parsed after, and the repeated hour shows in the dated form.
Whether "at" or a comma joins date and time follows the platform's ICU,
and both are parsed. A time alone is the older form, which the TUI still
prints for the session within a day.

No other parser in internal/provider fills in a missing date or year:
zhipuTime and mimoTime read full dates in UTC+8, and the rest read
RFC3339 or Unix times.

Verified:
- TestClaudeResetTimeAroundClockChanges and TestClaudeResetTimeAtNewYear
  are new. With upstream's claudeResetTime put back, 15 rows fail, e.g.
  `"Nov 1, 1:30am (America/New_York)" at 2026-11-01 01:20:00 -0500 EST:
  2026-11-01 01:30:00 -0400 EDT true (in -50m0s)` and `"Dec 31, 11:59pm
  (America/New_York)" at 2027-01-01 00:00:05 -0500 EST: 2027-12-31
  23:59:00 -0500 EST true (in 8759h58m55s)`; the 16 controls pass. With
  the change, all pass, as does TestParseClaudeUsage, also with
  -race -count=20.
- An oracle that lists every instant a zone shows a clock at was swept
  over 518 zones, every transition 2025-2030, 85M calls: no deviation in
  anything Claude Code can print. What remains is Feb 29 in a non-leap
  year and dated times the clocks skip, both unchanged from upstream.
- Under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE pinned:
  go vet ./..., go build, GOOS=linux go build -tags nogui,
  GOOS=windows go build, go test -tags nogui ./... all pass.
- Not run: claude -p /usage on a real account across a clock change or
  New Year.

* provider: a Claude reset further ahead than its window can reach is the one already past, so a /usage reading days old no longer rests an account for a year

claudeResetTime now gets the window's length (5 hours for "Current
session", 7 days for "Current week ...") and never reads a reset further
ahead than that plus claudeResetSlack (2h, for this machine's clock being
behind Anthropic's; Claude Code prints the reset from Anthropic's instant
with seconds dropped, so never later).

- A date with no year is in the first of last year, this year and next
  that is less than a day past and within the window. Before, it was the
  first less than a day past: read on Oct 5, a week's "Oct 1, 3pm" was
  2027-10-01, 8667h away, and routing (candidate.full, Allowance.Full)
  rested a full account that long. Now it is 2026-10-01.
- Where no year fits, the latest year where it is already past is taken,
  rather than dropping ResetsAt. The date read fine, and the window's
  length rules out every later year, so the reset has passed and the
  window has renewed, which is what elapsed() does with a past reset.
  Dropping it would keep a 100% window with no time to clear it: Pooled
  counts a full model window with no reset as out, so Opus on the account
  stayed refused until the next read, and the card showed 100% with no
  reset.
- A 5-hour window's date 6 days ahead ("Oct 11, 3pm" on Oct 5) is likewise
  last year's and reads as renewed; a week's keeps Oct 11.
- A time alone (the older form) is the next one within the window, else
  the latest day where it is already past: at noon, "11am" is today's for
  the session (was tomorrow's, 23h on) and tomorrow's for the week.
- A fresh /usage read now goes through elapsed() as a kept one does, so a
  reset it gives as past reads as renewed at once. Before, only the next
  look did, and the switch (NextLogin's usedPast) and quota alerts saw the
  fresh 100%.
- Dates with a year are unchanged.

Tests: TestClaudeResetTimeWithinItsWindow covers the cases above, the
inclusive bound (exactly span+slack is kept, a minute more is not, for a
date and a time alone), a week across New York's fall-back, and
claudeWindows' fresh and kept reads of a 4-day-old reset (Used 0, no
reset, Full zero). The DST rows and the other New Year rows pass
unchanged, at the week's length. Changed existing rows, each of which
asserted a reset further ahead than any window reaches: TestParseClaudeUsage's
"Current week (Opus) ... Jan 2 at 2pm" read Oct 1 (now 2026-01-02), the
#631 "Jan 2, 9:05am (UTC)" row (2026-01-02), and the New Year "Dec 30,
11pm" read Jan 1 01:00, 26h past (2026-12-30, not 2027-12-30). The #631
format rows are read on Oct 3 instead of Oct 1, so "Oct 9, 2:59pm" stays
within its week. Fixtures that gave a 5-hour session a reset the next day
(soon(1) at 3:30pm in claude_usage, claude_seats, claude_usage_switched;
claude_made_first in gateway; "Jul 20 at 10am" at midnight in
claude_scoped) now give one 3 hours on, as /usage would.

Fails without: with the bound removed and fresh reads not through
elapsed, TestClaudeResetTimeWithinItsWindow fails ("Oct 1, 3pm (UTC)" for
168h at 2026-10-05: 2027-10-01 15:00 (in 8667h), want 2026-10-01 15:00;
"Oct 11, 3pm (UTC)" for 5h: 2026-10-11 (in 147h), want 2025-10-11;
"read true: held until 2027-10-03 15:00"), as do TestParseClaudeUsage and
TestClaudeResetTimeAtNewYear on the changed rows. With the bound but
fresh reads not through elapsed, it fails with "read true: 5 hours
Used:100 ResetsAt:<4 days ago>".

Verified, under a temp HOME with GOPATH, GOMODCACHE and GOCACHE pinned:
go vet ./..., go build, GOOS=linux go build -tags nogui, GOOS=windows go
build, go test -tags nogui ./... (all pass), and go test -race -count=20
-run 'TestClaudeResetTime|TestParseClaudeUsage' ./internal/provider.
Not run: claude /usage on a real account read days late. Unchanged: a
week's time alone read minutes after its reset still reads as tomorrow's
(at most a day).

* gateway: a subscription account that fails with a window full rests until it renews, but no more than 8 days, so a reset time read a year off no longer benches it for that year

restAfter's default branch (a failure that isn't credit, quota, a rate
limit or verification: a 502, a sign-in error) rests a subscription
account until its fullest window renews, by c.full(now). That time is
the vendor's reading, not the failure's: Claude Code's /usage text, read
by claudeResetTime, takes a date with no year for next year when it reads
it wrong, and the account then sat out ~365 days until the user noticed
and lifted it by hand. The failQuota branch of the same function already
holds every rest, window included, to longestQuota (a week's window and a
day over); the window rest here now takes the same bound:
min(t.Sub(now), longestQuota). A window renewing within 8 days rests
exactly as before.

Why longestQuota and not the full window's own Span: Allowance.Full
returns only the renewal time, so the call site doesn't know which window
was full or how long it runs; Span is zero where a reading doesn't say
(plugin windows may not); and a failure that isn't about quota must not
sit out longer than one that is, which the quota branch already caps at
8 days. Bounding by Span would only shorten a misread 5-hour window from
8 days to 5 hours, and would have to change the quota branch with it, so
it is left for a change of its own.

Siblings: restAfterMarked is the only place that stores a rest
(restingUntil). Its other branches are bounded already: failQuota by
longestQuota (resets in the body, the noted header, the window and
Retry-After alike), failRate by longestWait and longestRateRest, credit
and verify by constants; a key never takes the default branch's window
rest (c.p.Account != nil). Every other use of a window's reset (capHeld,
Allowance.Full/Renewal/Restarts/Pace in weighing, the sink condition) is
computed again from the latest reading on each request and stores
nothing. Not changed here: cappedError's soonest renewal still becomes
the agent's Retry-After uncapped, and a stored window rest isn't lifted
when a later reading corrects the time (only OnRenewed or a manual
Unrest does), so a bad reading can still bench an account for up to 8
days. Claude's parsing is fixed in the commit before this one, and a
Claude account's window rest is lifted by a renewed reading in #1200.

docs/subsystems/gateway-routing.md says so in the runtime path; its
gateway test list already takes the new test by its TestRouting prefix.

Verified under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE
pinned: go vet ./..., go build, GOOS=linux go build -tags nogui and
GOOS=windows go build pass. TestRoutingHoldsAWindowRestToLongestQuota (new): a
Claude account whose window is full with ResetsAt a year out, failing
502, rests at most longestQuota; a control renewing in 3h rests 3h.
Without the change it fails: "window read a year off: rests
8759h59m59.999682292s by window (other), want at most 192h0m0s". It
passes with -race -count=20, with TestOrderedKeepsAnAccountAt98 and
TestQuotaRestsUntilItsReset. The routing reference's gateway test set
passes.

go test -tags nogui ./... ran with the machine at a load average of
~125 on 12 cores, and three packages failed there, none on this path:
internal/davsync timed out at 10m (it passes alone in 353s);
TestSetAnswersWhileDevinAndProvidersHang fails on e95852e3 too (4 of 20
with -race); TestQuietStreamsKeptAliveEveryProtocol/chat_mid-reply (a
keepalive written before the reply already passed) failed 6, then 1,
then 0 of 20 here with -race and 0 of 60 on e95852e3. Its provider is a
key that answers, so restAfter, the only code changed, never runs in it;
it is left for a change of its own, not called a flake.
2026-10-08 06:20:03 +08:00
XYenonandAmp d1bbc5b65b provider: Volcengine plans use their preset models instead of /models (xyenon) (#1216)
Coding Plan's /models returns Ark's general catalog, while Agent Plan's
returns 404. Skip their model-list requests and ignore old fetched lists,
including their protocol and key restrictions. Pay-as-you-go and an
explicit list URL still fetch normally; saved model choices stay intact.

Both plan endpoints were checked with real keys. TestVolcengineModelLists
fails without the no-list behavior and passes with it. Vet (gtk3) and
Linux GUI/CLI and Windows builds pass under a temporary HOME. The full
nogui suite passes except TestCodexArchivedSameSizeRewriteInvalidatesDiscovery
in sessions, also reproduced separately with -race -count=20.

Amp-Thread-ID: https://ampcode.com/threads/T-01a11791-db8b-7229-ade8-5e3600b835fc

Co-authored-by: Amp <amp@ampcode.com>
2026-10-08 05:37:56 +08:00
yetone 586f2bce8f context: magpie inspects the context window of every request, session and agent
The gateway reads each request's prompt into its parts (system prompt,
tools and MCP servers, memory, files, tool results, chat), measured
against the model's window and calibrated to the tokens the vendor
counted. Routing shows a request's window as a card: a 400-cell grid by
part, a hover naming what is largest in a cell, and the contents by part.
The Usage page's Context tab scores each agent out of 100 (cache reuse,
how lean it starts, how fast it grows, how reliably it answers), tags it
(lean, cache-friendly, mcp-heavy, compacts, near-full…), and draws each
session's growth with its latest request's card.

Older Safari (15) gets media-query and solid-colour fallbacks for the
card's container queries and color-mix.
2026-10-08 05:36:17 +08:00
yetone aff4f9f22c gui, tray, alerts, settings: magpie speaks Traditional Chinese (繁體中文) with Taiwan wording, picked on its own for a zh-TW, zh-HK or zh-Hant system
Settings → Language offers 繁體中文 beside 简体中文. On System, a
browser or OS locale of zh-TW, zh-HK, zh-MO or zh-Hant (zh_TW.UTF-8 too)
shows zh-TW; zh-CN, zh-SG and zh-Hans-HK keep Simplified. The tray menu
and quota alerts follow the same choice. Release notes fall back to
Simplified.

The zh-TW strings are generated from the zh strings with opencc
(cn→twp), then corrected to Taiwan wording: 設定, 檔案, 帳號, 伺服器,
預設, 閘道, 連接埠, 參數, 選單, 透過, 取得, 用戶端, 存取, 終端機, 「」
quotes. A quota window is 時段 and a context window stays 上下文視窗.
Number units use 萬/億, and #unitsRow is shown for zh-TW.

Verified:
- New gui-zh-tw.test.cjs, in Chromium and WebKit:
  - key parity, placeholders and tags against zh;
  - no Simplified characters;
  - the Taiwan words;
  - locale detection for each system tag;
  - <html lang>, nav labels, fmtN units, the picker;
  - Settings Language at 1000px and 440px.
  With the sysLocale zh-TW line removed, it fails. On the base assets,
  all 3 fail.
- gui-ja, gui-de and i18n-keys pass.
- go vet, and go test -tags nogui for ./internal/gui and
  ./internal/settings, pass. That includes the new zh-TW cases in
  TestTrayLang / TestTrayLabels / quota_alert_test, which fail without
  the zh-TW branches.
2026-10-08 05:00:55 +08:00
yetone 9dc0121f90 library: installing a set of skills skips the ones in the way and installs the rest; the toast names the ones already in the library and the ones skipped (lc on Discord: 安装skills的时候,没有跳过已经安装的)
installFrom stopped the whole set at the first skill whose name was taken,
and returned before change saved library.json. The folders it had already
copied for the skills before that one stayed in the library's folder,
unlisted. Every later try then failed on the first of them with "the
library's folder already has a caveman-commit in it".

Now each skill is installed on its own (InstallSkills, InstallMarketSkill
and AddNewSkills, through installChange):
- the library lists it from the same source: had already (Result.Had),
  nothing written, so the user's edits stay;
- a folder in the library's folder holding exactly its files (not a link,
  sameTree, which counts a failed read as different): listed where it is,
  as had;
- another skill by that name, listed or only a folder, or a folder that
  can't be read: left as it is, in Result.Skipped with why;
- the rest are installed (Result.Installed). Only when nothing was
  installed or had does the change fail, with the first reason.
The GUI's toast and the TUI's line say how many were installed, the ones
already in the library and each one skipped with why.

Verified (temp HOME, pinned caches):
- go vet -tags nogui ./internal/library ./internal/tui ./internal/gui .;
  GOOS=windows go build and GOOS=linux go build -tags nogui of
  internal/library and internal/tui: clean
- go test -tags nogui ./internal/library ./internal/tui ./internal/gui: ok
- with the old skills.go, market.go and skillnew.go, TestInstallSetSkipsOneInTheWay,
  TestInstallListsItsOwnLeftoverFolder (fails with lc's exact "the library's
  folder already has a caveman-commit in it") and
  TestInstallSetSkipsAnotherByTheSameName FAIL; TestSkillsFromGitHub's
  reinstall now asserts Had
- skill-install-skip.test.cjs (Chromium + WebKit, en and zh at 440px) fails
  on the old library.js ("status ok", no names) and passes now; with
  gui-ja, gui-de and 11 library GUI files, 99/100 passed, and the one that
  failed (by-agent, Chromium, at load average ~150) passes 3 times in a row
  in both engines when run alone
- not tried in the real app, or with lc's library
2026-10-08 04:57:40 +08:00
yetone bc7c62dc11 library: the skills market offers magpie's own magpie-quota skill first, marked official, so an agent can read what is left of every subscription, plan and key (magpie quota --json) and wait for allowance (magpie quota wait); it lives in magpie-community/plugins, and its card shows no installs or skills.sh link until skills.sh lists it (ttmouse on X: magpie quota works, suggested an official skill to go with it)
Verified: TestFeaturedSkills; market-featured-skill.test.cjs, Chromium and
WebKit, en/zh/ja/de at 520px, 8 pass (4 fail on the old library.js);
market-have and skill-online-local pass (34); a livemarket-tagged run of
InstallMarketSkill in a sandbox HOME fetched the real skill from GitHub
into the library; npx skills add magpie-community/plugins -l lists it.
go test ./...: all pass but TestSetAnswersWhileDevinAndProvidersHang
(2.8s under two other -count=5 suites); alone -count=5 passes.
2026-10-08 04:25:16 +08:00
yetone 842272df84 gateway: a Codex subagent's sealed task goes back to the Responses provider that answered its lead (a Sub2API relay of the ChatGPT backend), relayed as it came, instead of being refused before it is asked; the refusal names who sealed it (#1109)
Codex on Ultra with s2a/gpt-6-astra for both the lead and the subagent,
s2a a Sub2API relay: the relay's ChatGPT backend sealed the lead's
spawn_agent task, and magpie turned the subagent away because only a
Codex account counted as able to read it, telling a user already on a
magpie model to "use a Magpie-served model for the lead".

The provider that answered the lead (leadProvider, from the lead
thread's affinity in any scope) now reads it too when it is a key
provider serving the model on Responses; the task is relayed, even
when Codex offers web_search (it was translated, which dropped the
agent_message's encrypted_content). A subagent on another provider, or
whose lead magpie never answered, is still refused before anyone is
asked, with a message naming the lead's provider when known.

Verified: TestSealedTaskGoesBackToTheRelayThatSealedIt (lead and
subagent on s2a/gpt-6-astra, with Codex's web_search tool) fails without
the change with the old 400; without only the relay part it fails with
the ciphertext dropped from the upstream request.
TestSealedTaskRefusedByAnotherRelay covers a lead on s2b and an unknown
lead. go test -race ./internal/gateway, go vet ./..., GOOS=linux and
GOOS=windows builds pass.
2026-10-08 02:15:01 +08:00
yetone 8731dd9f38 docs: LESSONS.md gets the review of 10-07's commits — fix-the-class, re-derive, red-on-main and merge-record lessons seen a third day move into the code standards; not tried with the real thing seen a second day (b2345078→7837f90d, #1050, cda11d84); positives from 6b6113aa, d40fee80, bed306ae and #948 2026-10-07 23:46:48 +08:00
forever bed306ae60 gateway: reasoning off reaches Gemini 3 at its lowest level, and Google refusing minimal no longer stops a provider's thoughts (#1062)
* gateway: reasoning off goes to Gemini 3 at its lowest level where it has no minimal, and Google refusing minimal no longer stops the provider's thoughts until restart

With reasoning off, a request to Gemini's OpenAI-compatible API (AI
Studio's, or a proxy in front of it on this machine or the LAN) went with
reasoning_effort minimal, since Gemini 3 can't stop thinking.
gemini-3.8-flash has no minimal: models.dev gives its levels as low,
medium and high, and Google answers minimal with 400 "Thinking level is
unsupported: THINKING_LEVEL_MINIMAL". The message names thinking, so
refusesThinkingConfig took it for thinking_config turned away and marked
that field unfit for the provider. Until magpie restarted, nothing sent to
the provider asked for Gemini's thoughts again, on any model, and a client
that asked to see them got none. The turn itself was asked again with no
reasoning_effort, at the model's default level.

  * Reasoning off goes at the model's lowest level where its levels are
    known and have no minimal, and at minimal where they have it or aren't
    known (Request.OffLevel, geminiOffLevel). gemini-3.8-flash on AI
    Studio goes at low, in one request.
  * Where the levels aren't known (a proxy's own name for the model),
    Google's 400 for minimal is told apart from thinking_config turned
    away (geminiMinimalRefused, which also matches gemini-3.1-pro-preview's
    "thinking_level MINIMAL is not supported by this model" at
    generateContent). It is remembered as the model refusing reasoning off
    (offRefused, as for other vendors), and the model is asked again at its
    lowest level, low when none are known, and so from then on.
    thinking_config is kept, and the next turn that thinks gets the
    model's thoughts.
  * An effort of minimal set on the request, which fitFor keeps where the
    levels aren't known, is turned away the same way and asked again at
    low by the existing offEffort step, with its thinking_config.

Passthrough (a Chat client calling AI Studio itself) is unchanged: it fits
the effort to the model's levels where they're known, and has no retry for
a proxy whose levels aren't.

Tests, with what upstream's gateway gives:
  * TestGeminiThinkingOffAtItsLowest: an Anthropic client's turn with
    thinking disabled, then one thinking at high, then disabled again, to
    a fake Gemini that answers minimal with the 400 above, in its literal
    bytes. With the levels known, the first turn goes at [low]; not known,
    at [minimal low]. Either way the thinking turn then sends
    thinking_config {include_thoughts: true, thinking_level: high} and
    gets a thinking block, and the last turn goes at low in one request.
    Upstream sends [minimal <nil>] both ways.
  * TestAIStudioThinking: "off at its lowest" sends reasoning_effort low.
    Upstream sends minimal.
Each part was also broken on its own, and each break fails a test:
buildChat ignoring OffLevel ("reasoning_effort = "minimal", want "low"",
and [minimal minimal <nil>]), the loop never setting it ([minimal minimal
<nil>] both ways), no check for the refusal of minimal ([minimal <nil>]
with the levels not known), and geminiOffLevel ignoring the refusal
([minimal minimal <nil>]) or the known levels ([minimal low]).

Verified on 443f1cbc: gofmt -l, go vet ./..., go build, GOOS=linux go
build -tags nogui and GOOS=windows go build pass; go test -tags nogui
./... passes, and -race -count=20 on the tests above.

Live, with gemini-3.8-flash through a proxy on this machine in front of
Vertex AI's OpenAI-compatible API, the gateway in a temporary HOME (AI
Studio itself wasn't tried: there was no key for it). Upstream sent the
off turn at minimal, got the 400 and asked again with no
reasoning_effort; the thinking turn after it went as reasoning_effort high
with no thinking_config, and no thoughts came back. With this change the
off turn went at low in one request, the thinking turn with thinking_config
at high, and the last off turn at low. Under a name whose levels aren't
known, the first off turn went minimal, then low, and the next at low at
once. The thoughts reached the client as thinking with the next commit,
which reads how that endpoint marks them.

* gateway: Gemini's thoughts streamed from Vertex AI's OpenAI-compatible API reach the client as thinking, not as the start of the answer

A proxy on this machine or the LAN in front of Gemini is asked for the
model's thoughts as AI Studio is, in thinking_config. In front of Vertex
AI's OpenAI-compatible API, the thoughts don't come in AI Studio's
<thought> tags: each comes in a chunk of its own, the thought as the
delta's content, and the chunk is marked
"extra_content": {"google": {"thought": true}}. chatDecoder read only the
content, so the thoughts were streamed to the client as the first part of
the answer.

  * A chunk marked as a thought is thinking (KThink).

A whole reply from that API carries its thoughts in <think> tags instead.
That isn't changed: the gateway streams from the upstream when it
translates, and a Chat client's own request is passed through as it is.

Tests, with what upstream's gateway gives:
  * TestGeminiThoughtsMarkedInExtraContent: that API's stream for "你好"
    at high with the thoughts asked for, in its literal bytes
    (testdata/vertex_openai_thoughts.sse), to an Anthropic client: a
    thinking block that starts "**Acknowledging Greeting**", then the
    text "你好!很高兴与你交流。请问有什么我可以帮你的吗?". Upstream gives
    thinking "" and the thought at the head of the text.

Verified on 443f1cbc: gofmt -l, go vet ./..., go build, GOOS=linux go
build -tags nogui and GOOS=windows go build pass; go test -tags nogui
./... passes, and -race -count=20 on the tests of this and the previous
commit.

Live, through the proxy and temporary HOME of the previous commit, a turn
thinking at high gave a thinking block of 238 characters, and the answer
apart from it.
2026-10-07 18:51:01 +08:00
yetone c83c0d5fef gateway: sharing keeps the host MAGPIE_ADDR names (127.0.0.1 behind Tailscale Serve, one interface's address) instead of listening on every interface, and still asks what reaches it from elsewhere for a gateway key (#1112)
listenAddr turned sharing into 0.0.0.0:<port> and dropped the host of an
explicit MAGPIE_ADDR, so requiring gateway keys from remote callers also
widened the listener. A host MAGPIE_ADDR names is now kept while shared;
0.0.0.0, :: or no host still means every interface, and an unset
MAGPIE_ADDR still shares on every interface. LANURLs offers the pinned
address alone, or none on loopback (MAGPIE_PUBLIC_URL says the proxy's).

Verified: TestSharedKeepsPinnedAddr fails without the lan.go change
("shared, MAGPIE_ADDR on loopback, listens on 0.0.0.0:4555") and passes
with it, -race -count=20. go test -race ./internal/gateway ./internal/gui
green under a temp HOME.
2026-10-07 16:47:41 +08:00
yetone 6b6113aa7a gateway keys: a key can be created with a value of the user's own, one their clients already send, so moving from another gateway (CLIProxyAPI's api-keys) doesn't mean re-keying every client (love1sbug on X)
Gateway → Gateway keys → Add gateway key has a second field, "Your own key
(optional)"; `magpie gateway-key add <name> --key <value>` (or `--key -`
from stdin) does the same. Left empty, magpie makes a sk-magpie-key- as
before. A value is 8–256 printable ASCII characters with no spaces, can't
start with sk-magpie-, and can't be another key's. The list shows only its
last four characters ("…0042"); rotating it gives a magpie-made key.

From another computer any enabled key was already checked, so an own value
works there as is. From this computer the gateway only identified a request
whose key looked like sk-magpie-…, so an own value would have been let in
unidentified: not counted as the key, nor held to its limit, models or
accounts. managedKey now asks access.Named, which also knows the store's
own values (cached by the file's size and time).

Tested: go test -race . ./internal/access ./internal/gateway ./internal/gui;
-count=20 on the new and LAN guard tests; TestOwnKeyValueIsTheKey fails with
managedKey back on access.Managed. gateway-own-key.test.cjs passes 16/16
(chromium and webkit, en/zh/ja/de, 1000 and 440px), and the gateway-key
suites, i18n-keys, gui-ja and gui-de pass. A built binary in a sandbox HOME
with sharing on: own key from the LAN address 200, a wrong or no key 401,
from 127.0.0.1 200, the old value after rotate 401. GOOS=linux and windows
builds pass.
2026-10-07 14:34:36 +08:00
forever fb9d1a4cc6 provider, plugins: a sign-in reads as done only once its account is listed again, and the command line's plugin sign-in brings the account back and takes its lapsed mark off (#1061)
* provider: a sign-in reads as done only once the account it brings back is listed again, so the window opens it rather than saying magpie can't list it (TestClaudeTeamSeatBesidePersonal failed so on macOS in #1046's CI)

Signing in brings back an account removed from magpie (ShowAccount clears
its Hidden mark). Since 440e535b, (*signInFlow).finish recorded the outcome
and let go of the sign-in's lock before it called ShowAccount, so a poll of
SignInStatus could read done while the account was still hidden. The
window polls every 800ms and, seeing done, looks for the account to open;
missing, it says "{user} signed in, but magpie can't list it — please
report this". #1046's macOS job (run 37502933081) hit the same gap:
  signin_team_test.go:52: account after sign-in: false []
WaitSignIn, which the command line uses, waits for the end of finish and
was not affected.

finish now marks the sign-in finished first. Only the first finish records
an outcome, as before: a cancel or the timeout that comes after it is
refused, and so is a callback (claim). A done sign-in then calls
ShowAccount, and only after that reads as done. ShowAccount's error is
ignored, as before: if providers.json can't be written, the account stays
hidden and the sign-in still ends done. Until then the sign-in reads as
waiting; that takes longer only when the account was hidden, since only
then does ShowAccount write providers.json (and sync the agents' catalogs,
as it did inside finish before).

Every built-in and plugin sign-in's done goes through finish, so this one
change covers them all. The other places that show an account again do it
before their result can be seen: PluginAPIKey returns after ShowAccount,
and ImportLogins and ImportGoogleAccounts call it before they return. The
command line's own plugin sign-in never showed the account again; that is
a change of its own. ImportFactoryKeys shows none either, but the window
offers a Factory key's import only for a Factory it lists, and a removed
one is left out of the Add sheet. providers-accounts.md's sign-in row now
says when a sign-in reads as done.

Verified: TestSignInDoneOnceTheAccountIsBack records what the sign-in reads
as at the moment providers.json shows the account again (catalog.Changed),
for magpie's own callback (Codex), Claude Code's login run by magpie
(Claude) and a plugin's (FakeCo, with Bun). With finish from main all three
fail with "the sign-in read as done before its account was shown again:
the window, seeing it done, can't list it"; with this they pass.
TestSignInCanceledWhileTheAccountComesBack cancels the sign-in at that
moment and checks it still ends done, the account listed; with this finish
guarded on the state alone, as main's was, it fails with a "close of
closed channel" panic in the callback. With a 300ms sleep where main's
finish lets go of its lock, TestClaudeTeamSeatBesidePersonal fails 10 of
10 runs, each in a process of its own, with #1046's message; main as it is
passes 10 of 10 here, the gap being that narrow. With the same sleep after
this finish publishes done, or before its ShowAccount, it passes 10 of 10.
Under a temporary HOME with Bun 1.4.2, -race -count=20 on those three
tests and -race -count=2 on the package's sign-in, plugin and login tests
pass, as do go vet and the darwin, GOOS=linux (nogui) and GOOS=windows
builds; this commit alone builds and passes internal/provider.
With the next commit, go test -tags nogui ./... passes on main at 443f1cbc
and at 0bf9d77d. Three earlier full runs failed, each only in
internal/plugin, which is unchanged and imports nothing changed here:
twice TestHostJSCancellation/ready-wait at its 10-second Node deadline
(signal: killed) while other sessions' tests loaded the machine, the
timeout 76dc5aed and #1036 also record, and once, with Bun 1.3.13,
TestPluginHTTP2TakesTheProxy.
The GUI suite was not run: nothing in internal/gui/assets changes, and its
tests answer /api/signin from fixtures. Not tried with a real vendor's
sign-in.

* plugins: magpie plugin login and magpie accounts add finish a plugin's sign-in as the window does, bringing back a provider removed from magpie and taking the account's lapsed mark off

The window's sign-in to a plugin's provider, once the plugin has saved
the account, takes the account's lapsed mark off and shows again the
agent's own account removed in magpie (clearPluginLapse), and brings the
provider back if it was removed from magpie (ShowAccount): pluginDone
through finish, and PluginAPIKey for a key. The command line's
(pluginLogin, which magpie plugin login runs, as magpie accounts add does
for a plugin's provider or a subscription moved onto one) said "✓ signed
in to …" and did none of it:
- a provider removed from magpie stayed out of magpie providers, the
  agents' catalogs and routing until it was added back (in the window, or
  with magpie provider add);
- an account signed in again after its vendor turned the sign-in away
  still said "sign in again" until a request or a usage read went through
  it;
- an agent's own account (Kiro's, Devin's, …) removed in magpie stayed
  hidden after it was signed in to again, though the window's sign-in
  shows it again (#320).

PluginSignedIn now does all three for a sign-in magpie doesn't follow
itself, and PluginAPIKey and pluginLogin call it. providers-accounts.md's
sign-in row says so.

Verified: TestAccountsAddBringsBackARemovedPlugin signs in to FakeCo, the
plugin host's test plugin, with a key, removes it from magpie and runs
magpie accounts add fakeco with another key.
TestPluginLoginTakesTheLapseOff signs in with magpie plugin login's
browser way (open and xdg-open stubbed), has a usage read mark the
account lapsed as FakeCo's answer says, and signs in again. With
plugins_cli.go from main they fail with "signed in again, but not listed:
no provider "fakeco" — magpie providers lists them" and "signed in again,
but still lapsed: [{… Lapsed:gone@fake's FakeCo sign-in has expired —
sign in again …}]"; with this they pass. With PluginSignedIn's
ShowAccount taken out only the first fails, and with its clearPluginLapse
taken out only the second. Neither covers the own account: FakeCo has
none, and no test covers that part of clearPluginLapse on main either.
Under a temporary HOME with Bun 1.4.2, -race -count=5 on the two and on
TestAccountsOfAPlugin pass. Like TestAccountsOfAPlugin, the two are in
the root package, which has no testenv.Offline, so the plugin host they
start asks models.dev for its catalog.
go test -tags nogui ./... passes on main at 443f1cbc and at 0bf9d77d. Two
earlier full runs failed only internal/plugin's
TestHostJSCancellation/ready-wait at its 10-second Node deadline (signal:
killed) while other sessions' tests loaded the machine, the timeout
76dc5aed and #1036 also record; internal/plugin is unchanged and imports
nothing changed here.
Not tried with a real plugin's sign-in.
2026-10-07 06:24:24 +08:00
yetone 54b4e36dcf providers, gui: a custom provider saved as OpenAI Responses opens as OpenAI Responses, not OpenAI compatible (01huadalang on Discord: 我选 response 保存了然后再打开一会就变成 openai 兼容了)
The editor never saved which API the Base URL was picked as; reopening
showed the first API with a URL, chat first. A provider added as OpenAI
compatible keeps its chat URL when Responses is picked (#105), so after
Responses was picked, its URL typed and saved, it opened as OpenAI
compatible again. Picked with no URL of its own, the Save sent nothing
for it and the pick was gone without a word.

Now the pick is saved with the provider (baseAPI), sent back to the
editor and shown while it has a URL; a save that doesn't say keeps it,
and one whose URL is cleared elsewhere drops it. A Save with the picked
API's URL empty while another has one says so in the form. Requests are
unchanged: they go by which URLs are set.

Verified: reproduced on origin/main in a sandbox HOME with the built
binary (magpie web, real GUI via Playwright, fake Responses-only upstream):
a provider added as OpenAI compatible, then saved as Responses with and
without its URL typed, reopened as OpenAI compatible. With the change it
reopens as OpenAI Responses after a reload, a 65s wait and an app
restart, and the empty Save shows the message. A provider added as
Responses from the start was already kept and still is.
TestProviderBaseAPIKept fails without the change ("added as OpenAI
compatible, shown as \"\""); provider-base-api.test.cjs fails on the old
app.js (actual 'OpenAI compatible', expected 'OpenAI Responses') and
passes in Chromium and WebKit, en and zh. gui-ja, gui-de, i18n-keys and
the provider*/system-one/azure/add-sheet/menu-scroll GUI tests pass in
both engines. go vet, GOOS=linux/windows builds, go test -tags nogui ./...
pass under a temp HOME.
2026-10-07 06:14:20 +08:00
yetone 6f65ffb5ff agent: CodeBuddy Code, Cline, Gemini CLI, AtomCode, Goose and Reasonix installed in a WSL distro are found and wired there (lgtm on Discord: magpie 现在无法检测到安装在wsl中的codebuddy呀?)
wslKinds had none of these CLIs, so each was set up only on Windows.
Each now has a place-aware constructor (codebuddyIn, clineIn, geminiIn,
atomcodeIn, gooseIn, reasonixIn). It writes the distro's own config with
the gateway as the distro reaches it (mirrored or NAT) and the key it
takes from there. A distro reads none of Windows' variables
(CODEBUDDY_CONFIG_DIR, CLINE_DIR, APPDATA...). Kinds are found by their
folder or their command. Gemini CLI is found by its command alone,
because Antigravity keeps ~/.gemini too. Goose is found by its folder
alone, because a goose command can be pressly's. Reasonix also needs a
native version (new wslKind.accept). Gemini CLI also counts a .env that
names an old address with magpie's key as magpie's own. Before this, a
WSL restart that moved the distro's address made it read "custom", and
picking the model again stashed magpie's old address as the user's.

Verified:
- On the Windows test box (WSL2 magpie-desktop, NAT), with a sandboxed
  nogui magpie.exe (own USERPROFILE/APPDATA, port 3441), real CodeBuddy
  Code 2.161.4, Gemini CLI 0.63.0 and Cline CLI 3.0.68 were installed in
  the distro. `magpie ls` listed each as "· WSL magpie-desktop".
  Picking relay/glm-4.6 wrote ~/.codebuddy/models.json, ~/.gemini/.env +
  settings.json and ~/.cline/data/settings/providers.json in the distro
  at http://172.17.32.1:3441, and wrote nothing in the sandbox home's
  agent folders. The installs were removed afterwards.
- Not tried for real: AtomCode, Goose and Reasonix in a distro, and a
  chat request from a WSL CLI through the gateway.
- Tests: gofmt; go vet ./...; -race on internal/agent; the full
  `go test -tags nogui ./...`; GOOS=windows and GOOS=linux builds.
  TestWSLProbeFindsMoreAgents and the rest of TestWSLMore* panic with
  "no WSL agent codebuddy" on the old wsl.go.
  TestWSLGeminiAddressMoved fails ("auth custom") without the gemini
  routed change.
2026-10-07 05:37:27 +08:00
yetone d5468283bb gateway: a web page that isn't listed in Settings, on this computer or the gateway's own address is refused 403 on every route
On loopback a call needs no key and the body's Content-Type isn't
checked, so any page the user visited could send a "simple" text/plain
POST to 127.0.0.1:3425 and spend their subscriptions without reading the
reply. corsGuard now refuses a request whose Origin is an http(s) origin
that is not on Settings → Network and sharing → Web pages, not loopback
(localhost, *.localhost, 127.0.0.0/8, [::1], any port) and not the
request's own (host:port equal to Host). usemagpie.ai still reads
/v1/magpie/omarchy. No Origin (agents), "null" and non-http schemes
(app://, file://, tauri://, vscode-webview://, chrome-extension://) pass
as before: those clients could only call before by not enforcing CORS,
and no web page can send those origins. Such a page never read a reply
before (no CORS headers), so nothing that worked is refused.

Verified: TestForeignPagesRefused (text/plain POSTs from https://evil.example
and other foreign origins on every route family: /v1/chat/completions,
/v1/responses, /v1/messages, count_tokens, /v1/systemone, /v1beta,
/v1/embeddings, /v1/rerank, images, videos, the Codex backend path, /mcp,
/_magpie/claude-mcp, /v1/magpie/*, /v1/models, the GET /v1/responses
upgrade, OPTIONS; loopback, *.localhost, listed with a key, same-origin
on a LAN address through lanGuard, no Origin, null, app://, file://,
tauri://, vscode-webview://, chrome-extension:// answered) fails without
the change (120 routes answered, the fake vendor reached 139 times).
go test -tags nogui ./... green; -race -count=10 on the CORS tests;
darwin, linux nogui and windows builds. The built binary served from a
sandbox HOME on 127.0.0.1:3591: curl from the foreign origins got 403 on
every route with the vendor untouched; the allowed origins got 200.
2026-10-07 04:31:03 +08:00
yetone b234507842 gateway, settings, gui: web pages the user lists in Settings › Network may call the gateway from a browser, each request with a gateway key (SkyAerope, #1051)
A page on http://localhost:3000 asking /v1/models got a 404 to its
preflight and no Access-Control-Allow-Origin, so the browser hid every
answer. Settings › Network › Web pages now takes origins (cleaned to
scheme://host[:port]; "*" and anything that isn't an origin are refused,
said in the page's language). For a listed origin corsGuard answers the
preflight (204, the asked header names, Private Network Access) and adds
Access-Control-Allow-Origin; every other request from it needs an enabled
gateway key, even from loopback, or gets a readable 401. Default: none
listed, and a request with any other Origin or none (every agent's) is
unchanged. A save of the rest of the Settings page keeps the list.

Verified: TestCORSOrigins (gateway, loopback requests: preflight, keyed
calls, keyless/managed-token/turned-off key refused, unlisted origins incl.
"null" and port/scheme variants get nothing, agents untouched) fails
without corsGuard in Handler (preflight 404); TestCleanOrigins;
TestCORSOriginsSetting fails without keeping CORSOrigins in POST
/api/settings; TestSettingsSaveKeepsWhatItDoesNotSend. GUI
cors-origins.test.cjs (en/zh, 440px, add by button and Enter, refusals,
remove, no scroll, no left border) in Chromium and WebKit fails without
renderCORS; gui-ja, gui-de, i18n-keys, gateway-port, lan-docker,
redact-rule-row, settings-* pass in both. go test -tags nogui ./... green;
-race -count=5 on the new tests; darwin, linux nogui, windows builds.
click-scroll's two Requests-page subtests fail the same on origin/main.
2026-10-07 03:34:05 +08:00
yetone 0c08b1d5bb agent: Claude Code's tier and subagent models the user set for their own endpoint come back with it when magpie steps out (#1050)
They were stashed nowhere: Connect took ANTHROPIC_DEFAULT_*_MODEL,
ANTHROPIC_SMALL_FAST_MODEL and CLAUDE_CODE_SUBAGENT_MODEL over and
Disconnect or Claude Code's own model deleted them, giving back only the
base URL and token.
2026-10-07 02:25:49 +08:00
yetone c44698bdaa agent: a model given to one of Claude Code's tiers or its subagents stays theirs when the main model moves onto it and on again (#1050)
#1050's sonnet tier and subagents were given Sonnet 5.5 with the main
model on Opus 5.5. Moving the main model to Sonnet 5.5 made the two read
as following it, so its next move took them along: back on Opus 5.5,
ANTHROPIC_DEFAULT_SONNET_MODEL said Opus and CLAUDE_CODE_SUBAGENT_MODEL
was gone, the pick lost without a word. set now marks a tier or the
subagents whose own model the new main model is (ownKey, the mark a tier
already got when the main model was picked for it where it would follow
another), in magpie and through /model's Follow alike. While marked they
read as that model, not "same as model"; "same as model" picked for them
takes the mark away and they follow again.

TestReseatOff expected the sonnet tier given ds/gpt-5 to read empty
after the main model was reseated onto ds/gpt-5; it now keeps ds/gpt-5.
The #1050 drift tests leave the marks out, as a magpie before this left
the state.

TestClaudeOwnTierStaysThroughTheMainModel (magpie and /model) fails
without the change ("on the main model, the tier and subagents read as
their own": sonnet and subagent empty), as does TestReseatOff with the
old expectation flipped. go test -race ./internal/agent ./internal/profile
pass; -race -count=20 on the Claude, WSL and reseat tests passes.
2026-10-07 02:25:49 +08:00
yetone 99f07249e8 agent: Claude Code's tiers and subagents following a main model moved onto the model magpie gave them aren't "changed outside magpie" (#1050, cjyrainbow: Claude Code · WSL Ubuntu 已在 magpie 之外换掉 claude/claude-sonnet-5-5[1m])
With the sonnet tier and the subagents on Sonnet 5.5 and the main model
on Opus 5.5, moving the main model to Sonnet 5.5 in Claude Code's own
/model left the two following it: they read as empty, and Drift took
that for the agent's default replacing the Sonnet 5.5 in applied.json.
Drift now reads an empty field that follows another (Follows) as on that
field's model, so the usual rules apply: a move between magpie's models
isn't drift, the main model taken off magpie still is. The subagents
field now says it follows the main model, and a stopped WSL distro's
agent keeps Follows too.

TestClaudeFollowerOnTheMainModelIsNoDrift and
TestWSLClaudeFollowerOnTheMainModelIsNoDrift (running and stopped
distro) build #1050's record through Apply and the switch through
/model + Follow. Without the change both fail with
"replaced Field:sonnet Now: Want:anth/claude-sonnet-5-5[1m]"; without
the wsl.go line the stopped distro still does. go test -race
./internal/agent ./internal/profile pass.
2026-10-07 02:25:49 +08:00
forever 89b3a20f5d gui tests: a test run leaves no gui.test folder in the real ~/Library/WebKit or ~/Library/Caches (waitingsnow) (#1047)
TestTrayCellClickReleasedPanel shows its panel in the test binary run
again as a child. The package's TestMain gives the tests testenv's HOME,
but WebKit takes the home from the account's record, so every run wrote
the child's WebKit data to ~/Library/WebKit/gui.test and its caches to
~/Library/Caches/gui.test in the real home.

The child now starts through runAppKit (appkit_darwin_test.go). It sets
CFFIXED_USER_HOME, which CoreFoundation reads before the account's
record, to a temporary folder, so WebKit's data and caches go there. It
runs the binary under a name of its own, through a hard link: WebKit
names its folders after the process, whose name comes from the file, not
argv[0]. The test fails when a folder of that name is left in the real
~/Library/WebKit or ~/Library/Caches. The empty folders WebKit makes
under the name in the account's temporary folder (NSTemporaryDirectory,
which TMPDIR doesn't move) are removed. Wails v3 beta.24 offers no
non-persistent WKWebsiteDataStore: its windowNew makes a plain
WKWebViewConfiguration, which uses WebKit's default store.

CFFIXED_USER_HOME doesn't move the account's settings: cfprefsd still
serves the global domain and com.apple.universalaccess, where dark mode
and Increase Contrast are kept. A probe window read the same appearance,
Increase Contrast, Reduce Transparency, layout direction, languages and
non-default values (AppleShowScrollBars, AppleReduceDesktopTinting, the
zoom factor) with and without it, on macOS 26.7.1. Nothing is written
to the temporary home after the child exits.

Without CFFIXED_USER_HOME the test fails:

    trayusage_panel_darwin_test.go:20: the windows' process left /Users/wei/Library/WebKit/gui.test-1791307161567260000 in the account's real home
    trayusage_panel_darwin_test.go:20: the windows' process left /Users/wei/Library/Caches/gui.test-1791307161567260000 in the account's real home
    --- FAIL: TestTrayCellClickReleasedPanel (0.45s)

With it, it passes, and nothing new appears in the real ~/Library or in
the temporary folder.

PR #1042's TestMainWindowCorners and TestMainWindowRightToLeft, moved
onto runAppKit at its head f89991b1, pass and print the same with and
without CFFIXED_USER_HOME: corners 16.00pt (plain 16.00, toolbar
26.00), lights [[26 26] [49 26] [72 26]], title bar 66pt, a sheet 66pt
below the top, right to left lights [[874 26] [851 26] [828 26]].
Without it, both fail the same way.

go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./...,
GOOS=windows go build ./..., go test -tags nogui ./..., go test
-count=1 ./internal/gui/... and gofmt -l pass under a temp HOME with
Go's caches pinned. docs/subsystems/gui-shell.md says it. Tests only.
2026-10-07 02:23:19 +08:00
yetone 05683637d5 plugins: the Bun the plugin host runs on is kept as magpie-bun, so a proxy app's PROCESS-NAME rule can route the requests plugins (Cursor and the other moved subscriptions) make (#1048)
magpie downloads its own Bun into ~/.cache/magpie/bun/<version>/. It is now
written there as magpie-bun (magpie-bun.exe on Windows), and a Bun downloaded
before is renamed in place the first time it is looked for, so nothing is
downloaded again and no second copy takes disk. The host, bun add and bun
update all run it, so all of their traffic carries the one name. A
$MAGPIE_BUN the user set is run as it is.

Verified: TestBunDownloadedAsMagpieBun, TestBunDownloadedBeforeTakesTheName
and TestHostRunsAsMagpieBun fail without the change (the Bun is .../bun, the
host runs .../bun). TestHostRunsAsMagpieBun starts the real host on a copy of
Bun 1.4.2 and `ps -o comm=` names it magpie-bun on macOS. On the Windows box
the three tests pass with the Windows build of Bun 1.4.2, a running bun.exe
renames to magpie-bun.exe, and Get-Process/Win32_Process name it magpie-bun.
Linux not run here (CI's ubuntu job runs the test with Bun on PATH).
go test -tags nogui ./... green; -race -count=10 on the new tests green.
2026-10-07 01:37:31 +08:00
yetone de2c84ace7 gateway: a Responses relay that turns away reasoning with nothing sealed in it, naming only the input, is asked once more without it, and isn't sent it again for that model (#1044: Codex's /compact through Anyrouter failed with 400 invalid_request_error param=input on old conversations)
Codex hands back reasoning items with no encrypted_content (an rs_ id and
a summary, as magpie's translated replies give it). #1008 left them out
only for OpenAI's API and Azure OpenAI. A relay in front of OpenAI turns
them away the same way but with its own message ("bad response status
code 400", param "input"), so the compaction, which is relayed as it is,
failed. They are not left out for every other upstream: a DeepSeek model
behind a Responses relay wants its reasoning back (#388), and magpie's own
translator reads the summary as thinking.

passthrough now, on a 400 to a Responses request whose error names the
input (refusesInput: param input / input[…], or an item named by id),
asks once more without the bare reasoning (withoutBareReasoning), sealed
reasoning and every other item kept; once that works the provider and
model are remembered (bareReasoningRefused) and later requests go without
it at once. Any other 400 goes back as it came.

TestBareReasoningLeftOutWhereRefused (the reporter's item kinds: message,
function_call(_output), custom_tool_call(_output), two bare reasoning items
and a sealed one; the relay's 400 word for word) fails without the
gateway.go change for both the compaction and a relayed turn (400 back to
Codex) and passes. TestBareReasoningKeptWhereTaken and
TestBareReasoningKeptOverAnotherRefusal guard the upstreams that take it
and other 400s; TestAzureCompactionWithoutMagpiesReasoning (OpenAI/Azure
unchanged) still passes. -race -count=5 on these, TestCodexCompact* and
TestImageTool*; go test -tags nogui ./internal/gateway ./internal/provider,
go vet ./internal/..., gofmt, linux and windows (nogui) builds pass. Not
tried against Anyrouter itself (no account); the 400 is from the issue's
capture.
2026-10-07 01:34:57 +08:00
yetone 698da36f31 gateway, providers, gui: a model on this computer or the local network can be set to get requests unmasked by Settings' redaction (lc on Discord: 本地模型可以不脱敏)
The provider editor has a Redaction row, "Send requests unmasked", while
every address of the provider is on this computer or the local network
(localhost, *.local, a loopback or private IP): Ollama, LM Studio, a vLLM
box. Ticked (provider.Unredacted; CLI `magpie provider set <id>
unmasked=yes`), a request whose provider and fallbacks, or whose routing
group's models, all skip redaction (provider.SkipsRedaction) goes to them
as the agent wrote it; anything that may reach another provider stays
masked for all of them. An address edited to a vendor's masks it again; a
signed-in account, a plugin's provider and another magpie never skip. The
log and recent calls keep the masked body. count_tokens and embeddings
follow the same rule.

It is a per-provider choice, not automatic for local addresses, because
a local address isn't a local model: a relay run locally (new-api,
CLIProxyAPI) or Ollama's cloud models pass the request on to a vendor.

Verified: TestLocalUnredactedProviderGetsTheRequestWhole (set, unset,
fallback to a vendor, fallback to another local one, a group of it alone,
a group with a vendor; chat and count_tokens) and TestLocalUnredactedEmbeddings
fail without the gateway change ("sent whole false, want true: … my key is
{{API_KEY_…}}"; count_tokens alone: "counted whole false"); TestSkipsRedaction;
TestProviderSaveKeepsUnredacted fails without the preset save line.
provider-unmasked.test.cjs (Chromium+WebKit, en/zh, 900 and 440 wide) 32/32,
3 of 4 fail on the old app.js; gui-ja, gui-de, i18n-keys, provider-search
and the other provider editor suites pass. go test -race on gateway,
provider, redact, gui; -race -count=10 on the new tests; go vet,
GOOS=linux/windows builds. Real: a sandbox gateway in front of the local
Ollama (llama3 8B) through a logging proxy: unset, Ollama was sent
{{API_KEY_…}}; with unmasked=yes it was sent the key as written, and
answered both times.
2026-10-07 00:33:15 +08:00
yetone 92469ee9f9 library, gui: a check for updates lists the skills a repository added beside the library's skills from it, to Add or Ignore (Raven on Discord)
Raven: "check for updates" only updated skills already in the library; when
dontbesilent2025/dbskill or mattpocock/skills adds a skill, magpie never said.
CheckSkills now asks GitHub's git trees API once per repo@ref (ETag-cached, a
304 is free) and only when a skill folder under the installed skills' common
folder (newBase) is neither in the library nor already offered does findNew
fetch the tarball for names and descriptions. library.json's seenSkills keeps,
per repo@ref, the paths the install picker showed and the ones set aside, so
an offered skill is offered once. The Skills page shows them under "Also in
their repositories" with Add (to the agents that have the repo's other skills)
and Ignore, plus Add all / Ignore all (/api/library/skills/{add-new,ignore-new}).

Verified: TestCheckSkillsFindsNewOnes (fake repo: nothing offered after
install, no tarball on a second check; two added skills offered, a nested one
and a .wip one not; Add installs with the repo's agents; Ignore keeps it out
on the next check via 304; remove clears) fails without the change (no
NewSkills / findNew) and passes -race -count=10; TestNewBase. A real run in a
sandbox HOME against GitHub: tdd from mattpocock/skills/skills/engineering
offered its 19 siblings; dbs from dbskill offered nothing (the picker showed
all), 33 with seenSkills wiped, and Add installed dbs-action. GUI:
skill-new-in-repo.test.cjs (Chromium+WebKit, 860/440px, en+zh) fails on
main's library.js and passes; gui-ja, gui-de, click-scroll, library-items,
every-skill, pick-skills, import/remove-all-skills, skill-groups,
skill-repo-groups, github-token, library-all pass. -race on library, gui,
tui; gofmt, vet, darwin/linux/windows builds.
2026-10-07 00:26:54 +08:00
yetone a7e7fe2b0f library: switching a group of skills on or off no longer reads every copy every agent has (#1031, mintonight: 开关一整个技能组时很卡,要10多秒才能完成)
The page already sends one request for a group (agents-some), but its one
change ran a sync that, for every skill given as a copy (every WSL agent,
Windows without the right to link, agents set to copies), hashed the copy
and the library's skill twice, in every agent, on every change, and the
page's read hashed the copies once more. Over a WSL distro's share that is
every file of every skill read through 9P each click.

A copy's mark now keeps the hash of what was copied (markCopy). A copy
whose mark says the library skill's current hash, with nothing in it newer
than the mark, is current without its files being read (copyIsFresh);
takeEdits' walk of a copy is not done a second time in the same sync, and
each library skill is hashed once per change (libHash). Copies with an old
mark, or edited since, are hashed as before. Claude Desktop's copies and
project copies use the same check.

Measured in a sandbox (55 skills x 31 files, copies for 10 agents, a
5-skill group switched on for an 11th): the change went from 1.54s to
0.31s and the page's read from 0.66s to 0.25s on a local SSD; the copies
not touched are no longer read at all, which is the cost over WSL.

TestSyncDoesNotReadUnchangedCopies fails without the change ("codex's
copy of pdf was read and made again"): an unreadable file in an unchanged
copy is left alone now. TestCopyFreshByMark checks a changed library skill
is still copied again, an old mark without a hash still works, and an edit
in a copy is still taken. go test -race ./internal/library ./internal/gui
pass, the new tests pass -race -count=10; vet, gofmt, darwin/linux/windows
builds pass. Not run on Windows or WSL.
2026-10-07 00:14:00 +08:00