2515 Commits
Author SHA1 Message Date
yetone 7ea1cb6743 claude: a client that goes away while the run answers its tool results takes the turn back, not the whole conversation to a new Claude Code (#1365)
What changed: ended() kept a turn's id (turnUUID, backKey) only until the
reply that called a tool. From then on, a client that went away while the
run answered the turn's tool results (a cut connection, or the user
interrupting to send a message queued during the tool call) could not be
rewound: letGo ended the run. The client's next request, its results sent
again or the next turn, logged "tool results no run waiting", and a new
Claude Code was told the whole conversation in one message, with every
image in it, which the model in #1365 took for new. Now the turn's id lasts
across its tool rounds, until a reply ends without calling a tool. letGo
rewinds the run to the turn's user message, its tool rounds with it, and
the run waits at the conversation before the turn. The next request is
resumed there (nextTurn's key is backKey) and told the turn since its user
message. resume logs "a turn taken back goes on in its run" after the
"no run waiting" line. A run started anew for the turn, or one with a call
still in the client's hands, is ended as before. Nothing else sent to
Anthropic changes.

Why: the issue's log showed "no run waiting" four times, then a whole-
conversation replay. On current main a queued message with an image is
sent beside the tool_result (checked with Claude Code 2.1.295 against a
local mock) and match keeps it with the result, so it does not break
matching by itself. The MCP call reaches magpie ~3ms after the tool
reply's message_stop, well inside match's 500ms grace. What drops the run
is a client going away during a tool-result continuation. The exact client
trigger in the report is not confirmed.

What was run:
- New TestClaudeToolResultsGivenUpOnAreTakenBack (fake Claude Code that
  makes its call through magpie's MCP callback and answers
  rewind_conversation): the turn's results are given up on mid-reply, the
  run is rewound to the turn's message, and the resend goes on in the same
  process, told the turn and not turn 1's words. With ended() clearing the
  id as before, it fails: no rewind is asked.
- Real Claude Code 2.1.295 against a local mock of Anthropic:
  rewind_conversation to a user message, sent while the reply to that
  turn's tool result is streaming, answers rewound:true, and the next
  request's messages drop the turn and its tool round.
- go test -tags nogui ./internal/gateway/ (full package) under a temp HOME;
  -race -count=20 on the new test and the TestClaudeTurnGivenUpOn* /
  TestClaudeFirstTurnGivenUpOnEnds tests; gofmt -l; go vet for darwin,
  linux and windows (-tags nogui); GOOS=linux/windows go build.
Not tried with a real Claude account or Claude Desktop.
2026-10-09 11:05:33 +08:00
yetone 9f5812f3cf agents: an update says each agent's version from and to (37FlowAI on X: "更新后 显示一次各 Agent 的版本变更列表: 例如 Pi 从 v1.0x 更新到 v1.10")
Update all's line, when the run is done, and its toast now list every
agent it updated with the versions it went from and to ("2 agents
updated: Codex 0.155.1 → 0.159.0, Crush 0.3.0 → 0.4.0"); with some
failed, the ones that went well are listed before the ones that didn't.
The line stays one line (cut short when narrow, so the list under it
doesn't move), and its tooltip has each change and error on its own
line. A row's own pill says "Codex updated: 0.155.1 → 0.159.0".
Three new strings, in zh, zh-TW, ja and de.

Run: agent-update-all and cli-update in Chromium and WebKit (14 pass in
each); the new checks fail on origin/main's app.js (5 fail). A new
case updates two agents in en, zh, ja and de, and checks the line at
420 wide keeps its height with no sideways scroll. gui-de, gui-ja,
i18n-keys, cli-behind, click-scroll and agent-cli-missing pass in both
engines (22 each). Not tried in the real window with real CLIs behind.
2026-10-09 11:01:11 +08:00
yetone 1734d2b33d update: magpie web in a Docker container says to pull the new image instead of only linking the release page, and magpie update in one says the same instead of failing on the write (Chapin on Discord: 网页端的更新按钮…跳转到github)
The image runs /magpie as nonroot (uid 65532) in a read-only distroless root with no pkexec, so the updater's placeExe found the binary's folder unwritable and no way to elevate: stuck "not-writable", and the page said to run `sudo magpie update` or reinstall from the release page, neither of which works in a container (no sudo, and a replaced binary is lost when the container is recreated). The Download button there opens the release page, which is what Chapin saw if they run the image. Now, when the folder can't be written, nothing can elevate, and gateway.InContainer() (the existing .dockerenv / run/.containerenv / cgroup check, now exported) says this is a container, the stuck reason is "container" and the page says: pull ghcr.io/yetone/magpie:latest and recreate the container (new string in en, zh, zh-TW, ja, de). `magpie update` in such a container fails up front with the same advice, instead of downloading and then failing to replace itself. A writable binary in a container (a user's own image) still updates itself as before.

Tested: TestUpdateInContainerSaysPullTheImage in internal/gui (unwritable folder in a container: stuck "container", no StuckDir; outside one: not-writable with the dir; writable folder in a container: the exe is taken) and in package main (in a container the error carries the docker pull advice; outside one it doesn't). Both fail with the container check taken out (gui: Stuck not-writable; main: "the release lists no checksum"). update-stuck.test.cjs now has the container case in en/zh/zh-TW/ja/de; with the app.js line removed it fails 11 of 20; with it, 20/20 in Chromium and WebKit. i18n-keys, gui-ja, gui-de, gui-zh-tw, click-scroll and every update-* GUI test pass in both engines. Real run: the image built from this tree (VERSION=0.1.10), `magpie web` in it with MAGPIE_UPDATE_FEED at a host feed listing 0.1.11: /api/update answered state available, stuck "container"; `docker exec … /magpie update` printed the pull-the-image message and exited 1, the binary stayed 0.1.10. gofmt clean; go vet on ., ./internal/gui/, ./internal/gateway/ for darwin, GOOS=linux and GOOS=windows -tags nogui; full go test -tags nogui ./... under a temp HOME. The page's look in a real browser against the container was not looked at (the GUI test renders the same pill).
2026-10-09 10:43:14 +08:00
yetone d14b2a2957 update: magpie web from the Mac's terminal build downloads and puts in the update itself, as magpie update does, instead of sending the page to the release page (Chapin on Discord: 网页端的更新按钮…跳转到github)
The page's updater took the binary to replace only off the Mac (runtime.GOOS != "darwin"); on a Mac it replaced the .app and nothing else. The terminal build (magpie-cli-darwin-*, nogui, no .app to be in) is what `magpie web` runs on a Mac without the app, so its updater was always "available" with no stuck reason, and the pill and Settings' Download opened github.com/yetone/magpie-releases/releases/tag/vX, with nothing saying why, while `magpie update` in the same terminal replaced the binary fine (ReplaceBinary). updater.place now takes the binary on a Mac too when this build has no GUI (update.GUI false), where BinaryAsset() is magpie-cli-darwin-<arch>, the same file `magpie update` downloads. The desktop app's binary copied out of its .app (GUI true) is still not replaced by the terminal build; the .app is still what the app replaces; off the Mac nothing changes. Which install Chapin has isn't known yet (asked); a Docker container, the other way to land there, is a separate change.

The page side was already right: in magpie web the page opens the release link itself (webHost.OpenURL is a no-op, app.js window.open), so the server's own browser is never opened. The feed's url is always a full .../tag/vX (usemagpie.ai/api/latest checked: .../tag/v0.1.1131), and the page opens it only when set, so a link ending in tag/ is not something magpie makes.

Tested: TestUpdateTakesTheMacTerminalBuild (bare binary on darwin with no GUI is taken and a newer version is not "available"; the app's binary out of its .app isn't; the .app is; linux either way) fails on the old condition with `the Mac's terminal build: exe "", bundle "", stuck ""`. Real run on this Mac: a nogui build at 0.1.10 as `magpie web` under a sandbox HOME, MAGPIE_UPDATE_FEED at a local feed listing a 0.1.11 nogui build with its sha256: /api/update said ready (downloaded by itself), POST /api/update/install answered 204, the process re-exec'd and /api/update said current 0.1.11, the binary on disk is 0.1.11. The same with origin/main's code: state "available", the install call downloads nothing. The osascript password path for a binary in a folder the user can't write to was not tried.
2026-10-09 10:43:14 +08:00
yetone 3e9bbdaae9 gateway: a paused Codex sign-in isn't spent by native compaction either (JasonLeeForOnly on Discord: a ChatGPT account turned off in magpie still had its quota used)
The account Codex is signed in to can be paused in magpie while another
of its accounts is on (#263: the user's own ChatGPT account kept for
Codex's remote control and Computer Use, another doing the work). Turns
on Codex's own models already passed over it (codexAccounts, routing and
OwnPaused), but /responses/compact for one of those models still relayed
as it came, on the paused account's own token, and on history the
others had sealed.

compactOn now picks, for that case only, the first of the other accounts
on that isn't resting, and codexUpstreamOn relays the compaction signed
as that account (its token and chatgpt-account-id, through its proxy),
keeping the Accept Codex sent. A gateway key held to some accounts (#905)
is asked of the account it goes to, not the paused sign-in. Not paused,
nothing else on, an API-key Codex, or a request naming an account: the
relay is as before.

Not changed: a Codex provider switched off (Off) still relays Codex's own
model requests (the Codex app's gpt-5.6-luna/terra side generations,
memories, codex-auto-review, native compaction) on the sign-in as they
came; that is a design question, reported separately.

Tested: TestCodexPausedOwnAccountNotCompactedOn (in use: on the sign-in;
paused: acct-2 with its own token; key held to the paused sign-in: 403
with nothing asked; held to the spare: acct-2; the spare off: the sign-in
again) fails on origin/main at the paused step (tried [acct-1]) and
passes. gofmt; go vet -tags nogui ./internal/gateway on darwin, linux,
windows; go test -tags nogui ./internal/gateway ./internal/provider
./internal/agent under a temp HOME pass. Not tried with real accounts.
2026-10-09 10:38:40 +08:00
yetone 4b21fd1a19 provider, gui: a MiniMax Token Plan's video windows say their count, "1 / 5 个视频", as MiniMax's own CLI does (#1366, IamMiao)
MiniMax's `mmx quota show` shows the video bucket as counts, 4 / 5 left of
the day and 34 / 35 of the week, while magpie showed only a share (used
20%, 3%), and named the windows "Video · 24 hours" in English whatever
the language.

readMiniMaxPlan now also reads current_interval_usage_count and
current_weekly_usage_count, the fields mmx-cli 1.0.27 types and shows
(dist/index.d.ts QuotaModelRemain; dist/mmx.mjs). It reads them as the
CLI does: usage_count has held what remains as well as what is used, so
it is taken as whichever the remaining percentage agrees with within a
point, and as no count when neither does or the total is 0 (the general
bucket, which keeps its share alone, as in the CLI). The window carries
the count as Amount of Limit, in "videos" for the video bucket, the way
WorkBuddy's credits are carried, so the Usage page says it before the
share, used or left as the reader picks: "1 / 5 个视频 · 已用 20%" or
"4 / 5 个视频 · 剩余 80%". A used-up window (status 2) counts all of it
used. The video windows aren't routing limits (Aside), so routing is
unchanged. "videos", "Video · 24 hours" and "Video · 7 days" are in zh,
zh-TW, ja and de.

Verified:
- TestReadMiniMaxPlanVideoCounts (new), built from the reporter's
  numbers with mmx-cli's field names: on origin/main's planquota.go it
  fails (video windows have Amount 0, Limit 0, Unit ""); passes now.
  TestReadMiniMaxPlan and the other planquota tests pass.
- internal/gui/tests/minimax-video-count.test.cjs (new): Chromium and
  WebKit, 420 and 1100 wide, zh/en/zh-TW/ja/de, used and left: names and
  counts as above, the general windows share-only, nothing cut, no
  sideways scroll. On origin/main's i18n.js it fails in both engines
  (names untranslated).
- i18n-keys, gui-zh-tw, gui-ja, gui-de, api-key-usage, quota-* and
  usage-* GUI tests: 165/165 in Chromium and 165/165 in WebKit.
- gofmt -l (nothing), go vet ./internal/provider, GOOS=linux and
  GOOS=windows go vet -tags nogui ./internal/provider, go build,
  GOOS=linux go build -tags nogui, GOOS=windows go build, go test -tags
  nogui ./... under a temp HOME: all pass.

Not verified: no real MiniMax Token Plan key here, so the reply was not
read live; which way usage_count runs is taken from mmx-cli's own
handling, and both ways are tested.
2026-10-09 10:37:00 +08:00
yetone bf4d55acdd gateway: a Codex task sealed by a custom Responses provider still goes back to it after magpie forgets who answered the lead — a day later, after a restart, or after the lead's reply broke off — and the refusal says how to go on (#1367)
Autsunset (#1367): Codex with the lead and its subagents all on
xxxx/gpt-6-astra, a relay of ChatGPT Pro behind new-api. After quota
and overload errors, continuing the task was refused before xxxx was
asked: "This subagent's task was sealed by the ChatGPT backend that
answered its lead ... xxxx/gpt-6-astra is neither."

The cause: who sealed a task (#1109, #1237) was read only from the
affinity stick of the lead's conversation, and that record is lost in
three ways: it is kept 24 hours; a restart reads back only the latest
512 conversations from affinity.json; and a reply that breaks off after
its 200 (response.failed server_is_overloaded) deletes it (unanswered,
#733). Once lost, every later turn carrying a sealed message was
refused before anyone was asked, so nothing could answer the lead
again to remember it: the task stayed stuck.

When the sticks don't make the asked provider a reader, the usage log's
answers to the lead's thread and the request's own conversation in the
last 30 days now count too (loggedAnswers). A refused or failed call,
a title and Codex's own sign-in (Provider "openai" on /backend-api/codex,
still told of as the ChatGPT backend) count for nothing. The sibling
leadFirst, which puts the lead's ChatGPT account first in a group, reads
the lead's key or account from the log the same way. The refusal names
who answered the lead when the log knows, and both messages now say how
to go on: use the lead's model, or have the lead spawn the subagent again.

Verified:
- TestSealedTaskStillGoesToTheLeadsProviderAfterItsReplyBrokeOff and
  TestSealedTaskStillGoesToTheLeadsProviderADayLater (fake Responses
  relay over httptest, Fernet-shaped gAAAAAB... payloads, lead and worker
  on xxxx/gpt-6-astra) fail on origin/main with the reporter's exact
  400 and pass with the change; the sealed payload reaches xxxx as it
  came.
- TestSealedTaskLogDoesNotOpenItToAnotherProvider: a lead answered by
  another provider names it; a lead never answered (refused twice) and
  an answer older than 30 days stay refused. With the 30-day bound
  removed it fails.
- TestSealedTaskGoesToTheLeadsLoggedAccountFirst: with the leadFirst
  fallback removed the group's other account (acct-1) is tried first.
- TestSealedTaskFromChatGPTLeadStillRefused fails if Codex's own
  sign-in is not left out of the log's answerers.
- TestSealedReplyRefusedWhereTheLeadWasNotAnswered now expects the lead
  moved to another provider to be named "(third)".
- go test -tags nogui ./internal/gateway passes (sandbox HOME, pinned
  caches); sealed tests pass under -race -count=5; gofmt -l clean;
  go vet -tags nogui ./internal/gateway for darwin, linux and windows.

Not verified: real Codex with a real CPA/new-api relay of ChatGPT Pro.
If such a relay sends the subagent's request to another ChatGPT account
than the one that sealed the task, that account can't open it either;
magpie can't see or fix that.
2026-10-09 10:34:03 +08:00
yetone a8ca790855 plugin: the host takes a proxy set after it started (#1363)
iamyhzhao's moved Grok account reads as signed out after nearly every
reboot. The plugin host (Bun, host.js) reads MAGPIE_*_PROXY once, at its
start, and nothing restarted it when the proxy changed. After a reboot
magpie can start before Clash or another proxy app sets the system proxy,
so the host had no proxy for its life; the Grok plugin renews by running
`grok models`, which got no proxy from the spawn wrapper, couldn't reach
auth.x.ai, and token() said the sign-in had expired. Moving to the
built-in and back installs and removes the plugin, each of which restarts
the host, which is why that worked.

Now the host remembers the proxy it was started with (hostProxy of
env()), and get() replaces a live host whose proxy is no longer what a
new host would get (proxyMoved, looked at no more than every 15s).

Run: new TestPluginHostTakesANewProxy (real host on the cliproxy
fixture: no proxy, then HTTPS_PROXY set, the CLI gets it; unchanged
proxy keeps the same host) passes, and fails with the check disabled.
go test -tags nogui ./internal/plugin ./internal/provider
./internal/gateway in a sandbox HOME: ok. gofmt -l clean; go vet for
darwin, linux and windows on ./internal/plugin: clean.
Not verified: the reporter's proxy setup (Clash with system proxy is the
likely case, not confirmed), a real reboot, a real Grok account.
2026-10-09 09:49:03 +08:00
yetone e6f81585c4 gui: a plugin's signed-out account keeps Sign in again and Remove (#1363)
iamyhzhao: Grok, moved onto its plugin, reads as signed out after a
restart, and the row then has no Remove and no way to sign in again; the
only way out was moving to the built-in and back.

The account in use first never had a Remove, and a lapsed plugin account
in use still read as "In use" with the reason where Remove sits. Now a
plugin's account whose sign-in its vendor refused (marked lapsed, or its
allowance read saying the sign-in is gone, as signInGone reads it) shows
"Sign-in required", Sign in again (the plugin's sign-in) and Remove (asked
first, then login/forget), whatever else it is (in use, the agent's own).
Remove on a signed-out row shows without a hover. No new strings.

Run: new internal/gui/tests/plugin-signed-out-account.test.cjs in
Chromium and WebKit, en and zh, 420 and 1100 wide (26 pass); with this
app.js/app.css reverted it fails 18 (only the healthy-account cases
pass). account-*, own-signin-remove, saved-signedout-remove,
plugin-moved-signin, claude-auth-status, click-scroll, routing-account-*,
usage-accounts* and the rest of the 18 related files: 158 pass.
Not tried in the real window or with a real Grok account. A built-in
(non-plugin) account in use and lapsed is not changed.
2026-10-09 09:49:03 +08:00
yetone b37a082c48 davsync: WebDAV sync reads the backup uncompressed, so its ETag matches the write after it (#1362, laiquziru: TeraCloud synced once, then every sync said "the file on the server changed meanwhile")
What: dav.get sends Accept-Encoding: identity, and unpacks a body a server
gzips anyway (Go only unpacks what it asked gzip for itself).

Why: Go's client asks for gzip on every GET. InfiniCLOUD's (TeraCloud's)
WebDAV is Apache mod_dav_fs, whose PUT answers with no ETag (mod_dav.c's
dav_created: "### insert an ETag header?"), so the sync after the first
reads the file in full and pushes over the ETag that read gave. Apache's
mod_deflate (DeflateAlterETag AddSuffix, the default) gives the gzip copy
"<etag>-gzip", which no If-Match on the file matches: every write after the
first was 412, the retry read the same -gzip tag, and the state left the
local change pending, so every sync after failed. A read asked for as it
is gets the file's own ETag. nginx-style proxies that weaken the ETag
(W/"...") on gzip are covered by the same change.

TeraCloud behaviour assumed: seen without an account on 2026-10-09 against
https://domi.teracloud.jp: OPTIONS /dav/ says Server: Apache and
DAV: <http://apache.org/dav/propset/fs/1>; GET / with Accept-Encoding: gzip
gives ETag "fb-5d88268abcf08-gzip" + Content-Encoding: gzip, without it
"fb-5d88268abcf08"; GET with If-Match the -gzip tag is 412, with the plain
tag 200. Not seen: that mod_deflate compresses files under /dav/ (needs an
account). The fake (apacheDAV in teracloud_test.go) does: no ETag on PUT,
gzip + "-gzip" ETag on a GET that accepts gzip, If-Match strong against
the file's own ETag.

Real conflict detection is unchanged: If-Match is still sent with the
version read; TestSyncTeraCloudStillSeesOtherWrites lands another
computer's write between a's read and write on the same fake and checks
the 412, the merge over it, and b's file kept aside.

Run:
- new tests fail on the old code: without identity,
  TestSyncTeraCloudGzipETag "sync 2 after the first: the file on the
  server changed meanwhile (2 writes refused, 2 reads sent gzip)"; with
  If-Match dropped, TestSyncTeraCloudStillSeesOtherWrites "write over a
  replaced version: <nil>, want errChanged"; without the unpack,
  TestDAVGetGzipAnyway reads gzip bytes. All pass restored.
- sandbox HOME, pinned caches: go test -race -tags nogui ./internal/davsync/
  ./internal/gui/ . ok; -race -count=5 on the davsync sync/conditional/
  short-write/Nutstore tests ok
- gofmt -l clean; go vet (darwin, darwin nogui, linux nogui, windows) of
  internal/davsync; go build darwin, linux nogui, windows
Not run: a real TeraCloud account.
2026-10-09 09:46:37 +08:00
yetone 58ba737811 remote magpie: a remote magpie's model prices are this magpie's list prices for its models, so both Usage pages count a call at one cost (Sorghum on Discord: 希望本地 magpie 可以自动集成 remote magpie 手动设置的模型价格)
What changed
- The local magpie asks a remote magpie's /v1/models with a new header,
  X-Magpie-Prices (provider.PricesHeader, beside the Drawers and
  Videomakers headers in listHeaders).
- The remote answers each model's "magpie_price": what its own Usage page
  counts the model at (provider.EntryPriceIn: the price its user set by
  hand, else its provider's list price at its price rate; a routing group
  only when every member costs the same). EntryPriceIn is agent/route.go's
  entryPrice moved into provider unchanged, so the agents' cost and the
  remote's price are one rule.
- It tells prices only to a request from its own computer or one let in
  with a gateway key (local || sharedWith), and only when asked: a machine
  let in without a key (MAGPIE_ADDR open, not shared) and an older magpie
  or an agent that sends no header get nothing new.
- The local keeps the price with the fetched list (catalog.Model.Price; read
  raw, so an odd value or a negative part drops the price and keeps the
  model and the list) and uses it as that provider's list price
  (ListPrice -> remotePrice). A price the local user set (provider/model,
  provider/*, */model) still comes first, and the local provider's own
  price rate still applies on top.
- Old remote <-> new local: no field, models priced as before.
  Old local <-> new remote: no header, nothing new sent (and an unknown
  field would be ignored by encoding/json anyway).
- The model editor's price tooltip and `magpie model price` say the greyed
  price is what the other magpie counts the model at (remoteList; new
  string in zh, zh-TW, ja, de).
- Side effect: smallModel/cheapest picks over a remote's models (image
  description, web search) now see these prices, where every remote model
  was unpriced before.
- docs/subsystems/providers-accounts.md: a row for it.

What was run
- New TestRemoteMagpiePrices (real gateway handler behind lanGuard as the
  remote, a local remote-magpie provider fetching it): the remote's hand-set
  price is the local's price; a real chat call through it costs $9 on both
  the local's and the remote's usage records; the local's own price wins
  and survives a refetch; an old remote's list leaves the model unpriced.
  New TestRemoteMagpiePricesTold: priced for loopback asking, not without
  the header, not for a keyless LAN caller with MAGPIE_ADDR open, priced
  with a gateway key. New TestLiveMagpiePrice in catalog.
- Broken by hand, each red: no remotePrice in ListPrice (FAIL: priced
  false); remote price over the user's (FAIL: remote's price won); the
  local/sharedWith gate removed (FAIL: keyless machine priced); the header
  gate removed (FAIL: told to a list that didn't ask); sane() always true
  (FAIL: a/negative priced). A string-typed magpie_price first failed the
  whole list ("not a model list"), hence the raw read.
- gofmt -l clean; go vet ./..., GOOS=linux go vet -tags nogui ./...,
  GOOS=windows go vet ./...: clean; go build, GOOS=linux -tags nogui and
  GOOS=windows builds OK; go test -tags nogui ./... green under a temp HOME
  with pinned caches.
- GUI: model-price-edit (new remote-title check), model-price-tiers,
  provider-price-rate, gui-ja, gui-de, gui-zh-tw, i18n-keys: 18/18 in
  Chromium and WebKit; the title check goes red with the remoteList branch
  removed.
- Not tried between two real magpie binaries on two computers, nor in the
  real window.
2026-10-09 09:43:54 +08:00
yetone 5da6c24e80 agents, gui, cli: an agent can be shown only the models picked for it, so a model added later stays out of its list until ticked (#1337, nianlee-official)
nianlee-official asked for an agent whose model list grows only by hand:
today the Agents page's per-agent list hides models one at a time, and
every new model, of a new provider or an old one, shows up in the
agent's picker and config on its own ("New models are shown").

What changed:
- settings: PickedModels, per agent. An agent named in it is shown only
  the catalog entries listed for it (after Visible); HiddenModels is not
  read for it.
- provider: ModelOff(agent) is the one test CatalogFor, the Agents page
  count and Codex's own ChatGPT models (CodexNativeHidden) use, so the
  gateway's /v1/models for that agent's key, the lists magpie writes into
  its config and Codex's /model all narrow the same way hidden models do.
  SetOnlyPicked switches it on or off keeping what is shown as it is
  (on: what is shown becomes the picks; off: the listed models not
  picked become hidden). SetPickedModels keeps a pick whose provider is
  missing for now. renameModelPrefs moves picks with a provider rename.
  A model asked for by name still resolves against the full catalog.
- GUI: the list's foot "New models are shown" note is now a switch
  "Only models I pick", with a tooltip saying what each side does. On,
  a click sends what is ticked ({shown}), not what is hidden, so a model
  that arrived after the list was opened isn't picked by accident; the
  server still reads {hidden} from an older page. The model in use is
  always kept. Hide all / Show all's armed state takes the switch's room.
  zh, zh-TW, ja and de strings; the old note string is gone.
- CLI: magpie visible <agent> --only-picked | --show-new; magpie visible
  lists such an agent as "only models picked"; magpie models <agent>
  says how many are not picked, and what to do when none is.
- /api/settings keeps PickedModels like HiddenModels.
- docs/reference.md: the per-agent list bullet.
Not touched: #1302 (bulk agent visibility) and its files.

Run:
- go test -tags nogui ./... in a temp HOME: 44 packages ok.
- New: provider TestOnlyPickedModels, gui TestAgentModelsOnlyPicked,
  gateway TestOnlyPickedModelsList (opencode's key: a new provider's and
  an old provider's new model are left out of /v1/models, cursor's key
  has them, and a chat request naming fresh/x1 reaches the upstream) and
  TestCodexModelListOnlyPicked. With ModelOff made to ignore picks, all
  four fail.
- GUI: new agent-models-only-picked.test.cjs, Chromium and WebKit, en/zh/de,
  420 and 1100 wide (12/12). With save() sending {hidden} in pick mode it
  fails. agent-model*, agent-models*, codex-model-order, cursor-local-models,
  desktop-model-menu, click-scroll, gui-ja, gui-de, gui-zh-tw: 85/85.
- gofmt -l clean; go vet -tags nogui ./... on darwin, GOOS=linux and
  GOOS=windows; go build, and linux/windows builds.
- The built CLI in a sandbox HOME: visible codex --only-picked, a model
  added after it is left out of `magpie models codex` and kept in claude's;
  --show-new keeps it out and new ones show again.
Not tried: the real window against a live gateway, or a real agent
reading the narrowed config.
2026-10-09 08:50:14 +08:00
ggbdpq e9ed294383 provider, tests: a history write holds no reading waiter, and a batch of cached readings notes nothing new (#1358)
TestHistoryWriteHoldsNoWaiter is named for the guarantee that a history
write never holds a caller waiting on a reading, but its second caller
arrives after the reading has landed and answers from the cache: with
the note moved back into the reading's goroutine before close(r.done)
(the regression it is for), the test still passes. The new
TestHistoryWriteHoldsNoReadingWaiter starts caller B while the reading
is still in flight, so B waits on the reading's done channel: with the
regression injected, both callers are held ("both callers held by one
history write", 5/5); on main, the reading's done channel closes before
the note, and caller B answers.

Also from the #1318 review: every LoginUsage noted its batch's history
by reading and parsing quota-history.json under quotaHistMu even when
every reading in the batch was answered from the minute's cache, so a
cached batch re-read the file for points it already had
(TestCachedBatchNoteSkipsTheRead failed with "a cached batch read the
history file 1 times"). noteQuotaHistory now skips a batch whose
readings are all no newer than what was last noted for their account;
the noted times are kept per history file and in memory, so a new file
(or a test's fresh home) starts clean, and a fresh reading is always
newer than what was noted and goes through as before.

Verified: go test -tags nogui ./internal/provider -count=1 (full
package); the five history tests under -race -count=5; gofmt -l clean
on the three touched files; go vet -tags nogui ./internal/provider;
go build and GOOS=linux/GOOS=windows go build -tags nogui.

Generated-by: GLM-5.3-Flash (ZCode)
Review-run: with the note moved into the reading's goroutine before close(r.done), TestHistoryWriteHoldsNoReadingWaiter fails 5/5 ("both callers held by one history write") while TestHistoryWriteHoldsNoWaiter still passes; on origin/main's code it passes 10/10 under -race; the skip taken out fails TestCachedBatchNoteSkipsTheRead ("read the history file 1 times"); skip always taken fails 5 history tests; no per-path reset fails TestMergeQuotaHistory; no goroutine left past the tests; go test -tags nogui ./internal/provider/... ok; history tests -race -count=10 ok; gofmt -l clean; go vet -tags nogui ./internal/provider/ on darwin, GOOS=linux and GOOS=windows ok.
2026-10-09 07:26:00 +08:00
yetone 40eba22141 provider: the partners tests fetch one list at a time, as magpie does, so a fetch Partners started can't store its older list over the one fetched after it (TestPartnersFromTheFeed red on ubuntu CI at 439cfc4a: "partners after the list emptied = acme,regioned")
Cause: TestPartnersFromTheFeed's first Partners() call (to see that
nothing is listed before a fetch) starts a background refreshPartners,
since no list was ever fetched. The test helper fetchPartnersNow called
refreshPartners directly, skipping the partnerFetches guard that
Partners and PartnersNow use, so the two fetches ran together. When the
background fetch read the full list and stored it after the helper's
fetch of the emptied list, the test saw acme,regioned. magpie itself
never runs two fetches at once (only Partners and PartnersNow start one,
both through the guard), so this is a test-only race, not a product
bug. resetPartners also cleared the guard while a fetch could still be
running, which let a late fetch store into the next test's state.

Fix: fetchPartnersNow waits for a running fetch and claims the guard
before fetching (claimPartnerFetch), and resetPartners waits for a
running fetch before it forgets the list.

New test TestPartnersFetchedLastIsHeld: the fetch Partners starts reads
the full list and answers late; the list is emptied and
fetchPartnersNow runs; the empty list must be what is held. With the old
fetchPartnersNow it fails 5/5 ("partners after the list emptied =
acme"). The CI failure itself was reproduced by injecting the delay: a
300ms first response from the feed server and a 400ms pause before the
check made TestPartnersFromTheFeed fail with CI's exact message on the
old code; with the fix it passes 5/5 under the same injection.

Run (temp HOME, Go caches pinned):
- go test -tags nogui -race -count=20 -cpu 1,2 -shuffle=on -run Partner ./internal/provider/: ok
- go test -tags nogui ./internal/provider/...: ok
- gofmt -l internal/provider/: nothing
- go vet -tags nogui ./internal/provider/ (darwin, GOOS=linux, GOOS=windows): ok
2026-10-09 07:21:19 +08:00
yetone f368f709c3 test(usage): TestLogChangeTrustedAppendReuse counts the prefix it re-hashes, not an empty fingerprint (CI red on Windows since 439cfc4a)
What changed. TestLogChangeTrustedAppendReuse (Windows-only) took
`after.hash != ""` to mean "the read after a trusted append re-read the
historical prefix". Since #1357 (439cfc4a) the trusted, settled path keeps
a fingerprint without re-reading: the old snapshot's SHA-256 state is
continued over the appended lines. So the Windows "usage cache" CI step
failed with "trusted append scanned the historical prefix" while the
guarantee still held. The test now wraps logRecordHash, as
TestUsageAppendReadHashesNoPreReadScan does, and fails if the read after
the append hashes any bytes through it; it still checks the sealed blocks
are reused, and now also that the continued fingerprint equals a fresh
SHA-256 of the parsed bytes.

Verified rather than assumed: in log_index.go the trusted path skips the
prefix check (`trusted ||` short-circuits it) and the after-read check
(`!trusted || !settled`), and the parse seeks to old.off. Siblings: the
only build-tagged test in internal/usage is log_change_windows_test.go.
TestLogChangeSnapshotReuseWithoutFingerprint clears hash only to simulate
a cache hit with no fingerprint, which a change-time stamp alone still
serves; it doesn't use hash as a proxy for a re-read and passes.
incremental_log_test.go:147 asserts hash != "" on the unsettled path,
where the fingerprint is still required. Neither needed a change.

What was run. Windows 11 box (xiaomi-gpu; no Go there, so test binaries
were cross-compiled with go1.26.8 GOOS=windows and run in a scratch dir
with USERPROFILE/APPDATA/LOCALAPPDATA/TEMP redirected):
- origin/main's test, -count=5: FAIL x5 at :164 "trusted append scanned
  the historical prefix" (CI's failure reproduced).
- fixed test, -count=5: PASS x5.
- break A (continuation always re-verifies the prefix: `trusted ||`
  removed): FAIL x5 "re-hashed 143726 bytes of the 143726-byte
  historical prefix".
- break B (after-read check runs on the trusted path too): FAIL x5
  "re-hashed 143849 bytes of the 143726-byte historical prefix".
- CI's "Windows usage cache" -run set (no tags): all 11 PASS.
- whole package (-tags nogui): everything passes but
  TestDesktopSessionAccountMetadata, which fails identically with
  origin/main's binary; that is #1356, already filed, not touched here.
macOS: go test -tags nogui ./internal/usage/... ok (temp HOME, pinned
caches). gofmt -l clean; go vet, GOOS=linux and GOOS=windows go vet
-tags nogui ./internal/usage/ clean.
2026-10-09 07:20:48 +08:00
yetone 439cfc4ac4 usage: the parse is the log fingerprint, dropping the build's full re-read before it (#1357)
What changed. A usage-log snapshot's fingerprint is now produced by the
parse itself: each complete line read is fed into a SHA-256. When a build
continues the old snapshot's blocks, it continues the old snapshot's digest
too (crypto/sha256's state, kept on the snapshot as `digest`); otherwise the
digest starts fresh from byte 0 with the parse. The build's before-read
logRecordHash over the whole file is gone. The fingerprint covers the bytes
actually parsed (`off`), not info.Size(), so a half line another writer is
in the middle of appending is no longer counted as read; the unchanged
check, the prefix check and the after-read check all hash `off` bytes.

What stays, as #1328's tests require (ggbdpq's analysis on #1357): the
unsettled unchanged check, the prefix re-verification on a non-trusted
continuation, and the after-read check, which now compares the parse's own
digest against the file as it is after the read. A same-tick rewrite is
still caught at the build, not at a later read. On the trusted, settled
path nothing is re-read, as before; that snapshot now also carries a
fingerprint (the continued digest), so a later non-trusted append can
verify its prefix instead of rebuilding from byte 0.

Test changed: TestUsageRewriteBeforeFingerprintIsNotCached counted
logRecordHash calls and rewrote the file at call 1 (after the before-read
hash, before the parse) or call 2 (after the read). With the parse being
the fingerprint there is no call 1 any more, so the hash=2 case could no
longer fire. Its guarantee still holds and is still tested at both
boundaries: "before-read" rewrites at the build's first change-time look,
after its stat and before it opens and parses; "after-read" rewrites at the
after-read fingerprint check. Removing the after-read check makes
after-read fail. No other test changed.

New tests:
- TestUsageAppendReadHashesNoPreReadScan: 20,000-row settled log
  (2,760,615 bytes), 5 x Append + Summarize(Today), counts the bytes
  logRecordHash re-reads. origin/main: 7,728,984 bytes per cycle (2.80x the
  log), FAIL; this commit: 4,968,615 (1.80x), PASS. Wall time per cycle on
  an M-series Mac, 3 runs each: 3.43-3.52 ms before, 2.28-2.52 ms after
  (~32% less). The remaining two passes are the prefix check and the
  after-read check, which the same-tick guarantee needs.
- TestUsagePartialLineThenCompletion (settled and unsettled): another
  writer's half line, a read, then the line completed; the fingerprint
  covers the parsed bytes only and the completed record is counted. On
  origin/main it fails on the fingerprint check (it covered the half line).

Break checks: no after-read check -> RewriteBeforeFingerprint/after-read
fails; no prefix verification on a continuation -> SameSizeAndMtimeRewrite
append=true, SameTickRewrite append=true and LogGrowingRewriteThenAppend
fail; lines not fed into the digest -> SameTickRewrite fails.

Run: go test -tags nogui ./internal/usage/... ./internal/gateway/... (ok,
temp HOME, pinned caches, removed after); go test -race ./internal/usage/...
(ok); -race -count=10 on the rewrite/append tests (ok); gofmt -l (empty);
go vet, GOOS=linux and GOOS=windows go vet -tags nogui ./internal/usage/
(ok). Not tried on Windows or Linux hardware; the change is
platform-independent hashing over the same read.
2026-10-09 06:54:28 +08:00
yetone baecbb1be0 provider, gateway: Cursor moved onto its 0.2.x plugin gets Fast back, in the agent picker (#954) and for a Routing group's member, on the models Cursor has a fast variant of (#1360, loosheng: 插件 0.2.x 后 Cursor 的快速模式不见了,保存 cursor/<model>:fast 报 "has no fast mode magpie can ask for")
Plugin 0.2.x lists Cursor's picker once per context size (claude-opus-5-5@300k, @1m) and no -fast model, so CanFast, which looked for a -fast sibling, said no for every model: FastFor in route.go, canFast in the group editor, SetFastPick and cleanFast all go through it. The plugin itself still ran service_tier priority as the fast variant. opencode-cursor-auth 0.2.2 (magpie-community/plugins fb253f9) now says fast on a model listed at a size Cursor has a fast variant at; host.js passes it on as plugin.Model.Fast, CanFast takes it, and a plugin before 0.2.2 is still read by its -fast sibling. The Cursor mover asks for 0.2.2. On the way a fast request translated for a moved Cursor (Anthropic, Responses, chat not streamed) lost its service_tier: buildChat sets it only for host "cursor", and a moved provider's host is empty; buildHost names it.

Tested: the fake plugin lists sized models with fast on one (FAKE_FAST=tier); TestCursorPluginGroupFast (CanFast per size, @1m:fast refused, a group's @300k:fast sends priority), TestCursorPluginGroupFastSibling (the old -fast list), TestCursorPluginFastPick (moved: picker fast on chat, chat streamed, Responses and Anthropic sends priority, codex's doesn't). Without groupfast.go, host.js's fast or buildHost they fail. go test -race -tags nogui on gateway, provider, plugin, agent, gui; go vet, GOOS=linux/windows vet and build. With the built binary under a temp HOME, the real npm 0.2.2 plugin and Cursor's API answered by a preload (a picker fixture, the agent API's Run caught): /api/state offers fastFor on @300k and composer-2.5, not @1m or grok; /api/groups canFast the same; saving @1m:fast is refused, @300k:fast saved; opencode's picked @300k reaches Cursor's Run with fast=true on chat, Anthropic and Responses, codex's with fast=false. origin/main's binary in the same HOME offers none and refuses @300k. Not tried with a real Cursor account.
2026-10-09 05:32:36 +08:00
yetone ea6f8f89f8 routing: a group under the pointer stays where it is as requests come in (cwfox67 on X: picking a manual group's model meant fighting the page, which shook as it live-updated) — the Routing page keeps the groups in place on the screen only while they're in the upper half of the view or a field in them has the focus, else it keeps the requests list's place; at the page's foot the groups sit low in the view, so the list kept its place, grew with each request, and pushed a manual group's card down from under the pointer about to click it. Now the groups are also the part kept while the pointer is on them, and the pointer coming onto them or leaving them takes where the reader is again (readerLeaves), as their own scroll would. No new strings. Tested with a new internal/gui/tests/routing-pointer-pin.test.cjs in Chromium and WebKit at 420 and 1100 wide: wheeled down to the groups, pointer on the manual group's card, 8 requests streamed in (start, prompt read, done each): the card stays within 1.5 px on the screen. On origin/main it fails in both engines at 1100 (588 → 702 px). All routing-* GUI tests (199) and click-scroll pass in both engines. The wheel over the requests list still scrolls the list first (the browser's own scroll chaining); not changed. Not tried in the real window with a live gateway. 2026-10-09 04:03:39 +08:00
yetone 461708907a docs: the 10-08 review moves real use and GUI-in-both-engines into the code standards, adds gofmt and a Linux/Windows go vet to the checks, and adds lessons on Windows CI, telemetry disclosure, timing tests, guarantee tests, real vendor pages, CI's Go version and large commits without a body
192 commits and 18 releases on 10-08 (through 3c629133). The two lessons
seen a third day keep their concrete steps in acceptance criteria 4 and 5
(git grep a reworded string, a gateway guard through the real server
stack). bbf99143's syscall.Getsid shows a cross-OS build doesn't compile
_test.go, so vet joins the checks. #1318's test that passes with the waiter
held is filed as #1358.

Ran: read both files after the edit; docs only, no code changed.
2026-10-09 03:48:15 +08:00
Ethereal49 3c629133c2 usage: refresh caches after same-tick log rewrites (#1328)
* usage: same-tick rewrites refresh cached totals and request pages

Trust change stamps only when they were settled at the snapshot read's
start. Fingerprint recent snapshots and append prefixes, and discard
fingerprint trust when the content changes during a read.

Fixes https://github.com/yetone/magpie/issues/1305

Verified: deterministic old-predicate FAIL/new PASS; usage tests with race;
nogui vet; Linux nogui and Windows builds; usage test cross-compilation for
Windows, Darwin and the unsupported-stamp fallback on FreeBSD.
Full nogui suite exposed the independent sessions TestList empty-session
limit failure. Native Linux GUI vet/build lack pkg-config and GTK/WebKit;
native Windows/NTFS execution was not run.

* usage: keep FILETIME stamp ages valid beyond UnixNano range

Convert whole seconds and the 100ns fraction separately. Checked the epoch, current dates with fractional ticks and year 2500; Windows build and usage test cross-compilation pass. Native Windows execution was not run.

* usage: keep history cached after an append settles

An unchanged log now validates a recent snapshot's nonempty fingerprint
before publishing a settled clone. Preserve its version and sealed blocks
so the summary and page caches stay warm without mutating readers' snapshots.

Follow-up to the review on https://github.com/yetone/magpie/pull/1328,
for https://github.com/yetone/magpie/issues/1305.

On e28e686, the real-clock append/read/settle/read tests fail with a
version bump and 32,428 additional allocations over 4,000 versus 10 rows.
With this change the targeted race run passes, with 165 extra allocations.
The existing performance test now measures this path on the real clock.

Verification uses Go 1.26.8, a temporary HOME and pinned Go caches.
Native Linux GUI checks require missing pkg-config/GTK/WebKit libraries;
native Windows/NTFS execution is not available. No GUI assets change.
Implementation and verification used AI assistance.

Final checks: usage race PASS (421 test results); nogui suite PASS (7,273
test results, 44 packages; 21 skipped tests, 3 packages without tests).
Nogui vet, Linux nogui build, Windows build, and usage test cross-compilation
for Windows amd64, Darwin arm64 and FreeBSD amd64 PASS. Native Linux GUI
vet/build were attempted and failed at the missing pkg-config executable.
2026-10-09 02:48:29 +08:00
yetone c17cbfa161 routing, gui: the folded context window shows a thin bar of what fills it beside how full it is, one line high whatever a live request puts in it, and a request opened from the Usage page's Context tab shows its card unfolded, it alone (PILIPALA @NXirg2 on X, Jeremy.Zhou on Discord)
On af798bd1's fold: folded, the head carries a bar of the window's
parts (system, tools, memory, files, results, chat), morphed with the
head so a live request changes its widths only; the folded head is
pinned at the open head's 20px and doesn't wrap. "Latest request" and a
point of the session line on the Usage page's Context tab open the
request with { context: true }: its card shows unfolded for it, the
next request takes the reader's choice (magpie.ctxShut) again, which is
left as it was; folding that card by its title keeps it folded.

routing-context-fold (new): folded at 1920x1080, 1440x900 and 440x900
in en, zh and de, one line with the bar, the stage and (at 1440 and
wider) the request list in view, nothing running over; opening doesn't
move the page; the Usage page's request opens unfolded for itself only.
routing-live-patch's folded cases: the card, head and bar are the same
nodes across live requests and the card's height never changes. Both
fail on af798bd1's assets. No new strings.
2026-10-09 02:22:10 +08:00
yetone 03c8d21fde gui: opening the folded context window on Routing no longer scrolls the page 4px; the folded card keeps the open card's padding, so its title stays where it is and the browser's scroll anchoring has nothing to follow (af798bd1's fold)
The folded card had padding-block 10px against the open card's 14px, so
the title moved down 4px as the card opened and scroll anchoring moved
the page with it, at the top of the page too. A click never moves the
page. Folded, the card is 50px instead of 42px, still one line.

routing-ctx-fold now opens the card with the title in view and checks
that scrollTop is the same and the title moved at most 1px. On
af798bd1's assets it fails in Chromium and WebKit ("200 -> 204", "0 -> 4");
with this change it passes 3/3 in each.
2026-10-09 02:22:10 +08:00
yetone e20e1e9eea provider, gui: Detect asks a Gemini API too, at the URL's …/v1beta, with the key in x-goog-api-key and a gemini model from the list, and the editor shows Detect on a Gemini pick (#1346, NagaseMinato: 「检测」不会探测 Gemini 端点,为什么呢?)
Detect asked OpenAI chat, Responses and Anthropic only, and the editor hid
Detect when Gemini was picked. Now detectProtocols adds Gemini:
- a typed …/v1 or a bare host is asked at …/v1beta; a …/v1beta base is
  kept; a pasted …/models/x:generateContent URL is cut back to its base
  (for every API, so the others aren't asked under ":generateContent");
- the provider's own Gemini URL is asked as it is;
- the probe is the same tinyBody a Gemini test sends, x-goog-api-key, no
  Authorization; a model id with "gemini" in it is preferred.
Nothing is saved; "Use these" keeps the Gemini pick and its URL.

Run: go test -tags nogui ./... (all ok), go vet and GOOS=linux/windows
go vet on ./internal/provider ./internal/gui. GUI, Chromium and WebKit:
provider-gemini 8/8 (900px and 440px, en/zh/ja/de labels), provider-detect,
provider-detect-models, provider-base-api, provider-url-each-api,
provider-add-models, gui-ja, gui-de, gui-zh-tw, click-scroll.
Without the fix TestDetectGemini fails ("no model to try") and
provider-gemini fails 4 of 4 in Chromium.
Real: aihubmix (owner's key, sandbox HOME) at https://aihubmix.com/gemini/v1beta
with gemini-2.5-flash: Gemini ok 200; at https://aihubmix.com/v1 chat,
responses and anthropic ok and Gemini 400 (aihubmix serves Gemini under
/gemini). Not tried with a real Google AI Studio key.
2026-10-09 02:20:30 +08:00
yetone b946e018cf provider: MiMo's mover asks for opencode-mimo-auth 0.1.9, so a moved account with a Token Plan and no app membership is answered by its Token Plan instead of 403 membership_required on every model (yetone: 2 个模型中有 2 个没有响应)
Checked on the owner's real account (Token Plan Lite, no app membership) in magpie-dev after upgrading its plugin to 0.1.9: mimo-app/mimo-pro and mimo-app/mimo-flash answered 200 through the gateway on 127.0.0.1:3426, streamed and not, and the editor's /api/provider/test answered both ok. go test ./internal/provider -run 'Mover|Moved|Migrat|Mimo'.
2026-10-09 01:55:07 +08:00
yetone f76a14dc52 gui: Settings → Observability heads its three groups as every other Settings tab does, a small label over a card of its own, instead of grey bands inside one card (yetone)
Run: otel (new: the headings are #otelList > .row-head labels styled as
Network's, each over its own card, none inside one; fails on the old
assets), settings-sections, settings-groups, settings-shortcut,
mobile-web, readable-focus, search-first, plain-names, lightweight,
segs-press-redraw: all pass in Chromium and WebKit; go test
./internal/gui. Screenshots of Observability beside Network, both
engines, dark and light, en and zh, 1100 and 420px, looked at.
2026-10-09 01:50:52 +08:00
yetone b4aa62af96 docs: the READMEs' Privacy and the site's "What does magpie send about me?" (en, zh, ja) say a partner's daily counts are sent too
7d663a27 began sending, with the daily stats, each listed partner's
counts of times shown, opened, its key page opened and added. f9a1ca03's
Privacy section, written 40 minutes before, listed only the id, version,
system and the agents, providers and models, and said no usage is sent.
Docs/reference.md had the counts; the summaries didn't. They now name
them, as counts only, and the site's switch text says "Share the agents,
providers and models I use" leaves them out too (partnerCounting reads
NoUsageStats).

Ran: node --test site/*.test.mjs (23 pass).
2026-10-09 01:49:57 +08:00
yetone 97f825d560 gui: spinners turn round their own centre: the drawing turns inside the svg, round the viewBox's centre, since WebKit turned a whole svg on a pixel-snapped layer and its ring circled a point ~1.3px off (Agents' "Updating…", Sync, Save, the quota and usage refreshes, the agent fix) (yetone)
Measured on the real screen (headed WebKit + screencapture, 30 frames,
circle fit): the ring's centre wandered 2.7 device px before, 0.2 after.
Playwright screenshots repaint and never showed it. The CSS ring
spinners (sign-in, plugins) measured 0.2 already and are unchanged.
#sync's stop-on-a-whole-turn reads the svg's subtree animations.
Run: cli-update (fails on the old CSS), agent-update-all, sync-spin,
provider-save-busy, agent-app-version, agent-unreachable, aside-native,
usage-card-refresh, usage-remote-cards, usage-stale-answer: all pass,
Chromium + WebKit.
2026-10-09 01:46:48 +08:00
yetone 05b5965872 providers, gui: a partner listed since the add sheet last showed the partners puts a small dot on the "+ Add provider" button, its name in the title, until the sheet shows it (yetone: 有了新的合作伙伴的时候,这里能否有个小小的精致优雅的提醒吸引用户点击查看)
Ran: go test ./internal/provider ./internal/gui ./internal/tui .;
partners.test.cjs (chromium + webkit, en/zh at 900 and 440), i18n-keys,
gui-ja, gui-de; GOOS=linux -tags nogui and GOOS=windows builds.
2026-10-09 01:45:43 +08:00
yetone 7d663a2769 providers, stats: count how often each partner is shown, opened, has its key page opened and is added, per day and capped, and send the days that have ended with the daily stats as "magpie partner" events; off with the stats or with what magpie is used with (yetone: 能否统计每个合作伙伴的展示数和点击数)
Ran: go test ./internal/provider ./internal/stats ./internal/gui ./internal/tui .;
partners.test.cjs (chromium + webkit), privacy-usage-stats, i18n-keys,
gui-ja, gui-de; GOOS=linux -tags nogui and GOOS=windows builds.
2026-10-09 01:40:02 +08:00
yetone af798bd1bf routing, gui: the context window's card folds to its head, with how full it is beside the title ("114K / 272K · 42%"), and stays folded for the next request and after the page comes back, so the request list under it has the room (0xBCD18E on X: 这个"上下文窗口"的板块非常占"上下"高度,还不能折叠,下面只能展示三行)
ctxCard takes opts.foldable (Routing passes it; the Usage page's cards
don't): its title becomes a button with a chevron, aria-expanded, and a
click toggles .shut on the card, which hides all but the head. The choice
is kept in localStorage (magpie.ctxShut). The head is morphed in place, so
the click reads the button from the event, and the card that is there
keeps .shut across live patches; a new card takes it from the saved choice.

Verified: routing-ctx-fold.test.cjs (new), at 420 and 1100 wide in
Chromium and WebKit, 3/3 each: open, then folded to one line (<60px) with
the figure, the list under it up by >200px, nothing runs over at 420px;
another request picked is still folded; after a reload still folded;
opened again, 400 cells and the same height as before. It fails on the old
code (no .ctx-fold). routing-*, context-*, usage*, group-context,
click-scroll, panel-routing, old-webkit, gui-ja/de/zh-tw: WebKit 371/371;
Chromium 370/371 once at --test-concurrency=4, routing-blocked's "picking
the request moved the page", which passes 3/3 alone and in two more
routing-* runs at the same concurrency (196/196 each). Not filed as caused
by this change; seen once under load.
2026-10-09 01:40:02 +08:00
yetone 0d20aaba45 gateway: under In turn routing, new conversations sent at once spread over the accounts instead of all going to the one least held, which held one Antigravity account busy while the others sat idle (#1342, handong66)
leastHeld counted only conversations already answered, so every new
conversation in a burst saw the same counts and picked the same account.
A conversation that is opening now counts until it is answered or its
request ends.

Run: go test -tags nogui ./internal/gateway (ok); -race -count=5 on the
rotate/affinity tests (ok); go vet darwin, linux, windows (ok).
TestRotateSpreadsABurstOfNewConversations fails without the fix
("k1 got none of 6 new conversations sent at once ...: map[k2:4 k3:2]",
3 of 3 runs).
Not tried: a real burst on Antigravity accounts. The default (smart)
routing keeps using the first account while it has quota, by design.
2026-10-09 01:29:46 +08:00
yetone b2c97659fb gateway: a model picked for Responses is asked on Responses again after the relay once answers it there with "use /v1/chat/completions", not sent as chat from then on (01huadalang on Discord: 为什么会转成 chat,我都设置的 response)
A wrong-endpoint answer marks the model unfit on that API until magpie
restarts. With the user's per-model API (Names & levels, ModelAPI) the
only API usable gives, one such answer left usable empty, and every later
request fell back to p.Speaks(): chat first. Now the fallback is the
picked API; without a pick it is Speaks() as before.

TestModelAPIPickHoldsAfterWrongEndpoint (a relay with chat and Responses
URLs, the model picked for Responses, its first Responses answer a 400
"not supported in /v1/responses, use /v1/chat/completions"; a Responses
and a chat client each send twice) fails on the old code (the second
request goes to /v1/chat/completions, upstream [/v1/responses
/v1/chat/completions ...]) and passes with the change. go test -tags
nogui -count=1 ./internal/gateway under a temp HOME passes; go vet
(darwin, GOOS=linux, GOOS=windows) on ./internal/gateway passes. Not
confirmed that this is the reporter's case: their provider and request
weren't seen.
2026-10-09 01:24:47 +08:00
yetone cde2a351e2 library, gui: the Skills and MCP tabs' strip has a Discover button that goes to the market under the list, many screens down with dozens of skills (#1348, ttmouse)
The strip that stays at the top of the Library page gets a Discover button on
the MCP and Skills tabs. It scrolls the market's heading to just under the
strip (through scrollOnPurpose, so the click-scroll guard lets it) and puts the
cursor in the market's search box. The strip's buttons now keep together at its
end and wrap onto a line of their own when the window is too narrow, so the
window's 560px minimum doesn't scroll sideways.

New test library-discover-jump.test.cjs (Chromium and WebKit, en and zh, web
and window, 1000px and 560px; 60 skills, 40 servers): the market starts more
than 700px below the view, the button fits without sideways scroll, and after
the click the market is in sight under the strip with its search box focused,
and still is 500ms later. It fails without the library.js change (12 failures
waiting for the button).

Ran: library-discover-jump 64/64; library-*, market-*, click-scroll, gui-ja,
gui-de, gui-zh-tw, i18n-keys, skill-*, mobile-web, phone-web, project-* and
others in both engines, 520/524. The 4 failures: library-servers-bulk in
Chromium fails the same without this change (#1322), and mobile-web's 900px
desktop compare timed out / differed by 338px on Providers under
--test-concurrency=6; run alone it passed 30/30 twice.
2026-10-09 01:20:22 +08:00
yetone b3330ea82d gateway: a Responses request relayed with a tool call no result answers sends the call a synthetic result, instead of Volcengine coding plan's 400 "An assistant message with 'tool_calls' must be followed by tool messages" (#1341, XcodeFish)
The reporter's own experiment through magpie to the same upstream: a
function_call with no function_call_output was turned away with exactly
the reported 400, and the same request with the output went through.
Volcengine checks the exchange on the chat form it turns /responses into.
The Chat path has mended this shape since #1275 (pairToolMessages); the
Responses relay had only orphanedToolOutputs, which handles the opposite
case (a result with no call).

pairToolItems runs beside orphanedToolOutputs for every Responses request
not going to a ChatGPT account. A function_call or custom_tool_call that
no result in the input answers gets "[The result of this tool call is
unavailable: the turn was interrupted.]". As in the Chat path, that result
goes at the end of the run of calls and results the call is in, so no
result lands between two parallel calls. A result with no call is still
left to orphanedToolOutputs. A result naming a call from an earlier request
(previous_response_id) stays as it is. Based on ggbdpq's patch on the
issue, which put the result right after the call.

TestResponsesRelayAnswersALoneCall (through the whole gateway) fails
without the change ("the lone call went unanswered") and passes with it.
TestPairToolItemsAnswersALoneCall covers parallel calls, custom calls,
byte-for-byte pass-through of paired, out-of-order and
previous_response_id requests, and that the caller's buffer is left
alone. go test -tags nogui ./internal/gateway under a temp HOME passes, as
do go vet (darwin, linux, windows) and gofmt. Not tried against the real
Volcengine coding plan: the only Volcengine key here is a pay-as-you-go
Ark key with no DeepSeek model enabled. The reporter's Codex rollout shows
every call paired, so which call went unanswered in their request is not
known. They are asked whether it still happens.
2026-10-09 01:19:25 +08:00
yetone 23d9e5def8 gui tests: ledger-hscroll checks the Requests table's sideways scroll in a 600px window, where the table is wider by a margin no machine's fonts take away (#1340, wizzy-yang)
Since fdcdb5af (#860) the table fits a narrow window much more closely,
and at the test's 700px the Chinese table was 76px wider than the window
on this Mac but 33px on the reporter's machine (689 vs 656), whose
narrower fonts failed the test's "wider than the window by 50px" premise
in Chromium and WebKit. The product is right; the test's window was a
premise #860 took away. At 600px the Chinese table is 176px wider here.

Verified: ledger-hscroll passes 6/6 (Chromium and WebKit, en and zh).
With the reporter's narrower fonts stood in for by letter-spacing
-0.06em / -0.08em on .led (zh 696 vs 658), the old test fails zh in both
engines (2 pass, 4 fail) and the new one passes 6/6. No product code
changed.
2026-10-09 01:12:10 +08:00
yetone 844b9ca3ac provider, gateway, gui: a custom provider's Base URL can be a Gemini API (generateContent): …/v1beta, its models listed from it, the key sent in x-goog-api-key (#1346, NagaseMinato)
The custom provider editor's Base URL has a fifth API, "Gemini compatible"
(Gemini 兼容 / Gemini 互換 / Gemini-kompatibel), beside OpenAI, Anthropic and
Responses; More endpoints has a Gemini URL; the CLI takes gemini=. A
Gemini URL is kept as its base (…/v1beta, a relay's path prefix kept, a
pasted models/…:generateContent cut off). Every client's request, Chat,
Messages, Responses or Gemini, streamed or not, goes upstream to
models/{model}:streamGenerateContent?alt=sse under it, with no model in
the body and the key in x-goog-api-key; models are listed from its
/models, a page at a time, keeping those that serve generateContent.
Detect doesn't ask a Gemini API and is hidden while it is picked.
Factory's Gemini route and Bearer token are unchanged.

With five APIs the pick no longer fits a 900px editor in English and
German, so it wraps to a second row, and its thumb now follows the
picked option onto that row.

Verified against fake httptest upstreams only; no real Gemini key or
relay was tried:
- TestCustomGeminiUpstream (gateway): chat, chat stream, messages, a
  Gemini client's stream and non-stream, behind /proxy/v1beta. Fails with
  "chat: 404" without the gateway.go change.
- TestCustomGeminiProvider, TestGeminiBase, TestFactoryGeminiUnchanged
  (provider).
- go vet, HOME=$(mktemp -d) go test -tags nogui ./... (44 packages ok),
  GOOS=linux and GOOS=windows go vet on the touched packages.
- provider-gemini.test.cjs (new, en/zh/ja/de, Chromium + WebKit, 8 pass;
  fails with "900px: the thumb is 22px off" without the slide() fix),
  and in both engines: provider-base-api, provider-detect,
  provider-detect-models, provider-add-models, provider-output-picks,
  provider-search, provider-url-each-api, seg-contrast, settings-groups,
  menu-scroll, click-scroll, ledger-hscroll, strip-scrollbar,
  view-scrollbar, usage-ledger-chart, system-one, gui-ja, gui-de,
  gui-zh-tw: all pass.
2026-10-09 01:10:25 +08:00
yetone 88f2eb8aa5 gui tests: signin-paste-codex, signin-again and signin-closes-sheet give the sign-in window they open an empty page instead of the real vendor's (#1307's siblings)
Found by logging every off-host request in a full Chromium GUI run:
signin-paste-codex opened the real auth.openai.com (Cloudflare's
challenge, the page that hung login-import's clicks), signin-again
loaded www.codebuddy.cn with its downloads and trackers, and
signin-closes-sheet example.com. Each routed only its page. Now the
context answers anything off magpie.test with an empty page; a rerun
logged only the three window URLs, no subresources. Chromium and
WebKit, all three files: 9/9 pass each.
2026-10-09 01:07:41 +08:00
yetone f9a1ca0355 docs: a Privacy section in the READMEs and a "What does magpie send about me?" FAQ on the site's Get started (en, zh, ja): the daily event's id, version and system, and the agents, providers and models by magpie's own ids (a provider of the user's own only "custom"), what is never sent, and the Settings → Privacy switches; node --test site/*.test.mjs passes 2026-10-09 01:07:16 +08:00
yetone 9f0831b239 gui tests: copilot-ghe gives the device-sign-in window it opens an empty page instead of the real github.com and acme.ghe.com (#1307's sibling)
Like login-import's ChatGPT window, only the page was routed, so the
window GitHub's device sign-in opens loaded the real site (304 requests
to github.githubassets.com in one full Chromium run). It wasn't red here
(10 at once in Chromium: 0/20 failed), but its outcome rested on pages
the test doesn't control. Chromium and WebKit, en and zh: pass, and no
request went past the two window URLs, which the route answers.
2026-10-09 01:02:51 +08:00
yetone 9755201b44 gui tests: login-import gives the sign-in window it opens an empty page instead of ChatGPT's real one, whose Cloudflare challenge hung this page's clicks in Chromium (#1307)
The browser sign-in calls window.open on auth.openai.com, and only the
page was routed, so the new window loaded the real site: Cloudflare's
challenge. While it runs, Playwright's clicks on the opener in Chromium
stall at "performing click action" past their 5s.

Shown with a page that opens the same window and then clicks a button
30 times, 8 at once: real window, every run had 1-2 clicks time out
(max 5.0-5.5s), twice over; window routed to an empty page, 0 timeouts
(max 1.2-4.0s at load ~100-117). The test itself, 10 at once in
Chromium (load 150-190): before 20/20 and 19/20 subtests failed (click
or fill timeouts at lines 111-117, after the window opened); after
0/20 twice. WebKit, 8 at once: 0/16. en and zh both run.
2026-10-09 01:02:51 +08:00
yetone 7ddea2c2e0 plugin tests: TestHostRunsAsMagpieBun puts the Bun on PATH at the old name as a hard link, not a fresh copy, so macOS's first-run check of a new program no longer comes out of the host's one-minute start under a full go test run (#1306)
A copy is a new program, which macOS checks before its first exec; the
host's init ran it inside the test's minute. Timed here with load ~130
and syspolicyd not pegged: a fresh copy's first `--version` took
0.9-1.6s, a hard link of the same bun 0.04-0.3s; with syspolicyd busy
the same check took 26-55s a program in #1284. Where no link can be
made (another filesystem), the copy is run once before the minute starts.

Run in Docker (golang:1.26.8, linux/arm64, bun 1.4.2): -count=3 of the
test with bun on a bind mount (copy path) and beside HOME (link path),
all PASS; go test -tags nogui ./internal/plugin ok; go vet for linux,
windows and darwin clean. Not run: the test on macOS itself (Go tests
are kept off this Mac while syspolicyd is overloaded), so the red under
a slow first-run check is shown by the timings above, not by a failing run.
2026-10-09 01:02:51 +08:00
yetone 1e405be3fc gui tests: routing-sessions' suggestion groups answer the names ask with the titles their trace carries, as the gateway does; the Claude Code row has the page ask 300ms after it draws, and the empty answer took the titles away under load (#1307) 2026-10-09 01:02:51 +08:00
yetone 8576e3cc33 gui tests: routing-sessions leaves the held live trace for a reload to cancel; letting it go just before the reload had the page ask again while going away, which WebKit turned away "due to access control checks" as a page error (#1307) 2026-10-09 01:02:51 +08:00
Charan Rathore 023f5aaad2 provider: a background reading of an account writes its quota history too (#1318)
* provider: a background reading of an account writes its quota history too

GET /v1/magpie/quotas/history was written only by the Usage page's
reads, so a magpie only ever serving other magpies - nobody opens its
Usage page - kept an empty history while its routing read each account
every few minutes.

When a login's fresh reading is kept, it is now noted in the history as
a page reading is, named for its account (a login's reading itself
carries no user); a read that failed is skipped, not told again. The
test is the reporter's story: a background LoginUsage, then the
history, no Usage page involved; it fails on main. Keys' background
readings (KeyAllowance) are not noted here - the page names a key's
history line by its key name, which routing's reading does not have.

Invited by yetone on #1317 after 137ebee8 closed the cards half of

Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com>
#1313.

* provider: a LoginUsage batch notes its quota history once, off the request path

Per review on #1318: a kept reading per account meant N full
read/parse/write cycles of quota-history.json for N accounts, and the
write ran before close(r.done), so routing's waiters on a reading
waited on the disk, the writes serialized under quotaHistMu.

loginUsageAt now notes the batch once after wg.Wait(), each reading
named for its account, through noteHistory, whose write runs in its own
goroutine; a cached reading notes nothing new (its ReadAt is no newer)
and a failed one is skipped. Tests: several accounts cost one write
(TestBackgroundReadsNoteHistoryOnce) and a held write holds no waiter
(TestHistoryWriteHoldsNoWaiter).

Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com>

* provider: note the batch's quota history synchronously

The detached write goroutine outlived its caller: it read Path() and
wrote quota-history.json after a test had returned, racing t.TempDir
removal and leaking into the next test's HOME (go test
./internal/provider red with the PR, green on main). Note synchronously
in loginUsageAt after wg.Wait(): every reading has completed and
released its in-flight marker by then, so no routing waiter is held.
Only the LoginUsage caller pays for one write per batch, and Allowances
already calls it from its own background goroutine.

TestHistoryWriteHoldsNoWaiter now holds the write during the first
LoginUsage and asserts a second caller on the same account answers from
the completed readings, with a timeout on the write-start wait.

Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com>

---------

Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com>
2026-10-09 00:39:40 +08:00
yetone 84c3eb0362 provider: moved Cursor users get plugin 0.2.1: an id 0.1.x listed, at an effort only its other branch has, goes as that branch's nearest effort, not as the default's id with parameters that contradict it (magpie-community/plugins#54, geoqiao)
On 0.2.0, cursor/claude-opus-5 at xhigh went as claude-opus-5-thinking-high
with thinking=false, effort=xhigh, because Opus 5 without thinking stops at
high. 0.2.1 sends claude-opus-5-high with effort=high, as 0.1.11 did. The
cursor mover's min goes from 0.2.0 to 0.2.1, so keepMovedCurrent updates
every moved Cursor user.

0.2.1 on npm (registry.npmjs.org) has an index.mjs byte-identical to plugins
main cf0de01e. The plugin PR was reviewed at ea00bd9 and merged:
- bun test in packages/cursor: 49 pass, 0 fail
- whole repo: 987 pass, 0 fail
- bun scripts/check.mjs passes for all 15 packages
- the new test fails on 0.2.0's index.mjs
- not tried with a real Cursor account on the review side (the stored token
  answers 401)
The case where the default's own branch stops short is still open:
magpie-community/plugins#55.

go test -tags nogui ./internal/provider passes (temp HOME, pinned caches).
go vet -tags nogui ./internal/provider passes for darwin, linux and windows.
gofmt -l is clean.
2026-10-09 00:36:27 +08:00
yetone b91569d311 readme: feedback goes to Discord, and the repository takes no pull requests (only collaborators can open one) 2026-10-09 00:31:48 +08:00
yetoneandClaude 55209c5529 plugin tests: TestHostStreamCreditBoundsTheQueue waits for the given-up call to leave the host, which it does once its abort's write is known, after the child may already have read it (red on ubuntu CI at 2b5f19fc; a 50ms pause before the write is known fails the old check every time)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 00:23:17 +08:00
yetone ed31293c0e usage, gui: a reply its vendor let go in a burst over a couple of hundred milliseconds tells no speed, not 2,237 tok/s (John on Discord)
John's Kimi Code rows after v0.1.1119 (OpenCode, group/k3-glm, api.kimi.ai,
Chat, k3-256k): 550 tokens, 22 of them reasoning, in 16 s with the first
content at 5.2 s read 2,237 tok/s, and 395 tokens in 19 s 2,012. 528 answer
tokens at 2,237 tok/s is 236 ms: the burst the vendor let go at the end.
de5455b8 measured how long the content took to come (flow) and took the
window as no longer than it, so a burst sent in one write (flow ~1 ms) fell
under the 100 ms floor and told none, but a burst let go over 236 ms of
many events read its tokens over those 236 ms, under the 10,000 tok/s
ceiling. The time a burst takes to come is not the time it took to write.

usage.DecodeOf now tells no speed for a reply held back and let go at its
end, both ways the burst can show:
- its content came in under a quarter of its window (flow*4 < window):
  the text came early, the tool call it wrote over the rest of the time
  came at the end (window 10.6 s, flow 236 ms);
- it reasoned, and its answer came over 20 times faster than its
  reasoning came between its first content and its first text: the text
  came with the burst, so the window from it is the burst itself (22
  reasoning tokens over 10.5 s, then 528 in 236 ms). Hidden reasoning
  (OpenAI's, Claude's omitted) comes before the first content and leaves
  that time short; streamed reasoning comes at its answer's pace.
Between the two the window is the whole window again, not the flow.
routing.js's decodeOf and app.js's ledDecode do the same; docs/reference.md
says it.

Tests: TestKimiHeldBurstTellsNoSpeed, a fake Kimi Code (Chat: four
reasoning_content deltas, then text and a tool call whose arguments come
as 40 writes 6 ms apart over the last 236 ms; usage 538/550/22), through
the real gateway for a Chat, a Responses and an Anthropic agent, with the
wait shortened (first content 1.3 s, end 4 s): the held call and the held
answer tell none (before: 2,112 to 2,237 tok/s on all three), a steady
50 tok/s answer after the same reasoning keeps ~50. TestKimiHeldBurstAtJohnsTimes
feeds firstToken the same streams at John's times (5.2 s, 16 s): before,
2,138 and 2,237 tok/s. TestJohnsRowsTellNoSpeed: his rows as records
(before: 2,237, 2,237, 2,015). routing-burst-speed and usage-speed gain
his rows and a steady 50 tok/s one; they fail on the old routing.js and
app.js ("2237 tok/s", "2,237 tok/s") and pass now in Chromium and WebKit,
en and zh, with reasoning-speed, routing-request-metrics,
usage-ledger-detail, click-scroll, gui-ja, gui-de and i18n-keys.

Run: go test -tags nogui ./internal/... (golang:1.26.8 in Docker, as a
non-root user: as root, internal/agent's TestAside* write-refusal tests
fail because root ignores the file modes they rely on, unrelated to this);
-race -count=5 of the new and the burst tests; go vet (linux, windows,
nogui) on internal/gateway and internal/usage. Not tried against the real
Kimi Code API: the owner's Kimi Code key answers 403 (subscription has no
access), so how Kimi Code lets the reply go is inferred from John's
numbers; which of the two shapes his rows were is not known, and both are
covered.
2026-10-09 00:10:23 +08:00
yetone 3d34c2d747 routing: live, the next request is patched into the context window that is there, so its cells don't come in again and the card no longer goes and comes back, moving the request list under it (Zhenzhen on Discord)
Each new request in a session emptied the card while the gateway read its
prompt (the box collapsed to 0, everything under it jumped up), then drew a
new card whose 400 cells scaled in again, then a third card when it was
done; a request under way had no Prompt cache stat, so the grid moved
again when it came. Now ctxCard returns a card with ctxUpdate: the grid's
cells are made once and only their classes change, the head, stats,
legend, contents rows and foot are morphed in place, and an under-way
request keeps the cache stat's place with "—". Routing patches the live
card, keeps it in place while a live request has no prompt yet, and still
draws a request the reader picks afresh.

Verified: routing-live-patch.test.cjs (new) feeds two requests in three
steps each, at 420 and 1100 wide, in Chromium and WebKit: the old rows,
card, grid, cells, legend and contents rows stay the same nodes, the grid
and card keep their height on every ResizeObserver frame, the view never
scrolls. Without the change it fails: the card reads '' while the request
is under way, the card/grid are new nodes, the box goes 884 -> 0 -> 789
-> 884. routing-*, context-*, click-scroll, panel-routing, old-webkit,
gui-ja/de/zh-tw, usage* pass in both engines; go test ./internal/gui and
GOOS=linux/windows go vet pass.
2026-10-09 00:08:21 +08:00
yetone ae24bcfcfb agent, gui: Codex's row on the Agents page shows its desktop app's version beside its CLI's ("CLI 0.130.0", "App 26.930.31730"), and the app's alone where no CLI is on PATH (#1334, laacmilan)
The app's version is read from /Applications or ~/Applications
ChatGPT.app (or Codex.app) Contents/Info.plist, taken only when its bundle
id is com.openai.codex, so the ChatGPT chat app (com.openai.chat) isn't
taken for it. macOS only: the Windows Store package isn't read yet. The
app chip's tooltip says it reads the same settings as the CLI, so
connecting Codex connects both. At narrow widths the chip ellipsizes, and
below 250px it gives way so the name keeps its room.

Verified: TestCodexAppVersion fails without the CLI() change ("the app
alone, no CLI"); agent-app-version.test.cjs fails 12/16 without the app.js
change and passes 16/16 with it, Chromium and WebKit, en/zh/ja/de, at 980,
560 and 440px. agent-cli-missing, agent-connect, agent-field-names,
agent-layout, agent-update-all, cli-update, click-scroll, gui-ja, gui-de,
i18n-keys pass in both engines. internal/agent passes in Linux Docker
(non-root); full ./... on macOS; vet darwin/linux/windows. Read
26.930.51102 from the real /Applications/ChatGPT.app on this Mac. Not
tried on the reporter's machine.
2026-10-09 00:02:40 +08:00