Files
gateway: each gateway key can have its own limit, a token total and/or an estimated cost per day, week or month, and a spent key is refused before any provider is asked (margbug01 on #585, v0.1.671: 每个网关密钥都能设置自己的用量上限) — a gateway key named who called and the Usage page grouped its calls, but nothing held a key to an amount, so one client shared on the network could spend a subscription's whole window. A key now carries limit {period day|week|month, tokens, cost, cacheReads} in caller-keys.json, set with the new "limit-key" change. Windows are calendar ones in local time (from midnight, from Monday, from the 1st) and a call counts in the window it started in. Tokens are a call's uncached input, output and cache writes, plus cache reads when the limit says so; cost is an estimate at the Usage page's effective prices (usage.NewCoster), a call with no known price adding none and counted as unpriced. What a key used is summed from usage.jsonl (usage.Visit), so it survives a restart; the new internal/budget keeps the sums in memory per window and appendUsage writes a key's record through budget.Append, which appends and counts under one lock so a window read meanwhile never counts a call twice. Before a keyed POST reaches the mux, keyLimited reserves for it: its body's size in tokens (4 bytes a token) plus the key's mean output per call this window, at the window's cost per token or the named model's input price; it is let in while used + reserved is under the cap, so requests sent at once overshoot by about one call, and the reservation is released when the request ends, a streamed reply being settled then with the usage its vendor reported. A spent key gets a 429 in its API's error shape (Anthropic, OpenAI chat/responses, Gemini) naming the key, the cap and the reset time, with Retry-After, X-Should-Retry: false and X-Magpie-Limit-Reset, recorded as a rejected call (gateway_key_limit); other keys go on. Requests from this computer without a gateway key are not limited; a key used from this computer is. count_tokens and bridge callbacks are never held. GET /v1/magpie/limit tells a key its own status. Gateway keys rows show a Limit badge (what is used of it, per what, Spent in red) and a line under a limited key (used of limit, left, resets / refused until, in flight; unpriced calls in its title); Limit opens an editor under the row (window, tokens with k/m/万/亿, cost in US$, Count cache reads too, what is counted) staged until Save, marked unsaved, Cancel/Escape dropping it, a bad amount disabling Save, No limit removing it; en and zh; no left-border accent; no click scrolls. The CLI gains magpie gateway-key limit <id> [off | day|week|month --tokens N --cost USD --cache-reads] and a LIMIT column in list. TestKeyLimitRefusesBeforeUpstream (two calls within 700 tokens, the third a 429 the fake upstream never sees, message/type/headers, the rejected record, Anthropic's shape on /v1/messages, another key and a keyless loopback request let in, still refused after budget.Forget as after a restart, a raised and a removed limit let in, /v1/magpie/limit), TestKeyLimitHoldsParallelRequests (20 at once against 2000 tokens: some refused, at most one call over, no reservation left; without the reservation all 20 got in and 7350 were used), TestKeyLimitWindowResets, TestLimitWindow, TestLimitValid and TestGatewayKeyLimitCLI are new; gateway-key-limit.test.cjs (WebKit, en and zh) is new and passes, as do gateway-caller-keys, gateway-connect-keys, api-key-theme and click-scroll (whose request-pick case flaked once under a parallel run and passes alone, as on main). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState's known TempDir-cleanup flake aside).
portable: a portable magpie on Windows keeps WebView2's profile in data\webview2 and makes no Start-menu shortcut, and an installed one makes the shortcut once and never puts back one the user deleted (#508, suixinex: 便携模式时还是会创建 C:\Users\<username>\AppData\Roaming\magpie.exe\ 文件夹; 开始菜单的快捷方式删除后再打开magpie会重新添加) — the AppData\Roaming\magpie.exe folder is WebView2's user data folder: Wails v3 passes WindowsOptions.WebviewUserDataPath to go-webview2 as its DataPath, and left empty it is %APPDATA%\<exe name>, which portable mode (appdir) never set. The app's Windows options (windowsOptions) now give it appdir.WebView(): data\webview2 when portable, "" (WebView2's default, so the pages' storage and cookies there are kept) when installed. The shortcut came back because shortcut.Ensure ran at every start of the desktop app and wrote Magpie.lnk whenever it was missing or stale, with nothing recording that it had been made. Ensure now does nothing when portable (no shortcut, no App Paths entry); installed, it makes the shortcut on the first start and records that in start-menu-shortcut in magpie's folder (with the exe), keeps one that is still there pointed at the exe (moved exe, update), and makes none once the file is recorded and the shortcut is gone. A user who deleted it under an older magpie isn't given it back on updating either: a missing shortcut with no record but an App Paths\magpie.exe entry already there (every older magpie wrote it at each start) counts as deleted and is recorded as such. The Start menu, the shortcut writer and App Paths are behind function variables, so the decision is tested with fakes on any OS. Checked for other leaks: autostart only refreshes an entry the user turned on (Refresh never enables), the magpie:// link registration is needed for import links and stays, as appdir's doc says, as do temp folders; the update stages beside the exe, caches already follow appdir. Not changed: the Mac's WKWebView and Linux's WebKitGTK keep their own data in the profile (Wails has no option for it), which #508 didn't report. Nothing ran on Windows (the test box isn't reachable): no real WebView2 was started with the data path and no real shortcut was made or deleted. TestEnsureOnce, TestEnsureAfterOlderMagpie, TestEnsurePortable (fake Start menu in a temp folder), TestWebView and TestWindowsOptionsWebViewData fail without the change. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and vet ./..., GOOS=windows go test -c for shortcut, gui and appdir, go test -tags nogui -count=1 ./... under a temp HOME pass (TestUsageKeepsTimeToFirstToken and TestUserPath failed once on timing, pass rerun alone; untouched).
gateway: each gateway key can have its own limit, a token total and/or an estimated cost per day, week or month, and a spent key is refused before any provider is asked (margbug01 on #585, v0.1.671: 每个网关密钥都能设置自己的用量上限) — a gateway key named who called and the Usage page grouped its calls, but nothing held a key to an amount, so one client shared on the network could spend a subscription's whole window. A key now carries limit {period day|week|month, tokens, cost, cacheReads} in caller-keys.json, set with the new "limit-key" change. Windows are calendar ones in local time (from midnight, from Monday, from the 1st) and a call counts in the window it started in. Tokens are a call's uncached input, output and cache writes, plus cache reads when the limit says so; cost is an estimate at the Usage page's effective prices (usage.NewCoster), a call with no known price adding none and counted as unpriced. What a key used is summed from usage.jsonl (usage.Visit), so it survives a restart; the new internal/budget keeps the sums in memory per window and appendUsage writes a key's record through budget.Append, which appends and counts under one lock so a window read meanwhile never counts a call twice. Before a keyed POST reaches the mux, keyLimited reserves for it: its body's size in tokens (4 bytes a token) plus the key's mean output per call this window, at the window's cost per token or the named model's input price; it is let in while used + reserved is under the cap, so requests sent at once overshoot by about one call, and the reservation is released when the request ends, a streamed reply being settled then with the usage its vendor reported. A spent key gets a 429 in its API's error shape (Anthropic, OpenAI chat/responses, Gemini) naming the key, the cap and the reset time, with Retry-After, X-Should-Retry: false and X-Magpie-Limit-Reset, recorded as a rejected call (gateway_key_limit); other keys go on. Requests from this computer without a gateway key are not limited; a key used from this computer is. count_tokens and bridge callbacks are never held. GET /v1/magpie/limit tells a key its own status. Gateway keys rows show a Limit badge (what is used of it, per what, Spent in red) and a line under a limited key (used of limit, left, resets / refused until, in flight; unpriced calls in its title); Limit opens an editor under the row (window, tokens with k/m/万/亿, cost in US$, Count cache reads too, what is counted) staged until Save, marked unsaved, Cancel/Escape dropping it, a bad amount disabling Save, No limit removing it; en and zh; no left-border accent; no click scrolls. The CLI gains magpie gateway-key limit <id> [off | day|week|month --tokens N --cost USD --cache-reads] and a LIMIT column in list. TestKeyLimitRefusesBeforeUpstream (two calls within 700 tokens, the third a 429 the fake upstream never sees, message/type/headers, the rejected record, Anthropic's shape on /v1/messages, another key and a keyless loopback request let in, still refused after budget.Forget as after a restart, a raised and a removed limit let in, /v1/magpie/limit), TestKeyLimitHoldsParallelRequests (20 at once against 2000 tokens: some refused, at most one call over, no reservation left; without the reservation all 20 got in and 7350 were used), TestKeyLimitWindowResets, TestLimitWindow, TestLimitValid and TestGatewayKeyLimitCLI are new; gateway-key-limit.test.cjs (WebKit, en and zh) is new and passes, as do gateway-caller-keys, gateway-connect-keys, api-key-theme and click-scroll (whose request-pick case flaked once under a parallel run and passes alone, as on main). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState's known TempDir-cleanup flake aside).
codex: Ultra on GPT-6.1 Sol wherever it is served, Copilot's too, with Codex's agents taking parts of the task (#656) — Codex offers Ultra on a model whose entry lists "ultra", and on a thread at multi-agent V2 hands parts of the task to agents of its own; its catalog (models-manager/models.json) lists it for gpt-6.1-sol, gpt-6-sol, gpt-6-astra, gpt-5.6-sol and gpt-5.6-terra, not the Lunas, which stop at max. magpie listed Copilot's gpt-6.1-sol only up to max, so Codex had no Ultra for it. Now an entry for one of those models (by the id the vendor serves: openai/…, dated snapshots) that reaches max lists ultra too, in Codex's catalog and /v1/models, and its Codex entry says multi_agent_version v2, as Codex's own does; GPT-6 Luna still stops at max. A ChatGPT account's entry stays as the backend lists it, and a group with a ChatGPT account in it offers ultra but isn't told V2 (its lead's subagent tasks come sealed, #141); the "subagents on any model" setting still stamps v1 on OpenAI's entries. Ultra is no API's level: the gateway now always sends max for it (fitEffort), even to a model whose list says ultra. Subagent tasks with web_search kept (the translated path) were fixed in v0.1.689 (#613); TestCopilotUltraSubagentWithWebSearch now covers it against a stub Copilot: the lead's spawn_agent comes back with plain arguments and encrypted_function_args [], no "encrypted" goes upstream, and the subagent's agent_message reaches Copilot as text. TestCopilotSolOffersUltra (sol has ultra in /v1/models and Codex's catalog with v2, luna not; ultra goes to Copilot as max, with and without web_search) and TestUltraIsSentAsMax fail on the old code; TestUltraOnModelsThatOfferIt and TestUltraEntriesSayV2 cover entries, groups and the catalog. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried in the Codex App with a real Copilot account.
codex: Ultra on GPT-6.1 Sol wherever it is served, Copilot's too, with Codex's agents taking parts of the task (#656) — Codex offers Ultra on a model whose entry lists "ultra", and on a thread at multi-agent V2 hands parts of the task to agents of its own; its catalog (models-manager/models.json) lists it for gpt-6.1-sol, gpt-6-sol, gpt-6-astra, gpt-5.6-sol and gpt-5.6-terra, not the Lunas, which stop at max. magpie listed Copilot's gpt-6.1-sol only up to max, so Codex had no Ultra for it. Now an entry for one of those models (by the id the vendor serves: openai/…, dated snapshots) that reaches max lists ultra too, in Codex's catalog and /v1/models, and its Codex entry says multi_agent_version v2, as Codex's own does; GPT-6 Luna still stops at max. A ChatGPT account's entry stays as the backend lists it, and a group with a ChatGPT account in it offers ultra but isn't told V2 (its lead's subagent tasks come sealed, #141); the "subagents on any model" setting still stamps v1 on OpenAI's entries. Ultra is no API's level: the gateway now always sends max for it (fitEffort), even to a model whose list says ultra. Subagent tasks with web_search kept (the translated path) were fixed in v0.1.689 (#613); TestCopilotUltraSubagentWithWebSearch now covers it against a stub Copilot: the lead's spawn_agent comes back with plain arguments and encrypted_function_args [], no "encrypted" goes upstream, and the subagent's agent_message reaches Copilot as text. TestCopilotSolOffersUltra (sol has ultra in /v1/models and Codex's catalog with v2, luna not; ultra goes to Copilot as max, with and without web_search) and TestUltraIsSentAsMax fail on the old code; TestUltraOnModelsThatOfferIt and TestUltraEntriesSayV2 cover entries, groups and the catalog. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried in the Codex App with a real Copilot account.
sync: a backup on a slow line is no longer cut off at 2 minutes; a server that stops moving is (#657, irx999: a 43 MB backup with many skills to a WebDAV server on a 2.4 Mbps VPS, 305 KB/s, needs 2.4 minutes and every sync failed) — a sync had a fixed deadline in all (2 minutes from the gateway's loop and magpie webdav/s3 sync, 90 s from Settings › Sync now), so a large backup on a slow line never got through, however steadily it moved. WebDAV and S3 requests now go through a watchdog instead (stall.go): the backup's GET and PUT fail once nothing has moved either way for 60 s, each byte of the body sent or read putting it back, and after a PUT's body is all handed over the answer gets 60 s on top of as long as the write took (at least 4 minutes), for what still sits in the system's buffers to leave; the other requests (PROPFIND, MKCOL, HEAD, MOVE, DELETE) have 30 s in all. A stall says so ("the server sent and took nothing for 1m0s (PUT)"). Sync now and the CLI have no deadline; the gateway's loop keeps an hour as a backstop; a wait for another magpie's sync stays 30 s. TestSlowTransferIsNotCutShort (20 MB up and down at a steady trickle, over 5 times the watchdog each way), TestStalledTransferFailsPromptly (a server stopping mid-PUT and mid-GET fails within a second) and TestMetadataRequestHasALimit fail on the old code (no watchdog: the stalled ones hang) and pass. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried against a real slow WebDAV server.
fx: a rate fetched behind a page for another home sets nothing in this one, and the gui tests ask no rate of the network (follow-up to #541) — fx.Soon asks for a stale rate behind the page; in the gui tests a state read with no rate cached so reached open.er-api.com, and its answer, landing seconds later, was taken as the rate by whichever test ran then (TestFXStateDoesNotWait saw the live 6.71 where its fake endpoint said 7.31). A fetch whose cache path is no longer the one in force now returns without touching the in-memory rate or the failure time, and internal/gui's TestMain points the rate at a closed local port. go test -tags nogui ./internal/gui ./internal/fx pass three runs of four (the fourth, TestFXInState's TempDir cleanup finding .cache not empty, the flake it had before).
antigravity: a Gemini reply that says nothing is an error, asked again, not a turn ended as it should (#667: antigravity/gemini-3.8-flash handed pi content [] and zero usage as a normal stop, the agent's run ending mid-task; #636 Bug 3) — Antigravity's Code Assist stream (and any Gemini one) now and then ends with a STOP and no parts, with only reasoning, or with no candidate at all. translate passed the decoder's KStop on and finished the client's stream as it does any reply: Chat's finish_reason "stop" and [DONE], Anthropic's end_turn and message_stop, Responses' response.completed, and a non-streamed reply of 200 with no content — nothing an agent can tell from an answer, and nothing the holdWriter took for a failure, so nobody asked again. Now a reply from a Gemini upstream (CodeAssist, Gemini) holds its end (KStop, KUsage) until something is said in it — text, a call, a search, an image — and one that ended with nothing said, not cut at its length nor by a safety filter (still a refusal, #248), ends with an error event, "<provider>: an empty reply (the model answered nothing; try again)", or a 502 when not streamed. As a 502 before any content it is held, asked again of the same account (lastRetries) or of the next one, and an agent told it retries rather than ends its turn, as Cursor's and Devin's empty replies already are. Other upstreams aren't changed: Claude ending a turn with nothing after a tool's result is its answer. TestGeminiEmptyReplyIsAnError (Factory's Gemini route, which shares the decoder and translate: STOP with no parts, only reasoning, no candidate; Chat, Anthropic and Responses streamed and Chat not; asked again; text and reasoning cut at MAX_TOKENS still go as they came) fails on the old code with the issue's wire shape and passes. Against the owner's real Antigravity account in a sandbox gateway (temp HOME, port 3488): a prompt asking gemini-3.8-flash-low for no output gave, on the old build, finish_reason stop with no content 3/3, and on this one the error event after the retries 3/3; ordinary replies on Chat (streamed and not), Anthropic and Responses, and a bash tool call, are unchanged. go vet, builds (darwin, GOOS=linux -tags nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (a first run had TestClaudeAccountNamedAsItsLogin and an internal/gui fetching test fail, timing tests that passed on the rerun).
gui: the quota curves can be turned off, picked once in the allowances' head, and the cards drawing them are calmer (ARNO on Discord: 新版本加上了额度走势后,额度面板变得非常混乱和难以观看了) — since
9ccabdfb every account on the Usage page had its own "2 days | Cycle" segmented control over its curve, so a card with two accounts carried two, there was no way to have none, and the tray's cards drew a thin line each. The range is now one pick in the allowances' head, beside Hide accounts, in the app's own menu (Trends: Off / 2 days / Cycle; 走势:关闭 / 2 天 / 本周期), remembered in magpie.quotaRange as the range was. Off draws no curve on any card, the tray panel's thin lines included; the panel follows a pick made in the window at once (storage event) and keeps it after a restart. The cards themselves: an account after a curve's legend wasn't set apart by the rule every other section gets (the next email sat right under the legend), now it is; the plot is 52px rather than 64; a window far shorter than the span drawn (a 5-hour one over a week's cycle, an all but upright comb) is drawn faint and without its upright even burn; a legend key never breaks inside (a long name ends in …); the curve's head names its range in small print instead of holding the control. quota-curve.test.cjs fails on the old code (no pick in the head, a control on every card, no Off, the 5-hour window's even burn over the week, no rule before the second account) and passes: Off removes every curve without moving the page or the pick, survives a reload, and the tray's line goes when Off is set from another window and stays gone after its reload. In WebKit: quota-curve, click-scroll, panel-usage, usage-fold, account-mask and mobile-web pass; before/after screenshots with seven subscriptions (two Claude, two Codex, Antigravity's five windows, GLM, Kimi) in en and zh, window, tray and a 390px web view. go vet, builds (darwin, GOOS=linux -tags nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn, TestUserPath and TestClaudeSignedOut, timing tests that pass alone.
imagemcp: an image that takes longer than the agent gives a tool call is answered "still being made" before the agent gives up, and is saved all the same (#575: 图片生成的mcp经常遇到超时问题 — "Tool execution timed out after 30000ms" in one agent, "Error: Request timed out" in another; the agent then looked for the file, found none, and asked for the image again, and again timed out) — generate_image held its tools/call open until the gateway's image came back, and an image model (gpt-image at quality high, 1024x1536, through a subscription) often takes a minute or more, while agents give an MCP tool call as little as 30 s, the MCP SDK's default is 60 s, and only Codex and Goose have a timeout magpie writes for them (tool_timeout_sec, timeout). So the call was cut short, the generation went on unseen, and the agent's retry started another one that was cut short too. Now each generate_image and generate_video call runs as a job that goes on to its end whatever becomes of the call, and the call waits for it at most answerWithin (20 s, under the shortest of those limits): what is ready by then is answered as before (errors too); what is not is answered, not as an error, that it is still being made, where it will be saved, not to ask for it again, and an id for the new tool generation_result, which waits on the job the same 20 s at a time and answers its paths once it is saved. A call given a progressToken is sent notifications/progress every 5 s while it waits, for clients that wait longer on progress, and
magpie mcp image waits for jobs still running when the agent closes it (up to ToolTimeout) so what is being made is saved. TestSlowImageAnswersBeforeTheAgentGivesUp (a gateway taking 700 ms against a 150 ms answerWithin: the call answers in time with an id and progress notifications, generation_result then answers the saved file, the gateway was asked once, an unknown id is a tool error) fails without the change (the call took 703 ms and answered only when the image was saved). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState's known TempDir cleanup flake aside); go test -race ./internal/imagemcp passes.
library: Claude Desktop takes skills, into Cowork's skills-plugin with their manifest entries (#638) — Desktop had MCP servers only and showed as "has no skills folder". Every account's local-agent-mode-sessions/skills-plugin/<org>/<account> (one with a manifest.json or .claude-plugin/plugin.json, in Claude and Claude-3p alike, no id hard-coded) gets a copy of each skill it is given — a real folder, as Cowork won't read a linked skill's other files from outside the plugin — and an entry in manifest.json (skillId, name, description from SKILL.md, creatorType user, syncManaged false, updatedAt, enabled), which Desktop lists from. The other entries and keys, and an entry's enabled and other fields, stay; times are written as the manifest's are (ms or ISO). A name Desktop has of its own (built in, or made there) is left and reported. The copy carries magpie's mark with the hash of what was copied: it is made again when the library's skill changes, left as it is once changed in Desktop (reported), and set aside into the backups rather than deleted when the library stops giving it; only magpie's copies and entries are ever taken out, and magpie's entry whose folder went gets it back. Desktop's own skills aren't offered to bring in. The Skills tab says Desktop shows changes once its window is reloaded (⌘R / Ctrl+R). TestClaudeDesktopSkills (two accounts, Claude and Claude-3p) and library-desktop-skills.test.cjs (WebKit, en/zh) cover it. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with a real Claude Desktop.
mcpauth: a sign-in asks for offline_access when the authorization server offers it, so Vercel's MCP stays signed in (#615: "vercel老是掉"; Neon, Notion and Supabase stay) — mcp.vercel.com's resource metadata names only the scope "openid" (its 401 names none), and magpie asked for exactly that; its authorization server, https://vercel.com, is an OpenID provider whose scopes_supported lists offline_access, and Vercel's docs say that scope is what issues a refresh token (access tokens last an hour; refresh tokens 30 days, single use, rotated). So magpie's Vercel sign-in had no refresh token, and an hour later Token found it due with nothing to renew it by and marked it dead: "sign in again". Neon (no scopes, refresh by default), Notion ("default") and Supabase (its own scopes) give a refresh token without it. Now discovery adds offline_access to the scope asked for (authorize and registration) when the authorization server's metadata lists it and the scope named any; a sign-in that names no scope keeps the server's default. A rotated refresh token was already saved. The test fake gains OpenID, a Vercel-like server (resource names only "openid", no scope in the 401, refresh token only with offline_access); TestSignInAsksForOfflineAccess fails on the old code (scope "openid", no refresh token, ErrExpired at the first renewal) and passes. A Vercel sign-in made before this has no refresh token and has to be made again once. go vet, builds (darwin, GOOS=linux -tags nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn and TestUserPath, timing tests that pass alone. Not tried with a real Vercel account.
netproxy: a host whose IPv6 takes the connection and drops it is reached over IPv4 (tony on Discord: models.dev sync and the update check failed with a bare EOF, direct or auto, while curl got through) — behind a TUN (xray) without IPv6 out, a connection to a site's IPv6 address is accepted, so the dialer's own fallback to IPv4 never starts, and closed once TLS begins. A connection to an IPv6 address that ends before a byte comes back now marks its host:port to be dialed over IPv4 for 30 minutes, and the request that met it is sent once more over IPv4 when nothing of it can have reached the site (HTTPS, or an idempotent method) and its body can be had again; proxied requests and IPv4 connections are left as they are, and TLS, ALPN/HTTP/2, headers and bodies are unchanged. Every transport made with Dispatch (http.DefaultClient, the gateway's) and the update client get it. TestIPv4Fallback fails without the change (Get "https://example.com/": EOF). go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestFXInState flaked on TempDir cleanup under load, passes alone).
library: RTK says when rtk isn't on the PATH the agents get, and puts it there when asked (#601, Moody-Sin: magpie好像没有正确安装RTK — Pi said "RTK disabled: rtk binary not found in PATH" while the Library showed RTK working) — rtk's hooks run it by name (Pi's extension probes rtk --version and turns itself off when that fails; Claude Code's and Codex's are rtk hook …; the commands they rewrite to are rtk git status and so on), but magpie looked for rtk in more places than an agent's PATH: ~/.local/bin and ~/.cargo/bin, which the app adds to its own PATH whether the user's shell has them or not (rtk's own install.sh puts it in ~/.local/bin and only warns when that isn't on PATH), and WinGet\Links on Windows, which winget doesn't always link into. So rtk was "installed", the agents were switched on with it by its whole path, and none of them could run it. Now the PATH a terminal opened now has is read — the login shell's (proc.LoginPath, the one UserPath already asks for, kept a minute) or on Windows the registry's (moved from provider's registryPath) — and when rtk isn't in it the RTK card says so, with what Put RTK on PATH will do, and each agent that has rtk is tagged Not on PATH (不在 PATH 中). Put RTK on PATH (magpie library rtk path) links rtk into the first of ~/.local/bin, ~/bin, /opt/homebrew/bin, /usr/local/bin, linuxbrew's bin, ~/.cargo/bin that is on that PATH and writable, or on Windows adds rtk's own folder to the user's PATH in the registry (a link needs Developer Mode, and a copy is left behind when winget upgrades rtk; winget's package folder doesn't change across upgrades) and tells Explorer; with no such folder it says which folder to add to PATH in the shell profile — shell profiles are never edited. rtk is also found in winget's package folder (WinGet\Packages\rtk-ai.rtk_*), and rtk's script upgrades a linked rtk where the link points, not over the link. TestRTKOffPath and library-rtk-path.test.cjs (en, zh) fail without the change. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and vet, go test -tags nogui -count=1 ./... under a temp HOME pass but the known TestFXInState flake. The Windows registry path is built, not run on Windows.
test: one list of the agent folders a sandbox clears, so no sandbox misses an agent (yetone, on #522: A shared list of env vars for the other sandboxes would be a welcome follow-up) — every sandbox kept its own list of the variables that point at an agent's folder, and each had drifted: the gateway's cleared 5 of the 32 magpie reads, the provider package's 2, the tui's 10, the library's 20. A sandbox clearing 2 meets whatever Grok, Kimi, Hermes, MiniMax, Qoder or ZCode the developer has installed, which is how #522 came about — Qoder's two were in the library's list and in none of the four that failed. internal/agentenv is now the one list, and the 21 sandboxes that fake an environment with no agent in it clear it, each keeping beside it the variables that are not an agent's: APPDATA and LOCALAPPDATA, which a sandbox sets to a folder of its own rather than clearing, VISUAL and EDITOR. A name in the list is one magpie reads, and TestVarsAreRead walks the sources to say so, which is how two names a sandbox had been clearing turned out to be read by nothing at all — GEMINI_CLI_HOME and OPENCODE_CONFIG in internal/backup's, while OPENCODE_CONFIG_DIR, the one internal/agent reads (agents.go:545), was not in it. Where a sandbox set an agent's folder to a fixture of its own, the clearing now comes first, since a list of 32 wipes what a list of 5 left standing. Not converted: the omp tests' four variables, which are the ones omp's own lookup consults (a Pi fork) rather than every agent's, and the warm-up's Claude Code traffic flags, which are not folders — CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC stays out of the list for that reason too, its one test setting it on purpose. On Windows 11 the whole suite answers as it did before the change: 31 packages pass, and the same 15 tests in internal/library, internal/gateway and internal/agent fail for what they failed for before (symlinks this machine has no privilege for, and TestSuffixModesReachAgentFiles). go vet ./..., go build -tags nogui (windows, GOOS=linux, GOOS=darwin), and go vet -tags nogui for linux and darwin over every package touched pass. (#543)
models: reasoning levels are borrowed again for models Azure also hosts, and a routing group keeps its members' levels when one thinks with none to pick from (ARNO on Discord: routing group在接入harness的时候,没有设置reasoning_efforts。我发现是之前很多能自动推导reasoning_efforts列表的模型现在都推导失败了) — the Azure OpenAI preset (
c35471cf, Catalog "azure, openai") was taken for a maker, so models.dev's azure list was the maker's word on every model it hosts: Kimi K2.5, DeepSeek V3.2 and R1, Grok 4.20/4.1 Fast Reasoning, listed there with no levels, took none on a relay or custom provider (and the gateway capped them at high as levelless), where the providers listing any give low…high, none/high and the rest. Azure is now marked Hosts, as Groq and Ollama Cloud are; its own provider still takes its own list's word. And groupEntries narrowed a group to the levels every member has, so one member that thinks with no levels (a thinking switch, a Claude 4.5 with a budget alone, OpenRouter's qwen3.8-omni-flash) left the group none and dsh's route wrote it without reasoningEfforts; such a member is now skipped as an unknown one is, since the gateway sends it the effort asked, no more than high (fitFor). The same Entry.Efforts feeds dsh, Codex's catalog, Pi and OpenCode. On a copy of the owner's config and models.dev cache: no plain model loses levels; group/auto-claude-haiku-4-5 and -sonnet-4-5 get low/medium/high/max again (they had them on 09-30), group/auto-qwen3-8-omni-flash none/low/medium/xhigh; across models.dev, the 34 ids Azure emptied (kimi-k2.5, deepseek-v3.2, deepseek-r1, grok-4-20-reasoning …) borrow again. TestHostListIsNoMakersWord (provider) and TestDshGroupHasItsMembersLevels (agent: the group's dsh entry has reasoningEfforts and the thinking field offers low/high/max) fail on the old code. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
providers: each model's credit rate shows by it in the provider's model list and an agent's model picker, Qoder's 0.5× and WorkBuddy's 0.03×, a discount's price before it struck through (01huadalang on Discord: qcoder 或者 workbuddy 能不能实时抓取或者按一段时间抓取能展示列表模型的积分消耗倍率,这样不用打开软件查看,能选择到便宜的模型) — magpie read each model's price only to tell free from not: Qoder's price_factor (
06584e15) and WorkBuddy's "credits" ("x0.03", wbFreeCredits), so the lists said FREE or nothing, and picking a cheap model meant opening Qoder or WorkBuddy. catalog.Model now keeps the price, Rate (the credits a request costs, as a multiple; 0 is not listed, or Free) and RateWas (the price before a discount running now), and carries both through provider.Entry, agent.Option and the providers page's models. Qoder (and Qoder CN): Rate is the price_factor; RateWas an active promotion's before_promotion_price_factor, else an original_price_factor above the price (Qwen3.8-Flash: free, 0.1× struck through), and a promotion's 0 that isn't free is its price before times its discount_factor. WorkBuddy and WorkBuddy AI (the CodeBuddy plan; both builds read the same /v3/config): Rate is the credits (wbCredits; x0.00 stays FREE, an unreadable or infinite rate is none). A plugin's model gives it as it gives free: rate and rateWas on the model its provider.models hook lists, a number (0.5) or as the vendor writes it ("x0.03", "0.5×"), which host.js reads (rateOf) into plugin.Model.Rate/RateWas and pluginCatalog keeps; the moved Qoder and WorkBuddy users get it once their plugins send it. The badge is small and grey (the context badge's colours, no uppercase), beside FREE where that shows, its title saying what a request costs (每次请求消耗 0.5× 积分;折扣前 / 限时免费,原价). Rates are as of the provider's last list: a built-in's is fetched at start-up when it has none, on Refresh and when its key changes, a plugin's when its plugins are asked again (start-up, a sign-in, a plugin change); nothing asks on a timer. Kiro, Cursor, ZCode and Devin list no per-model rate magpie reads, so they show none. TestParseModelsRate (qoder) and TestWBCreditsRate (provider) are new and don't build without the change; TestFakePlugin now checks a plugin's rate given as a number and as "x0.03" (0 for both with host.js not reading them). model-rate.test.cjs (WebKit, en and zh: the chips' badges and titles, struck-through prices, grey not green, no stripe, a click picks and scrolls nothing, the picker's badges) fails without the change and passes with it; click-scroll, models-free-only, model-pick and favorite-star pass in WebKit. No request was made to Qoder, WorkBuddy or any vendor. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME, go test ./internal/gui pass.
gateway: a request archive, off unless turned on, keeps each call's headers and bodies in the user's own S3 bucket and a recent call reads its own back (Jorben on Discord: 请求存档) — the Gateway page's recent calls only ever held the last bodies in memory, the first 256 KB of each, with no headers, gone at a restart. A switch over Recent calls ("Request archive", POST /api/settings/archive, settings' requestArchive) turns it on, refused with a reason while sync isn't set up with an s3:// bucket; with it on, each call the gateway serves gets an id (UTC HHMMSS-<16 hex>, said back in X-Magpie-Archive-Id) and, once it is recorded, its method, path and query, request headers and body and response status, headers and body go as one JSON file to <prefix>/magpie/archive/<YYYY-MM-DD>/<id>.json in the bucket sync keeps its backup in, signed with the same client (davsync.Bucket, no new SDK). Before anything leaves, Authorization, x-api-key, Cookie, Set-Cookie, any header or JSON field or query parameter named for a key, token, secret or password, and anything that looks like a key or token anywhere in the text (the redaction rules' patterns, in a stream's data lines too) become [REDACTED] — gone, no placeholder kept; the archive is plain JSON, readable in the bucket, not sealed with the passphrase. Uploads go one at a time on their own goroutine from a queue of 16 calls, each body capped at the 256 KB the call already keeps and marked truncated; a full queue drops the call with a log line, a failed upload is logged and shown under the switch until the next one goes, and no request waits on any of it. A recent call the archive kept shows its date and id under its bodies, and "Fetch from archive" (GET /api/archive?date=&id=, only a date and an id of that shape) shows the request's and response's headers and bodies as the bucket has them, or says it isn't there yet. The hook is in record() and the Call literal, so PR #239's request log lines are not touched; the archived call carries its usage, and with #239 its vendor request id. Only calls through serve() are archived, not Codex's backend, image or video calls. TestArchiveOn, TestArchiveOff, TestArchiveName, TestRequestArchiveToS3 (a real gateway request through the signature-checking fake S3), TestScrub and request-archive.test.cjs fail without the change; TestSettingsSaveKeepsWhatItDoesNotSend now sets requestArchive. A sandbox magpie web with a fake vendor and a fake S3 server uploaded nothing while off; on, it uploaded a signed archive with none of eight planted secrets in it, read it back through /api/archive and in the page, and with the S3 server down served the call in 0.1 s and said why the upload failed. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (Claude account tests and TestClaudeRunEndsWhenTheClientLeaves pass alone); request-archive, click-scroll, old-webkit, sse-body, gateway-fold pass.
portable: a portable magpie on Windows keeps WebView2's profile in data\webview2 and makes no Start-menu shortcut, and an installed one makes the shortcut once and never puts back one the user deleted (#508, suixinex: 便携模式时还是会创建 C:\Users\<username>\AppData\Roaming\magpie.exe\ 文件夹; 开始菜单的快捷方式删除后再打开magpie会重新添加) — the AppData\Roaming\magpie.exe folder is WebView2's user data folder: Wails v3 passes WindowsOptions.WebviewUserDataPath to go-webview2 as its DataPath, and left empty it is %APPDATA%\<exe name>, which portable mode (appdir) never set. The app's Windows options (windowsOptions) now give it appdir.WebView(): data\webview2 when portable, "" (WebView2's default, so the pages' storage and cookies there are kept) when installed. The shortcut came back because shortcut.Ensure ran at every start of the desktop app and wrote Magpie.lnk whenever it was missing or stale, with nothing recording that it had been made. Ensure now does nothing when portable (no shortcut, no App Paths entry); installed, it makes the shortcut on the first start and records that in start-menu-shortcut in magpie's folder (with the exe), keeps one that is still there pointed at the exe (moved exe, update), and makes none once the file is recorded and the shortcut is gone. A user who deleted it under an older magpie isn't given it back on updating either: a missing shortcut with no record but an App Paths\magpie.exe entry already there (every older magpie wrote it at each start) counts as deleted and is recorded as such. The Start menu, the shortcut writer and App Paths are behind function variables, so the decision is tested with fakes on any OS. Checked for other leaks: autostart only refreshes an entry the user turned on (Refresh never enables), the magpie:// link registration is needed for import links and stays, as appdir's doc says, as do temp folders; the update stages beside the exe, caches already follow appdir. Not changed: the Mac's WKWebView and Linux's WebKitGTK keep their own data in the profile (Wails has no option for it), which #508 didn't report. Nothing ran on Windows (the test box isn't reachable): no real WebView2 was started with the data path and no real shortcut was made or deleted. TestEnsureOnce, TestEnsureAfterOlderMagpie, TestEnsurePortable (fake Start menu in a temp folder), TestWebView and TestWindowsOptionsWebViewData fail without the change. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and vet ./..., GOOS=windows go test -c for shortcut, gui and appdir, go test -tags nogui -count=1 ./... under a temp HOME pass (TestUsageKeepsTimeToFirstToken and TestUserPath failed once on timing, pass rerun alone; untouched).
usage: WorkBuddy's credits are said beside their share — on the Routing page, the Usage page and the menu bar, used or left as the share is (#659)
site, gui: release notes survive GitHub limiting usemagpie.ai, and a failure to get them says so, apart from a release without any (#661) — /api/notes answered 503 {"error":"no releases"} with Cache-Control public, max-age=300 whenever GitHub's API limited the worker's shared IP, and /api/latest fell back to the release page's redirect with notes "" (kept 300 s too), so the update window had nothing to show. The worker now: never keeps an error (503 is no-store); takes the notes from GitHub's releases feed (releases.atom, not API-limited) when the API says no, turning its HTML back into the markdown the app draws and putting back the <!-- lang:zh --> marker GitHub strips (before the first Chinese heading after an English one); keeps the last whole answer for 30 days in the edge cache and serves it (merged with the feed's newer releases) when GitHub fails; keeps a degraded answer 60 s instead of 300; and sends a GITHUB_TOKEN secret to the API where one is set (none is yet). In the app, /api/whatsnew says error (and the release page's url) when the notes couldn't be had, and Settings' What's new opens a dialog saying "Couldn't load the release notes" with Try again and "Open the release page" (through the app), or "No release notes provided." with the release page and no retry for a version with none, instead of a passing toast; en/zh. Tests that fail on the old code and pass: site/worker.test.mjs (API limited: notes from the feed in either language, briefly kept; all of GitHub down: 503 no-store, then the last whole answer; the token sent), TestWhatsNewTellsFailureFromNone, and whats-new-failed.test.cjs (WebKit, en/zh: failure with retry and link, retry showing the notes, none without retry, no click moves the page). The feed parser was tried on the real releases.atom (v0.1.730–739: English and Chinese split as the API's bodies are). go vet, builds (darwin, GOOS=linux, GOOS=windows), go test -tags nogui -count=1 ./... under a temp HOME, node --test site/ and whats-new.test.cjs pass.