2515 Commits
Author SHA1 Message Date
yetone 69c21cdd77 gateway, settings, gui: a reply whose reasoning or text has become a loop of the same few lines is ended with an error the agent can act on, instead of running on to its output limit (#1359, Moody-Sin: WorkBuddy's DeepSeek v4.1 flash at max effort reasoned "Now." "Writing." "Go." "Let me write." "OK." "Producing." … until max tokens, while dsh waited and Pi hung)
holdWriter.Write, which every streamed try goes through (passed through or translated; a key's, a built-in subscription's, or a plugin's fetch, as a moved WorkBuddy's), feeds a loopGuard (loop_guard.go). It reads the reasoning and the text of each protocol's deltas (Anthropic, Chat, Responses, Gemini) and cuts each into units: lines and sentences, compared without case or a closing stop. A list's "1." or a decimal doesn't end a sentence. A unit with no word in it (braces, rules, 0x00, hashes, rows of numbers) isn't counted. When the last 256 counted units of either are among 24 or fewer distinct ones, the try is cut. What came with that write goes out, then the protocol's stream error (Anthropic error event, Chat error chunk, Responses response.failed server_error, Gemini 502 UNAVAILABLE), naming the loop and quoting its units, and the vendor's request is cancelled. The cycle needn't be exact; the reporter's has 9 units in two variants. It falls back to nobody and rests nobody (fail "loop"), and the usage record's err_type is reply_loop. A reply sent whole (no stream, or Gemini without alt=sse) isn't read. Settings › Models › Replies › Stop looping replies (noLoopGuard, on by default) turns it off; new strings in zh, zh-TW, ja and de.

Tested:
- loop_guard_test.go, with the screenshot's 28 lines cut into random 1–7 byte pieces, as one line, and in Chinese. It trips within the window. It reads every protocol's shapes, and tool-call arguments never trip it.
- No trip on magpie's own sources fed as both reasoning and text: gateway.go, fallback.go, anthropic.go, app.js, i18n.js, app.css, routing.js, sessions.js, index.html, docs, LESSONS.md and go.sum. Nor on generated numbered lists, logs, CSV, hex, rules and base64.
- Through the gateway on all 4 protocols with a fake DeepSeek, and on WorkBuddy both built-in and through the community plugin's fetch in the plugin host (TestLoopEndsWorkBuddysReply). In each case the agent gets the reasoning and then the error, the vendor is let go within 2000 lines, nobody rests, and usage keeps reply_loop.
- gateway.go as 4,900 lines of reasoning passes whole on every protocol. With the setting off, a loop runs to its end.
- With the guard removed, the end-to-end and WorkBuddy tests fail on every route.
- The built binary (magpie serve on 127.0.0.1:3592, temp HOME, fake DeepSeek upstream), checked with curl on chat, messages, responses and gemini alt=sse. Each loop ended with that protocol's error, the upstream was let go after 256–271 lines, and the usage log had err_type reply_loop and routing fail "loop". The long gateway.go reasoning came through to [DONE], message_stop, response.completed and STOP. With noLoopGuard, all 100,000 lines passed.
- GUI: loop-guard-setting.test.cjs in Chromium and WebKit, en/zh/ja/de, at 1100 and 420 wide. gui-ja, gui-de, gui-zh-tw, i18n-keys, member-model-setting, click-scroll and settings-sections/groups pass.
- go vet, GOOS=linux/windows vet and builds, go test -race ./internal/gateway ./internal/settings, and the full go test -tags nogui ./... all pass.

Not tried with the real WorkBuddy or DeepSeek model.
2026-10-09 22:32:31 +08:00
yetone ab3fe0fe41 gateway: a Claude subscription turn whose saved session Claude Code can't load goes on in a new Claude Code instead of 502 "Claude Code ended", and a Claude Code that ends early says why (FrierenF on Discord)
What: a conversation let go past idleMost is resumed from its saved
session (unshelve, `claude --resume <sid>`). Claude Code 2.1.295 that
can't load the session prints "No conversation found with session ID"
and exits 1 before it reads anything, without answering a control
request (tried with the real binary in a scratch CLAUDE_CONFIG_DIR: a
missing and an empty session file both do this; a loadable one answers
initialize). With the safety context set (Claude Code's auto mode),
start's initialize got its reply channel closed and the turn was
answered 502 "Claude Code: Claude Code ended", with no reason; without
it, the turn got an empty 200.

Now:
- start sends initialize to a run resumed from a saved session too, as
  the probe that Claude Code loaded it; when that run fails, the session
  is let go and a new Claude Code is told the whole conversation in the
  same request (start / startRun).
- cliControl reports a run that ended before answering with its exit
  status and stderr tail (endedBefore, shared with whyEnded), and its
  timeout no longer says "safety context" for every request.
- The other paths that can meet an ended run already start a new one:
  resume (setSafeguards / tell failing -> abort, fall through) and the
  tool-results path (claimResume / continueWith -> runExpired).
- The resume harness's fake Claude Code now acknowledges control
  requests as Claude Code does.

Not known: why FrierenF's saved session couldn't be loaded, or whether
their run ended for another reason; their next failure will now name it.

Run:
- TestClaudeUnloadableSessionStartsAnew (both with and without
  safeguards) and TestClaudeEndedRunSaysWhy fail on origin/main:
  "502 Claude Code ended"; no run started anew (empty 200);
  "Claude Code: Claude Code ended" lacks exit status 3 and stderr.
  They pass with the change, -race -count=10.
- go test -tags nogui ./internal/gateway/ under a temp HOME, pinned
  caches: ok (174s).
- gofmt -l clean; go vet (darwin), GOOS=linux go vet -tags nogui,
  GOOS=windows go vet (with and without nogui) for internal/gateway;
  go build, GOOS=linux go build -tags nogui, GOOS=windows go build: ok.
- Not run: a real signed-in Claude account (no credentials copied), the
  Windows box, the rest of the repo's packages.
2026-10-09 22:29:41 +08:00
yetone 7953c7ef9b agent: a config folder holding only skills/ no longer counts as the agent installed, since skill installers make one for every agent they know (qtwaiter on X)
qtwaiter's Agents page listed Goose, Crush, Command Code, fx, Devin,
Hermes and Droid, none installed. Skill installers (npx skills and the
like) write <folder>/skills for each agent they support, and a config
folder alone made an agent detected. agentDir now takes a folder for the
agent's own only when it holds something besides skills/ (.DS_Store
aside); the default rule, Goose's and fx's detect use it.

Run: go test ./internal/agent ./internal/library ./internal/gui
./internal/profile ./internal/backup ./internal/tui;
TestSkillsOnlyFolderIsNotInstalled fails on the old code for all seven.
gofmt, go vet darwin/linux/windows clean.
2026-10-09 22:26:05 +08:00
yetone 603dc0c44f dsh: Disconnect keeps the model picked in dsh 0.2, which saves it into magpie's agent-default-model entry (#1392)
What dsh 0.2.0-rc.2 changed (read in @deepseek-ai/dsh-settings and
dsh-agent-default-model from npm): ~/.dsh/settings.yaml is gone. On its
first boot dsh renames it to settings.yaml.imported and merges its sections
into the booted profile's patch list (profiles/<name>/cordis.patch.yml);
nothing reads the .imported file afterwards. A /model pick is no longer
kept in settings.yaml either: agentDefaultModel.saveSelection goes through
ConfigEditor.edit, which replaces the config of the profile's last
agent-default-model entry. That entry is magpie's, so the user's pick sits
under magpie's "# magpie" mark. Profile paths, the home layer, .env and
.credentials.yaml are unchanged.

What broke: Disconnect took that entry for magpie's own, dropped it and put
back what magpie stashed from before it connected, so the model picked in
dsh (or imported from settings.yaml) was lost. Seen with real dsh
0.2.0-rc.2: after the import, Disconnect left `[]` and dsh started on its
shipped deepseek-flash instead of the imported deepseek-v4-flash.

Fix: dshKeepPick, run per profile before Disconnect takes magpie's wiring
out, hands a marked agent-default-model that is off magpie's route back to
the user (mark removed, old stash discarded). An entry on magpie's route,
or one on the pre-0.1.5 wiring, is still magpie's. dshStart still reads a
settings.yaml not yet imported, which dsh will put over the booted profile.

Run: TestDshPickSavedIntoMagpiesEntryOutlivesDisconnect (fixture from the
patch real dsh 0.2.0-rc.2 wrote) fails on the old code ("the pick made in
dsh was lost", the stashed deepseek-v4-pro came back) and passes. Real dsh
0.2.0-rc.2 under a temp HOME/DSH_HOME, fake gateway on 127.0.0.1:3561:
`dsh headless "say hi"` sent POST /v1/chat/completions, Bearer magpie,
model deepseek/pro to it, before and after a settings.yaml import; after
Disconnect `dsh headless --dump-config` keeps deepseek-v4-flash;
reconnecting sends deepseek/flash to the fake gateway. go test -tags nogui
./... (temp HOME, pinned caches) passes, -race -count=5 -run TestDsh
passes, gofmt -l clean, go vet and GOOS=linux/windows go vet -tags nogui
./internal/agent pass.
2026-10-09 22:26:00 +08:00
yetone dec951229c agent: Grok Build installed by xAI's installer shows its newest version and offers grok update, and its version is read again after each update (xiaoxiaofeixz on Discord: Grok at C:\Users\<user>\.grok\bin\grok.exe)
Grok Build had no cliSpec and no Bin (grok is also Homebrew's regex
tool), so the Agents page showed nothing about its CLI: no version, no
newest, no update.

xAI's installers (read from https://x.ai/cli/install.ps1 and install.sh):
- install.ps1 copies grok.exe (and agent.exe) into %USERPROFILE%\.grok\bin
  and puts that on the user PATH; no junction, no versioned folder.
  grok update copies the new grok.exe over it (the running one renamed to
  grok.exe.old), so its size and time change.
- install.sh links ~/.grok/bin/grok to ../downloads/grok-<platform>;
  grok update downloads ../downloads/grok-<version>-<platform> and relinks,
  a new real path per version.
- The newest version of a channel is the body of x.ai/cli/<channel>
  (stable, alpha, enterprise; GCS fallback), the channel kept under [cli]
  in ~/.grok/config.toml.

cliSpec gains `at` (where the vendor installer puts the CLI under home,
read there instead of PATH for an agent with no Bin) and `latest` (the
newest its own updater installs, for a CLI not on npm). grok's spec: at
.grok/bin/grok(.exe), self = grok update for a real path under
.grok/bin or .grok/downloads, latest = grokLatest (channel pointer, 6h
memo per channel).

TestGrokInstalled builds the platform's installer layout under a temp
HOME/USERPROFILE with a fake grok on PATH, a served channel pointer and
fake version/update runs: PATH's grok isn't taken; 1.0.46 shows 1.0.50 and
"grok update" (grok.exe update on Windows); the update reads 1.0.50; grok's
own update outside magpie (auto_update) is read again; the alpha channel's
newest is alpha's. It fails without the grok spec (macOS and the Windows
box: "before: {Version: ...} false") and passes with it.

Real use: macOS, a sandbox HOME with the real grok 1.0.46 in install.sh's
layout: CLI() said 1.0.46 / 1.0.50 / grok update, UpdateCLI ran the real
grok update and read 1.0.50 (~/.grok untouched). Windows box (10.0.0.132,
sandboxed USERPROFILE/APPDATA/LOCALAPPDATA): the real grok-1.0.46 exe at
home\.grok\bin\grok.exe, same result through a real grok update; the real
profile's .grok, LocalAppData\grok and user PATH unchanged. There
TestGrokInstalled, TestCodexInstallPs1Junctions and
TestHowInstalledWindowsShim pass.

Siblings: Claude's install.ps1 lands in ~/.local/bin/claude.exe (already
matched); Cursor's and Qoder's Windows installers make no junctions or
links, and those agents have no cliSpec (not in scope); opencode has no
install.ps1 (404); Factory's refused the fetch (403), droid has no
cliSpec either.

go test -tags nogui ./internal/agent (temp HOME), gofmt -l, go vet, and
GOOS=linux / GOOS=windows go vet -tags nogui ./internal/agent pass.
2026-10-09 22:25:24 +08:00
wizzy-yangandwizzy-yang 6dd8c9aaf0 gui: a page's own scrollbar is dragged by the mouse (#1213)
Dragging a scrollbar did nothing on any page with content taller than the
window: the thumb would not move, and letting go left it where it started
(emo172, #1089). The wheel worked, so only the drag was broken, and a
scrollbar dragged in a container the app does not listen to scrolled
normally.

"Where the reader is" holds a view still unless the reader asked for the
scroll: the wheel, a touch, the keys, or a drag of the content, each of
which says so through an event. A scrollbar dragged by the mouse has no
such event — Chromium and WebView2 give it no pointermove — so every
scroll it produced was put back as it came, the thumb sliding back under
the pointer. The wheel works because wheel is one of the events.

The press on a scrollbar does reach the view, with the pointer past its
content in the scrollbar's band, and the lift ends the drag. Held from
that press to the lift, the drag's scrolls count as the reader's.

Two more things hold the page still that a wheel already lets go of, and
the drag lets go of the same two:

- what the last click was holding in place, which keeps what was clicked
  on the screen frame by frame for a few seconds after it;
- the reader's window itself, which WebKit needs held open past the lift:
  it sends the drag's last scrolls once the gesture is over, after the
  pointerup, so closing the window on the lift put those back.

A scroll nothing asked for — code setting scrollTop — is still put back:
only the bar's own drag counts as the reader's, not every scroll.

view-scrollbar-drag.test.cjs is new. It drags a page's own scrollbar in
both engines in English and Chinese, straight away and after a click in
the page that holds what was clicked; it checks the page follows the bar
and stays where it was left, that the wheel still scrolls, that a scroll
code set is still put back, and that a scroll arriving after the lift is
still the reader's. It uses the pages' own scrollbar CSS rather than one
of its own: a scrollbar-width set on the view makes Chromium drop every
::-webkit-scrollbar rule (app.css) and draw Windows' classic bar. The
Routing page is dragged at a narrow window, where it has room to scroll:
at a wide one it lays its stage beside the list and has ~35px left, not
enough to tell a drag from a nudge. The scroll arriving after the lift is
checked on the list page only. It was left off Routing while #1249 was
open — WebKit clamped Routing's last stretch there, so a scroll sent
after the lift measured that bug rather than the window held open past
it. #1249 is fixed in main now, so the check could be widened to Routing;
it is not, because that clamp was only ever checked on Windows headless
WebKit here and not on the macOS WKWebView, and widening a check is
beyond what review asked for.

Three of the checks were reworked after they were run four test files at
a time and each was found to pass for the wrong reason, or to race:

- the click in the page lands on a part that leaves the page as it is.
  The Routing page's own header opens the group editor in English,
  growing the page and scrolling it on its own, so where the drag left
  the page passed without the drag having moved it. The page is measured
  again where it is now and the drag is judged by how far it moves it.
- the scroll arriving after the lift is written from the pointerup
  itself, installed in the page before the lift. A round trip between the
  lift and that scroll measured 6-180ms alone and 314ms four files at a
  time — past the 250ms window the app holds open for it.
- the wheel's scroll is waited for to come to rest, not for a fixed
  time, which under load was not always long enough.

The scroll code sets a value the page is not already at, and runs before
any click: a click holds what was clicked on the screen for a few
seconds, and that hold moves the page on its own, which would answer for
the app.

It fails on main (the drag leaves the page at 0) and passes with this.

Verified at this head, rebased onto main 0d8f85e4 — which brought
#1249's fix, a change to the same scroll handler (its early return and
puttingBack), and #1236's scrollbar CSS. The merge kept main's structure
and folded barDrag === v into its first branch, so a dragged bar is
treated as the reader's and never recorded as a put-back.
node --check; go vet ./...; go build (windows); GOOS=linux build -tags
nogui. view-scrollbar-drag 5/5 in Chromium and WebKit, en and zh, three
solo runs each with no flake, and 5/5 again four test files at a time,
three rounds each on both engines. The nineteen scroll-related GUI files
(click-scroll, menu-scroll, strip-scrollbar, view-scrollbar,
view-no-sideways, sessions-toolbar, sess-agent-strip, agent-grip,
group-member-drag, usage-arrange, provider-order, purpose-filter,
proto-menu-overflow, routing-scroll-end, routing-purpose-state,
routing-steady, routing-served, fold-show-grip) 94/94 in both engines,
main's new view-no-sideways included since the base changed the
scrollbar CSS this test measures against; gui-ja / gui-de / gui-zh-tw /
i18n-keys 10/10 in both. Two mutations at this head: main's app.js fails
all five (the drag leaves the page at 0), and removing only the merged
barDrag === v condition — leaving the rest of the feature — fails WebKit
5/5 and Chromium 2/5 ("a scroll arriving after the lift is still the
reader's"), so the line the rebase folded in is guarded and the new
scrollbar CSS has not let the page pass without it. The other four
per-guard mutations (the whole barDrag rule, the hold let go on the
press, the window held open past the lift, the reader-position
put-back) were each run red without their guard at the pre-rebase head;
the rebase only folded barDrag into main's first branch and left those
four paths as they were.

Not verified here: only on Windows, in Chromium and WebKit through
Playwright. Not tried on macOS or Linux.

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-09 22:24:07 +08:00
yetone ecc94dbf41 gateway: a routing group's DeepSeek reasoning, its text in it, no longer sticks a Codex conversation on OpenAI destinations (#1411, cth123456: "Invalid 'input[n].content': array too long")
A group turn served on DeepSeek hands Codex a reasoning item with both
content (reasoning_text) and encrypted_content. Codex sends it back on
every later turn, and the /backend-api/codex handler keeps reasoning for a
group (codexInput(body, true)). Routed next to a Codex account or a relay
in front of OpenAI, the request was turned away with 400 "Invalid
'input[n].content': array too long. Expected an array with maximum length
0 ...", which neither the foreign-seal retry nor #1044's bare-reasoning
retry matched, so every later turn failed the same way.

In passthrough, for an upstream that isn't sent reasoning text back
(not replaysReasoning, so DeepSeek still gets it):
- a Codex account, openai.com or Azure is never sent a reasoning item with
  content parts (withoutReasoningText), as codexInput already leaves it out
  for Codex's own models; its seal is the other vendor's, so the item goes;
- any other upstream that answers 400 naming input[n].content "array too
  long", where item n is reasoning with content, is asked once more without
  such items, and the provider's model is marked unfit for them
  (reasoningTextRefused), so later turns go without them the first time,
  as #1044 does for bare reasoning.
The destination's own sealed reasoning, messages and tool calls stay.

Verified:
- TestReasoningTextLeftOutWhereRefused (group to a strict relay, the
  issue's literal item) and TestReasoningTextNotSentToCodexAccount (group
  to a Codex account) fail without the change with 400 "AgentRouter:/Codex:
  Invalid 'input[1].content': array too long ..."; the relay is asked twice
  then once, the account once. TestReasoningTextKeptWhereTaken keeps the
  item for an upstream that takes it.
- Real ChatGPT backend (the owner's Pro account, gpt-5.5, store false): the
  issue's item answers 400 "Invalid 'input[1].content': array too long.
  Expected an array with maximum length 0, but got an array with length 1
  instead." param input[1].content, code array_above_max_length; the test
  fixture uses those bytes. The same request without the item completes.
  Not tried against AgentRouter itself.
- gofmt -l, go vet, GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/gateway, go test -tags nogui -count=1 ./internal/gateway
  under a temp HOME: all pass.
2026-10-09 22:14:45 +08:00
yetone 1ddcc3e8cc gateway: a provider that refuses a system message inside messages gets its text on the top-level system, and an empty one left out (#1407, liuyejinghong: Command Code "Invalid input at messages.1.role")
Claude Code puts system messages inside messages[] (its environment, and a
turn's effort as output_config on an empty one). Command Code's Provider
API (the "commandcode" API-key preset, Anthropic at
api.commandcode.ai/provider) turns away any of them with 400 "Invalid input
at messages.N.role", so every such turn failed.

It is handled as the other shapes Claude Code sends and a relay may refuse
(anthropic_shapes.go): on a 400 naming messages.N.role, where message N of
the body as sent is a system one, the provider is marked unfit for
shape:messages[].role=system, asked again at once, and from then on sent
each system message's text at the end of the top-level system (a string
system becomes one text block first), an empty one left out. A role
refusal over a user or assistant message marks nothing, and a provider that
takes system messages gets them where the agent put them. This runs in
forward/forwardOnce, so it also covers a moved commandcode-plan, whose
plugin fetch passes the gateway's Messages body to the same API.

Verified:
- TestSystemMessageRefusedGoesOnTop (the issue's request and the
  effort-only system message) fails without the change with
  "status 400: ... Command Code: Invalid input at messages.1.role";
  TestSystemMessageKeptWhereTaken checks the neighbours stay as sent.
- Real Command Code (the owner's GOAT key): curl user,system,user gives
  400 "Invalid input at messages.1.role"; user,user and a top-level system
  array give 200. Through this gateway (New().Handler(), temp HOME, real
  key) both the issue's request and the effort-only variant answer 200.
- gofmt -l, go vet, GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/gateway, go test -tags nogui -count=1 ./internal/gateway
  under a temp HOME: all pass.
2026-10-09 22:14:45 +08:00
yetone 02dfb7aa2c profile: applying a profile writes only agents found on this computer, so a profile synced from another one no longer makes missing agents look installed (qtwaiter on X)
ApplyFields looped over every known agent. A profile saved on another
computer (profiles sync over WebDAV) names agents missing here; writing
their model created their config folder, and a config folder alone counts
as installed, so the Agents page grew agents never installed. Snapshot
already takes only detected agents; Apply now matches it.

Run: go test ./internal/profile ./internal/gui ./internal/tui
./internal/backup; TestApplySkipsUndetectedAgents fails on the old code
(agy made to look installed). gofmt, go vet darwin/linux/windows clean.
2026-10-09 21:50:19 +08:00
yetone 3691c2047d plugin, agent: bun is given a SOCKS proxy through the bridge, so a plugin installs with only a SOCKS system proxy (#1409)
With only a SOCKS system proxy on macOS (127.0.0.1:4401), every plugin
install failed: netproxy.Env put socks5://… in bun add's *_PROXY, and Bun
takes only http:// and https:// proxies, so it stopped with
"UnsupportedProxyProtocol downloading package manifest". The plugin
host's own fetches already went through netproxy.Bridge (hostEnv), but
bunCommand, which runs bun add, update and remove, did not.

- netproxy.EnvForBun rewrites a SOCKS5 HTTPS_PROXY/HTTP_PROXY/ALL_PROXY
  (any case) to the loopback HTTP bridge in front of it; others as they are.
- bunCommand uses it, so add, update, remove and the host's run all get it.
- Sibling: an agent CLI installed with bun is updated by `bun add -g`
  with the same env (updateEnv in cliupdate.go); npm is left as it is.
- provider-plugins.md says so.

Run:
- TestInstallThroughSOCKSProxy (real bun 1.4.2, fake registry, SOCKS
  proxy only): on the old bunCommand it fails with the reporter's exact
  "error: UnsupportedProxyProtocol downloading package manifest"; passes
  with the fix, through the SOCKS proxy.
- TestEnvForBunBridgesSystemSOCKS (system SOCKS proxy via Env) and
  TestUpdateWithBunBridgesSOCKS fail with EnvForBun / the bun branch
  broken ("bun is given …socks5://…"), pass with it.
- Real use: a throwaway test with a real SOCKS5 server and only
  *_PROXY=socks5://… installed @magpie-community/opencode-cursor-auth
  0.2.2 from registry.npmjs.org through it; on the old code the same
  failed as UnsupportedProxyProtocol. Scratch HOME, not committed.
- gofmt -l clean; go vet ./...; GOOS=linux go vet -tags nogui ./...;
  GOOS=windows go vet ./...; go build; GOOS=linux go build -tags nogui;
  GOOS=windows go build; go test -tags nogui ./... (scratch HOME) all ok.
2026-10-09 19:35:35 +08:00
yetone bc7362904f quotas: another computer's magpie that won't tell its quotas says why on its card — not shared there, this key not taken, or not reached — instead of "Allowance unavailable" (Fim980 on X)
Fim980's tray showed one "Remote magpie · 暂时无法获取额度" card and
nothing else. A remote magpie's 403 (a gateway MAGPIE_ADDR opens, as
Docker's does, serves models to anyone but tells its quotas only when
shared on its local network — TestQuotasOverLAN keeps that), its 401 and
a failed connection all fell to the generic label. remoteJSON now gives
each its own error (errRemoteNotShared, errRemoteKey,
errRemoteUnreachable), and quotaError says what to do there, in en, zh,
zh-TW, ja and de; magpie quota and the TUI show the English sentence.

Run: go test ./internal/provider ./internal/gateway (pass);
TestRemoteMagpieUnreachable's new 403/401 cases and
usage-remote-cards.test.cjs's three new cards fail on the old code;
the GUI test passes in Chromium and WebKit, en/zh/ja/de; gofmt, go vet
darwin/linux/windows clean.
2026-10-09 19:34:11 +08:00
yetone f575da41cc provider: the Kiro mover asks for opencode-kiro-auth 0.1.8, which sends a tool named past 64 characters under a short name (#1393)
Kiro answers a tool name longer than 64 characters "Invalid tool use
format." (REQUEST_BODY_INVALID). Claude Code names a plugin's MCP tools
mcp__plugin_<plugin>_<server>__<tool> (the reporter's Cloudflare docs
tools are 70 and 71), so every turn of a moved Kiro account that offered
one failed: the plugin passed the name through. The built-in has cut such
names since e3b6397a (#369, TestBuildKiroToolImagesAndLongNames), so
users who haven't moved were not affected and the built-in is unchanged.

magpie-community/plugins e1efc3d (kiro 0.1.8) gives the plugin the
built-in's rule: a name outside [a-zA-Z0-9_-]{1,64} is sent as its head
plus a hash of the whole name, the same in every turn, and Kiro's calls
come back under the caller's name, streamed and not. Raising min makes
keepMovedCurrent update every moved user's plugin.

Run: HOME=<temp> go test -tags nogui ./internal/provider (ok) and
-run Kiro ./internal/gateway (ok); go vet (darwin, linux, windows) and
gofmt -l on ./internal/provider. Plugin side: tool-names.test.mjs fails
3 of 3 without its change and passes; bun test across the repo 1003
pass. npm view shows 0.1.8. Not tried against Kiro itself: no Kiro
sign-in on this machine.
2026-10-09 19:31:37 +08:00
yetone 655f96ede4 gui tests: usage-fold and account-mask no longer reload the Usage page the moment it opens, so WebKit has no first reads to cancel and report as page errors (#1350)
Both tests opened the page in a new browser context, cleared their
localStorage keys and reloaded at once. A new context's storage is already
empty, and the reload lands while the page's first reads (agents/cli,
agents/install, usage, upstream, update, whatsnew) are on their way. WebKit
logs each fetch it cancels as "Fetch API cannot load … due to access
control checks." with source "javascript", which Playwright's WebKit turns
into a pageerror, and the tests' final assert.deepEqual(errors, []) failed.
The app catches every one of those reads; nothing is wrong in the page.

Instrumented runs show it: requestfailed "cancelled" for whatsnew/update
and the two page errors within 70ms after the first reload, none after.

Verified (Playwright 1.62.1, macOS arm64), WebKit "several accounts"
(en+zh), 8 copies at once, 5 rounds:
- before: 3 of 80 cases red, each with "Fetch API cannot load … due to
  access control checks" right after the first reload
- after: 80 of 80 pass, no page error
- usage-fold.test.cjs + account-mask.test.cjs, Chromium and WebKit,
  10 runs: 100/100 pass (10 cases each run)
2026-10-09 19:24:16 +08:00
yetone ee14595ff3 gui tests: warm-at-times opens each language in its own locale and calls a time field squeezed when it is narrower than its own text, not under a fixed 60px (#1353)
WebKit draws <input type=time> in the context locale's clock. Playwright's
WebKit takes the machine's locale when none is given, so on a machine set
to a 24-hour locale the field reads "09:00" and is 59.0625px wide, whole,
with nothing clipped; in en-US it reads "09:00 AM" and is 82.0625px.
The 60px floor was calibrated to a 12-hour machine, so the 900px WebKit
cases failed for every reporter on a 24-hour locale. Chromium draws the
field at 85.125px in every locale.

Each language now runs in its locale (en-US, zh-CN, ja-JP, de-DE), so the
test is the same on every machine and covers both clocks, and "squeezed"
compares the field with a copy of itself laid out in a .warm-at of its own
in the same row.

Verified (Playwright 1.62.1, macOS arm64):
- with the locale pinned and the old 60px floor: webkit zh/ja/de 900px
  fail with "time field squeezed to 59.0625", as reported; en passes.
- new test, 20 runs of the whole file, Chromium and WebKit, en/zh/ja/de,
  900px and 440px: 320/320 pass.
- break check: #warmAtSegs .warm-time input.at { width: 44px } fails all
  8 cases at 900px ("squeezed to 44 of 59.0625 / 82.0625 / 85.125").
2026-10-09 19:24:16 +08:00
yetone 62b1c995ff provider, codex: an account the Codex app holds is switched off whatever its windows read, and never switched to (CavillZhang on X: "out of Codex and Work usage", some accounts not switched)
A Business seat at its workspace owner's limit (rate_limit.allowed false,
no credits) reads, say, 40% on every window, so NextLogin never counted it
spent: Codex said "You're out of Codex usage" and magpie kept it there. It
could also move Codex onto, or back to, a held account.

NextLogin now treats SubscriptionQuota.Held (codexHeld) as spent: moved off
at any share, skipped as a spare, and not gone back to while held.

The test TestCodexHeldAccountSwitchedUnderTheShare was already on main,
unfinished and failing (swept into 34a59837 from a stalled session's
working tree); this finishes it: all held -> stays; a spare freed -> moved
to it; not back while the first is held; back once it has room. It fails on
the old NextLogin. go test provider, gateway; go vet darwin/linux/windows;
gofmt -l clean. Not tried with a real held Business seat.
2026-10-09 14:24:50 +08:00
yetone 68aab298d3 gateway, codex: a provider the user added by its address is sent the service_tier Codex asks for, and offered Fast and Ultrafast (hsiangron on X)
Codex's Fast (service_tier=priority) and Ultrafast (ultrafast) were dropped
on the way to a custom provider: the gateway only passed a tier on to
chatgpt.com and Cursor, and magpie's models.json entries for such a
provider's models offered no tiers, so the user's own picks were written
over.

- Request.Tier keeps the client's service_tier as sent; for a provider with
  no preset and no account (OwnTier) both buildResponses and buildChat send
  it as it is. One that refuses the field by name is asked again without it
  and remembered as unfit for it, as the other optional fields are.
- codexcat offers such a provider's model the tiers Codex's own entry gives
  the same slug (dated snapshot and provider prefix stripped), Fast on
  another GPT model, none otherwise. Presets and groups are unchanged.

Run: TestOwnProviderGetsTier (Responses/Chat x priority/ultrafast/flex),
TestPresetProviderGetsNoTier, TestOwnProviderRefusingTier,
TestOwnProviderTiers; the gateway ones fail without the gateway change and
TestOwnProviderTiers fails without the codexcat change. go test gateway,
codexcat, catalog, provider; go vet darwin/linux/windows; gofmt -l clean.
Not tried against the reporter's relay.
2026-10-09 14:16:53 +08:00
yetone 0b5b10da2a agent, gateway, gui, tui: a Codex that goes through magpie only to fail over to the user's other ChatGPT accounts says so on the Agents row and keeps to its own models (#1385, shenghsi)
With several ChatGPT accounts on, failover writes openai_base_url into
~/.codex/config.toml so a turn the signed-in account can't pay for goes on
to the next account. With Codex's switch off, the row said only "not
connected", but every request still went through the gateway, and the
gateway's /backend-api/codex/models answer added all of magpie's models
(62 for the reporter).

- agent: Agent.FailingOver means not wired, while the stash's codex.failover
  (or a CC Switch mirror's codex.mirror_failover) base URL points at magpie.
  FailoverSaid is the line the CLI list and the TUI show. CodexOwnOnly is
  true for this machine's Codex when it is failing over and no WSL Codex is
  connected, since a WSL Codex shares the gateway path. Sync ages a
  models_cache.json that isn't tagged with the own-only list.
- gateway: codexModels leaves out provider.CodexListed when
  gateway.CodexOwnOnly (set by main to agent.CodexOwnOnly) says so. The
  ETag and X-Models-Etag carry provider.CodexOwnListTag. Neither tag
  contains the other, so Codex fetches the list again when it is connected
  or disconnected.
- gui: /api/state carries failover. The row says "Not connected · goes
  through magpie only to fail over to your other ChatGPT accounts". Its
  title says what turns it off. A "ChatGPT accounts" button opens
  Providers › Codex. Strings are in zh, zh-TW, ja and de. openProvider no
  longer draws the Providers list before it has loaded. It was only safe
  once the tab had been opened, and this is its first caller from another
  page.
- docs: agent-wiring and gateway-routing describe the failover-only Codex
  and list the new tests.

A user's hand-written openai_base_url (#856) isn't failover's, so it stays a
plain not-connected row.

Verified:
- TestCodexFailoverOnlyKeepsOwnModels (agent) builds the reporter's state in
  a temp HOME: Codex signed in, two more ChatGPT logins on, Sync, so the
  stash has codex.failover=1 and no joined/beside. It checks that the row is
  not wired and is FailingOver, that FailoverSaid names Providers › Codex,
  and that the real gateway handler's /backend-api/codex/models lists only
  the backend's gpt-5.5. It also checks that the cached full list is aged,
  and that after Join magpie's fake/m1 is back.
- TestCodexModelListOwnOnlyForFailover (gateway) checks the list and both
  ETags with the hook on and off.
- agent-failover.test.cjs checks the row text, title and button in en, zh,
  zh-TW, ja and de, at 980px and 420px, in Chromium and WebKit (20 pass).
  It also checks that the button opens Codex's editor, that the page
  doesn't scroll sideways, and that no click moves it.
- Broken by hand, each test goes red:
  - codexModels ignoring the hook fails both Go tests.
  - failingOver returning false fails the agent test.
  - dropping the Sync cache case fails the agent test.
  - removing the GUI branch fails the GUI test.
  - drawing the list before it has loaded (the old openProvider) fails the
    GUI test.
- go test -tags nogui ./internal/agent ./internal/gateway ./internal/provider
  ./internal/gui ./internal/tui ./internal/codexcat . in a temp HOME: all ok.
- node --test agent-*.test.cjs (254 pass) and i18n-keys, gui-ja, gui-de,
  click-scroll: all pass.
- gofmt -l is clean.
- go vet -tags nogui on darwin, linux and windows: clean.
- go build, and GOOS=windows go build: ok.
2026-10-09 14:10:13 +08:00
yetone 1bc0ed404a minimax-code: magpie's models have one slash in the whole ref, so SubAgents take them (#1387, worksyang)
MiniMax Code 3.1.0 checks a SubAgent's model against /^[^/\s]+\/[^/\s]+$/
(uQn in @minimax-ai/code's chunk-2XPMNZVR.js), and magpie wrote its models
under custom_provider.magpie by catalog id, so
custom_provider:magpie/bb-codex/gpt-6.1-sol was refused
(AGENT_CONFIG_INVALID).

- The writer keys each model by its catalog id with "~" for each slash
  (bb-codex~gpt-6.1-sol, provider.FlatID), when exactly one served id
  spells that way (provider.Flattened); else it keeps the slashed id.
- The gateway takes such an id back to that one provider's model or group
  in validateModel and unprefixed (unflat / provider.Unflat): only an id
  with "~", no slash, that resolves as nothing else; two ids spelled alike
  are neither. Never a bare-name first hit. Key limits, middleware and
  usage see the catalog id, so a key held to bb-codex/* is refused
  other~gpt-6.1-sol on every route, count_tokens too.
- A config an older magpie wrote moves on the next sync: the user's keys on
  a model go to its new key, defaultModel, defaultLightModel and
  memory.captureModel/consolidationModel follow, and the slashed key stays
  with enabled: false, so a session begun on it still resumes (checked
  with mcode exec --continue). Get still reads magpie/<catalog id>, and
  Disconnect still puts back the stashed default and variant.

Other agents: no installed agent bundle here (cline, command-code,
qwen-code, deepseek, pi, omp, codebuff, openclaw) has this regex; native
agents were not checked.

Verified:
- go test -tags nogui ./internal/agent ./internal/gateway ./internal/provider
  (temp HOME), gofmt -l, go vet darwin/linux/windows.
- New TestMiniMaxCodeOneSlash uses the reporter's regex and ids;
  TestFlatModelIDsReachTheirProvider has two providers serving
  gpt-6.1-sol. Breaking unflat, mcodeKey, the key carry-over, the legacy
  enabled:false and the ref rewrite each turned the tests red.
- Real run: built magpie serve in a temp HOME on 47812, two fake upstreams;
  `magpie mcode magpie/other/gpt-6.1-sol` wrote other~gpt-6.1-sol; real
  mcode 0.6.5 exec reached other's upstream with gpt-6.1-sol (usage:
  other/gpt-6.1-sol); its agent loader accepted an agent.md with
  custom_provider:magpie/other~gpt-6.1-sol and refused the two-slash one;
  `magpie mcode default` put minimax/MiniMax-M2.7 back.
2026-10-09 14:08:07 +08:00
yetone e66fa16593 codex: magpie's actor header goes into a [model_providers.magpie.http_headers] table the config already has, instead of failing every model switch (wztlink1013 on Discord)
wztlink1013 (v0.1.1134, Windows, Codex 0.162.0) could not switch Codex's
model on the Agents page: "config.toml: edited TOML is invalid: toml: key
http_headers should be a table, not a value".

Cause: putProvider rewrites [model_providers.magpie] whole with
http_headers = { "x-openai-actor-authorization" = "magpie" }. A config
that holds the headers as their own [model_providers.magpie.http_headers]
table keeps that table (a separate block the rewrite leaves), so the file
defined http_headers twice and writeTOML refused it. Reproduced in a temp
dir with exactly that message, in LF and CRLF files alike, so line
endings are not the cause. With only the subtable and no parent table the
same edit failed as "key http_headers is already defined". Dotted keys
inside the table (http_headers."x-org" = …) did not fail but were
dropped, the user's other headers with them.

Fix: edit.Inline, an inline table SetTOMLTable merges into the spelling
the file has: in [name.key] each entry is set with SetTOMLKey's in-place
edit (other headers and comments kept); among dotted keys their lines are
kept as written and each entry set there; otherwise written inline,
byte for byte as before. putProvider passes the header as an Inline.
SetTOMLKey's body moved into setTOMLKeyLines so both share it.

Siblings checked: no other agent writes an inline table with
SetTOMLTable (kimi's providers.magpie and Codex's thread tables are
scalars only); the MCP writer (putCodex) already removes a server's
subtables before writing it. Not changed: a magpie provider spelled by
dotted keys outside its own table ([model_providers] magpie.x = …, or at
the top) still fails as "table magpie already exists"; that is not this
report's shape.

Run:
- TestSetTOMLTableInlineMergesUsersSpelling (internal/edit): own table,
  own table without the header, own table only, dotted keys (with and
  without the header), inline, absent; each LF and CRLF, checked by
  decoding the result. Red with the subtable branch disabled (the
  reported error) and with the dotted branch disabled (user's x-org lost).
- TestCodexMagpieHeadersAsOwnTable (internal/agent): the reporter's shape
  through codex().Fields[0].Set, LF and CRLF. Red with putProvider back
  on edit.Raw: "key http_headers should be a table, not a value".
- gofmt -l (clean); go vet, GOOS=linux go vet -tags nogui, GOOS=windows
  go vet (with and without nogui) on internal/edit and internal/agent;
  go build for darwin, windows and linux (nogui).
- go test -tags nogui ./internal/edit ./internal/agent ./internal/library
  ./internal/provider under a temp HOME with pinned caches: all ok.
- codex-cli 0.161.0 (CODEX_HOME in a temp dir, codex mcp list) loads the
  merged config. Not tried on the Windows box or Codex 0.162.0.
2026-10-09 13:50:53 +08:00
yetone 71ee5e3519 gateway: a Claude subscription conversation whose client took its images out goes on in its run (#1382, Songxiaor)
Hana's pre-step hook replaces each image before the last assistant message
with "[图片已省略:历史图片保留为文件引用,避免重复发送原始 base64]" in the
tool result or user message that held it. Over pi-ai's openai-completions
the user message of the results' images ("Attached image(s) from tool
result:" and the images) is gone and each result's text has the line added
to its end. The step after one that read an image missed its run both
ways: tool results failed follows() (the hash of what the run heard), and a
new turn failed turnKey and looseTurn.rewrote (message count changed). A
new claude -p was told the whole conversation: 10 of 67 requests, 79% of
the session's cache writes.

lostMedia (claude_lost_media.go) tells this rewrite by its shape, not by
Hana's string: comparing the conversation a run had (each part kept as the
hash and length of its words, heardRuns) with the request's, a role's run
of messages at a time, it holds when at least one image or file was taken
out and nothing else changed: each image/file is still there, gone, or has
text in its place; a tool result may have text added to its end only when
it carried images or the images after the results went; the caption
before those images goes with them only when a result before it grew.
follows() checks it when the hash misses (toldRuns), and looseTurn.rewrote
when the #912 cut rule doesn't match; the looseTurn key drops the message
count, which the per-turn comparison covers. messageWords is now built from
messageParts, the same words, so the hash and the comparison can't drift.
The shape over the string: any client that strips media the same way is
covered, and a compaction or edit (#459) changes words lostMedia never
accepts. Not covered: a tool result of an image alone, whose pi-ai text
"(see attached image)" is replaced rather than added to.

claude_lost_media_test.go builds its fixtures from pi-ai's literal Chat
Completions bodies (read from the installed HanaAgent server seed, 1.0.0-beta: pi-ai
openai-completions.js and Hana's media-strip hook): a table of 18 shapes
for lostMedia, an end-to-end new turn through serveClaudeSubscription with
the script Claude Code (a tool's image, a user's image), and tool results
after the image step through serveSubscription (goes on; a changed user
message or a changed result still starts anew). The two end-to-end tests
fail with lostMedia unhooked from follows/rewrote; each guard in
lostFromRun was broken by hand and turned a table case red.
docs/subsystems/claude-subscription-bridge.md has the rule.

Run: gofmt -l; go vet -tags nogui ./internal/gateway/ (darwin, GOOS=linux,
GOOS=windows); go test -tags nogui ./internal/gateway/ -count=1 under a
temp HOME (ok); -race -count=5 on the lost-media, rewritten, #459, letGo,
given-up and fork tests (ok). Not tried against a real Hana or a sandbox
magpie with a real claude: the harness's script stands in for Claude Code.
2026-10-09 13:45:42 +08:00
yetone 98236edd77 agent: Codex installed by install.ps1 on Windows shows its newest version and offers codex update, and its version is read again after each update (xiaoxiaofeixz on Discord: 不会自动检测最新的版本)
Codex's install.ps1 puts %LOCALAPPDATA%\Programs\OpenAI\Codex\bin on PATH.
That folder is a junction to ~\.codex\packages\standalone\current\bin, and
current is a junction to releases\<version>-<target>, retargeted by each
update. filepath.EvalSymlinks leaves junctions alone since Go 1.23, so
magpie saw the binary only as ...\Programs\OpenAI\Codex\bin\codex.exe:

- howInstalled didn't find .codex/packages/standalone in its path, so the
  Agents page showed the version with no newest version and no update.
- installedVersion's stamp was that unchanged path plus size and mtime.
  npm's archive, which install.ps1's legacy layout copies from, gives every
  version's codex.exe the same 1985 mtime. So a new release was asked again
  only if its size happened to differ, else after the 24h memo.

proc.RealPath is now the path Windows opens (GetFinalPathNameByHandle),
junctions followed, and filepath.EvalSymlinks elsewhere. cliupdate's
howInstalled and installedVersion and proc.Version use it.

TestCodexInstallPs1Junctions (Windows) builds install.ps1's layout with
mklink /J, two releases whose codex.exe have the same bytes and mtime. It
checks that codex is recognized as self-updated (codex update) and that
retargeting current makes the version asked again. On the Windows box
(10.0.0.132, sandboxed USERPROFILE/APPDATA/LOCALAPPDATA) it fails on the old
code (howInstalled nil; "0.161.0" after the update) and passes on the new.
The rest of the agent package fails on Windows with the same 17 tests
before and after (TestFolderVarsAreListed, TestAside*, TestVSCodium, ...),
which CI never runs there. `codex update` on Windows is in codex.exe 0.162.0
(win32-x64): it runs install.ps1. The layout was read from
https://chatgpt.com/codex/install.ps1.

Not tried with a real install.ps1 install (it rewrites the user's PATH in
the registry). go test -tags nogui ./internal/agent ./internal/proc
./internal/provider ./internal/library (macOS), gofmt, and go vet for
GOOS=linux and windows pass.
2026-10-09 13:44:50 +08:00
yetone 1069785dc7 catalog: a model a relay renamed (claude-deepseek-v4.1-flash[1M]) gets its levels, window, images, name and price from the model it is (#1383, uWydnA)
Relays rename models for Claude Code with a "claude-" prefix and/or a
window mark ("[1M]", "[200K]"). Every capability getter looked the bare id
up exactly, so such a model had an empty reasoning menu, no images (the
gateway's vision stand-in described pictures to a model that sees them)
and a 0 window.

Now every getter shares one lookup (lookup -> found -> renamed):
- found is the chain EffortsOf and ContextOf already had (exact, a Bedrock
  geography, "(variant)", ":tag"), now also used by Thinks, SeesImages,
  NameOf and OutputOf, which matched the exact id only.
- renamed applies only when models.dev lists no model by the id at all
  (a new `listed` set of every bare id, whatever it says of it): a window
  mark comes off first, and if what is left is listed it is the model
  (Claude Code's claude-opus-5[1m] stays claude-opus-5, never "opus-5");
  else a "claude-" prefix comes off when the rest is listed and names a
  version, so the reporter's counter-example claude-auto[1M] is not
  models.dev's router "auto".
- ContextOf gives a mark's window (what the relay serves it at, as Claude
  Code takes [1m] for 1M); NameOf keeps the mark ("DeepSeek V4.1 Flash
  [1M]") so two models apart by window alone read apart.
- Siblings: PricedBy (last, after the dot/dash match) and ListedBy take
  the rename too; Knows follows from ContextOf/EffortsOf/Thinks.

Ran: the reporter's TestRelayRenamedIDLosesCapabilities verbatim, plus
tests for every getter on the renamed forms, Claude [1m] ids, a listed
claude-glm-5 keeping its own (empty) levels over glm-5's, near misses
(claude-auto, -flashy, [fast], [infm], anthropic- prefix) matching
nothing, gpt-5.5/auto/opus-5/glm-5 unchanged. Broken by hand seven ways
(no rename, no listed gate, no version check, mark window ignored, prefix
before the mark-stripped id, PricedBy or ListedBy without the rename):
each FAILs naming the getter. Against today's models.dev api.json:
claude-deepseek-v4.1-flash[1M] -> low,high,max, 1M, images; claude-auto[1M]
-> nothing; claude-code (listed) as before. gofmt -l clean; go vet,
GOOS=linux go vet -tags nogui, GOOS=windows go vet ./...; go build for
darwin, linux (nogui), windows; go test -tags nogui ./... in a temp HOME
all ok.
2026-10-09 13:35:14 +08:00
yetone 4cbde14cea gui tests: rate-limited-sink wheels the WorkBuddy AI pool's "Goes to the back" into sight before clicking it, as aab2e441 did for the group editor's rows (#1376)
The pool sits under the 1400px window's foot. In WebKit Playwright's own
scroll into view is put back by the page (it undoes a scroll the reader
didn't make), so the click timed out with the option outside the
viewport, en and zh. The test now wheels to it as the reader would, then
reads the scroll position the "a click never scrolls the page" check
compares against, so that check still covers the click alone.

Ran: before the change, BROWSER=webkit: 2 pass, 2 fail (both "the
Routing page sets it", "element is outside of the viewport"). After:
Chromium + WebKit, en + zh (the languages this test covers), 3 runs of
8/8 pass, then WebKit alone 3 runs of 4/4 pass.
2026-10-09 13:31:30 +08:00
yetone 2c36d3f282 gui tests: TestSetAnswersWhileDevinAndProvidersHang tells that the set didn't wait by order, not a 1.5s clock, and its fake devin ends with the test (#1375)
The 1.5s wall-clock bound failed at 1.6s under a loaded full run. The
fake devin CLI now takes 20s and touches a marker when its answer lands;
after the requests the test says no marker is there, which no request
that didn't wait can see, whatever the load. The bounds are 10s: slack
against load on one side and against the fake's 20s on the other, and
they still stop a set that waits on the never-answering provider.

As ggbdpq proposed on the issue, with one change: their fake ran
`sleep 20` and outlived the test (LESSONS: a fake a test starts ends
when the test does). This one sleeps in 0.1s steps, stops on a file the
test's cleanup writes, and the cleanup waits (5s at most, else an error)
until every run of it has ended. Seen: 2 runs alive at cleanup, 0 after.

Ran: -count=3 -v pass (sets 5-111ms); -race -count=10 pass; the whole
internal/gui package pass; gofmt -l clean; go vet, GOOS=linux and
GOOS=windows go vet -tags nogui ./internal/gui clean. Broken by hand
(DevinFamilies ignoring DevinNoWait): FAIL, "set claude-opus-5-5 took
21.6s" and "a devin answer landed before the requests were done".
2026-10-09 13:29:08 +08:00
yetone 3cd0b5f78d gui: lightweight mode no longer lets go of a main window that is open under another app's, so Command-Tab back to magpie finds it there (#1381, ttmouse)
Cause: lightweight mode (#580) let go of the main window once two of its
30 s looks found it "not shown", and it asked Wails' IsVisible, which on
the Mac is the window's occlusion state. A window the user had
Command-Tabbed away from, covered by the other app's window, counted as
closed: 30-60 s later its webview was dropped and the window closed, and
Command-Tab back to magpie found a blank shell or no window at all. Hidden
with magpie (Command-H) it went the same way, since hiding orders every
window out.

Change: windowUp says whether a window is up as the user sees it:
ordered in on any Space, covered or not, minimised, or open as magpie was
hidden (watchHide records those on NSApplicationWillHideNotification).
Only a window magpie closed itself is down. lighten (through lightShown)
and mainShown use it, so the Dock-while-open setting and a restart to
update also keep a covered window as open. Elsewhere windowUp is
IsVisible as before (visible there covers covered and minimised).

Run:
- Reproduced with a sandbox build of origin/main (own HOME and
  CFFIXED_USER_HOME, port 3481, dock+lightweight on): with the open
  window covered by a full-screen window of a helper process for 70 s the
  log said "the main window's webview let go" and the window was a blank
  white shell. With this change, the same run let go only the closed
  panel and the window kept its page (screenshot of the window).
- Activation itself was checked by handing activation from the helper to
  magpie (NSRunningApplication activateFromApplication): with the window
  there it came forward. A real Command-Tab keystroke was not sent.
- New TestCoveredWindowStaysUp (AppKit, own process): open, covered by
  another window (until IsVisible says false), hidden with the app,
  minimised -> up; closed by magpie, also with the app hidden -> down.
  With windowUp put back to IsVisible it fails: "open: the window counts
  as up=false, want true"; without the hidden clause: "hidden with
  magpie: the window counts as up=false, want true". Passes -count=10.
- gofmt -l; go vet ./internal/gui; GOOS=linux go vet -tags nogui and
  GOOS=windows go vet (with and without nogui) ./internal/gui; go build,
  GOOS=linux go build -tags nogui, GOOS=windows go build;
  go test ./internal/gui and go test -tags nogui ./internal/gui under a
  temp HOME: ok.
2026-10-09 12:59:29 +08:00
yetone 4b56c8672d plugin: an update Bun holds back says why, instead of saying it was made (sweanng424 on Discord)
What: Update, Update all, the hourly auto-update and the move's install
ask npm for the plugin's newest version and have bun install that exact
version (installAt), then check it is the one installed. When bun won't,
the error says why, reason first for the status pill: "<pkg> 0.1.21 isn't
installed yet: Bun's minimumReleaseAge (in a .bunfig.toml) holds back
versions published less than 2 days ago, and magpie installs it once it
is older (bun's own words)". Another bun error (a registry of the user's
own without the version) is "<pkg> <v> isn't installed: <bun's error:
lines>". Specs stay as they were (name@latest); pinned and git plugins,
and npm not answering, install as their spec says, as before.

Why: the Factory card stayed at 0.1.18 with 0.1.21 out, through clicking
update and restarts. bun add pkg@latest resolves "latest" by bun's own
config: a minimumReleaseAge in the user's ~/.bunfig.toml or
$XDG_CONFIG_HOME/.bunfig.toml makes it install the newest version older
than that and exit 0. 172800s (2 days) gives exactly 0.1.18 (0.1.19-21
were published 10-08). So every update path succeeded with nothing
changed, and the page said "updated to v0.1.21". Asked for 0.1.21 itself,
bun fails with "blocked by minimum-release-age: 172800 seconds". The same
code serves every moved plugin (Cursor, Devin, Kiro, Qoder, ...), so all
of them had it. npm view: 0.1.21 is latest; npmmirror has it too.

Run:
- TestUpdateBunHoldsBackSaysWhy (new): real bun 1.4.2 against a fake
  registry with 0.1.18 (3 days old) and 0.1.21 (1 hour), a bunfig with
  minimumReleaseAge = 172800. On the old code it fails: "Update with
  0.1.21 held back by Bun: <nil>, want bun's reason (installed 0.1.18)".
  Breaking Update alone fails at "Update all: <nil>", breaking the hourly
  update alone at "the hourly update: <nil>". Without the bunfig it
  installs 0.1.21 and keeps the spec @latest.
- Real use: magpie web (nogui build) under a sandbox HOME, real npm, with
  the bunfig: add factory-auth@0.1.18, spec @latest, POST
  /api/plugins/upgrade and /update return the reason above, the card
  stays 0.1.18; bunfig removed, upgrade installs 0.1.21.
- Ruled out on macOS and the Windows box: plain upgrade 0.1.18 -> 0.1.21
  works there without a bunfig, host running or not.
- gofmt -l; go vet (darwin, GOOS=linux, GOOS=windows, -tags nogui) on
  internal/plugin, internal/provider, internal/gui; go build (darwin),
  GOOS=linux and GOOS=windows -tags nogui builds; go test -tags nogui ./...
  under a temp HOME, all ok.
2026-10-09 12:57:56 +08:00
yetone 8e0e7d3c40 dsh: the desktop app's profile made while magpie runs gets magpie's route within a round, not at magpie's next start (star on Discord)
star on Discord reported DeepSeek Harness Desktop with the red
"已不经过 magpie" line. One way there that a desktop user meets: the
CLI's web profile wired, then the desktop app opened for the first time
while magpie runs. dsh makes profiles/desktop/cordis.patch.yml as its
empty template ([]), dshCheck says the desktop profile has none of
magpie's models (Drift "unwired"), and only dshSync — the catalog sync,
at magpie's start or a catalog change — filled new profiles, so it stayed
so until Reapply or a restart. The reporter's exact detail is cut off in
the screenshot, so this is a likely cause, not a confirmed one.

dshWiredOnce, the 30s serving round, now runs dshFillNewProfiles over a
profile that is dsh's empty template, beside the wired ones: magpie's
route and start go in as the catalog sync would put them. A profile with
entries of its own and no route is the user's and is still left alone.

Run: TestDshDesktopProfileMadeWhileServing fails on the old code ("the
round left the desktop profile bare"), passes with the fix, and checks a
user's own unrouted profile is not written. go test -tags nogui
./internal/agent ./internal/gui under a temp HOME with pinned caches: ok;
go test -race -run TestDsh -count=3: ok. gofmt clean; go vet darwin,
GOOS=linux -tags nogui, GOOS=windows; go build darwin, linux nogui,
windows: ok. Not tried with the real desktop app live.
2026-10-09 12:56:08 +08:00
yetone ee3f83b59d agents: DeepSeek Harness Desktop installed is no missing dsh CLI — its version shows instead of 未找到 CLI (star on Discord)
star on Discord: "DeepSeek Harness 就是现在已经有桌面版了,但是他还是非要找CLI".
With DeepSeek Harness Desktop in use and no dsh on PATH, the Agents page
said 未找到 CLI and Install another agent offered npm's dsh: CLIMissing
(#843) took ~/.dsh without a CLI for an uninstall. The desktop app
(DeepSeek Harness.app, com.deepseek.dsh, 0.2.0-rc.2 from
download.deepseek.com) keeps the same ~/.dsh — its own profile,
profiles/desktop/cordis.patch.yml, and .credentials.yaml — and needs no
CLI; its terminal command (Manage dsh Command…) is optional.

- desktopapp.go: dsh's app is known by its bundle id on macOS and by
  %LOCALAPPDATA%\Programs\DeepSeek Harness\DeepSeek Harness.exe on
  Windows (electron-builder per-user NSIS, productName DeepSeek Harness,
  getWindowsInstallationDirName); HasApp says either is installed.
- install.go: CLIMissing is false with the agent's desktop app installed,
  so the GUI row, Install another agent and /api all follow; the app's
  version shows through the existing CLI().App path.
- dsh.go: the restart notice also sees DeepSeek Harness.app running.

The red "已不经过 magpie" line in the report is a separate dshCheck
finding, not caused by the missing CLI: a desktop-only ~/.dsh shaped
from the app's real new-profile template wires cleanly (Check "", no
Drift; TestDshDesktopOnlyWired). The reporter's exact detail is cut off
in the screenshot and is not reproduced.

Run: TestDshDesktopAppIsNoMissingCLI fails on the old code ("with the
desktop app: missing true, offered true"; with the Windows path removed,
"with the Windows app: missing true, offered true"), passes with the
fix. go test -tags nogui ./internal/agent ./internal/gui under a temp
HOME with pinned caches: ok. gofmt -l clean; go vet darwin, GOOS=linux
-tags nogui, GOOS=windows on both packages; go build darwin,
GOOS=linux -tags nogui, GOOS=windows: ok. Not tried: the real desktop
app running live (not installed here; its userData isn't redirected by
a sandbox HOME), a real Windows install.
2026-10-09 12:56:08 +08:00
yetone 4dc7222fdc kimi: each of magpie's models in Kimi Code has its name (display_name) and, where magpie knows it, its most output (max_output_size); a value the user set in the table stays (Sorghum on Discord)
What: kimiModelTables writes display_name (the name magpie shows for
the model, left out when it is just the id) in every [models."magpie/…"]
table, and for the new Kimi Code max_output_size (the catalog's output,
capped at the window by maxTokens; left out when unknown). kimi-cli gets
display_name only: it has no max_output_size. What magpie last wrote in
each table is kept in the stash (kimi:<path>:wrote); a value there that
isn't it is the user's and stays, while magpie's own follows the catalog
and goes when the catalog no longer knows it. The record goes when magpie
steps out.

Why the keys are safe: Kimi Code (MoonshotAI/kimi-code) has had both
maxOutputSize and displayName on its model alias since 0.2.0, its model
record passthrough on 2.x (agent-core-v2 kosongConfig/configSection.ts);
max_output_size becomes the request's max_completion_tokens for the kimi
type. kimi-cli (MoonshotAI/kimi-cli) added display_name to LLMModel in
1.37.0 (0bc7928, #1948); its config never sets pydantic extra, so older
ones ignore the key.

Run: TestKimiNamesAndOutput (new; both config dirs, with hand-set values)
fails three ways with the change broken: user values not kept
(display_name "Big One · Relay", want "My Big"), magpie's stale value kept
(max_output_size 32000, want 64000), neither key written (display_name "").
go test -tags nogui ./internal/agent/... (temp HOME, caches pinned) ok;
gofmt -l clean; go vet + GOOS=linux/windows go vet ./internal/agent ok;
go build, GOOS=linux go build -tags nogui, GOOS=windows go build ok.
Real use in a temp HOME: Kimi Code 2.1.1 (npm) `kimi doctor config` OK on
the written config, and `kimi -p` against a local fake server answered,
its request carrying max_completion_tokens 32000; kimi-cli 1.52.0's own
load_config read display_name and ignored an extra max_output_size.
2026-10-09 12:51:30 +08:00
yetone 6f59be4ef7 plugins: Qoder's mover asks for 0.2.11, so a moved Qoder CN enterprise VPC account keeps its model list and renewal mark apart from a public account with the same uid (plugins#40)
0.2.11 (magpie-community/plugins#40, AwadYoo) keys the plugin's model
listings and its "renewed" marks by deployment and uid instead of uid
alone. Raising min lets keepMovedCurrent update every moved Qoder user.

Run: plugins bun test 1000/0 under a temp HOME at the PR head, the two
new tests fail with the key reverted to cred.uid; npm view shows 0.2.11;
here go test -tags nogui ./internal/provider -run Moved|Migrat|Qoder|
Mover|KeepMoved ok, gofmt -l clean.
2026-10-09 12:51:13 +08:00
如是 e119470a70 gui: the ZCode plugin's claim line is translated — its name is a sentence magpie knows, its count and plan name are data (#1244)
* gui: the ZCode plugin's claim line is translated — its name is a sentence magpie knows, its count and plan name are data

The ZCode plugin's card gets a line when the app holds a gift plan the
account hasn't been granted yet. It was a window named after the plan and
displaying "1 to claim · claim it in the ZCode app", which stayed English
on a Chinese interface: a window's display is shown as it came
(quotaCount returns w.display for a window with no limit, app.js) and only
its name goes through t() — on the card, in the tips and in the chart. The
plugin cannot know the interface's language (host.js and provider.Provider
hand it no locale) and magpie's translation is keyed by the string, so the
sentence has to be the name. The plugin's own change is
magpie-community/plugins#43: the window is named "Gift plans to claim in
the ZCode app" and the display carries "1 · ZCode Trust Build" — one name
for any count, and the plan's name as the vendor gave it.

zh, zh-TW, ja and de each get that name: 有赠送额度待领取 · 请在 ZCode
客户端领取, 有贈送額度待領取 · 請在 ZCode 用戶端領取, 受け取り待ちのギフト
枠があります · ZCode クライアントで受け取ってください, Geschenk-Kontingente
zum Abholen in der ZCode-App (yetone/magpie#1001).

quota-name-translated.test.cjs is new and holds the rule itself, not just
this line: a window whose name is a sentence magpie knows and whose
display is the plugin's own, in English and Chinese, Chromium and WebKit —
the name reads in the language, the display does not. It fails on the two
Chinese runs without the zh entry. gui-de/gui-ja/gui-zh-tw keep every zh
string covered, and i18n-keys.test.cjs passes.

* gui: the claim line's translations fit the card at 560px, and the test checks every language at 440 and 560

The names added for "Gift plans to claim in the ZCode app" were too long for
the card: .quota-labels > span is nowrap with no ellipsis and no tooltip, so
anything wider than its .quota is cut off mid-word with nothing saying so
(yetone on #1001, measured in en/zh/zh-TW/ja/de at 360–1000px in both
engines). ja was the worst — cut by 79px at the default 660px window and
129px at 560 — with de cut at 560 and zh/zh-TW at 440.

Every language is now a shorter sentence, checked in the real page at 440
and 560px in Chromium and WebKit: nothing overflows and nothing is clipped.
en is unchanged (it is the key, and it fits at 440). zh/zh-TW drop the
"· 请在…领取" clause for a leading "待在…领取的"; ja uses the wording
suggested in review; de likewise.

quota-name-translated.test.cjs now covers every language magpie has, not
only en and zh, and renders each at 440, 560 and 1000px rather than 1000
alone — the width that could never catch this — asserting the name's right
edge stays inside its .quota and that it isn't clipped. Red/green: with the
previous long wording restored, the fit assertion fails with the measured
overflow (zh and zh-TW by 14px at 440, ja by 189px, de by 68px); with the
new wording all ten pass. i18n-keys, gui-de, gui-ja and gui-zh-tw pass.

* gui: the claim-line test's README says what it covers, and its fit check measures the box

Two things from review (#1001): the README paragraph still said "in
English and Chinese" and said nothing about the widths, though the test
now covers every language magpie has at 440, 560 and 1000px — a
translated name is a sentence and .quota-labels > span is nowrap with no
ellipsis, so the width is the point of the test, and 1000 alone could
never catch a name that is too wide.

And the fit check read `s.scrollWidth > s.clientWidth + 1`, which is
always false on an inline span: the dead half is gone and the name's own
box is compared with its .quota instead, which is what actually catches a
cut name (the `over` value beside it was doing the work). Red/green, with
the long wording restored: "the name is cut off at 440px: 14px past its
quota, wider=true" (zh, zh-TW), 189px (ja), 68px (de).
2026-10-09 12:35:34 +08:00
yetone 78ae171a6d gui: the folded Codex row says which copy to reopen, "已接入 · 重新加载编辑器窗口后生效" for an editor's Codex, instead of only "reopen Codex" (#1374, SivanCola)
Folded, the row said "Connected · takes effect once Codex is reopened"
whichever copy was left on the old list. The reporter reopened the
ChatGPT app while the copy left was VS Code's Codex, and read the line
that stayed as a false alarm; only the opened row (2f74a963) said which.

connectLine now names the copy from a.staleCopies' kinds, as the
opened row does:
- one kind left: how that one is reopened ("reload the editor's
  window", "quit and reopen the Codex app", "restart Codex's
  app-server", "reopen {app}" for another app's own Codex, "restart
  Codex in the terminal"), worded short with the kind first, since the
  folded line is cut at narrow widths;
- several kinds: "{n} copies of Codex to reopen";
- the line's title has every copy whole, with its start and command
  (⌘Q, codex app-server daemon restart), for when the words are cut;
- no staleCopies, or a kind magpie doesn't know (Claude Code): the
  old words.
Strings in zh, zh-TW, ja and de with the same placeholders.

Verified:
- agent-stale-copies.test.cjs gains "the folded row says which Codex
  to reopen": ide (the reporter's case), app, embedded and several,
  Chromium and WebKit, en/zh/zh-TW/ja/de, 980px and 440px: 20/20
  pass; with app.js put back they fail ("ide: Connected · takes
  effect once Codex is reopened"). The opened-row tests still pass.
- gui-ja, gui-de, gui-zh-tw (every string, same placeholders) pass in
  both engines.
- real GUI: a magpie built from this tree, `magpie web` under a
  sandbox HOME on 127.0.0.1:3486, a real codex app-server started
  from <HOME>/.vscode/extensions/openai.chatgpt-…/bin/…/codex before
  Codex was connected: /api/state gave staleCopies ide plus the
  machine's ChatGPT.app, and the folded row read "已接入 · 2 个运行中的
  Codex 待重开" / "Connected · 2 copies of Codex to reopen", its title
  naming the editor's copy and the app's, Chromium and WebKit, 440px
  and 980px. The one-kind case wasn't reproduced live (the machine's
  ChatGPT app isn't mine to quit); it is covered by the test above.
- go test -tags nogui ./internal/gui under a temp HOME; GOOS=linux and
  GOOS=windows go vet -tags nogui ./internal/gui.
2026-10-09 12:17:21 +08:00
yetone 457119fec3 gui: text selected in a dialog and let go outside it no longer closes the dialog; only a press and a release both on the backdrop do (#1373, xiaozhu1337)
Dragging a selection out of a field (to bring in the part past its
end) and letting go over the backdrop gave the click to the press's and
release's nearest shared ancestor, the backdrop, so every backdrop
handler that checked only e.target closed the dialog.

backdropClick(el, on) in app.js records where the pointer was pressed
and let go, and a click counts as one on the backdrop only when both
were. Every dialog that closes from its backdrop now asks it:
- the shared #modal (provider and other editors, cancelEdit),
- the confirmation that borrows #modal (closeConfirmAsk),
- the library's dialog that borrows #modal (closeLibModal, library.js),
- confirmAction's <dialog> (its ::backdrop is the dialog outside its box).
Menus and pickers close on mousedown outside and were never affected.

Verified:
- new internal/gui/tests/dialog-backdrop-drag.test.cjs, Chromium and
  WebKit, en and zh, 1100px and 440px: 8/8 pass. With the change
  reverted all 8 fail ("a selection dragged out keeps the dialog");
  with only confirmAction reverted they fail on "a selection dragged
  out of the confirmation keeps it".
- the whole GUI suite in both engines: the only failures are
  purpose-filter in WebKit (fails the same without this change, #1291),
  rate-limited-sink "says who is at the back" in WebKit (fails the same
  without this change) and library-servers-bulk in Chromium (failed
  under the full run only, passes alone).
- real GUI: a magpie built from this tree (`magpie web` under a sandbox
  HOME on 127.0.0.1:3486), Chromium and WebKit, zh and en, 440px and
  980px: a drag from the provider editor's Name field out over the
  backdrop selects the text and leaves the editor open; a click on the
  backdrop still closes it.
- go test -tags nogui ./internal/gui under a temp HOME; GOOS=linux and
  GOOS=windows go vet -tags nogui ./internal/gui.
2026-10-09 12:17:21 +08:00
yetone be2037285a davsync, gui: a server file that isn't a backup is said as what it is, and can be replaced by this computer's setup (JasonLeeForOnly on Discord)
JasonLeeForOnly's WebDAV sync failed with only "not a magpie backup" for
several versions, and nothing on the page got past it. A WebDAV GET of the
backup can come back as a body that isn't the file in several ways; each is
now reproduced with an httptest server that answers the way the real one
does:

Unpacked (WebDAV and S3): gzip with or without Content-Encoding (a proxy
that drops the header), x-gzip, zstd with or without the header, deflate
(zlib or raw), and a header that says gzip over a plain body. Bodies are
told by magic number; a backup is JSON, so either magic is never the file.
Brotli (br) can't be unpacked and is said as itself, with what to turn off.

Said, with no upload offered: an HTML page answered with 200 (Nextcloud
login after a redirect, Cloudflare Access, an AList/OpenList web app, an
rclone directory listing, a captive portal), with its <title> and the
host/path a redirect ended at (query, fragment and user dropped), and
where the WebDAV address is for 坚果云 and Nextcloud; an XML answer, by its
root element.

Said, with Upload as the way out: an empty (0-byte) file, a backup cut
short or damaged (the automatic rebuild is unchanged and still goes first
when its rules allow), another app's file ("format": "…"), other JSON,
and any other file by its content type. Messages carry the size, kind and a
page title only, never the file's contents.

backup.Open now tells ErrNotBackup (doesn't begin like a backup) from
ErrCorrupt (begins like one, cut short). The status carries the kind as
serverFile; Settings › Sync and backup says it in the reader's language,
wraps it whole, and shows "Upload this computer's setup" only when the file
may be replaced. davsync.Upload (POST /api/davsync/upload, `magpie webdav
upload`, `magpie s3 upload`) asks first in the GUI, re-reads the file,
refuses a backup this computer opens, one sealed with another passphrase,
a newer magpie's, a page and an XML answer, keeps the server's copy in the
sync folder (0600, newest 5) and writes nothing if that fails, then writes
with If-Match on the version read. New strings in en, zh, zh-TW, ja, de.

Verified: internal/davsync/not_backup_test.go (TestReadCompressedBackup,
TestReadBrotliSaid, TestServerAnswerSaid, TestCutBackupSaidAndUploaded,
TestS3ReadCompressedObject; Upload-only parts stripped to compile) fails
on e4a90aa6 for every compressed case but "gzip said", Brotli, every
server-answer case, the cut case and S3 gzip, and passes here. Dropping
the Replace check in Upload turns the page/XML cases red ("Upload wrote
over a page"). gofmt -l clean; go vet darwin, GOOS=linux and GOOS=windows
-tags nogui on ., davsync, backup, gui; full go test -tags nogui ./... ok;
-race on davsync and backup ok. GUI: sync-not-backup.test.cjs (en, zh,
zh-TW, ja, de; 900 and 440px), sync-*, s3-sync, gui-de, gui-ja, gui-zh-tw,
click-scroll in Chromium and WebKit, 81 pass. Not tried against a real
WebDAV server or on the Windows box; the reporter's server is unknown.

Note: 2018882e on main carries this subject by mistake (another session read a shared message file); it holds only GTK DPI code. This commit is the actual WebDAV change.
2026-10-09 12:14:26 +08:00
yetone 28c31a365e readme: ordered for a reader deciding whether magpie fits (noffle's Art of README, via better-readme-skill): who it's for and who doesn't need it, then Quick start and Supported agents before the features; the six feature cards that repeated the sections below are gone, usage and sharing are tables, the intent and nested-routing shots give way to the intent docs link; zh mirrors en and gains the missing Qwen Code; every relative link and image checked 2026-10-09 12:13:28 +08:00
yetone b86001d432 gui: on GTK 3 with no font DPI set, magpie gives GDK one before the first webview, so the window and panel no longer collapse (#1371, haoxianhan)
This empty commit holds the message for 2018882e, which was pushed with
another session's davsync commit message by mistake (a reused scratch
file). 2018882e changes only docs/subsystems/gui-shell.md,
internal/gui/app.go and internal/gui/fontdpi_* (+ testdata probe); it
contains no davsync change.

GDK's unset font resolution is -1. WebKitGTK 2.54's GTK 3 settings
reader (SystemSettingsManagerProxy::xftDPI) multiplies it by 1024
unchecked, and every page laid out at a negative width with a
9000000px body font: the window showed its text one word per line.

fontDPI queues a G_PRIORITY_HIGH idle just before app.Run. Wails makes
its GtkApplication (and so initialises GTK, with its program name set)
inside Run and makes each window from a default-idle-priority callback,
so the check runs on the main thread after GTK's own start and before
the first webview, without initialising GTK earlier than Wails does.
A positive DPI (the user's own) is left alone. An unset one takes
gtk-xft-dpi, or 96, times a valid GDK_DPI_SCALE, as GTK does for a
positive gtk-xft-dpi. GTK 4 builds, macOS and Windows are unchanged.

Run, in Docker (Debian 13 arm64, GTK 3.24.49, WebKitGTK 2.54.0, Xvfb):
- testdata/fontdpi_probe.c without the guard at DPI -1: viewport
  -40320, font 9000000 (the reporter's literal values); with it: DPI 96,
  viewport 420, font 16.
- TestFontDPIUnset (-tags gtk3): 4 cases pass (unset -> 96, unset with
  GDK_DPI_SCALE 1.25 -> 120, bad scale -> 96, a user's 144 kept). With
  the guard returning early it FAILs: "font DPI -1, want 96", "page laid
  out at viewport -40320, body font 9e+06px".
- The real magpie binary (-tags gtk3, `magpie gui`, an LD_PRELOAD that
  leaves GDK's DPI at -1 after gtk_application_new): without fontDPI()
  both webviews are made at -1, the main page measures viewport -57600,
  font 9000000px, and the screenshot shows the one-word column; with it
  both are made at 96, viewport 600, font 13px, normal layout.
- go vet -tags gtk3 ./internal/gui; go vet ./...; GOOS=linux go vet
  -tags nogui ./...; GOOS=windows go vet ./...; go build; GOOS=linux go
  build -tags nogui; GOOS=windows go build; go test -tags nogui
  ./internal/gui; gofmt -l: clean.

Not run: a real Wayland compositor (the reporter's Niri) or WebKitGTK
2.54.1 exactly; the unset DPI was injected, not natural. The test skips
without a display, so CI only compiles it.
2026-10-09 12:10:30 +08:00
yetone 2018882e47 davsync, gui: a server file that isn't a backup is said as what it is, and can be replaced by this computer's setup (JasonLeeForOnly on Discord)
JasonLeeForOnly's WebDAV sync failed with only "not a magpie backup" for
several versions, and nothing on the page got past it. A WebDAV GET of the
backup can come back as a body that isn't the file in several ways; each is
now reproduced with an httptest server that answers the way the real one
does:

Unpacked (WebDAV and S3): gzip with or without Content-Encoding (a proxy
that drops the header), x-gzip, zstd with or without the header, deflate
(zlib or raw), and a header that says gzip over a plain body. Bodies are
told by magic number; a backup is JSON, so either magic is never the file.
Brotli (br) can't be unpacked and is said as itself, with what to turn off.

Said, with no upload offered: an HTML page answered with 200 (Nextcloud
login after a redirect, Cloudflare Access, an AList/OpenList web app, an
rclone directory listing, a captive portal), with its <title> and the
host/path a redirect ended at (query, fragment and user dropped), and
where the WebDAV address is for 坚果云 and Nextcloud; an XML answer, by its
root element.

Said, with Upload as the way out: an empty (0-byte) file, a backup cut
short or damaged (the automatic rebuild is unchanged and still goes first
when its rules allow), another app's file ("format": "…"), other JSON,
and any other file by its content type. Messages carry the size, kind and a
page title only, never the file's contents.

backup.Open now tells ErrNotBackup (doesn't begin like a backup) from
ErrCorrupt (begins like one, cut short). The status carries the kind as
serverFile; Settings › Sync and backup says it in the reader's language,
wraps it whole, and shows "Upload this computer's setup" only when the file
may be replaced. davsync.Upload (POST /api/davsync/upload, `magpie webdav
upload`, `magpie s3 upload`) asks first in the GUI, re-reads the file,
refuses a backup this computer opens, one sealed with another passphrase,
a newer magpie's, a page and an XML answer, keeps the server's copy in the
sync folder (0600, newest 5) and writes nothing if that fails, then writes
with If-Match on the version read. New strings in en, zh, zh-TW, ja, de.

Verified: internal/davsync/not_backup_test.go (TestReadCompressedBackup,
TestReadBrotliSaid, TestServerAnswerSaid, TestCutBackupSaidAndUploaded,
TestS3ReadCompressedObject; Upload-only parts stripped to compile) fails
on e4a90aa6 for every compressed case but "gzip said", Brotli, every
server-answer case, the cut case and S3 gzip, and passes here. Dropping
the Replace check in Upload turns the page/XML cases red ("Upload wrote
over a page"). gofmt -l clean; go vet darwin, GOOS=linux and GOOS=windows
-tags nogui on ., davsync, backup, gui; full go test -tags nogui ./... ok;
-race on davsync and backup ok. GUI: sync-not-backup.test.cjs (en, zh,
zh-TW, ja, de; 900 and 440px), sync-*, s3-sync, gui-de, gui-ja, gui-zh-tw,
click-scroll in Chromium and WebKit, 81 pass. Not tried against a real
WebDAV server or on the Windows box; the reporter's server is unknown.
2026-10-09 12:10:04 +08:00
yetone 34a598372c providers, gateway, gui, cli: each usage window of an account can have a cap of its own beside the account's (willz on Discord)
willz asked for a cap per window: a friend's account should stop at 50%
of its five hours, while its week has its own 40%. Until now an account
had one usage cap, the same share of every window.

What the user sees: the account's pill still sets the cap for all its
windows and stays the default. Each window's meter on the account's row
is now a button: a click opens the app's own menu headed "Cap for
<window>" with the account's cap, No cap on this window, 50–90%, and
Other… typed in place. A window with its own cap says it beside its
figure ("Cap 50%", or "No cap"), its mark sits there, and the account's
pill says "Caps by window" when only windows have caps. The account is
held while any window is past its own cap, and the row's note, the
gateway's 429 and the Routing trace name that window's cap. CLI:
magpie provider account-cap <id> <account> --window "5 hours" 50
(none: no cap on that window; default: back to the account's), and
`account-cap <id>` lists the overrides.

How: Provider.AccountWindowCaps (providers.json, by account and the
window's capId, its name in lower case; 1–99, or 100 for none) beside
AccountCaps. provider.WindowCaps gives a window its share (its own, else
the account's); Allowance.CapHeld and capReached judge each window by
its own share and report the fullest holding window's cap.
provider.HoldShare becomes HoldCaps, used by gateway capHeld (plan and
group filtering, cappedError, trace) and codex_backend's route-or-relay
choice; codex_switch's NextLogin moves the agent off at per-window caps.
QuotaWindow carries capId; Limit.name is exported as Limit.Name. A
config with no window caps reads and routes exactly as before
(TestSetWindowCap reads an old one). The meter is a span with
role=button, Enter/Space: a <button> as a flex item of the wrapping
meter line kept WebKit's row at its one-line height until the next
layout, so the row grew under the pointer at the first click.

Verified:
- go test -tags nogui ./... in a sandbox HOME: all ok. New
  TestWindowCapHeld and TestSetWindowCap (provider), TestWindowCapsHoldTheAccount
  through the real gateway (5h 60%/cap 50 goes to the spare; 20/30 under
  50/40 is used; 20/45 with the spare past its own cap is a 429 naming
  "past its 40% cap"), TestAccountCapCmd --window cases.
- Break check: with WindowCaps.Of ignoring the window's own share,
  TestWindowCapHeld fails ("5 hours at 60% of 50%: <nil>") and
  TestWindowCapsHoldTheAccount fails (the request is answered 200 by the
  capped account).
- internal/gui/tests/window-cap.test.cjs: Chromium and WebKit, 420 and
  1100 wide, en, zh, zh-TW, ja, de (60 tests): held note per window,
  marks, menu from the meter with no native select, keyboard, posts of
  50, typed 45, 100 and 0, no page scroll on a click, no sideways scroll,
  no left border stripes, every new string translated. With
  windowOwnCapOf returning 0 it fails.
- account-cap, gui-zh-tw, gui-ja, gui-de, i18n-keys pass; full GUI suite
  in both engines (483 files, concurrency 6): 4315 of 4323
  pass; the 8 that failed (library-servers-bulk, purpose-filter,
  routing-proxy, routing-zcode-blocked) touch no account row and all 32
  of their tests pass when run alone
- gofmt -l clean; go vet -tags nogui ./... on darwin, linux and windows,
  and windows without the tag for ., internal/gui, internal/provider,
  internal/gateway; go build ./...
- Not tried in the real window with a live gateway or real accounts.

Docs: gateway-routing.md (HoldCaps, window caps, tests),
providers-accounts.md (AccountWindowCaps, tests), reference.md.
2026-10-09 12:00:26 +08:00
yetone 3c7473eb3f redact: a value masked once is masked again wherever a request has it, whatever words are around it
The masking rules find a value by what is around it: DB_PASSWORD= before
a password, a bound an email may not touch (so an email that ends a
sentence isn't one). The vendor writes about a placeholder in words of
its own ("your password is {{SECRET_…}}", "I will write to {{EMAIL_…}}."),
the agent reads the value there (Restore), and on the next turn that text
is in the history, where no rule matches it, so it went out as written.

Now every value a placeholder was made for (redact's in-memory values,
bounded by maxValues, never written anywhere) is also matched as an exact
string in each string Mask and MaskJSON look at, and goes as the same
placeholder it had (same kind, same keyed hash), so history and vendor
caches stay stable:

- only from 8 characters (the assignment rule's minimum): the 3-character
  passwords the user:password@ rule takes, and the user's own words
  (which Options.Words matches everywhere already), are not;
- personal values only while personal masking is on, secrets only while
  secret masking is on;
- not as a piece of a longer value: a phone or card number with a digit
  beside it, an email inside a longer address (a full stop after it may
  end the sentence), a secret with a letter or digit against it;
- where a rule finds the same span, the rule's kind still wins, so
  nothing masked before changes placeholder;
- in what a vendor signed (7137feb4), the values masked elsewhere in the
  request go back as their placeholders as before, and so do known
  secrets, for a turn whose other copies were compacted away; known
  personal values don't, since a number the vendor wrote of its own
  (13800138000) may be one masked in another conversation, and masking
  it would break the signature.

A prefix index and a 4-byte bitset keep the lookup cheap: a ~1 MB body
masked in ~79 ms with 0, 1k or 200k known values alike (rules dominate).
Every route goes through redact.MaskJSON / Mask (chat, Responses,
Anthropic, Gemini, count_tokens, embeddings/rerank, the Codex backend,
image and video prompts), so each gets it; Scrub, the archive's, gets the
known secrets too.

Tests: TestEchoedValueMaskedNextTurn (gateway: two chat turns where the
fake vendor echoes the placeholders in other words; the second request
must not hold the password or the email, and keeps the first turn's
placeholders; then the echoed text alone through /v1/responses,
/v1/messages, count_tokens, Gemini generateContent, embeddings and the
Codex backend) fails on origin/main with the second turn sending
"…password is Mv4kestrelPass28, and I will write to
tomas.ng@quietfield.dev.". TestKnownValueMaskedInOtherWords and
TestKnownInSigned fail there too; TestShortValueNotMaskedEverywhere and
TestKnownReindexed pin the bounds and the eviction rebuild.

Run: gofmt -l (nothing); go vet -tags nogui ./... on darwin,
GOOS=linux and GOOS=windows; go build -tags nogui for darwin, linux,
windows; go test -tags nogui ./... under a temp HOME with pinned caches
(all ok); internal/redact -count=3 and -race. Not tried against a real
vendor.
2026-10-09 11:56:10 +08:00
yetone 9fbdd584ed codex: a thread started on a provider config.toml no longer has opens again, through magpie (#1372, obstrux: "ChatGPT 无法加载 config.toml … Model provider custom not found")
The Codex app resumes a thread on the provider it was started on, kept in
Codex's state database (app-server thread_processor.rs:
merge_persisted_resume_metadata), whatever config.toml's model_provider is
now, and loads no config for it when config.toml has no table of that id
(core config: "Model provider `{id}` not found"). CC Switch puts every
third-party thread on "custom" and rewrites config.toml whole when it
switches, so a switch to its official provider leaves those threads with no
table, and the app can't open them. magpie never removes [model_providers.custom],
but nothing put it back either.

While magpie is wired into Codex (takeTables: set, Join and Sync), each
provider a thread in state_*.sqlite names that config.toml has none of
(table, inline or dotted) gets a table pointed at magpie's gateway, as
CC Switch's own tables already are. Codex's built-in providers (openai,
ollama, lmstudio, amazon-bedrock[-runtime], ollama-chat) and magpie's own
are left out, and a provider the user has stays as it is. The table stays
when Codex steps off magpie, as magpie's own does, for the threads that
name it. Not wired, nothing is written. sessions.CodexThreadProviders reads
the distinct model_provider of the threads table, read-only.

Run:
- Real Codex 0.161 in a sandbox HOME: `codex exec` recorded a thread under
  a [model_providers.custom] pointed at a fake Responses server, the table
  was removed, and the app-server's thread/resume answered "failed to load
  configuration: Model provider `custom` not found" (the reporter's error).
  With this build's `magpie codex fakerelay/m1` (sandbox HOME, gateway on
  127.0.0.1:3546), [model_providers.custom] was written to the gateway, the
  same thread/resume succeeded, and a turn on it completed through magpie to
  the provider (its request reached the fake). The thread kept "custom".
- TestCodexWritesTablesThreadsName (signed in and not) fails without the
  call ("custom not written through magpie"), and its inline case fails
  without the parsed check ("edited TOML is invalid: key inl should be a
  table"); TestCodexThreadTablesOnlyWired.
- gofmt -l, go vet, GOOS=linux/windows go vet -tags nogui on agent and
  sessions, GOOS=linux go build -tags nogui, GOOS=windows go build, and
  go test -tags nogui ./... under a temp HOME. One full run failed
  TestSetAnswersWhileDevinAndProvidersHang (internal/gui, set took 1.6s
  against its 1.5s wall-clock bound; Claude only, Codex not wired, so this
  code isn't on its path); it passes alone 5/5 (0.19-0.42s) and in a rerun
  of gui, agent and sessions together.
2026-10-09 11:55:33 +08:00
yetone ff93c29224 gui, provider: Try again on a held move takes the models unticked in the editor, not only the saved ones (noting_ever on X: deep-model unticked, and WorkBuddy AI's move still failed on deep-model)
A move held back by a model the plugin doesn't serve says to untick it
under Models. The editor's model chips are a draft until Save, and the
Move / Try again button posted only {id}, then threw the draft away, so
the move checked the saved picks, which still had deep-model, and failed
on it again. This holds for every mover: they share renderMove and the
provider/move handler.

Now Try again sends the editor's picks when they differ from the saved
ones, and provider/move saves them first (provider.SetModels, through
agent.Reseat so an agent on a dropped model is moved off it), then moves.
A move that sends no models leaves the picks as they are (CLI, Plugins
page).

Run: TestMoveTakesEditorPicks (fails on the old handler: the move saw
[auto deep-model] each time) and plugin-move-unserved.test.cjs's new
"Try again takes the models unticked", en and zh, in Chromium and WebKit
(fail on the old app.js: no models posted); plugin-move*.test.cjs 20/20;
go test ./internal/provider ./internal/gui on HEAD plus this change;
go vet (darwin; linux provider; windows gui+provider); gofmt -l clean.
2026-10-09 11:54:30 +08:00
yetone ee657c0969 usage: a Kimi Code membership's k3 is counted at Kimi's API price for kimi-k3, not ≈¥0.000, and kimi-for-coding is unpriced rather than free (#1370, maicent: Kimi Code 的模型计费都是 0)
Why it was 0: magpie already counts a subscription's calls at the API list
price (a Claude or Codex account at its maker's, cf6b82f7). A Kimi Code
provider is looked up in its own models.dev catalog first, and models.dev
lists every model of kimi-code-plan-global / kimi-code-plan-cn at $0 (what
the membership charges per token), so k3, k3-256k, kimi-for-coding and
kimi-for-coding-highspeed were all "priced" at $0. The same $0 catalogs
were also among the maker catalogs, so MakerPrice("k3") was $0 too.

What changed (internal/provider/models.go):
- membershipCatalogs (the two Kimi Code plan catalogs) are never read for a
  price: ListPrice, MakerPrice and PricedName skip them.
- A provider served at a Kimi Code catalog prices its model as the Kimi API
  model it is (kimiCodeAPI), per Kimi Code's docs
  (https://www.kimi.com/code/docs/en/, read 2026-10-09): k3 and k3-256k are
  K3 -> kimi-k3; kimi-for-coding-highspeed is K2.7 Code HighSpeed ->
  kimi-k2.7-code-highspeed. models.dev's canonical_model_id agrees. The
  prices come from moonshotai's catalog, which matches Kimi's pricing page
  (https://platform.kimi.ai/docs/pricing/chat: kimi-k3 $3 in, $15 out,
  $0.30 cached, $3 cache write; kimi-k2.7-code-highspeed $1.90/$8/$0.38).
- kimi-for-coding is K2.8 Preview, which the Kimi API doesn't sell: it has
  no API price, so it is now unpriced (the "—" / "no known price" the GUI
  already shows) instead of ¥0.000. The user can give it a price with
  `magpie model price kimi-code/kimi-for-coding …` or in Names & levels.
- The alias is scoped to Kimi Code providers: a relay's own "k3" keeps its
  own catalog price; Moonshot pay-as-you-go's kimi-k3, an OpenCode free
  model ($0) and Codex's maker price are unchanged.
- PricedNameFor(provider, model): the Requests list's "API price
  reference" names kimi-k3 for a Kimi Code k3 (internal/gui/usage.go).
Docs: reference.md (What a model costs) and providers-accounts.md.

Tests: TestKimiCodeListPrice (internal/provider; fixture
testdata/kimi_code_catalog.json is models.dev's own moonshotai and Kimi Code
plan entries) and TestKimiCodeCost (internal/usage; the reporter's k3 row,
240K in / 78.4K out / 10.0M cached, counted at $4.896 in the ledger and the
summary, kimi-for-coding unpriced, moonshot/kimi-k3 unchanged). Both fail on
the old models.go (every Kimi Code model priced at $0, MakerPrice(k3) $0,
k3 cost=0, unpriced 0); PricedNameFor's check fails with the alias turned
off ("k3-256k").

Run: gofmt -l (empty); go vet -tags nogui, GOOS=linux go vet -tags nogui,
GOOS=windows go vet -tags nogui on internal/provider, internal/usage,
internal/gui; go build, GOOS=linux go build -tags nogui, GOOS=windows go
build; go test -tags nogui -count=1 ./... under a temp HOME with pinned
caches: all ok; -race -count=3 on the new tests ok. No GUI asset changed,
so the Playwright suite wasn't run. Not tried with a real Kimi Code
account or the reporter's usage log.
2026-10-09 11:48:41 +08:00
yetone 6df59bb186 providers: MLX-Serve is a local-server preset beside oMLX (zachzhao1984 on X: "Magpie 可以预置支持一下 MLX-Serve")
MLX-Serve (github.com/ddalcu/mlx-serve, MIT) serves OpenAI Chat and
Responses at /v1 and Anthropic Messages at the root on one port, 11234 by
default (src/main.zig), and asks no key unless started with --api-key
(docs/api.md). So it joins Ollama, LM Studio and oMLX in the add sheet's
On this machine section as a NoKey local preset: Chat and Responses
http://localhost:11234/v1, Anthropic http://localhost:11234. Its icon is
the app icon from its repo (app/appicon.png) at 96px, listed in
pngIcons as the other PNG icons are.

TestMlxServePreset checks the preset's endpoints and FromPreset;
TestMlxServeModels lists models from an httptest /v1/models built from
src/server.zig's listing shapes (a loaded and an unloaded model with
their extra fields). Both fail without the preset.

Run: gofmt -l; go vet -tags nogui ./internal/provider/ on darwin, linux
and windows; go test -tags nogui ./internal/provider/ under a temp HOME;
add-sheet and nokey-editor GUI tests in Chromium and WebKit; the add
sheet opened in both engines shows the MLX-Serve tile with its PNG
loaded (96px natural, not the generic fallback).
2026-10-09 11:45:53 +08:00
yetone d1d4b7ed2a usage: a heatmap of the last 53 weeks on the Requests tab, by tokens, requests or cost (#1369, nianlee-official)
What changed: the Requests tab of the Usage page shows a heatmap card like
CC Switch's: a cell per day for the last 53 weeks, Monday on top, month
names over the weeks and weekday names at the edge, a Tokens / Requests /
Cost switch (kept in localStorage), a five-shade Less…More legend, and a
tip on each day with its date, tokens, requests and cost.

How: GET /api/usage/heatmap (usage.HeatmapOf) reads QueryPage's cached
request index over a hidden period, "heatmap", which starts on the Monday 52
weeks before this week's. Its rows are summed by local calendar day in both
the compact and the ledger path. So no raw log is scanned per render, and the
days count what the chart counts:
- the page's agent/provider/model/key filters, but not the period or day
  picked;
- the gateway's own rejections left out;
- cost of priced calls only, with an unpriced count.
The days are in the local time zone and the shades use theme tokens
(.l0–.l4, as in the sessions calendar). There are no left-border accents and
no native <select>. A click never scrolls the page. A narrow window scrolls
the card sideways inside itself, with the newest week in sight (kept there
as it's laid out or resized, until the reader scrolls back), and the weekday
names stay at the card's edge. The card is hidden when no day has calls. New
strings are in zh, zh-TW, ja and de.

Run:
- go test -tags nogui ./internal/usage ./internal/gui (new TestHeatmapDays,
  TestHeatmapOf, TestUsageHeatmapRoute); gofmt -l clean; go vet darwin,
  GOOS=linux and GOOS=windows go vet -tags nogui on both packages; go build
  for darwin, linux and windows.
- New internal/gui/tests/usage-heatmap.test.cjs in Chromium and WebKit, at
  420 and 1100, in en, zh, zh-TW, ja and de, plus dark and the empty case.
  It checks:
  - the cells, labels, legend and the busiest and quiet days;
  - the newest week in sight and only the card scrolling sideways;
  - the tip's text and that it stays inside the card;
  - that a metric click moves nothing and is kept;
  - that the filters, but no period or day, are sent.
- Breaking checks: with the keep-at-end guard reverted, 5 of its 11 subtests
  fail in WebKit; with the server's heatmap dropped, TestUsageHeatmapRoute
  fails.
- i18n-keys, click-scroll, gui-de, gui-ja, gui-zh-tw, sessions-usage-open
  and all usage-*.test.cjs GUI tests pass in both engines (161 each).
- A sandboxed `magpie web` (temp HOME, 2605 fake calls over 400 days, ports
  3671/3672): the card fills 369 days at 1280 and 420 wide, and the tip
  reads "2026年10月1日周四 / 2.6M token · 23 个请求 · ≈$1.05+".
2026-10-09 11:33:47 +08:00
yetone 1965e1f0f4 redact: a tool call's arguments are masked again, whatever their keys are called (7137feb4 let them out)
7137feb4 kept two kinds of strings from the masking rules, and both
reached what the agent itself wrote:

- Any object with a non-empty "signature", "thoughtSignature",
  "thought_signature" or "encrypted_content" counted as sealed by the
  vendor, so its strings went out unmasked but for the placeholders of
  values masked elsewhere. A tool_use input like a mail tool's
  {"signature":"Best, Bob","body":"DB_PASSWORD=… call 13800138000"} sent
  the password and the number in the clear; before 7137feb4 both were
  masked. A text block carrying a "signature" key did the same.
- Every key named id, …_id or …Id was kept, in a tool's arguments too:
  {"user_id":…,"phone_id":"13800138000"} went out as written.

Now only a thinking or reasoning block (Anthropic's signature, Responses'
encrypted content, reasoning.* details) or a part with Gemini's thought
signature counts as sealed, and nothing inside a tool_use input or a
functionCall's args does. In those arguments the keys are the tool's, so
no key keeps its value there (an encoded data: value still is); ids
outside them stay as 7137feb4 left them. A signed Gemini functionCall
part still goes back as written. The response side (writer.go) restores
ids again, which changes nothing for a real id and puts back a
placeholder the vendor echoed into a tool's user_id.

Ran (Docker golang:1.26.8, GOTOOLCHAIN=local): TestMaskJSONToolArgsMasked
(new: signature key and id keys in tool_use input, Gemini args, a text
block with a signature key) fails on origin/main in all four cases and
passes; 7137feb4's TestMaskJSONSignedGoesBackAsWritten,
TestMaskJSONKeepsIDs, TestSignedThinkingGoesBackAsSigned and
TestToolCallIDsGoBackAsGiven pass. gofmt -l clean; go vet -tags nogui
for linux, darwin and windows on internal/redact; go test -tags nogui
./internal/redact ./internal/gateway ./internal/usage pass. Not tried
against a real vendor.
2026-10-09 11:29:07 +08:00
yetone e4a90aa62f groups, routing: a found group the user changed drops a removed provider's members, and a switched-off provider's members are said as switched off (Discord)
A user on Discord: in magpie's found (auto-…) routing groups, a provider
removed or switched off stayed in the group and had to be taken out by
hand; one switched on again should be back by itself.

A found group magpie derives already followed its providers (built from
the providers that are on). The gap was a found group the user had changed
(any edit on its card stores it under its auto-… id with its members):
its members stayed as they were.

Now:
- provider: groupsIn reads a stored auto-… group without the members of
  providers magpie no longer has (withoutRemoved). This is read-time only,
  never written back, so a provider back under its id (an account shown
  again) is back in the group. A manual group's pick and a model a rule
  sends to are kept, since taking them out would change where requests go
  without asking, or stop the group from saving. If no member would be
  left, all of them stay. The user's own groups (other ids) keep their
  members as they were.
- A switched-off provider's members stay in any group. The gateway
  already skips them (membersIn), and they are routed to again once the
  provider is on.
- gui: groupsState marks such a member providerOff, with its provider's
  name, icon and model. The Routing page says it as "<provider> · <model>
  (switched off)" on the card. A manual group's pick and the editor's row
  say why: "{name} is switched off: the group skips it until it is on
  again" (new string in zh, zh-TW, ja, de). Before, it was a bare id.

Run:
- go test ./internal/provider TestFoundGroupFollowsItsProviders. It fails
  on the old group.go: "c removed, auto-x's members: [a/x b/x c/x]".
- go test ./internal/gui TestGroupMemberOfASwitchedOffProvider.
- New internal/gui/tests/routing-provider-off.test.cjs in Chromium and
  WebKit, en/zh/zh-TW/ja/de, at 1100 and 420: 10/10 each. On the previous
  assets it fails: the card said "B Cloud · sol" with no "(switched off)".
- The full ./internal/provider and ./internal/gui go test suites, in a
  sandbox HOME: ok.
- ./internal/gateway -run 'TestGroup|TestAuto|TestRouting': ok.
- gofmt -l clean. go vet -tags nogui on darwin, linux and windows: ok.
- All routing-*, panel-routing, click-scroll, gui-ja, gui-de, gui-zh-tw
  and i18n-keys GUI tests: 265/265 in Chromium and 265/265 in WebKit.

Not tried with real providers in the live app.
2026-10-09 11:10:12 +08:00
yetone 55544f09f6 routing, gui: a provider's accounts and a group in the group fold on the Routing page, long ones folded at first, what the reader picks kept (Aiirobyte on Discord)
Aiirobyte: a routing group of many accounts took so much of the page that
editing the groups below it was a long scroll. The stage folded only runs
of three or more API keys (#1319).

Now:
- Three or more accounts of one provider side by side for one model fold
  into one row the way keys do: the count, the lit one and what it does,
  how many are available and resting. A request to a folded account flies
  to the fold.
- A group in the group's heading folds from a click, Enter or Space (it is
  a button now, with a chevron). Folded, it says "{n} on" and what its
  seats do together; requests under it fly to the heading and a failure
  tags it.
- Accounts and keys sums a provider's accounts up in one row too.
- Keys start folded as before. Accounts and group headings start open
  under six, folded from six. What the reader opens is kept in
  magpie.routingKeysOpen as before, what they fold in the new
  magpie.routingFolded, so both directions survive a reload.
- Redraws go through steady(), so a click keeps the page where it is.
- Strings in en, zh, zh-TW, ja, de. Display only: the gateway's order and
  choice don't change.

routing-account-order now opens the relay's key fold by :not(.open),
since the account folds there start open.

Run: new internal/gui/tests/routing-group-fold.test.cjs (defaults, click
and Enter in place with the page not moving, kept across reloads both
ways, a live request to a folded group heading with its tag, Accounts and
keys folds, 420px fit) in Chromium and WebKit, en/zh/zh-TW/ja/de, 40/40.
On origin/main's assets it fails (no account fold, heading not foldable).
All routing-* tests, panel-routing, click-scroll, gui-ja, gui-de,
gui-zh-tw and i18n-keys: 255/255 in Chromium and 255/255 in WebKit. Not
tried in the real window with a live gateway.
2026-10-09 11:10:11 +08:00
yetone 7137feb4fc redact: what a vendor wrote and checks goes back as it wrote it — signed thinking and tool call ids (Jeremy.Zhou on Discord: OpenCode Zen answered 400 "invalid …" until 脱敏密钥 and 脱敏个人信息 were off)
Masking ran its rules over every string of the request except a fixed list
of keys, including two kinds of text the vendor itself wrote and checks
when it comes back:

- A thinking block (Anthropic's signature; also Gemini's thoughtSignature
  and Responses' encrypted reasoning). The vendor thinks about the
  placeholders it was given, the agent gets the values back (Restore), and
  on the next turn the rules masked that text again. Rules depend on
  what is around a value: "the password is {{SECRET_…}}" came back as
  "the password is hunter2abc1", which no rule matches without its
  DB_PASSWORD=, an email ending a sentence fails the email rule's bound,
  and a number the vendor wrote of its own ("13800138000") turned into a
  {{PHONE_…}}. The text no longer matched its signature, and Anthropic
  answers 400 "messages.1.content.0: Invalid `signature` in `thinking`
  block" — and the value itself went to the vendor in the clear.
  Now an object that carries a non-empty signature, thoughtSignature,
  thought_signature or encrypted_content is masked only by putting back
  the placeholders of the values masked elsewhere in the same request
  (longest first): what the vendor wrote, its own words untouched.
- A chat tool result's tool_call_id. "id", "tool_use_id" and "call_id"
  were kept, "tool_call_id" wasn't. GLM's ids are call_ and 19 digits,
  and ~5% of them pass Luhn as a bank card number, so the tool message's
  id became call_-{{BANK_CARD_…}} while the assistant's tool_calls id
  stayed, and the upstream refused the orphaned tool result ("messages
  with role 'tool' must be a response to a preceeding message with
  'tool_calls'"). Now every key named id, …_id, …Id (and their plurals)
  is left alone.

Account masking (账号打码) is display only and doesn't touch requests.

Tests: TestSignedThinkingGoesBackAsSigned (a fake Anthropic upstream that
signs its thinking with an HMAC and checks it, two turns through the
gateway) and TestToolCallIDsGoBackAsGiven (a fake chat upstream that
refuses a tool result with no matching call) fail on origin/main with
exactly those 400s; TestMaskJSONSignedGoesBackAsWritten (Anthropic,
Gemini thought and functionCall, Responses reasoning shapes, plus an
unsigned thinking block still masked) and TestMaskJSONKeepsIDs fail there
too. All pass with the change.

Run: gofmt -l (nothing), go vet -tags nogui ./... on darwin, GOOS=linux
and GOOS=windows, go build -tags nogui (darwin, linux, windows),
go test -tags nogui ./... under a temp HOME: all ok. Not tried against the
real OpenCode Zen, and the reporter's exact error text and model weren't
seen; both upstream checks are modeled on Anthropic's and OpenAI's
documented errors.
2026-10-09 11:09:22 +08:00
yetone 6d1322e58e agents, profiles: a profile keeps each agent's model list, and switching profiles switches it (#1368, liuheo)
Saving a profile (+ Save current, magpie save, the TUI) now keeps, beside
every detected agent's fields and the library, the agent's model list as
the Agents page's Pick sets it: the models taken out one by one
(hiddenModels), or "only the models I pick" and those picks
(pickedModels), the order put on them (orderedModels), and the
visibility `magpie visible` narrows it to (visible). It is kept under the
id the lists are kept by (agent.ListsFor), in profiles.json under a
"models" key beside "library". Applying a profile puts each list back
exactly (provider.SetModelLists: one settings write, renamed providers'
ids followed), then the fields, so the model is set last over the list;
the lists are written once through catalog.Touched, the same path a pick
made by hand takes, so Codex's magpie-models.json and the other agents'
model files are rewritten. A list that changed counts in "N settings
changed".

Left out on purpose: fastPicks (how a model is sent, not whether it is
listed, and it stays with the model) and agentEfforts (Cursor Private
Inference's effort is already an agent field a profile keeps).

A profile saved before this has no "models": it is read with none, leaves
every agent's list as it is (unknown, not empty), and is written back
byte for byte. An agent a new profile doesn't name keeps its list too.
A profile's details show a Model list line per agent: "8 hidden", "only
the 3 picked" or "every model shown", in all five languages.

Verified:
- internal/profile TestProfileSwitchesModelList: three profiles (two
  hidden; all shown with an order; only-picked), each applied rewrites
  settings and Codex's magpie-models.json to that list. Fails with Apply's
  list step removed ("applying home changed nothing", the hidden list
  not restored) and with catalog.Touched taken out of SetModelLists
  (Codex's file keeps the old list).
- TestOldProfileKeepsModelList: an old profile keeps codex's hidden and
  claude's only-picked, writes back unchanged; one naming only codex's
  list leaves claude's. Fails when a missing "models" is read as empty.
- The built nogui binary in a sandbox HOME (env -i): magpie save home /
  save work / use home / use work switched Codex's magpie-models.json
  between m1 and m1-m3.
- profile-details.test.cjs (en, zh window and panel, zh-TW, ja, de) in
  Chromium and WebKit; fails without the app.js line. With
  profile-confirm, panel-profiles, gui-ja, gui-de, gui-zh-tw, i18n-keys
  and click-scroll: 38/38 in each engine.
- gofmt -l clean; go vet (darwin), GOOS=linux go vet -tags nogui,
  GOOS=windows go vet on profile, provider, gui; go build, linux nogui
  and windows builds; go test -tags nogui ./... under a temp HOME, all ok.
Not tried in the real window with a live gateway.
2026-10-09 11:06:07 +08:00
yetone 16dc154589 claude: a new Claude Code told a whole conversation is told which turns were answered already and which turn to answer, and the gateway logs that it happened (#1365)
What changed: renderClaudePrompt told a run started anew the conversation
as one stretch of Human:/Assistant: text, every image of every turn in it,
and the model in #1365 took the earlier images for new ones. When the
conversation has a reply that calls no tool, the turns up to it now go in
<conversation_history>, with a note that the images and files in them were
sent with those earlier messages and need no answer again, and the rest
(from the user message after that reply, as nextTurn splits it,
historyEnd) in <current_turn>. Images stay in place inside their section;
none is dropped. A first turn, a conversation with no reply in it (one-off
asks, the auto mode classifier's transcript), or messages ending in a
reply are told as before. start logs "a new Claude Code is told the whole
conversation" with the message and image counts, after the "tool results
<why>" line that says why no run took it.

Why: a rebuild can't always be avoided (a run past its idle time with no
saved session, a run started anew for the turn and given up on, a
conversation the client changed). Its replay should not read as the user
sending everything again. Only the text of the one message a new run is
told changes, no header or other request field.

What was run:
- New TestClaudePromptMarksEarlierTurns: the earlier turn and its image are
  inside <conversation_history>, the current turn's text, tool round and
  two images inside <current_turn>, and a first turn or reply-less
  conversation has no markers. With historyEnd's split turned off it fails.
- go test -tags nogui ./internal/gateway/ (full package) under a temp HOME,
  including the existing renderClaudePrompt tests (prompt cache, foreign
  calls, images); -race -count=20 on the new tests; gofmt -l; go vet for
  darwin, linux and windows (-tags nogui); GOOS=linux/windows go build.
Not tried against a real Claude model: whether it now leaves earlier
images alone is untested.
2026-10-09 11:05:33 +08:00