Commit Graph
2053 Commits
Author SHA1 Message Date
yetone 18d973ba7b docs: LESSONS.md gets the rest of 10-06's review — merging before writing what was run (9 of 10 merges), the reviewed head (#974), re-deriving what a change builds on (e0bf3e61, 2a990175) and another app's semantics from its code (#966/#996) are seen a second day; two new: no blocking send under a lock the reader needs (4fb8e887, c8690ca0), and a test's fake processes end with it (38a0f470) 2026-10-07 00:23:19 +08:00
yetone 1dceda18a1 agent: taking magpie out of a running OpenHanako 1.0 puts its agent on one of OpenHanako's own models, as provider/id (#1040: "model: OpenHanako: 400 Bad Request /api/agents/xiaojing/config: models.chat requires provider/id")
OpenHanako 1.0.0-beta's PUT /api/agents/:id/config takes models.chat only as a model it has, with both provider and id (its Uh + requireModelAvailable): no model or an id alone is a 400. Turning magpie off sent back what magpie had stashed, and with nothing stashed (the agent was put on magpie in OpenHanako, or under another agent than the stash key's, as the reporter's xiaojing) that was null, so the reset failed and magpie's provider stayed. A stashed id alone, or a model whose provider has gone, failed the same way.

Now, while OpenHanako runs, the reset reads its models (GET /api/models) and puts the agent back on the stashed model when it has it (an id alone found by its id), else on the first model of OpenHanako's own providers; with none but magpie's it says to add a provider in OpenHanako first and changes nothing. The stash is dropped only once the model is back. With OpenHanako closed the files are written as before.

TestHanakoLiveBackNeedsProviderID (a fake that answers as 1.0's server does: nothing stashed, an id alone, a provider/id whose id has a slash, a provider gone, only magpie's models) fails on the old code with the reported "models.chat requires provider/id" and passes. Against the real OpenHanako 1.0.0-beta server (from HanaAgent.app's seed, in a sandbox HANA_HOME on port 3519, with a deepseek provider): magpie connects and disconnects; with the stash lost the old code's disconnect gives the reporter's 400 and the new one puts the agent on deepseek/deepseek-chat and removes magpie. Not tried with the desktop app's sealed catalog or on the reporter's machine. go test -race ./internal/agent, vet (darwin, linux, windows) and gofmt pass.
2026-10-07 00:17:38 +08:00
yetone 117b6c7fb4 usage tests: TestReasoningReplyTimedByItsAnswer's timeline rows are made now, as LedgerSeries buckets today by the clock
The test gave LedgerSeries records dated 2026-10-06 14:30 UTC while
LedgerSeries reads today from time.Now(), so it passed only on 2026-10-06
and has failed on every machine since ("part: {Calls:0 ...}" just after
midnight CST on 10-07). The summary and row checks keep the fixed date;
the rows given to LedgerSeries take the clock's. go test -race
./internal/usage passes; gofmt and vet are clean.
2026-10-07 00:16:20 +08:00
wizzy-yangandwizzy-yang 764bbfdad5 sessions, gui, tui, cli: the Sessions list is as long as the count beside it, and drawn in pages (#1019)
The Sessions page's list and its session count came from two different
lengths: the count is over every session (statsFor's Overview), while
GET /api/sessions fell back to sessions.Limit (200) when the page asked for
no number — and the page never asks for one. So the page said "238 sessions"
over a list of 200, and each agent whose sessions were oldest lost them
whole: Codex had 121 sessions here and the list showed 7, OpenCode 33 showed
2, ZCode 20 showed 3.

  * sessions.All (5000) is what a listing that means every session asks for,
    and the endpoint reads it when no number is asked of it; a caller that
    asks for one still gets it. It lives in internal/sessions so the page, the
    TUI and the CLI ask for the same number.
  * sessions.Limit stays 200 and is not raised: traceRecentFiles sizes the
    window gateway attribution is judged over by it (TraceSessionVisible
    decides whether a session has a readable local transcript), so raising it
    would change which calls count as magpie's.
  * The page draws that list in pages: SESS_PAGE (200) rows, and "Show N
    more" appends the next. A filter click or a keystroke in Search draws the
    first page and no more, so the cost does not grow with the history. The
    data stays complete, so the count and the list still agree. A list served
    4000 sessions long rebuilt every row on every keystroke — 275–320 ms a
    keystroke on a fast Mac with very small rows, worse on Windows — and now
    draws 200.
  * The note under the list counts the list the filters kept, not every
    session on the computer: it says "showing {drawn} of {n}" while pages are
    left and "{n} session"/"{n} sessions" once the whole list is drawn. Before
    it counted the whole history beside the drawn rows, so a search matching
    one session read "the list holds all 2600, 1 drawn" — as if 2599 were
    behind a "Show more" that isn't there. It also no longer says "drawn",
    which is how the page is built, not what a reader sees.
  * The CLI read sessions.List(0) — 200 — and then cut to its own --limit, so
    --limit 500 could never show more than 200 and the "%d more with --limit"
    foot was counted off the truncated list. It reads the whole set now; only
    o.limit rows are printed, so that costs a parse, not output.
  * The TUI read List(0) beside a head that says how many sessions there are,
    and then List(sessions.Limit+1) — 201, still not the whole set. It reads
    sessions.All now.

What the wider list costs is unchanged: every session file is read either way
to sort by last activity. Measured here with 384 sessions and 84.5k calls:
cold List(200) 10.3 s, List(5000) 10.5 s. What it adds is per-row work while
List holds the lock — a Codex session's titles stat, a pi session's omp
look-up, 25× the rows of JSON — hundreds of milliseconds, not seconds.

Tests, each failing without its change:
  * TestSessionsListIsAsLongAsTheCount (internal/gui): 205 Claude Code
    sessions as Claude Code keeps them; all 205 with no number asked, 5 with
    ?limit=5, 201 with ?limit=201. Fails with the list holding 200.
  * TestSessionsListIsNotCutAtTheLimit (internal/tui): 210 sessions, of which
    the ten past sessions.Limit are all in the list. Fails at 201.
  * TestSessionsMoreCountsTheWholeSet (root): the default --limit 20 over 212
    sessions says "192 more with --limit", and --limit 205 prints 205 rows.
    Fails saying 180 (counted off the truncated list).
  * sessions-paging.test.cjs (Chromium and WebKit, en and zh): 2600 sessions;
    the first page is 200 rows with "Show 200 more", the button appends to
    600 with the rows above left in place, a keystroke in Search draws a page
    in under 250 ms and starts the pages again, a search matching one session
    reads "1 session" and never the 2600 on the computer, and the note follows
    the list the filters kept (200 of 1111 after that search). Fails on
    origin/main (200 rows are not a page there) and with the pages taken out;
    counting every session again, or writing "1 sessions", fails it too.

Verified: go vet; go build (windows); go test ./internal/sessions
./internal/tui ./internal/gui . — only what origin/main fails (TestCursor,
TestGatewayFixQuitsOnlyAMagpie, TestPurposeAssetMatchesUsage,
TestUpdateThatWontStart, TestPluginListSaysMiddleware,
TestPluginOnOffRmShortName); node --check app.js and i18n.js; the browser
suites above plus sessions-overview, sessions-today, sessions-usage-open,
sessions-toolbar, sessions-calendar-fill, usage-sessions-agents-fit,
i18n-keys, usage-ledger and click-scroll.

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-07 00:16:13 +08:00
yetone d7b1d9df41 gui: with Settings' Bar icon on, Omarchy's bar shows one magpie, not the widget and the tray icon side by side (Alex on Discord)
On Omarchy the tray's StatusNotifierItem is always registered, so a user
who had magpie in the bar's tray and turned on Bar icon got the widget
beside it: two magpies. Wails has no Hide on Linux (a no-op), so the item
now says Status "Passive" while the widget is in (set through Wails' own
properties, as the tooltip is, with NewStatus sent), and "Active" again once
it is taken out. Omarchy's Tray.qml leaves Passive items out of both its
pinned row and drawer (bucket()), as waybar's tray does; Omarchy itself
hides Dropbox's tray item when its Dropbox widget is in the bar. The status
is set when the app starts (from whether the widget is in) and whenever
Settings' Bar icon adds or removes it (onBarIcon), from what is in the bar
afterwards even if the omarchy command failed.

TestBarIconTellsTheTray (POST /api/omarchy/widget on, then off, with
stand-in omarchy commands) fails with the call taken out: "Bar icon on: the
tray was told [], want [true]"; passes with it, -race -count=10.
On the omarchy VM (Omarchy 4.0.4 shell): a test SNI item pinned in the bar
showed when Active, was gone within a second of Passive + NewStatus and
came back on Active. This build (go build -tags gtk3 on the VM, vet clean),
run in a sandbox HOME with MAGPIE_OMARCHY=1: with the widget's files in its
config the tray item's Status read "Passive" over busctl, without them
"Active". Not run on the VM: turning the switch at runtime (the route is
covered by the test). go test -race ./internal/gui ./internal/omarchy,
gofmt, vet, darwin/linux nogui/windows builds pass.
2026-10-07 00:16:02 +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
yetone 14af0f100b provider, plugins: a plugin's browser sign-in finishes from the address the browser ended on, for magpie on a server or in Docker (Chicring)
The built-in sign-ins that come back to a port on this machine (Codex,
ChatGPT API, Devin, Kiro, Zed, Gemini, Antigravity) already took the
pasted address. Their plugins didn't: a subscription moved onto its plugin
(Devin, Kiro, Zed) or a plugin's own browser sign-in (Trae) showed only
"the account shows up here as soon as you're done", and a browser on
another computer than magpie's ended on a page that won't load with no way
to finish.

Now a plugin's "auto" sign-in whose page names a port on this machine to
come back to (redirect_uri, auth_callback_url, callback on localhost, or
Zed's native_app_port: loopbackPorts) sets pasteCallback, so the GUI shows
the same Callback URL field as the built-ins, and `magpie plugin login` /
`magpie accounts add` ask for it too. SubmitSignInCallback opens the pasted
address on 127.0.0.1 at that port, never through a proxy, so the plugin's
own listener finishes the sign-in as it would for the browser; an address
on another port or host is refused, a page that sends the browser on to a
page that comes back to the same port (Kiro's AWS sign-in) becomes the next
page to open. Command Code's page posts its key from script, which leaves
no address to paste, so it keeps offering the plugin's API key way.

Verified: TestPluginSignInTakesAPastedCallback (fake plugin with a loopback
sign-in, $FAKE_LOOPBACK) fails without the wiring in pluginBegin ("a
sign-in that comes back to a port here takes no pasted address:
{... PasteCallback:false ...}") and passes with it, -race -count=10;
TestLoopbackPorts covers the community plugins' page addresses.
go test -race ./internal/provider ./internal/plugin and the root package's
Plugin|Login|SignIn tests pass; vet, gofmt, darwin/linux nogui/windows
builds pass. With the real community Devin, Zed and Kiro plugins in a
sandbox HOME, each sign-in offered the field and a pasted address reached
the plugin's listener and the vendor (bogus code refused by Devin 401, Zed
"couldn't be decrypted", Kiro 400). signin-paste-plugin.test.cjs (new),
signin-callback and signin-paste-codex pass in Chromium and WebKit, en and
zh, at 360px; no new strings. Not tried: a real successful sign-in from a
browser on another computer.
2026-10-07 00:13:03 +08:00
wizzy-yang 9d84443086 test: a profile with no catalog to write back from is still said (#1037)
yetone's review of #1029 asked for this branch: `canWriteBack` is false when
magpie has no catalog, and the loop over the other profiles must then say a
profile whose start names one of magpie's models, not pass it over in
silence. Setting `canWriteBack` to true and dropping the guard both keep
every other test green, so nothing covered it.

TestDshCheckSaysWhenNoCatalogAndAProfilesStartIsMagpies is the case: no
provider saved (no catalog), web wired with magpie's route and start, and a
cli profile stripped of the route with its start still on one of magpie's.
It passes here and fails with the guard mutated either way.
2026-10-07 00:02:41 +08:00
yetone b1cba65477 davsync tests: the slow transfer runs on a line with small buffers, and the PUT's tail is the server's to hold (TestSlowTransferIsNotCutShort red on ubuntu in PR #1030's run 37466034290: "the server sent and took nothing for 300ms (PUT)")
The test scales the watchdog from 60 s down to 300 ms and sends 20 MB at 6.4 MB/s, taking each of magpie's reads of the body as the line moving. On Linux the line's buffers aren't scaled with it: the kernel hands a writer room in bursts, waking it once a third of its send buffer has drained, and it tunes that buffer up to 4 MB (the receiver's window opens in steps the same way). A probe on magpie's body reads in a golang:1.26.3 container showed gaps of 130–290 ms between two of them while the line never stopped, every run; one over 300 ms failed the test. That is the test's ratio, not the watchdog's: at the real 60 s, a burst that long needs a line under ~25 KB/s. Not a race (the same in -race and without).

narrowLine serves the fake WebDAV server and gives the client with both ends' buffers held to 128 KB (SetWriteBuffer on the client's connection, SetReadBuffer on the server's), so a burst is a chunk or two. The MBs that used to sit in the buffers after magpie had handed the body over, which is what allSent's longer wait is for, are now the server's own tail: it answers the PUT twice the watchdog after the body is in. The server also grows its buffer to the body's length up front.

On Linux (golang:1.26.3, 14 CPUs): before, 5 of 30 runs failed (-race -count=10 -cpu 1,2,4); after, 30 of 30 pass, and -race -count=20 -cpu 1,2,8 of TestSlowTransferIsNotCutShort, TestStalledTransferFailsPromptly and TestMetadataRequestHasALimit with four busy loops beside them pass. With allSent changed to put the watchdog back by stallAfter alone, the test fails 3 of 3 ("took nothing for 300ms (PUT)"), so the tail is still tested. go test -race ./internal/davsync (darwin), go vet and gofmt -l pass. No change outside the test.
2026-10-06 23:25:27 +08:00
yetone 60f87223c3 gui tests: skill-how clicks a By agent choice once its sheet has finished growing in (skill-how.test.cjs red on WebKit about 2 runs in 5, Codex's way posted "" where "link" was asked)
The test's press() reads a button's box and clicks the middle of it with the mouse. The By agent sheet opens with the dialog's entrance, which grows it from about a third of its size, and on WebKit the box was read some 25 ms in (transform matrix(0.34…), Links 16 px wide at x 346); by the time the click landed the sheet had grown, and that point was over Library's way, left of Links. So Codex was given the library's way, and posts[2] said how "" in both en and zh. Chromium was mostly through the entrance by then. library.js has no race here: a click lands where the sheet is drawn when it lands.

press() now waits until #modal has no running animations before reading the box, as other GUI tests do with getAnimations. Before: 3 of 6 WebKit runs failed with how '' at line 168 (a probe logged the box mid-entrance each time). After: 15 of 15 WebKit and 15 of 15 Chromium runs of skill-how.test.cjs pass. No change outside the test.
2026-10-06 23:25:27 +08:00
yetone e2f1c6bacf agent: OpenHanako 1.0's encrypted provider-catalog.json is never written over; magpie's address and models are kept in its plugin file (owner: "~/.hanako/provider-catalog.json: top level is not a JSON object")
OpenHanako 1.0.0-beta seals provider-catalog.json under a key its desktop app keeps in the keychain ("HANA-SECRET-1.AES-256-GCM.<nonce>.<body>", shared/credential-envelope.ts); its own compatibility note says a reader without the key must refuse a sealed file. With OpenHanako closed, magpie's Sync couldn't read the key out of it, took magpie's entry for changed, and edit.SetJSON failed on the file. Now, when the catalog is sealed:
- Sync and a model pick keep magpie's definition (address, api, models) current in provider-plugins/magpie/providers/magpie.json, where OpenHanako has moved it (everything but the key and headers, which stay in the catalog: wun's overlay), and the key, unreadable, counts as unknown, not changed, in Sync and Check; Check names magpie.json for the address.
- Adding magpie with no plugin file, or taking it out, needs the key in the catalog: with OpenHanako closed it says "open OpenHanako and try again" and changes nothing (taking out refuses before the agent's model is put back). While it runs, its API does it all, as before.

TestHanakoSealed (a catalog sealed the way OpenHanako seals it, a plugin on an old port with an old model) fails on the old code with the reported "provider-catalog.json: top level is not a JSON object" and passes; a copy of the owner's ~/.hanako (sealed by 1.0.0-beta) syncs with no error, its catalog's bytes unchanged and the plugin's address and models current, Check then empty. go test -race ./internal/agent, vet (darwin, linux, windows) and gofmt pass.
2026-10-06 23:06:47 +08:00
yetone 7bdb0b34e8 ui-preview: the sandbox gets made-up data before it is recorded, so a change that only shows with data can be seen (owner: pr-preview 的时候有时候没有数据压根儿测不了,能否帮忙造一些假数据之后再测啊)
seed.mjs write fills the fresh home before magpie starts:
- a Sub2API relay (three keys, rotate, 5h/1d/7d windows, a month of balance history), Acme Cloud and a routing group with a rule;
- 30 days of requests from Claude Code, Codex, OpenCode and Gemini CLI over models priced on models.dev, at different speeds, with TTFT, cache reads, reasoning, sessions, accounts and a few 429/502s;
- 308 Claude Code and 94 Codex session files, more than a page, many never sent through magpie;
- two ChatGPT accounts signed in to Codex (Pro with credits, Plus near its limit) with five weeks of quota history;
- library skills from GitHub (one author with two repositories) and a folder, and some only in the agents' folders.

seed.mjs serve stays up as the mock: the fake providers in the Chat, Responses and Anthropic APIs, the relay's /v1/usage, and, as magpie's HTTPS_PROXY, chatgpt.com's usage and Codex models under a certificate from a CA that run.sh makes. Every other host is tunnelled as it is, so DeepSeek still answers for real. The warm-up also goes through the relay, Acme and the group, and record.mjs's guide says what the sandbox now has.

Checked by running run.sh in a Linux container:
- the Codex cards are read live, not stale, and their history draws;
- the relay's windows and balance trend show;
- every model is priced;
- sessions list 200 of 402;
- the warm-ups all pass.
Then record.mjs ran for real (DeepSeek planning, Playwright) on 6e53ee1f's diff (#1031). It planned and shot the skills page's author groups, with GitHub skills above and local below.
2026-10-06 22:49:36 +08:00
Zhizhen He dfc66030e8 provider: keep saved credentials when reading or backing them up fails (#1034)
Follow up #965: keepUnreadLogins still ignored read and backup failures,
so a new sign-in could replace credentials that had not been recovered.
Return those errors and stop writeLogins before replacing the original.
A missing logins.json can still be created, and successful backups keep
the existing behavior. Document the save failure contract.

TestUnreadLoginsWriteErrorsKeepCredentials fails on the latest base for
both faults with "original credentials overwritten" and passes with the
fix. The unread-logins tests also pass under -race -count=20.

On 588e51ea, the full nogui suite, go vet, and macOS/Linux(nogui)/Windows
builds pass under a temporary HOME with pinned Go caches. The full suite
uses a temporary, checksum-verified Bun for the CLI's plugin test.
2026-10-06 22:48:34 +08:00
yetone 5b9b9c9b05 gui: a call's JSON body in the Gateway page's recent calls folds, is coloured and copies a key or a value (owner: 这里应该支持 json 对象折叠以及代码高亮以及方便复制某个key/value)
Request and response bodies that are JSON, a stream's events and the reply put together are drawn as a tree: keys, strings, numbers and keywords in the code colours, an arrow before each object or array folds it to {…} with its count (lazily drawn on first unfold, folds kept across the list being redrawn), a copy button at the end of a line (on hover; faded rather than hidden so the pointer crossing to it keeps the line hovered) copies a string's text or an object's JSON, and a click on a key copies its name. The body's innerText stays the pretty-printed JSON (buttons are SVG only, 0 wide, nothing out of flow, which Chromium would break lines around). The {…} summary sits on the head's line, so a click on it holds that line, not the next one the unfolding pushes down. A cut or non-JSON body is as before.

json-tree.test.cjs passes in Chromium and WebKit, en/zh/ja/de, 1000px and 440px; sse-body, recent-calls-more, request-archive, request-archive-usage, gateway-caller-keys, routing-side-calls, gui-ja, gui-de, i18n-keys and click-scroll pass in both engines.
2026-10-06 22:41:31 +08:00
yetone dcbae2ef98 library, gui: the MCP servers page has the skills page's Select, Turn all on, Turn all off and Remove all, under one "In the library" heading (#1027, emo172)
The servers page had only By agent and the order, each with its own "In the library" heading, so with both the heading showed twice. Turning every server on or off, or removing them all, took one chip or one row at a time. The page now has one heading (lib-serverhead, which wraps when narrow) holding the order, Select, Turn all on, Turn all off, By agent and Remove all, as the skills page does. The skills page's helpers now serve both pages rather than being copied. picking names the page ("skills" or "servers"). pickingFor, pickRow, pickBar and groupChips(kind) are shared, and so are everyButtons, confirmEveryOff and everyOf, which replace everySkillButtons and everySkill. removeEveryButton, confirmRemoveEvery and removeEvery replace removeEverySkillButton. The skills page says and does what it did before.

- Turn all on and Turn all off post every agent shown to servers/agents-all (EveryServerAgents, now serversAgents(nil, …)). A hidden agent is left alone. Turn all on is greyed once every server is on for every agent that can reach it. Turn all off asks first, in the page.
- Select puts a box on each row. A row's click ticks it rather than opening the server. The bar's chips post the servers picked to the new servers/agents-some (SomeServersAgents), which gives an agent only the ones it can reach (mcpFile.supports) and leaves unnamed servers and agents as they are. An agent that can reach none of the picks has its chip greyed, with the reason.
- Remove all asks first. It says which agents lose the servers, the projects they are taken from, the sign-ins forgotten, and that the agents' own servers stay. It then posts every name to the new servers/remove-all (RemoveServers). This is one change that takes each server out as RemoveServer does: out of the library, its projects and its icon (removeServer, now shared by both), then out of every agent through syncMCP, which takes only what Applied says magpie wrote, and then forgets its sign-in. A name the library hasn't is listed in Unremoved, and the rest go all the same.
- The pick bar wraps, so its chips and Done fit at 440px in ja and de.

TestRemoveServers checks a server the user put in Claude Code's file and Codex's own "docs" of a library server's name: both stay, while fs, docs and neon leave the library, both agents and the project's .mcp.json, and the project's own "team" stays. neon's sign-in is gone, "nope" is reported, and an empty list or one with only "nope" errors. TestSomeServersAgents checks that Codex gets fs but not the SSE web, that git stays untouched, that a call naming no servers or no agents errors, and that a call naming an unknown server writes nothing. Both pass with -race -count=10, alongside TestEveryServerAgents and TestRemoveServer*.

library-servers-bulk.test.cjs passes on Chromium and WebKit in en, zh, ja and de at 440px. It checks one heading with every button, Turn all on's post without the hidden Copilot CLI, Turn all off's Cancel posting nothing and Confirm keeping Copilot CLI's, the pick bar's codex chip posting agents-some for the two picked and its greyed state for web alone, the All box, Done, and Remove all's lines, Cancel and post. It also checks for no sideways scroll and that each control clicked stays where it was on the screen. The fake check answers every server, since an unchecked one is checked again at each drawing. It fails without the change and passes 6 runs beside pick-skills, every-skill and gui-ja at --test-concurrency=4. by-agent, pick-skills, every-skill, remove-all-skills, group-*, library-*, mcp-signin, project-servers, skill-*, i18n-keys, gui-ja and gui-de pass. skill-how's WebKit case and click-scroll's "Live and a day" fail the same on the unchanged origin/main when run beside each other, and pass alone. go test -tags nogui ./internal/..., go vet, gofmt -l, and the linux (nogui) and windows builds pass. Not tried in the real app. docs/subsystems/library.md says it.
2026-10-06 22:24:04 +08:00
yetone 51d2cf88ea plugin host: a plugin's node:http2 connection goes through magpie's proxy like its fetch does (#1035, cataglyphis: Cursor opus-5-5 high ends after ~148s with "Cursor: 502 Bad Gateway" cursor_error) — the Cursor plugin runs agent.v1.AgentService/Run over node:http2, which Bun connects directly and ignores the proxy that host.js gives every fetch; the built-in sent the same request through s.client, so a user who needs magpie's proxy (setting, env or the system's) to reach Cursor had their sign-in, models and usage proxied but every Run went out directly. host.js now wraps http2.connect: for a URL proxyFor gives a proxy to (provider/account via, global, SOCKS bridged), it opens an HTTP CONNECT tunnel (Proxy-Authorization from the URL) and runs TLS with ALPN h2 over it; "direct", a caller's own createConnection and unproxied URLs keep Bun's connect; a tunnel that fails ends the session with "proxyconnect tcp: …". TestPluginHTTP2TakesTheProxy (an HTTP/2 vendor at an unresolvable host reached only through a CONNECT proxy; direct and a dead proxy fail with the reason) fails without the change ("h2: getaddrinfo ENOTFOUND h2vendor.invalid"; it carried 0) and passes -race -count=10. With the owner's Cursor account in a sandbox HOME and a logging proxy set, Claude Code's captured request on cursor/claude-opus-5-5 high: before, the proxy saw api2.cursor.sh only; after, it also sees agentn.global.api5.cursor.sh and the reply streams. The reporter's 502 wasn't reproduced here (direct runs answer in 4–20s). go test -race ./internal/plugin, the gateway/provider Plugin|Proxy|Cursor tests, gofmt and vet pass. 2026-10-06 22:16:24 +08:00
wizzy-yangandwizzy-yang a0cc4562e7 agent: a dsh patch list that lost its route to dsh's own save is written back quietly (#1029)
dsh reads a patch list live and keeps the model its sessions start on
across its own saves of the file (its desktop app writes the list anew
from a snapshot as it saves), but such a save has been seen to take
magpie's route out while keeping the start: the providers beside
magpie's stayed, the start still named magpie's, and the row went red
"no longer goes through magpie" until Apply again — which the next save
reddened again.

The start naming a model of magpie's is what says magpie was not taken
off: taking it off is the switch's doing and it takes the start with it,
and a list whose start is dsh's own or the user's stays as it is. So
dshRouteAgain writes the route back beside the user's own where the
start survives it, and dshCheck says nothing for the loss where a round
can repair it — said with no catalog to write from, it stands as before.

yetone's review of #1029: the home layer is only written into where it
already holds an llm-pi-ai entry of its own — its config replaces every
profile's whole entry (dshOver), so one magpie added there would take the
user's own providers out of dsh's list (#804) — and the loop over the
other profiles no longer skips itself with no catalog: only a profile the
round can write back is passed over, and one taken off magpie for real is
still said. A list still on the old wiring is dshMove's, not the round's
to write beside.

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-06 22:09:44 +08:00
yetone 33e4f165aa docs: how magpie picks an account — the steps a request's accounts go through, what Make first counts under each routing, Smart's and Weekly pace's real rules, rests after running out, gateway vs. on its own, and how often an allowance is read (#1032, Cyb3rN8)
docs/reference.md gets a section "How magpie picks an account" after Routing
groups; docs/subsystems/gateway-routing.md links it and notes the two
points users most often misread. Docs only; no behavior changes.

Every claim in the issue was checked against origin/main (38a0f470):
internal/gateway/routing.go (weighRouted, restAfterMarked, rateRest),
affinity.go (affine), fallback.go (perKeyBarred, plan, restLast),
gateway.go (serve), internal/provider/routing.go (Allowance.For, Renewal,
Pace, Allowances), codex_switch.go (NextLogin, KeepOnAnAccountWithRoom),
subscription_usage.go (claudeWindows), claude_limits.go, login_usage.go,
and app.js's Make first.

Claims in the issue that were wrong or incomplete:
- The order has more than four steps: Sink (when on) after the routing,
  the provider's fallback models after its accounts, then resting ones
  last over all of them, then affinity.
- A Claude account that has to be signed in again is not filtered out: it
  stays a candidate and is passed over at its turn, without resting.
- "Used to about 90% and it switches" holds only for new conversations:
  affinity keeps a conversation on an account until 98% (100% In order).
- Smart: Make first is not "useless once resets differ by an hour". Resets
  are compared window by window, biggest first, each truncated to the clock
  hour, so 13:55 and 14:05 differ while 14:05 and 14:55 tie; the order
  counts only when every window ties (or, at 90%+, shares are equal).
- Weekly pace: Make first counts only when the recent tokens are equal,
  not "similar" (a decayed float: in practice both zero).
- Saved Claude accounts' usage heard from rate_limit_event has no 1-hour
  validity: it is kept until replaced (a window past its reset reads as
  empty). claudeHeard's hour only lets such a reading stand over a failed
  /usage of the signed-in account.
- Claude's /usage also runs once after start and on `magpie accounts`, the
  TUI's Usage page and `magpie quota wait`, not only the GUI refresh.
- The direct-use switch also moves on at a usage cap, uses 100% under In
  order, goes back once the account it moved off has every window under
  90%, and never moves to a saved Claude account whose allowance isn't
  known yet.
- "Response header" in the rest order is magpie's own carrier of the
  refusal's reset time; Retry-After is held to an hour.

No UI hint: the natural spot (Make first's tooltip) would reword existing
strings in four languages; left for a separate change.
2026-10-06 21:52:41 +08:00
yetone 38a0f4709c provider tests: the fake claude auth login ends once its test's folder is gone, rather than polling for ever — a TestClaudeSignInByPaste run that ended without signing in left its fake looping every 50ms for 2 days (pid seen 10-06, ppid 1). Its loop waits for the callback's file or the pasted code, both in a TempDir the test removed, so neither ever came. Checked: the loop body exits 3 after its folder is removed; TestClaudeSignIn*, TestClaudeSavedAccountDir pass -race -count=5. 2026-10-06 21:46:29 +08:00
yetone 6e53ee1f1d library, gui: skills installed from GitHub are above and this computer's below, and one author's repositories are one group (#1031, mintonight: 同一个作者的skill没有自动分组,原本在local的一些技能自动跑到上面来了 … local和online上下分开,给local中的skill分组后也只显示在下方的local里面,恢复上方online的自动分组功能)
Two changes put local skills among the online ones. 6d198f7f (White Immortal on Discord) gave a skill not installed from GitHub the repository magpie traces for it (the skills CLI's lock, the git clone it is in, From) and made it a repository group, sorted by name among the groups of skills installed from GitHub; so op7418/Humanizer-zh and typesafe-ai/skills, local skills, sat between online groups. And 6ac01d28 (#791) put a group of the user's own above every group by source, so a group made of local skills was at the very top. The groups by source were always by repository (owner/repo, since 23847a8a), never by author: skills.sh, which the Library's search reads, lists many authors' skills a repository each (oil-oil/oil-ui, oil-oil/oil-ppt …), so one author's skills installed from it were a group each.

skillGroups now orders the page in two: above, the groups with a skill installed from GitHub (kind github, or CC Switch's from there) and the user's groups of such skills alone; below, the user's groups with a skill of this computer's in it, the repositories traced for local skills alone (still a group each, as 6d198f7f made them), and On this computer last. Above, an author with more than one repository is one group named by the author, opened at github.com/<author>, its parts each repository once it has more than 8 skills; a repository on another git host isn't merged. What the agents get is unchanged.

skill-online-local.test.cjs (two oil-oil repositories, mattpocock/skills, a traced local skill, a user group of local skills, one of a GitHub skill, a local skill, and an author of 10 skills in two repositories; Chromium and WebKit, en, zh, ja and de, 1100px and 440px, no sideways scroll) fails against the old library.js and passes. skill-repo-groups and skill-groups say the new order (the traced gitlab group and a group with a local skill are below GitHub's). every-skill, group-skill-chips, import-all-skills, library-all, library-own-folder, own-skill, pick-skills, remove-all-skills, shared-skills, skill-name-many-agents, skill-parts, i18n-keys, gui-ja and gui-de pass on Chromium and WebKit; skill-how fails now and then on WebKit (zh: the codex agent's how posted as "" for "link") the same at origin/main without this change. go test -tags nogui ./internal/... under a temp HOME, go vet, gofmt -l, linux (nogui) and windows builds pass. No Go change.
2026-10-06 21:30:13 +08:00
yetone ee2588c268 provider: a key that says it is out of its windows while they are being read has them read again once that reading is back (TestRestingKeyReadAgainInAnOrderedGroup red on macOS CI, runs 37467016755 and on main since 555f5706) — since 555f5706 the first planning of a lone key reads its windows behind the request. When that reading was still out as the key refused for quota, StaleKeyAllowance only zeroed the time it was read, and the reading, asked before the refusal, came back, set it to now and was trusted for a minute: the rest out of the key's windows wasn't lifted by the reading planned after it, though its limit had been raised. StaleKeyAllowance now marks a reading that is out (stale), and once it is back the key's windows are read again at once; forgetting every key's windows clears the mark. TestKeyAllowanceStaleMidRead (the first reading held until after StaleKeyAllowance, then answered; the second answered with the week half used) fails without the change (asked once) and passes -race -count=20 with the other TestKeyAllowance tests. The gateway test fails every time with its first reading delayed 300 ms ("still rests by window …, its windows read 2 times", as CI said) and passes with the change. go test -race ./internal/provider (8 of 9 runs pass; the first run's failure wasn't kept and didn't come back), go test -race -count=10 -run 'TestResting|Key|Lone|Allowance' ./internal/gateway, go vet, gofmt -l pass. 2026-10-06 21:29:55 +08:00
tsuixl 29f4909262 fonts: list upright styles before slanted faces at the same weight (#1030)
Follow up on the Menlo style ordering noted in PR #1007. Compare normal, italic and oblique explicitly after weight, retaining the existing width and name tie-breakers.

The Menlo regression fails before the change with Italic, Regular, Bold Italic, Bold, and passes with Regular, Italic, Bold, Bold Italic. Group/tie-break tests, fresh Windows native discovery, focused nogui GUI/font tests, go vet and Windows/Linux nogui builds pass.
2026-10-06 21:15:19 +08:00
yetone c8690ca0d2 gateway: a Claude subscription run knows the tools ToolSearch loaded before its agent is handed the results (TestToolSearchLoadKeepsTheRun failed on CI now and then; wizzy-yang's #1026 retried the assertion) — on the continue path run.continueWith answered the agent's MCP call before run.tools recorded the request's tools, so a turn that finished quickly could be shelved (shelve copies run.tools) or asked offers() without the tool ToolSearch had just loaded, and the next request started a new run; on a new run, run.tools was written without run.mu while the agent was already running. The tools are now recorded under run.mu before continueWith, and a new run's tools and search are set under one lock. A 50ms sleep after continueWith fails TestToolSearchLoadKeepsTheRun 5/5 on the old code with CI's message and passes with this; go test -race -count=100 -cpu 1,2,8 -run TestToolSearch and the gateway's subscription tests pass; gofmt, vet pass. 2026-10-06 21:11:25 +08:00
yetone a8d798e2cb gateway, provider tests: the tests stay off the network (testenv.Offline in their TestMains) — after 555f5706 (#1016) routing reads a plan key's quota windows, and a test whose provider has a vendor's real base URL and a made-up key would ask the vendor in the background. Routing's own read (keyAllowance, allowances) was already stubbed in routing_test.go's init, and a survey with a dial guard found no plan-key dial, but it found others: every plugin host's first start fetched models.dev's catalog (65 dials in the gateway's plugin tests), and provider tests asked api.github.com (TestSubscriptionUsageEachAccount, TestSubscriptionUsageServesStale), chatgpt.com (TestCopilotQuotaWithoutEditorsSignIn), server.codeium.com (TestDevinSignedInOnlyInMagpie) and decide.test (TestModelTest) with made-up accounts; each passed whether the vendor answered or not. internal/testenv's Offline wraps http.DefaultTransport's DialContext (so http.DefaultClient and every transport cloned from it later): localhost, *.localhost, loopback and unspecified addresses are dialled, anything else is refused before it is looked up, said once per address on stderr ("testenv: a test asked models.dev:443, refused: tests stay off the network"). internal/gateway's TestMain and internal/provider's (kiro_main_test.go, in RunIn's setup) call it before the tests run. TestLocal (the address table) and TestOffline (a loopback httptest server answers through DefaultClient; models.dev is refused through it and through a Clone made after the call) pass -race -count=3. go test -race -tags nogui -timeout 30m ./internal/testenv ./internal/provider ./internal/gateway passes under a temp HOME (gateway 516 s). The gateway and provider test binaries were then run under sandbox-exec with every non-loopback network-outbound denied, with the kernel's Sandbox reports streamed: both PASS, Offline refused only the six addresses above, and the kernel reported no denial, so no transport outside DefaultTransport reaches the network either. go vet, gofmt -l, linux and windows (nogui) builds pass. Tests only. 2026-10-06 21:08:57 +08:00
yetone f311a61859 gui tests: usage-speed.test.cjs's comment gives the numbers the fixture makes (75.0 tok/s for glm-fast against the hour's 69.7, not 80.8 against 75.2) — comment only 2026-10-06 21:08:57 +08:00
yetone 70e7e36675 gui tests: a press is made only on a target wholly in sight, so Playwright's click doesn't scroll it the last fraction of a pixel itself (request-archive-usage.test.cjs: "a click moved the page: 860 to 861" in Chromium once the Usage page's archive switch shows, which #1017 makes it do) — reader.inView took a target cut off by up to a pixel at the view's foot as in sight; Fetch from archive spanned 539.80–562.59 in a 562px view (the switch makes .led-head 4px taller, so the 20px wheel steps land the rows elsewhere), and Playwright's click scrollIntoViewIfNeeded'd it 1px before dispatching, before the app's capture-phase click listener took the positions hold() keeps, so the app saw nothing to put back. A raw page.mouse.click at the same point moves nothing; no page code scrolls or focuses there, and overflow-anchor: none changes nothing. inView now wants the target wholly in the view (or, taller than it, its top in sight), and the press test's web ±1 allowance ("settles a row's first opening up to a pixel off") is gone: a click moves the page by nothing. With #1017's diff applied, request-archive-usage.test.cjs failed in Chromium (both app modes) and passed in WebKit; with this it passes 5/5 in both, and 3/3 with the allowance removed. The 32 suites using reader.cjs, request-archive*, usage-*, click-scroll and ledger-* pass in Chromium and WebKit (275/275). No app change. 2026-10-06 21:08:57 +08:00
yetone aad799eae6 library: an RTK release winget or Homebrew doesn't have yet says "v0.51.0 is out · waiting for winget" with no Upgrade, until it does (#1025, Fermin214)
On Windows, RTK installed by winget was 0.50.0 and GitHub had 0.51.0, which winget didn't yet. Library → RTK showed "v0.51.0 可升级" with Upgrade; Upgrade ran winget upgrade, which rightly said "winget's RTK is 0.50.0 so far…", and the card went back to "可升级" with Upgrade. The card compared rtk's version only with GitHub's latest release, never with what the package manager that installed it has, and the upgrade's answer was forgotten as soon as the card was read again.

RTKView.CheckLatest now also asks, when the latest release is newer than this rtk and it was installed by winget or Homebrew, what that package manager has: winget show --id rtk-ai.rtk --exact (the version is the first one after the line naming the package, as winget's labels are in the system's language), and brew info --json=v2 rtk without auto-update, as the Agents page's CLI updates read Homebrew. An upgrade through either records the version it left as the newest that package manager has. The answer is kept six hours (a failure fifteen minutes) and is asked again after that. When it has nothing newer than this rtk, the view says waiting (winget or Homebrew) and waitingHas. The card then shows "v{v} is out · waiting for {via}" (zh "v{v} 已发布 · 等待 {via}", ja, de), with a tooltip saying what it has, and no Upgrade button. Once it has the release, Upgrade is back. A package manager that can't be asked, or doesn't say, leaves Upgrade offered as before. GET /api/library/rtk runs the whole check within its 3s budget. magpie library rtk prints the same. Cargo (built from rtk's repository) and rtk's own script (its GitHub releases) can't lag, so they are unchanged. The Agents page's CLI updates already compare with the installing channel's own latest (Homebrew's or npm's), so they don't have this bug. The Japanese "v{v} is out" is shortened to "v{v} 公開済み", because with Upgrade beside it the card head overflowed at 440px.

TestRTKWaitsForWinget (a fake winget whose show prints Chinese labels: unaskable leaves Upgrade; asked, it waits; the reporter's upgrade waits with the "so far" note, and so does the card read again; winget given 0.51.0 and asked again, Upgrade is back and works) and the extended TestRTKUpgradeBrew (Homebrew's 0.49.0 after brew upgrade waits, on the card read again too) fail without the change (waiting <nil>) and pass. TestRTKChannelVersion covers winget show in English, German and Chinese with spinner frames, no package, and brew info's JSON. -race -count=10 on them. library-rtk-upgrade.test.cjs's new test (Upgrade finds winget behind: the waiting tag, no button, the note; a reload still waits; winget has it: Upgrade back; card head and page fit at 440px) fails without the library.js change and passes on Chromium and WebKit in en, zh, ja and de. i18n-keys, gui-ja, gui-de and the library-rtk-* tests pass on both engines. go test -tags nogui ./internal/..., go vet, gofmt -l, darwin, linux (nogui) and windows builds pass. Not tried with a real winget or on Windows: winget show's output is taken from its documented format, and the version line is assumed to follow the package line in every language.
2026-10-06 20:56:52 +08:00
yetone dd027bc433 sessions: a Claude Code workflow's agents are read as their session's calls (ksinverse on X: 发现Claude的用量和会话不太准,codex就非常好。是不是sub-agents没有被统计进去?默认开ultracode) — Claude Code writes a workflow's (ultracode) agents a folder deeper than other subagents, <session>/subagents/workflows/<run>/agent-*.jsonl beside the run's journal.jsonl, and ccFiles read only <session>/subagents/*.jsonl, so with ultracode on every workflow agent's calls and tokens were missing from Usage, the Requests page and the session's totals. ccFiles now reads each run's agent-*.jsonl too (the journal isn't a transcript and stays out), and sessionOfPath gives them their session, so they count as the session's like any subagent's; Claude Desktop's and Qoder's readers, which share ccFiles, follow. TestCallsWorkflowAgents (a session, a workflow agent's file and a journal with a line that reads as a call) fails without the change (1 call, the agent's missing) and passes. On this machine's real ~/.claude (read only), 34 workflow-agent calls of one session were unread and are now that session's. go test ./internal/sessions, -race -count=10 on TestCalls*/Subagent*/Trace*, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried against the reporter's own transcripts. docs/reference.md says it. 2026-10-06 20:54:36 +08:00
yetone a334fe091c gateway: a request a proxy or tunnel on this computer forwards to loopback is answered as one from another machine, and needs the gateway key (#1022, znjhahaha: asked for a Cloudflare Tunnel in magpie, with the gateway key checked for requests through it) — local() was the request's address alone, so cloudflared, ngrok, Tailscale serve or funnel, frp's HTTP proxy, or a Caddy or nginx here, all reaching 127.0.0.1:3425 for clients anywhere, were taken for this computer's agents: whoever had the tunnel's address used the user's subscriptions with any token, read /v1/magpie/quotas (the accounts and their balances) and the remote cards, and their usage was counted as local, all without sharing on. reference.md said a proxy here had to sign its clients in itself; a quick tunnel signs in nobody. A request on loopback carrying a forwarding header (Forwarded, X-Forwarded-For, X-Real-IP, Cf-Connecting-IP, True-Client-IP, Tailscale-User-Login), which no agent sends, is now proxied, and local() is false for it, so every check built on local() takes it as from the network: lanGuard refuses it while magpie isn't shared, saying it came through a proxy or tunnel and that Settings → Share on local network and a gateway key let it in; shared, it needs an enabled gateway key and is then sharedWith, as a LAN client is, for the model APIs, quotas, remote cards, concurrency, session route and MCP sign-ins; the Omarchy theme and the Codex title link stay this machine's only, and its usage is no longer marked local. A gateway MAGPIE_ADDR opens to the network stays open to a tunnel as to anyone; one MAGPIE_ADDR keeps on loopback is closed to it. MAGPIE_TRUST_PROXY=1 says a proxy here signs its clients in itself and keeps what it forwards local, as before (listed in agentenv_test's notAnAgent). A tunnel forwarding bare TCP (frp tcp, ssh -R, socat) adds no header and can't be told from an agent: reference.md says to point one at the LAN address with sharing on. Running cloudflared from magpie with a pasted Tunnel Token is not done here: magpie would have to download or run a program it doesn't ship; reference.md says how to serve magpie through a Cloudflare Tunnel today. TestProxiedLoopbackNeedsTheKey (from 127.0.0.1 with cloudflared's, ngrok's, nginx's, RFC 7239's, Akamai's and Tailscale serve's headers: refused unshared, 401 shared without a key, with magpie's token or a wrong key, let in with the key under its identity, a disabled key refused; an agent on loopback let in with any token; MAGPIE_TRUST_PROXY; MAGPIE_ADDR on loopback and open) and TestProxiedLoopbackRoutes (quotas, quotas history, models, Omarchy and /mcp through a tunnel, unshared and shared, with and without the key) fail on main (200 for every tunnel unshared) and pass -race -count=10 with TestLANGuard, TestQuotasOverLAN, TestManagedLANGuard and TestExplicitOpenGatewayAcceptsAllRemoteTokens; go test -race -tags nogui ./internal/gateway (579 s, under -timeout 30m) and go test -tags nogui ./internal/... under a temp HOME pass; go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. No GUI change. Not tried with a real cloudflared (not installed here); its Cf-Connecting-IP and X-Forwarded-For are what Cloudflare documents it sends. 2026-10-06 20:35:18 +08:00
yetone 555f570660 gateway, provider: the Routing page says what a lone key or account has left, and a key bought as a plan (Kimi Code, GLM Coding Plan, MiniMax, OpenCode Go, Command Code) is weighed by its plan's windows (#1016, gphysign: Kimi Code (China) with one key on, Smart routing; the card said only "Kimi Code (China) 只启用了一个 Key,无需选择。" while /v1/magpie/quotas had its 5 hours 49 used, 51 left and its 7 days 45 used) — two gaps. weighRouted returned before reading anyone's allowance when there was one candidate, so a lone key or a lone subscription account was never known in the trace, and the Routing page, which shows a bar and "{n} used · renews in {d}" for a known row, had nothing to show; every single-account subscription said "what's left not known yet" the same way. And KeyAllowance read only a sub2api key's limits, so a key whose plan the Usage page reads (planQuotaSourceOf: Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code) was never known to routing, with one key or several. weighRouted now reads what each candidate has left before returning a lone one (one alone is still weighed against nobody). readsKeyWindows takes a plan key too; keyWindows reads it through planKeyWindows (planWindows, with Zhipu's team quota behind a key that has none of its own, as PlanQuotas does), and PlanQuotas' own read of a card is noted for routing at once (noteKeyAllowance), so the Usage page and routing never ask twice; the card kept on disk seeds it after a restart (keyCardTag keys a plan card as plan:, a sub2api one as balance:). A key at such a vendor that turns out to be pay-as-you-go, with no windows, is asked again every 30 minutes (keyNoWindowsAge), not every minute it is used. So, beyond the issue: several plan keys on one provider are now weighed by their windows under Smart, usage and pace, and a plan key the vendor refuses as out of quota rests until its window renews and comes back once read short of full, as a sub2api key does. affine no longer says a lone key or account all but used up "moved" its conversation: there is nobody to move to (at > 0). The GUI needed no change. TestLoneOneTellsWhatItHasLeft (a lone Kimi key known at 49% with both renews, the week first; a lone Codex account known; a plain DeepSeek key not), TestLoneSpentKeyKeepsItsConversation (fails with the at > 0 guard taken out), TestPlanKeyAllowance (the windows read behind the request, asked once; DeepSeek and Moonshot keys never asked), TestPlanKeyAllowanceFromItsCard (PlanQuotas' read known at once without a second ask; after a restart with the vendor down, the card on disk stands in and a failed read keeps it) and TestPayAsYouGoKeyAskedSeldom (a GLM pay-as-you-go key asked once, not two minutes on, again 31 minutes on) all fail without the change and pass -race -count=20 (provider) / -count=10 (gateway). routing-key-windows.test.cjs gains a lone Kimi Code key at 1100px and 440px (the one-key story, the row's bar at 49%, its provider row "49% used · renews in …" fitting, no sideways scroll) and passes in full on Chromium and WebKit in en, zh, ja and de; routing-quota-left and panel-routing pass. go test -tags nogui ./internal/..., go test -race on ./internal/provider and ./internal/gateway in full, go vet, gofmt -l, darwin, linux and windows (nogui) builds pass. Not tried with a real Kimi Code key. StepFun's Step Plan is read through a sign-in, not the key, so it isn't one of these. docs/subsystems/gateway-routing.md, providers-accounts.md and docs/reference.md say it. 2026-10-06 20:25:04 +08:00
wizzy-yangandwizzy-yang 3bcf71b003 gui: the speed chart's marks that leave their column get a leader line (#1018)
The Speed trend's column for a day is that day's own speed — every reply's
tokens over every reply's decode time — while each model's mark is drawn at
its own speed. A model faster than the day it is in leaves its mark above
the column, in nothing, reading as a stray dash (huoranxuanyuan, #860): with
five models the fastest is nearly always above the day's own, so this is the
chart's usual look, not an edge case.

Each mark that leaves its column now gets a dotted leader line down to the
column's top, so it reads as that column's; the line stops at the top when
the mark is within a pixel of it, or it would be drawn back down into the
column. The column itself is drawn in the track's colour rather than the
grey the stacked charts use for "Other", so it reads as the track the marks
sit on. The colour goes through style, not the presentation attribute:
var() substitutes in a style and not in an attribute.

box.emphasize() strokes a line and fills a rect: the marks' leader lines are
lines, so dimming a day or emphasizing a model sets their stroke where it
sets the marks' fill, and the two read the same.

usage-speed.test.cjs's hour(0) fixture now has the day's own speed under
glm-fast's, so the case asserts the leader line — that it is drawn, that it
runs down the mark's middle from the column's top to the mark's foot, that
it is dotted, that the column is the track's colour, and that the colour
resolves (it goes through style). It fails on origin/main, where the mark is
drawn with no line at all.

Verified: node --check; go vet; go build (windows); the new assertions
failing on origin/main's app.js and passing on this one; usage-speed 6/6 and
usage-ledger-chart 18/18 in Chromium and WebKit, en and zh; 560px with
nothing past the view and no sideways scroll.

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-06 20:23:11 +08:00
XYenon 0fb669175b agent: fx is found by its folder, and omp isn't found by Pi's (an fx on PATH is often antonmedv/fx, the JSON viewer; PI_CODING_AGENT_DIR pointed omp at Pi's folder, so a machine with only Pi showed omp as installed) (#1021)
fx no longer has a command to look for: `fx` is a common name, and WSL already went by its folder alone. It is detected when ~/.fx is a folder.

omp reads PI_CODING_AGENT_DIR as Pi does, so its folder may be Pi's. ompDir now says when the folder was moved that way (shared), and such a folder alone is no sign of omp: its config.yml, its command, or its own ~/.omp/agent is. It keeps its Bin, so CLIMissing still offers it again.

TestFxDetected (an fx command alone, a file named .fx, a .fx folder) and TestOmpDetectedWithPiDir (Pi's folder, the omp command, config.yml, ~/.omp/agent) fail without the change and pass. go test ./internal/agent, go vet, gofmt -l pass.
2026-10-06 20:14:37 +08:00
yetone 655cdd0abd provider: the hook a landed usage refresh tells is held atomically and told before the refresh is done (#1023) — OnSubscriptionUsage was a plain package variable that the background refresh SubscriptionUsage starts read after closing its pending channel, unsynchronised, while the tray sets it as it starts (watchTrayUsage) and a test swapped it: go test -race ./internal/provider failed in TestSubscriptionUsageServesStale when TestSubscriptionUsageReadingWhileStale's refresh, waited for by its pending, was still reading the hook after it (subscription_usage.go:185 against subscription_usage_test.go:84). OnSubscriptionUsage is now a setter over an atomic.Pointer, so setting it while a refresh is under way is safe in the app too, and the refresh tells it before closing pending, so whoever waits for a refresh has it finished, hook and all, and may change what it reads. The tray's hook only signals a buffered channel and never waits, so telling it first holds no one; the test's hook no longer blocks either. With -race -count=20 the two tests failed on main with the DATA RACE and pass now; go test -race -tags nogui ./internal/provider passes in full; go vet, gofmt -l, darwin, linux and windows (nogui) and windows builds pass. 2026-10-06 19:59:29 +08:00
TryWorld fde0c38c56 fix(gateway): a refusal that arrives once the reply began rests nobody (#1015)
A member whose reply had begun and whose vendor's safety filter then
refused the turn — Chat's code content_filter, Anthropic's stop_reason
refusal, Responses' bio_policy — was booked as a vendor failure
(Fail other) and rested with a backoff, so later requests skipped a
healthy account. The same refusal, said before any of the reply was, is
booked failRefused and rests nobody, and the request is handed to the
next member (#248). Only the timing differed: a refusal is about the
request's content, not about the account (5353ed54 rested every
WorkBuddy AI account over one system prompt).

The refusal was read only while the stream was still held for its first
content (`scan`): once that content has been said the reply goes through
as it comes, and an event after it is never classified. The holdWriter
now reads the events of a reply that has begun for a refusal in one
(`refusalAfter`), with a word filter before the JSON is read, so an
ordinary stream pays one substring test a write, and the mid-reply
branch books such a refusal as failRefused without resting it.

What the agent gets does not change: the reply and the vendor's own
error event go to it as they came, nobody else is asked (its 200 has
been sent), and the conversation is still booked as unanswered. An
error of another kind — an unreachable vendor, an overloaded one —
still rests as it did (#733), as does a reply too long.

TestRefusalMidReplyRestsNobody is new. On main, with a group of two
Chat members whose first answers half and is then refused:

    after content:  try0: status=200 fail=other rest=other
    before content: try0: status=400 fail=refused rest=<nil>

With this change the after-content case reads fail=refused,
rest=<nil>, as the before-content one does, and the test fails on main
and passes. TestRefusalAfterContentPassesThrough passes unchanged.

go test -race -count=5 -run TestRefusal, go test ./internal/middleware,
go vet (windows, darwin), the darwin, linux and windows builds, and
gofmt -l on the three files pass. One test in internal/gateway fails on
this machine either way — TestSweepBridgeProjectsTakesOnlyThe-
BridgesFolders needs SeCreateSymbolicLink — so it is skipped by name
rather than taken for a baseline. Not tried with a real vendor's
safety filter.
2026-10-06 19:54:53 +08:00
yetone e6dd5d20bb usage, gateway, gui: a reply that reasoned is timed by its answer, from its first text, not by all its output from its first token (tony on Discord #general: Usage › Requests read "gpt-6.1-sol 163 token/秒 · 8,036 个请求 · 首字 14 秒" and asked whether subagents were added up) — speed was every output token over the time after the first content (text, reasoning or a tool call). Output counts the reasoning, and a model whose reasoning is hidden (OpenAI's encrypted reasoning, a summary sent when it is done, Claude's omitted thinking) writes it before the stream shows anything, inside the TTFT; so a 2,000-token think and a 200-token answer over the 1.5 s after the first token read 2,200 in 1.5 s, 1,467 tok/s. Concurrency was not the cause: the aggregate is Σ tokens over Σ per-request windows, so subagents running at once never add up. In the owner's own usage.jsonl codex/gpt-6-luna turns read up to 600 tok/s (out 496, reasoning 224, 11,055 ms, ttft 10,228) and its aggregate 133; it is 80 now. usage.DecodeOf is the one rule: a reply with reasoning counts out − reasoning over ms − firstText, one that reasoned and wrote only tool calls tells no speed, one without reasoning is timed from its TTFT as before; DecodeWindow's burst cut (#731) still applies. Record.Decode feeds summarize, the ledger's Totals and its timeline Parts; a route keeps its reply's reasoning (Route.Reasoning, from gateway.go and codex_backend.go), and routing.js's decodeOf/speedOf times its rows, the story and a session's average by it (an older route takes its served usage's reasoning); app.js's ledDecode does the Usage page's rows, whose tooltip says the reasoning left out, and the Output speed help says the rule, in en, zh, ja and de. History is read the same way, as it keeps the reasoning and the first text. Routing never ranks by speed. TestReasoningReplyTimedByItsAnswer (the 2200/2000 turn, a summary 10 ms before the text, a concurrent subagent, tool calls only, reasoning over output, no reasoning, not streamed: 1,200 tokens in 11 s through summarize, addRow, LedgerSeries and Decode) fails without the change and passes -race -count=10; TestUsageKeepsTimeToFirstToken now has its paced replies report reasoning and checks the route keeps it. reasoning-speed.test.cjs (Routing rows 133, 100, none and 100, the story never 1,467, the session 105; the Usage rows, tooltip and KPI the same; no sideways scroll) passes on Chromium and WebKit in en, zh, ja and de, and fails against the old assets (1467 tok/s); routing-request-metrics, routing-burst-speed, usage-speed, usage-ledger-detail, i18n-keys, gui-ja, gui-de, ledger-hscroll and click-scroll pass on both. go test -race -tags nogui ./internal/... under a temp HOME passes but internal/proc's TestUserPath ("the login shell's PATH never came"), which failed under the full run's load, passes 3/3 alone and is not touched here; go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with tony's data. docs/reference.md says how output speed is counted. 2026-10-06 19:47:56 +08:00
yetone 76321e3b42 gateway: a lone provider's Claude or GPT reasoning streams as it comes, not held for a refusal no one else could answer (iTianbao on X: 请加一个开关,让思考实时透传; through a relay Claude Code showed the thinking only all at once with the text, Codex none until then) — holdWriter held Claude's, GPT's and Gemini's reasoning (refusesAfterThinking, up to holdThinking) on every candidate, so a refusal after it could go to the next one (#248), but a refusal on the last candidate is never asked again (the !last checks in serve), so there it only delayed the thinking. On the last candidate Claude's and GPT's reasoning is now shown at once (thinkingShown), and a refusal after it reaches the agent as the vendor's own stop; Gemini's stays held there, for a reply that only reasoned to be asked again (#667, TestGeminiEmptyRepliesAskedAgain). A group with members after it still holds as before. Before: on a slow fake relay (4 thoughts 300ms apart, then text) every relay shape (Messages, Chat with reasoning_content and reasoning, Responses) for claude-sonnet-4-5 and gpt-5.1 gave Claude Code's thinking_delta and Codex's reasoning_summary_text.delta all at ~1.2–1.5s with the text; after, 0/300/600/900ms. TestThinkingStreamsWithNoOneElse (Messages, Chat for Claude and GPT, Responses; each asked from /v1/messages and /v1/responses) fails without the change in every case and passes; TestHeldThinkingKeepsTheAgentAlive's nobody-left case now expects the thinking and Claude's refusal rather than a held error. -race -count=5 on TestThinking*, TestRefusal*, TestHeld*, TestGeminiEmpty*; go test -tags nogui ./internal/gateway, go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay: whether it sends GPT's reasoning at all (OpenAI's Chat API doesn't) is unknown, and no switch was added. docs/subsystems/gateway-routing.md says it. 2026-10-06 19:34:24 +08:00
tsuixl bd33e575f1 gui: let users choose installed interface and code fonts (#1007)
* gui: choose installed interface and code fonts and styles

Discover installed styles through DirectWrite, CoreText and Fontconfig. Keep choices per computer, apply them before paint and notify both desktop webviews after saving; defaults retain platform and Omarchy fallbacks.

Verified seven focused Go tests, including real Windows discovery (90 families, 276 styles), 14 font browser cases in Chromium/WebKit, 68 settings/zoom/Omarchy/web cases, go vet, Windows amd64/arm64 and Linux nogui builds. Against the original assets, the new UI test fails with Settings must offer an interface font picker (0 != 1). Full Windows suites have separately reproduced upstream failures; macOS/Linux native discovery is covered by the added CI step.

* gui: retain bundled Mac fonts and variable style precision

Only filter an undownloaded CoreText face when it is explicitly downloadable; bundled fonts are installed without being downloaded. Use resolved font names when descriptors omit them. Preserve fractional weights and nonnegative CSS widths for named variable styles.

* gui: restore the authored font cascade when returning to defaults

Apply family-only trait overrides only while a custom face is active. The browser regression catches code under a bold parent becoming 700 instead of its original 400. Keep the compatibility shim first, and apply boot fonts before visible content.

* gui: keep font picker review separate from CI integration

Leave the native test sources and manual verification command with the feature. Move the added workflow step to a follow-up so the font picker can take the normal review path requested on PR #1007.
2026-10-06 19:32:56 +08:00
yetone 490d2ea362 gui: the tray panel's Allowances tab hides subscriptions and moves them, from an Arrange at its foot (H20 on Discord: "菜单栏的详情页能不能选择某些不显示,有点额度并不想看到或者可以调整顺序,目前的还不行") — the panel's tab already followed the Usage page's order (usageOrder, byUsageOrder), but it could be set only by dragging a card's logo on the window's Usage page, which nothing in the panel said, and there was no way to leave a subscription out of the panel at all. The tab's foot now has Arrange, and "{n} hidden" beside it when some are: Arrange lists a row a subscription (balances too) in the shared order, its logo the handle (a drag, or Alt+arrows; the grip by it on hover only, as on the Usage page) and a Hide/Show pill, under a note that hiding is the panel's only and the order is the Usage page's too; Done draws the cards again. What's hidden is kept in magpie's settings (settings.PanelUsageHidden, panelUsageHidden), not the browser: routing, caps, the Usage page and the menu bar's cells still have it, and a menu bar cell for a hidden subscription still opens its card (panelPeek) until the panel is put away or the tab left. POST /api/usage/arrange takes order and panelHidden and changes only the one it is sent (both were whole-order before; an order alone still works), and the Settings save keeps panelUsageHidden as it does usageOrder. Saves draw at once and are put back with the error if they fail; no click moves the page (the view's scroll is kept across the redraw); no native select, no left-border stripe. Which allowances the menu bar shows beside its icon stays Settings' pick (trayUsages). New t() strings have zh, ja and de. TestUsageArrangePanelHidden (each field set and cleared on its own, a Settings save between) passes -race -count=20, TestSettingsSaveKeepsWhatItDoesNotSend's fixture names the field. panel-arrange.test.cjs (440px: the hidden card left out and counted, Arrange keeping the reader's scroll, the rows and note, the hover-only grip, no stripe, no sideways overflow, Show/Hide posting panelHidden without a scroll and a failed save put back, Alt+arrow and a drag posting the order, Done's cards in it, a menu bar cell opening a hidden card until the panel hides, the Usage page keeping the hidden card) passes on Chromium and WebKit in en, zh, ja and de; click-scroll, gui-ja, gui-de, i18n-keys, panel-fold, panel-usage, quota-*, routing-quota-left, tray-cell-click, tray-inuse, tray-logos, tray-usages, usage-accounts(-fold), usage-arrange, usage-fold, usage-quota-head, usage-card-refresh, usage-stale-answer, usage-remote-cards, api-key-usage, claude-usage-asked and usage-checkin pass on both. go test -tags nogui ./internal/... under a temp HOME, go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried in the real tray panel on a Mac. docs/reference.md and docs/subsystems/gui-shell.md say it. 2026-10-06 19:18:14 +08:00
yetone 588e51ea4c agent, gui: an agent connected by its config to an address where nothing answers is told, not shown connected (#1013, cjyrainbow: Codex Desktop's WSL environment reads Windows' ~/.codex/config.toml, whose openai_base_url and [model_providers.magpie].base_url the reporter pointed at Windows as WSL sees it, forwarded to 127.0.0.1:3425 by a portproxy of their own; after a reboot the portproxy listened no more and the Agents page still said Codex was connected) — Drift looked only at the config, which was right. Now an agent pointed at an address off loopback (this machine's Codex at an address the user kept, #816, through Agent.reach; every WSL twin under NAT, at Windows as the distro sees it, through Agent.Gateway) has that address tried from here (reach.go: GET /v1/models, any HTTP status an answer, 2 s timeout, in the background, at most every 20 s per address, never waited for), and one that doesn't answer while the gateway answers on loopback is a new drift, "unreachable", with Addr and the advice in Detail: under WSL (or on Windows) magpie with Settings › Share on local network on, or the user's netsh interface portproxy, which may need setting up again after a restart; Windows' firewall allowing the port for the WSL network; on Windows 11, networkingMode=mirrored; elsewhere, the forward to start again or gateway.URL() to point at. An answer not had yet, and a gateway down on loopback too, are no drift. The config is never changed for it, except where the address is one a WSL distro reached Windows at before (distro.Was, the last 4 kept in wsl.json as a re-probe finds Gateway changed) and the distro reaches Windows at another now: Drift's Move names that one, and Reapply moves the config there (Agent.move; Codex's codexMoveGateway rewrites openai_base_url and magpie's own provider's base_url, only where they begin with the old address). An address no distro was seen at is the user's own and is never offered a move. In the GUI the row's line says "{agent} is set up, but nothing answers at the address it reaches magpie by" in red with the advice as its tooltip, and its pill is "How to fix" (an i, not the reapply arrow), which opens the advice in a dialog (too long for the status line; it wraps and can be copied) and sets nothing — or "Use <address>" (the existing "Use {url}" string) when there is a Move, which reapplies; two new t() strings with zh, ja and de. The probe runs from Windows, not from inside WSL, so a firewall keeping WSL out isn't seen; the advice names it. TestKeptAddressNotAnswering (the reporter's case: Codex at 172.28.96.1, unreachable with the address and loopback in the detail; no drift once it answers, none with the gateway down too), TestKeptAddressProbeNeverWaitedFor, TestKeptAddressMovedWithWSL (Move from a Was address, Reapply rewrites both URLs, Check empty and no drift after; a 10.0.0.7 address is told but never moved) and TestWSLAgentAddressNotAnswering (Codex in a NAT distro told, with Share on local network, portproxy and firewall; a mirrored one never probed) fail without the Drift change and pass; TestLoopbackNotProbed, TestReachProbe (httptest: a 401 is an answer, a closed port none) and TestWSLWas pass. TestMain stubs reachProbe so no test dials out. -race -count=10 on them; go test -tags nogui ./internal/... under a temp HOME, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. agent-unreachable.test.cjs (the window at 560px and the tray panel at 440px: the red line and tooltip, How to fix's dialog posting nothing, Use <address> posting the reapply, no sideways scroll) fails on main and passes on Chromium and WebKit in en, zh, ja and de; agent-connect, agent-layout, agent-field-names, aside-native, gui-de, gui-ja and i18n-keys pass on both. Not tried on a real WSL NAT setup (the Windows test box didn't answer ssh). docs/subsystems/agent-wiring.md says it. 2026-10-06 19:17:55 +08:00
yetone c5a54bc74b agent: VSCodium's magpie group is written for the provider its installed Copilot Chat declares, OpenAI Compatible (customoai) on the Marketplace's 0.48.1 (#1014, xybio: VSCodium's Chat picker showed only Auto) — VSCodium has no Copilot Chat of its own, and the last Marketplace Copilot Chat it can install, 0.48.1, declares no Custom Endpoint provider (its contributes.languageModelChatProviders: copilot, copilotcli, claude-code, anthropic, xai, gemini, openrouter, openai, ollama, customoai, azure), so the customendpoint group magpie wrote in chatLanguageModels.json was never read. magpie now reads the package.json of the github.copilot-chat installed under ~/.vscode-oss/extensions (vscodeChatOf: the one extensions.json lists, else the highest github.copilot-chat-<version> folder not in .obsolete) and writes customendpoint when it is declared, customoai when only that is: each model's window as maxInputTokens beside maxOutputTokens (0.48.1 adds them up), and the token as x-api-key, since 0.48.1 drops an Authorization in requestHeaders and its apiKey has to be a ${input:} secret; the gateway already takes a key from x-api-key, past an empty Authorization, so Usage still counts VSCodium. No extension found, or a package.json that can't be read, keeps customendpoint as before; one declaring neither is said in Notice and Check. Writing the group removes magpie's group for the other provider in the default's and each profile's file, and Disconnect removes both; the user's own groups, under either provider, stay. Sync (at magpie's start and on catalog changes) rewrites a group written for the other provider, and Check reports it until then. VS Code and Insiders, whose chat is built in, keep customendpoint unchanged. The Notice says 0.48.1 registers its providers only while signed in to GitHub with a personal Copilot plan (its BYOK registration is gated on the Copilot token's individual/BYOK flag). TestVSCodiumCopilotChatOpenAICompatible (the real 0.48.1 provider list installed in a temp ~/.vscode-oss: Connect writes customoai with x-api-key magpie-vscodium and no Authorization or contextWindow, the user's groups kept, Check empty; a Custom Endpoint group an older magpie wrote is flagged and rewritten by Sync; a Copilot Chat declaring customendpoint switches it back; one declaring neither is reported; Disconnect removes both) and TestVSCodeChatInstalledWithoutExtensionsJSON (folder scan, .obsolete, unreadable package.json) fail on main, which writes customendpoint, and pass; -race -count=10 on the VS Code tests, go test -tags nogui ./internal/..., go vet, gofmt -l, linux and windows (nogui) builds pass. No GUI change. Not tried with a real VSCodium. docs/subsystems/agent-wiring.md and docs/reference.md say it. 2026-10-06 19:09:26 +08:00
forever b0a96cbbd7 gateway, provider, gui: a Sub2API key given 5h/1d/7d limits is weighed and rested by its own windows, like an account (#1004)
* gateway: a Sub2API key out of its 5h/1d/7d limit ("…限额已用完") rests as out of quota, not a 1-minute rate limit

Sub2API refuses a key that has spent the 5-hour, day or 7-day limit its
owner gave it with 429 "api key 7天限额已用完" (or 日限额, 5小时限额),
typed rate_limit_exceeded. It matched rateWords and not plannedWords
("限额" is not "额度"), so failure() said rate: the key rested a minute,
was tried again, refused again, and sank, for the days its 7-day window
had left. plannedWords now has 限额已用完, so it is failQuota.

The relay's other refusals keep their classes, fixed by the test: the
pool out of accounts (v0.1.149's 503 "No available accounts": other;
later versions' 429 "All available accounts are currently
rate-limited": rate), its upstream's rate limit (rate), the key's own
total quota API_KEY_QUOTA_EXHAUSTED (quota) and the wallet's 403
INSUFFICIENT_BALANCE (credit).

Verified: TestSub2APIKeyLimitIsQuota, with Sub2API v0.1.149's message
bodies. Without the change it fails: "429 {…api key 7天限额已用完…}:
rate, want quota", and the same for 日限额 and 5小时限额.

* provider: a Sub2API key's /v1/usage shows its 5 hours / 1 day / 7 days windows instead of nothing at "remaining"

A Sub2API key its owner gave a 5-hour, day or 7-day spending limit
answers /v1/usage in quota_limited mode with rate_limits — each
window's limit, used and reset_at in dollars — and, without a total
quota of its own, no "remaining". With the Balance URL set to it, the
key's card said `nothing at "remaining" in the reply`.

readSub2APIKeyLimits reads rate_limits into the windows the cards
already name 5 hours, 1 day and 7 days, in USD. A window not started
(used 0, no reset_at) is taken to renew a span from now; a window with
a limit of 0 is none. The card shows them, beside a balance field the
user wrote; with none, Check balance and `magpie provider show` give
what the fullest window has left. A wallet key's reply is unchanged.

KeyAllowance gives routing the same windows, per provider and key: read
behind the request, never waited for, at most once a minute (once for
any number of asks at a time); a failed read keeps the last; after a
restart it is seeded from the card's last reading in quotas.json;
StaleKeyAllowance has it read again at the next ask. ForgetBalances (a
key or Balance URL changed) has every key read again, a read begun
before dropped, the last reading standing until then. Only keys whose
Balance URL is a Sub2API /v1/usage are asked: any other key, and every
account, costs nothing here.

Verified with real bytes of a limited key's reply, its key removed
(testdata/sub2api_usage_key_limits*.json): TestReadSub2APIKeyLimits,
TestSub2APIKeyLimitsOnItsCard, TestKeyAllowance*. Each fails without
its part, e.g. the card without the windows: `Error:nothing at
"remaining" in the reply`; KeyAllowance waiting for the read: "waited
30.0s"; asking every time: "asked 11 times"; a failed read asked again
at once: "asked 22 times after failing"; not seeded: "seeded = [],
false"; any key asked: "asked /api/usage/token"; ForgetBalances
dropping what was read: "after ForgetBalances, and a read begun before
it: []"; leaving a read under way loading: "the key's windows were
never read"; a stale-generation read landing: it replaced 95%; a limit
of 0 taken: "a day's limit of 0 = [{… Used:100 …}]"; Balance from the
first window only: "$300.00" for $80 left.

* gateway, gui: keys whose windows magpie reads are weighed and rested by them, like accounts

Smart, least-used and pace routing weighed only subscriptions by their
windows: weighRouted skipped every key (c.p.Account == nil), so a
Sub2API key beside Codex accounts in a group went by its place in the
order, and, refused for its 7-day limit, rested a quarter of an hour at
a time while the window had days to run.

A key whose own windows magpie reads (KeyAllowance: a Sub2API key given
a 5h/1d/7d limit, its Balance URL /v1/usage) is now weighed as an
account is: fine, low (90%) or spent (98%), the one renewing soonest
first, in a provider and in a group alike, and its trace row carries
them. Out of quota it rests till the window it filled renews, by
candidate.full; when its windows as last read aren't full it rests
quotaRest, and either way they are read again (staleKeyAllowance).
Every other failure of a key backs off as before, never to the window:
the relay out of accounts for everyone (503/429) says nothing of the
key's own spend. capHeld and pooled stay accounts' only. Keys magpie
reads no windows of are ordered exactly as before.

The Routing page tells such a key as it does an account: its row's bar,
used share and renewal, why it went first by its allowance, "Every key
and account here is at 90% or more", and the Smart header. A key not
read yet beside one read is told as weighed with it, not "keys go in
their order" or by its tokens. New strings in en, zh, ja and de.

Other users of the relay draining its accounts don't show in a key's
windows, which count only its spend: they show as the pool's 503/429,
which back off and fall over to the next key, account or fallback.

Verified: TestKeysWeighedByTheirWindows, TestGroupWeighsKeyWindows,
TestKeyOutOfItsWindowRestsUntilReset, TestKeyPoolErrorDoesNotRestForWindow;
and routing-key-windows.test.cjs in Chromium and WebKit (en/zh/ja/de).
Without the change they fail: "smart: kw#a kw#b kw#c"; "the key renewing
first: codex sub2api"; "full: rests 14m59.99s by quota"; "read again:
[]"; backing off to the window: "pool empty: rests 71h59m59.99s by
window (other)"; and on the page "relay-a goes first: keys go in their
order, those that suit the request first." where all are at 90% or
more, or a key not read yet beside one read.

* gateway, provider: a Sub2API key out of its limit is back once its windows read short of full, not only when the window renews

A key refused for its 5h/1d/7d limit rests till the window it filled
renews: days, for the week. Its windows are read again behind the next
requests (a minute apart) and by its card, but nothing lifted the rest
when they came back short of full, so the owner raising the key's limit
in the relay's panel, or resetting its usage, left the key out till the
week turned, though the relay would take it at once. Accounts have this
already: a Codex reset spent tells OnRenewed, and the gateway lifts the
account's rests (renewed).

A key's reading, routing's or its card's, now tells OnRenewed the same
way, as agent "" and the key's KeyAllowanceID, when a window it was full
in (Allowance.Full at SpentShareOf its routing, as candidate.full counts
it) is full no more, or full till sooner. Its first reading, a failed
read, or one that finds it fuller tells nothing. The gateway notes the
key's KeyAllowanceID on its rest out of quota only, so renewed lifts
that rest and no other: the relay out of accounts for everyone (503)
still backs off, and another key still out of its week stays out.

Where nothing weighs the key by its windows, nothing read them again
either: an ordered group weighs each member's keys alone, so a member
with one key, like a provider with one key, never asks for them. After
the one read its 429 starts, the key sat out its week on that reading.
restingOf, which plans a resting key behind the rest, now asks for the
windows of a key resting out of them (agent "" and a user on its rest),
so the next request planned past a minute reads them, behind it, and a
reading short of full brings the key back there too. No card is read
for it, and a key resting for any other reason asks nothing.

Verified: TestKeyAllowanceRenewed (provider) and
TestKeyBackOnceItsWindowIsNotFull (gateway, end to end through the key
cards' reads and restAfter), each failing with a piece of the change
undone: never telling, "its week's limit raised to $1,000: told []" and
"a, its limit raised: still rests by window till …"; telling on any
change, "its first reading, out of its week: told [/sub2api#…]"; a
failed read telling, "a read that failed: told [… …]"; the card not
telling, "its usage reset, read on its card: told [/sub2api#…]"; the
rest not noted, "a, its limit raised: still rests by window"; every
rest of a key noted, "c, the relay out of accounts: …, false"; renewed
not matching the key, "b, still out of its week: …, false". And
TestRestingKeyReadAgainInAnOrderedGroup (gateway: an ordered group, its
first member one key on a fake /v1/usage, no card read after the 429),
failing without restingOf's ask: "its limit raised: still rests by
window till …, its windows read 1 times".

Tried on a real Sub2API v0.1.149 relay, in a sandbox HOME, its key's 7d
limit lowered under its spend for the test and then restored, with
magpie serve on a group ordered: the key, then a local stand-in. The
key answered 429 "api key 7天限额已用完"; it rested by window till its
7d reset, 2026-10-13 00:00 +08:00, and the stand-in served. Planned
again a minute later, still over, it still rested. With the limit back
at $800, the next request was planned with it resting, the stand-in
serving, and read its windows; the one after went to the key, 200.
2026-10-06 18:57:47 +08:00
yetone 87487c5ef0 plugin, provider: a plugin can press its vendor's daily check-in itself (auth.checkin), and magpie runs it as its own check-ins (Lemon on Discord #feedback: provider 每日签到的方法能放到插件里面去做吗; ARNO earlier asked for Qoder's) — the check-ins magpie had (WorkBuddy, Trae CN, MiniMax Code, Qoder) are each written in magpie against the vendor's endpoint, even for a subscription that moved to its plugin, so a plugin author had no way to give their provider one. A plugin's auth hook may now have checkin(getAuth, provider) beside usage: the host lists the provider with checkin: true (plugin.Provider.Checkin) and answers checkin({provider, account}) (plugin.AccountCheckin), refreshing the account as usage does, with {outcome, credit, streak, message}; outcome is claimed, done, ineligible, inactive, captcha or failed, and a throw or an outcome outside these is failed with the reason. internal/provider/plugin_checkin.go runs these through checkin.go's checkiner, as the built-ins are: each signed-in account in use once a Beijing day (first look 2 minutes after start, then every 30), a failure asked again after wbCheckinRetry, every other outcome settled for the day, results kept in plugin-checkin.json by provider|account; a failure is recorded as failed and resets nothing. captcha is new (CheckinCaptcha): the vendor wants a captcha, which magpie never solves, so it is said as "check in in its own app", not as a failure, and not asked again that day. The results mark the account's Usage card (checkinBy "plugin:<provider>", WithCheckins), so the card's check-in row, its Auto check-in and Check in now work for it (POST /api/settings/plugin-checkin {provider, on}, POST /api/usage/plugin-checkin {provider}); Settings → Usage gains a Plugins tab, shown while such a plugin is signed in, a Daily check-in row per provider with Off/On and how each account went; magpie accounts checkin and c on the TUI's Usage page include them (the TUI's "no account" line names check-in plugins). The switch (settings.PluginCheckins, by provider) is off by default; for WorkBuddy, Trae CN, MiniMax Code and Qoder it follows that vendor's existing switch until set on its own, and magpie's own check-in through the plugin's fetch leaves a provider whose plugin checks in to it (pluginChecksIn), so no account is checked in twice. The Settings page's save carries PluginCheckins over (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). No plugin declares the hook yet; Qoder's check-in stays magpie's own here. TestPluginCheckinRunsAsTheBuiltinsDo (the fake plugin with FAKE_CHECKIN, five accounts answering claimed, done, captcha, a throw and an unknown outcome: off presses nothing; on, each outcome kept with its vendor; a minute on nothing pressed again; past the retry only the two failures; the next day all five; PluginCheckins and the cards marked; another provider's press asks none) and TestPluginCheckinTakesOverTheVendors (a qoder plugin without the hook leaves magpie's Qoder check-in its account, with it none; PluginCheckinOn's fallbacks) pass -race -count=10, and the latter fails with pluginChecksIn stubbed false; TestTUIChecksPluginsIn passes -race -count=20. plugin-checkin.test.cjs (the card's row with the captcha said, Auto check-in posting {provider, on}, no page move; the Settings Plugins tab at 440px with the row fitting, no sideways scroll, On posting) passes on Chromium and WebKit in en, zh, ja and de; usage-checkin, i18n-keys, gui-ja, gui-de and settings-* pass. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with a real plugin or account: no published plugin has the hook yet. docs/subsystems/provider-plugins.md (the hook's contract) and docs/reference.md say it. 2026-10-06 18:40:45 +08:00
yetone b56bfb5a94 gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it. 2026-10-06 18:26:31 +08:00
yetone 82c8735a94 library: a float in a Codex server's entry is written back as a float, and inf and dates as TOML has them (Lullaby on Discord: an internal MCP server that worked configured in Codex looped with its tool arguments lost after it was taken into the Library; they suspected tool_args / strict) — every key Codex reads for a server (codex-rs/config/src/mcp_types.rs RawMcpServerConfig: command, args, env, env_vars with { name, source } tables, cwd, url, bearer_token(_env_var), http_headers, env_http_headers, http_headers_helper, environment_id, auth, oauth.*, scopes, oauth_resource, startup_timeout_sec/_ms, tool_timeout_sec, enabled, required, startup_readiness, supports_parallel_tool_calls, tool_input_schema_max_bytes, omit_tools_from, default_tools_approval_mode, enabled/disabled_tools, tools.<tool>.approval_mode/output_token_limit, name) was checked through ImportServer and a SaveServer that adds an argument and gives the server to Claude Code too: all of them come back with their values, args with JSON and spaces in them verbatim, tables written inline. The one change was tomlValue writing a float with fmt.Sprint, so tool_timeout_sec = 120.0 came back the integer 120 (Codex reads either, as serde's f64 takes an integer, so this is not the reporter's loop), and +Inf, which isn't TOML, for inf; a date or time the user put in an entry went back as a string. tomlFloat now keeps a float one (".0" where it has no point or exponent; inf, -inf, nan), and go-toml's LocalDate/LocalTime/LocalDateTime and time.Time are written as TOML's own. Grok Build's entries go through the same writer. TestCodexServerKeysSurviveTheLibrary (the fixture above, compared value and type for every key) fails without the change (tool_timeout_sec float64 → int64) and passes; TestTOMLValueKeepsTypes (floats, an exponent, inf, a local date, time and date-time, an offset date-time) fails without it (+Inf doesn't parse) and passes. -race -count=10 on them with TestServerEveryFormat, TestServerKeepsUsersKeys and TestPutCodex*; go test -tags nogui ./internal/library ./internal/edit, go vet, gofmt -l, linux and windows (nogui) builds pass. The Library's MCP sign-in proxy (gateway /mcp/{name}) relays the body untouched. Not tried with the reporter's server; what broke their tool calls is not found here. 2026-10-06 18:22:41 +08:00
yetone 38cc30b09a tray: with the menu bar's magpie icon off, a click opens the panel on the tab left last, not Allowances (the owner: 每次都会自动跳转到 allowances,我记得之前是可以的)
#662 (ebcd5281, v0.1.749) made a click on one of the menu bar's allowance cards open the panel on Allowances at that card, and left a click on the bird as it was: the panel opens on the tab it was left on. 9aa7a3dc then let the menu bar show the cards without the bird, and the cards became the whole item: every click landed on one, so the panel opened on Allowances every time and there was nowhere left to click that kept the tab. Without the bird, mpClickCell now leaves the click to the item's own action, as a click on the bird; with the bird, a card still opens its allowance. The tab is remembered as before (magpie.panelTab); nothing of it changed.

TestTrayImageClickWithoutBird (each cell of the logo and plain layouts, laid out with no bird: not taken for its card, no callback) fails without the change ("cell 0 taken for its card with no bird") and passes; TestTrayImageClickIdentity keeps the bird's case. Tried in the real menu bar with a sandbox magpie (trayNoBird, the owner's trayUsages): origin/main opened on Allowances after Routing was picked; with the change, Usage picked, the panel closed and reopened twice by clicking the cards, opened on Usage both times. go test ./internal/gui, -race -count=5 on the tray click tests, go vet (darwin, windows), linux (nogui) and windows builds pass.
2026-10-06 18:03:57 +08:00
yetone a87dc1ff2b gateway: a WebSocket upgrade on /v1/responses is answered 426 "magpie relays Responses over HTTP only", not 404 (#1005, Clarence12138) — Pi's Responses WebSocket extension opens GET /v1/responses with Upgrade: websocket, and only POST was routed there, so the catch-all's 404 ("magpie serves …") read as a broken gateway and the extension retried five times a session before falling back to SSE. GET /v1/responses and /responses now tell an upgrade 426 with the reason and the way that works (POST, streamed as SSE) — the answer a client falls back to HTTP on at once, as the Codex backend path already gives (TestCodexWebSocketUpgradeRequired) — and a plain GET 405 with Allow: POST. Relaying Responses over a WebSocket itself is not done here (a long-lived connection whose model is named per response.create, outside the per-request routing, failover, key limits and usage the gateway has). TestResponsesWebSocketUpgradeRequired fails without the change (404) and passes; go test ./internal/gateway, go vet (darwin, windows), gofmt -l and the linux build pass. Not tried with Pi's extension itself. docs/reference.md and gateway-routing.md say it. 2026-10-06 17:55:40 +08:00
yetone 6ba82d2f07 gateway: reasoning magpie made isn't relayed to Azure OpenAI or OpenAI's API, and a summary request refused for an item not found is asked again as text (#1008, zhouxiong100: Codex on an Azure OpenAI deployment failed every compaction with "Azure OpenAI: Item with id 'rs_18dbe693b3dce4800000003c' not found", Reconnecting 5/5, store=true by a middleware no help) — Codex offers web search on every turn, so its turns on Azure went out translated, and the translated replies gave Codex reasoning items magpie made: rs_ and newID (the reporter's id is one, a UnixNano in hex and a counter), a summary and nothing sealed. Codex hands them back on every turn. buildResponses replays no reasoning to Azure, so the turns worked; Codex's compaction offers no tools, so it isn't translated but relayed as it is, and Azure, with store false, looked rs_… up among stored items, found none and answered 400 for the whole request. codexCompact asked again as text only on a 404 (#866), so the 400 went back to Codex. passthrough now drops reasoning with no encrypted_content from a Responses request to OpenAI's API or Azure OpenAI unless store is true (withoutBareReasoning): both look such an item up and can't have it, its summary being the model's notes no model reads back; their own sealed reasoning still goes, and every other host gets the request as before (a relay may read the summary for a vendor that wants reasoning back). The same holds for any turn relayed as it is, Codex's with web search off for one. codexCompact's fallback also takes a 400 naming an item not found (itemNotFound: unreadableItem and "not found"), as a relay in front of either passes on; any other 400 still goes back as it came, and the local summary names the status it got. TestAzureCompactionWithoutMagpiesReasoning (the Azure preset at a fake /openai/v1/responses answering a storeless request with a bare reasoning item Azure's 400 word for word; the compaction and a relayed turn, each asking Azure once with the sealed reasoning kept and magpie's dropped) fails without the passthrough change (the turn 400 with the reporter's exact message, the compaction asking twice) and passes; TestCodexCompactItemNotFoundAsksAgainAsText fails without the codexCompact change (400 to Codex) and passes; TestCodexCompactOther400Stays guards other 400s. -race -count=10 on them, TestCodexCompact* and TestAzureRoutes. go test -tags nogui ./internal/gateway ./internal/provider, go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with a real Azure OpenAI resource (none on this machine); the 400's text is from the reporter's screenshot and issue. docs/subsystems/gateway-routing.md says it. 2026-10-06 17:53:18 +08:00
yetone b95ce8c6f3 provider: ZCode's mover asks for @magpie-community/opencode-zcode-auth 0.1.11, which names a gift plan ZCode holds but hasn't granted on the usage card ("1 to claim · claim it in the ZCode app") — #1001 (MiFaZhan): a day's ZCode Trust Build is not on the account until the ZCode app claims it, and an account signed in through magpie alone let it lapse. The plugin (magpie-community/plugins#30) reads ZCode's own GET /api/v1/zcode-plan/billing/preview with the card and adds one window set aside, so routing, caps and the menu bar pass it over; it never claims (the claim needs the Aliyun captcha only the app's renderer makes). A failed or empty preview adds nothing and an error card asks nothing. keepMovedCurrent updates every moved ZCode user to it. go test ./internal/provider and gofmt pass; the plugin's bun test (72) and check.mjs pass, 4 of its new tests fail on 0.1.10. Not tried with a real ZCode account here. 2026-10-06 17:50:39 +08:00
yetone 5361a2178e agent, gateway: Cursor Private Inference's row has an effort, which magpie's gateway asks its requests for (#1003, gjf7) — other agents' rows pick a reasoning effort that magpie writes into their config, but Cursor Private Inference reads only its base URL and key from the environment, and its app offers no Reasoning control for most of magpie's models, so there was no way to set one short of a routing group with a member fixed at a level, which the reporter used as a workaround. Its row now has an effort field like the others': the default (as Cursor asks) first, then every level the models it lists have (cursorLocalEfforts: their levels in MemberEfforts order, low/medium/high where a model reasons with levels unknown, none at all when nothing it lists reasons or it isn't connected). The pick is kept in magpie, settings' agentEfforts by agent (provider.AgentEffort / SetAgentEffort, a level of MemberEfforts or "" for none), and the gateway's candidate loop asks a cursor-local request for it with withFixedEffort, fitted to the model's levels and bounded by a group's, on a model that reasons (the new Provider.Thinks: levels, its list's Reasoning, or models.dev's). Precedence: a group member's fixed effort, then a :effort suffix, then a routing rule's turn pick, then the agent's pick, then the request's own; the pick wins over a level Cursor sent, as Cursor sends its own default as often as a choice, and Default hands the choice back to Cursor. A model that doesn't reason and every other agent's requests go as they came. The Settings page's save carries agentEfforts over as it does fastPicks (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). In the window the field sits under "Settings" rather than "New sessions" for this agent alone, as it applies to every request from then on, not to what a session starts on; no new t() strings (Settings, effort and default are translated in zh, ja and de already). TestAgentEffortOnCursorLocal (Chat and Responses with and without a level of their own, Claude on Messages made to think, OpenCode's request untouched, a non-reasoning model untouched, a member fixed at low kept, the pick taken off) fails without the gateway change and passes; TestCursorLocalEffort covers the options (none with no reasoning model, the union of levels, the low/medium/high fallback, none while disconnected), Apply and a bad level refused; both pass -race (-count=20 gateway, -count=10 agent). cursor-local-effort.test.cjs (the window at 560px and the tray panel at 440px: the field under Settings, the slider's five stops with Default first, a pick posting the field, no sideways scroll) passes on Chromium and WebKit in en, zh, ja and de; claude-effort, subagent-effort, agent-field-names, agent-connect, agent-layout, agent-tweak-fold, agent-models, cursor-local-launch and i18n pass on both. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with the real Cursor Private Inference: whether it sends a reasoning level of its own on models it shows a Reasoning control for is unverified, which the precedence makes moot. docs/subsystems/gateway-routing.md and agent-wiring.md say it. 2026-10-06 17:29:28 +08:00
yetone ec6df1de57 gateway: a request offering web search is translated on the client's own API where the provider serves it, not on Chat (#997, wlj521: Codex on Xiaomi MiMo's pay as you go showed "Responses → Chat" on v0.1.1082 with all three URLs filled) — Codex offers its web_search tool on every turn, and a provider that doesn't search by itself can't be relayed it: magpie searches for it, which takes a translated request. That translation went to usable's first API, Chat for every model but OpenAI's and Claude's, so on any provider serving Responses (MiMo, whose api.xiaomimimo.com/v1/responses answers 401 to a made-up key, a wrong path 404) each Codex turn went out as Chat, and Claude Code's WebSearch on a provider with an Anthropic URL the same. The translation now stays on the client's API when the provider serves it there and neither ResponsesFirst nor MessagesFirst prefers another (firstElsewhere); the OnMessages check moved before the search check so a Claude model still goes to Messages. The search tool itself isn't sent to the provider (forwardTranslated drops it where the provider doesn't search), and magpie's own web_search function is given on Responses as it was on Chat. TestSearchOfferedStaysOnTheClientsAPI (the preset's three URLs; Codex's Responses with a function and web_search, Claude Code's Messages with web_search_20250305, Chat's web_search_options) and TestCodexSearchAnsweredOnResponses (a searcher configured: the model is asked on /v1/responses twice, the search runs once, Codex gets the answer) fail without the change (/v1/chat/completions) and pass; -race -count=5 on them and TestWebSearch*, TestClaudeFromChat*, TestAutoRelays*. go test ./internal/gateway ./internal/provider, go vet, gofmt -l, linux/windows (nogui) builds pass. Not tried with a real MiMo key (none on this machine) or a real Codex. docs/subsystems/gateway-routing.md says it. 2026-10-06 17:16:50 +08:00