* davsync: scope provider and library credentials during partial sync
* davsync: a sync writes which part carries its credentials, and no blank clears one
yetone's review of the first cut found two losses the per-part markers
caused, and both are fixed by making the markers say exactly which part
they belong to:
1. A keyed computer's library-only upload cleared a keyless computer's
private MCP token. The blanks in the uploader's library are redactions
it downloaded and never held a value for, not clears it made; take now
keeps the server's value for an empty secret whatever the policy is, so
only a server taken out here is gone. The test that had the loss as
its expected now expects the token kept.
2. Mixed versions dropped the remote's provider keys. A keyed upload
didn't write the whole-bundle Keys bit, and an older keyless magpie,
which reads no per-part marker, uploaded its own providers over the
server's with nothing to keep. take sets Keys for the providers' again.
The per-part markers are three-state now: a whole collect and a magpie
before them write none, and the whole-bundle bit says it then; a sync
writes one for every part it merges — true when the upload carried that
part's credentials, false when it didn't — and that bit, not Keys, is
what a reader goes by. That is what keeps 1 and 2 apart: the providers'
whole-bundle bit is set for older readers, while the settings' and the
library's own false is what tells a new reader they came without keys and
their local values stay. take marks its merged bundle as a sync one
(BundleVersion), so a sync's readers never fall back to Keys for a part
it wrote.
The two regression tests of the first cut that encoded the losses as
expected are updated: library/clear keeps the server's secret, and the
keyed providers upload writes the whole-bundle bit while leaving the
other parts' contents alone.
A remote that main already flipped to Keys=true over redacted settings or
library isn't repaired by this: the fix keeps new syncs from flipping it,
and a keyed computer syncing those parts repairs them by writing them
whole.
Validation on Windows: go test ./internal/... under a temp HOME passes
whole; the davsync, backup, access and settings packages pass with -race;
stashing the change fails TestTakeScopedPartCredentials (8 cases), the
scoped-sync tests that cover both losses, and the balance-token and
restore-scope tests that read the markers. go vet ./internal/... and the
darwin, GOOS=linux and GOOS=windows builds pass.
* davsync: preserve local secrets when applying partial uploads
* Add German translation and harden paths against unset env vars
Add a German locale to the GUI, tray menu, usage alerts and website home page, and refuse to start when the home folder is unset or relative so nothing is written under the working folder; the pi plugin no longer adopts the user's PI_CODING_AGENT_DIR, and an inherited APPIMAGE no longer selects portable mode. Scripts refuse to run from the wrong folder or empty a shared out folder, and a new internal/testenv sandboxes every test package from the user's real files, which also fixes the Claude picker and Termux installer tests on Windows.
* Makefile: know magpie's folder by its go.mod, so paths with spaces work
The check compared $(CURDIR) with $(dir $(abspath …)), which make splits on spaces, so a checkout under 'My Projects' or 'John Doe' refused every target. It now reads go.mod relative to the working folder and wants magpie's module there: make -C and a symlinked Makefile work, make -f from another folder still stops before any recipe runs.
* i18n: German for the strings main added since, with a parity test
75 strings main added (group order, patterns, slow-start wait, usage caps, RTK savings …) get their German, and two that main dropped go. gui-de.test.cjs checks, as gui-ja does, that de has every zh string with its placeholders and tags, and that the window reads in German when picked or on a German system.
* appdir: ignore relative folder variables when reading, keep them for children
CheckEnv no longer unsets anything. magpie reads the XDG folders, APPDATA/LOCALAPPDATA and every agent's folder variable through appdir.Getenv/LookupEnv, which pass over a relative value; the environment stays as it is, so the agents magpie starts get the variable as the user set it. That covers what proc.UserPath lends from the login shell too, which is set after main's check and reached the desktop app unchecked. On Windows a path rooted on the current drive (\Users\x, Git Bash's /c/x) counts as usable, and magpie version and help answer without a home, before run makes any folder.
* Sandbox the tests main added since
internal/wslrun gets testenv's TestMain, and four tests main added that set HOME alone set their home with testenv.SetHome, so on Windows they don't read the runner's profile through USERPROFILE.
* agentenv: PI_CONFIG_DIR and OPENCODE_DB take relative names on purpose
omp puts $PI_CONFIG_DIR under the home and OpenCode a relative $OPENCODE_DB in its data folder, so neither depends on the working folder: appdir no longer passes them over.
* i18n: German for the Library's multi-select and System One's decision API
The 19 strings main added since (#791's skill multi-select, the decision API snippets) get their German; the one main replaced (four APIs → five APIs) goes.
* appdir: tidy the import into magpie's group; say what Go makes of /c/x
The appdir import joins the group of magpie's own imports where a file has one. rooted's comment now says that Go takes a Git Bash path like /c/x as \c\x on the current drive, not C:\x.
A request still in flight across a calendar reset could reserve the next
window's entire budget even though that window had no usage. Scope held
reservations by the usage file, gateway key and window start, and use the
same key for admission, status and release.
Cover day, week and month boundaries with token and cost caps, including a
late check for the old window and settlement while the new window has an
active reservation. All six cases fail before the change. The key-limit
tests pass normally and under the race detector; access tests and vet of
the affected packages also pass.
A group's catalog entry took the smallest member's output limit, so /v1/models and the agents' catalogs told agents that, and every reply through the group was capped at it, even from the members that write far longer ones. The group's Output is now its largest member's (as its context window already is, #700), and the gateway, for a group request, lowers the agent's max tokens to each member's own known limit (its catalog Output: the user's per-model setting, else the vendor's list or models.dev) only when the request asks for more: max_tokens / max_completion_tokens (Chat), max_output_tokens (Responses), generationConfig.maxOutputTokens (Gemini), and for Anthropic max_tokens with the thinking budget kept under it (budget = limit-1, or thinking off when no 1024+ budget fits). A request asking for less, or a member with no known limit, goes as before.
TestGroupOutputPerMember (members at 4096 and 32000: the group says 32000; asked for 20000, the 4096 one gets 4096 and the 32000 one 20000; asked for 1000, 1000) fails on the old code at either half; TestWithMaxOutputAnthropicThinking. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn, TestUserPath and TestClaudeSignedOut failed under load and pass alone).
* aside: magpie picks Aside's model, its thinking level and its four task models (X: junjie — Aside Settings → Models has a Default model row magpie could not touch, and Fast/Standard/Deep/Visual rows it could not reach at all)
Aside reads the same provider list Pi does, so magpie hands it the whole
catalogue in a provider block of its own, carrying the magpie-aside token so
its requests are told apart in usage. What differs is the default: Aside keeps
it as one object in ~/.aside/u/0/settings.json naming the provider, the model,
the thinking level and fast mode together, so the model field reads and writes
two keys where Pi's are two strings, and both go through Aside's own settings
API — the running daemon holds its default and writing the file behind it
leaves the daemon on what it already had, read back the same way with the old
model still named after magpie had written the new one. The API is reached
through the repl, the settings command having no set of its own.
The four task roles are the same shape as the default and are wired the same
way, each a Quiet field following the model, so a role the user has not set is
not a line of its own and follows the default. A role is written whole:
thinkingLevel is required by Aside's schema and a role without one is refused,
so a role keeps the level and fast mode it had, a new one takes the default's,
and where neither is set it takes the level Aside gives its own roles. Taking
magpie out puts every role back, the user's own model where there was one and
nothing at all where magpie was the only thing that set it, and a role the user
moved onto a model of their own is left alone.
Each change checks the value that came back: Aside answers a set with the
settings as they now are and drops what it will not take without a word — a
role naming a model it has not resolved yet is the one that happens — so
without the check a change that went nowhere read as one that landed. Caught,
it is written to the file, which is what Aside reads at its next start, and
the Agents page says so.
The account magpie wires is 0: the folder is ~/.aside/u/0 while the CLI names
it u0, and TestMain stands in for the binary so a test can never change the
real Aside's default. A models.json carrying a magpie provider of someone
else's is refused rather than written over, and one that will not parse is the
user's to fix.
With the owner's real Aside under a sandbox HOME: a pick reached the gateway
and was attributed to a client named Aside, the default put the user's own
minimax model back and took the magpie provider out, and a role set through
magpie was read back by the running daemon the moment it had a magpie model to
resolve. Not tried: a change written to the file taking hold at Aside's next
start, which needs a restart. go vet, builds (darwin, GOOS=linux,
GOOS=windows) and go test -tags nogui -count=1 ./... pass (TestBunThroughBridge
fails, reproduced on a pristine worktree of the parent; internal/plugin passes
alone and times out only under the full run's load).
* aside: the row appears for an Aside that is installed and not yet used (the CI preview of #789 could not find .row.agent[data-id="aside"])
Detected() took ~/.aside/u/0, the account folder, and that only exists once
someone has signed in. The binary was the other look and the CLI installer
links it into ~/.local/bin, which a magpie launched from Finder has no PATH
entry for, so an Aside that was installed and never used showed no row at all —
the preview installed one, drove the UI and could not find it in either the
installed list or the row selector. The installer writes ~/.aside/cli and the
daemon writes the rest, so ~/.aside is the folder that is there from the
install, and it is the one Detected() should ask about. Path stays the
account's settings.json, which is the file magpie edits and the only one
worth reading a model out of.
TestAsideIsDetectedBeforeAnyAccount fails on the old Dir: it takes PATH away
so the binary cannot be found either, makes the installer's folder and no
account, and expects the row.
* aside: a wedged repl gives up, and the model comes off through Aside (yetone on #789)
Three things about the way out of an agent and into one, all found on a real
Aside.
The repl had no timeout. proc.Command(...).Output() waits as long as the
command does, so a daemon that has wedged or one waiting on input leaves a pick
in the Agents page or the CLI hanging on it, and switching off asks up to five
times over — the default and four roles. proc.CommandContext with ten seconds:
Aside answers in milliseconds, and a run that does not is written to the file
instead, which is what Aside reads at its next start, with the notice that says
so. TestAsideGivesUpOnAReplThatHangs runs the real command against an aside
that never returns: it passes in the timeout, and without CommandContext it
hangs until the test's own timeout kills it.
Switching off with no model of the user's behind magpie's wrote settings.json
directly. By then the provider block was gone, so the running daemon went on
using magpie/… with no way to reach it, and asideStale was never set, so
nothing said the change needed a restart. It goes through asideApply like
every other change, with the value that came back checked.
The provider block now goes last, after the default and the four roles are put
back. A step that fails on the way out otherwise leaves the settings naming a
provider that is no longer there. TestAsideDefaultTakesTheProviderOutLast
watches the block from inside the settings step and fails on the old order.
The icon is Aside's own brand-embossed-light.svg with the embossing's
transparency taken off, so the mask is solid and takes the row's text colour
like every other agent. As it was, the white fills at 8–20% opacity the dark
and light brand files are built with left the mark almost invisible on a light
row, and it vanished entirely in dark mode. Same paths, same shape, now solid;
checked in the app next to Pi's.
Tested against Aside 1.0.928.1 (daemon 1.26.928, CLI 1.26.916.1741) on
darwin/arm64. go vet, builds (darwin, GOOS=linux, GOOS=windows) and
go test -tags nogui -count=1 ./internal/agent pass (408).
* aside: the model Aside draws with is magpie's to set too (X: Aside Settings → Images had no row for it)
Aside keeps a fifth model setting beside the default and the four task roles:
imageGenerationModel, the provider and the model it draws pictures with and
nothing else — no thinking level, no fast mode, since neither means anything for
a picture — and no key at all on an account that has never picked one.
It is a magpie field like the roles: Quiet, so it is not a line of its own on an
account that has none, and taking the model after while it is unset. A model of
magpie's needs the provider block to reach it through, and what it named before
is remembered the same way a role's is, so `magpie aside model default` puts the
user's own picture model back and takes a picture model of magpie's off rather
than leaving it naming a provider that is no longer there.
Not wired: customModels, which is the user's own model definitions with their own
credentials, and asideModelCatalog, a list of model ids to pin or hide. Neither
is a model magpie picked, so neither is a setting of magpie's to own.
Aside 1.0.928.1, daemon 1.26.928. go vet, the agent/gui/profile tests, the three
CLI cross-builds and the CLI verification under a temp HOME all pass.
* fix(gui): a reply that broke off is told as failed everywhere
733's tryOk rule — a try with a status under 400 and no fail didn't
answer — was put through the Routing page's log and its try tally, but
every place that judges a request as a whole still read the status alone:
a request whose reply had begun and then broke off (its 200 sent, the
vendor's error after it) kept an answerer it never had. Its row was a
green dot reading "provider · model" as if it answered, the replay and
the Agents panel told the same, and the "errors your agent saw" stat,
which counts the request, picked nothing when clicked since it only knew
statuses of 400 and up. The log of the very request said it began
answering and then broke off, so the page disagreed with itself.
outcome() asks tryOk for who answered, and a route with an error is bad
even under 400; the swap and routed marks, on the row, the seat and the
log, only show on a try tryOk answers; the stat's click knows an error;
and the two play animations send a try with a fail back as failed. The
row, the replay, the Agents panel, the stats and the animations now tell
the same story the log does.
routing-broke-off.test.cjs — Chromium and WebKit, English and Chinese —
fails on the old page with the row's class "rt-req ok" and passes: the
row is bad, the errors stat picks the request, and its story says it
began answering and broke off.
* fix(gui): a 200 with a note of its own is still an answer
yetone's review of the first cut: outcome() read a route's error field
for "bad", but a 200 whose error is magpie's own note answered — Codex's
titles off in Settings writes one, as a title reply with no title in it
does, and their tries carry no fail. Every titled thread's first request
turned red. Reads the tries now, as the rest of the rule does: a route
under 400 is bad only when it has tries and none answered.
The Agents panel's row said so too: its class came from the outcome, but
its "where it went" text still branched on the status alone, so a
broken-off request showed red with "→ provider" and no failure. It
branches on the outcome now.
routing-broke-off.test.cjs gains the note case (200, an error, a try with
no fail): its row is "rt-req bad" on the first cut and "ok" with this.
The broke-off case's rows, the errors stat and the story still hold, on
Chromium and WebKit, in English and Chinese.
Validation on Windows: node --test routing-broke-off.test.cjs — 8/8 on
Chromium and WebKit in both languages; both cases fail on the code
before this commit's routing.js (the broke-off row as "rt-req ok", the
noted one as "rt-req bad").
* fix(gui): the errors stat picks a broken-off reply, not a noted 200
yetone's second round: the stat's click looked for a status of 400 or an
error, which a 200 whose error is a note of magpie's own — Codex's titles
off in Settings, a title reply with no title — answers: every titled
thread's first request was a newer "error", and "show the latest request
that failed" jumped to a green row. It asks the same question the rows
and the story do now — outcome(r)[1] === "bad" — so the stat, the rows and
the story name the same request.
routing-broke-off.test.cjs puts the noted 200 above the broken-off one:
clicking the stat picks the broken-off request under it, and the noted row
is not picked. That fails on the previous commit (the noted 200, newer and
with an error, was picked) and passes. Two test fixes go with it: the
unpicked row's aria-pressed is "false", not absent, and the day list is
waited for before it is clicked, which webkit needed mid-run.
Check for a key/value match before indexing its value boundary. Compact
JSON arrays and TOML table headers can contain secret-related words
without matching that pattern.
Cover both cases in TestMask.
Every string the window, the panel and the menus have in Chinese now has a Japanese one too: 2917 entries in I18N.ja, translated to one glossary (プロバイダ, エージェント, ゲートウェイ, ルーティンググループ, 使用量, サインイン…), each keeping its {placeholders}, tags and leading spaces. Language takes "ja" (settings.Langs); System picks ja when navigator.language starts with ja, and <html lang> is ja-JP so CJK text draws in Japanese glyphs. Dates and numbers format through intlLang() (zh-CN or ja-JP) where they said zh-CN only. The tray menu, the quota alerts (7 日枠を 80% 使用、明日 14:30 にリセット) and the restart-to-update item have Japanese of their own (trayLang reads ja from the setting or a ja_JP/ja-JP system). Release notes stay English for ja, as the site has them in en and zh. The 万/亿 units option stays Chinese only.
Looked at in the Omarchy VM (magpie web, tokyo-night, ?locale=ja): Agents, Providers' Add sheet, a link's import, Routing, Usage, Gateway, Settings and the panel read in Japanese with nothing cut or overflowing. gui-ja.test.cjs (every zh key has a ja one with the same placeholders and tags; ja picked, a ja-JP system, en and zh systems, on WebKit and Chromium) and the ja cases in TestTrayMenuLabels and TestAlertText fail on the old code. On WebKit the other GUI tests fail as on the base but mobile-web's desktop comparison with origin/main, whose Settings now has a fourth language. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
* fix(agent): installs without Node ask for it the way that works
The commands that install Node first, for a machine without it (#727),
failed in ways that say nothing about the real problem.
On a Mac or Linux, `curl -o- … | bash` keeps going when the download
fails: a pipe's status is the last command's, so blocked or 404'd
(raw.githubusercontent.com is commonly unreachable), bash reads nothing,
still exits 0, and the user is told `nvm.sh: No such file or directory`
— the network's fault told as a missing file. The vendor commands beside
it all curl with -f (install.go's own scripts do); this one now does too,
so a failed download stops the line with curl's own words.
On Windows, winget asks to accept the msstore source's agreements the
first time it runs, which waits for input a copy-pasted command can't
give: Node isn't installed, and npm.cmd isn't found. The command now
carries --accept-source-agreements and --accept-package-agreements, the
flags Microsoft documents for unattended installs; they are idempotent
where the source is already accepted.
TestInstallCommandsWithoutNode expects the two commands as they now
stand, and fails on the old ones ("curl -o- …" and the winget line
without the flags, on darwin, linux and windows alike);
TestInstallWithoutNodeParses still reads each line with `bash -n`.
Validation on Windows: go test ./internal/agent under a temp HOME leaves
the three Unix-only failures the base commit leaves (TestCodexStaleOnly
ForMagpiesChange, TestSuffixModesReachAgentFiles, TestWSLProbeScript
SaysWhere); go vet ./internal/... and the darwin, linux and windows
builds pass.
* fix(agent): a failed Node download stops the line where it failed
yetone's review of the first cut: `curl -fsSL … | bash` doesn't stop at a
failed download either, and my claim that -f alone fixes it was wrong — a
pipe's status is the shell's, bash reading nothing exits 0, and the &&
chain goes on to source nvm.sh, which reports the missing file exactly as
before. Reproduced against a 404 server on this machine: `curl: (22) …
404` then `bash: …/.nvm/nvm.sh: No such file or directory`.
The command downloads to a file now and runs that: a failed download ends
the chain at curl, with curl's own words about why. pipefail isn't an
option — the line is pasted into the user's own shell, and not every one
of them sets it.
TestInstallWithoutNodeStopsWhenTheDownloadFails runs the real line,
against a local 404 server with a temporary HOME, on each POSIX shell
here: the old line failed with the download's error *and* nvm.sh missing,
the new one stops at curl's own error and never mentions nvm.sh. It is
skipped where there is no curl. TestInstallCommandsWithoutNode expects
the line as it now stands.
Validation on Windows: go test ./internal/agent under a temp HOME leaves
the three Unix-only failures the base commit leaves (TestCodexStaleOnly
ForMagpiesChange, TestSuffixModesReachAgentFiles, TestWSLProbeScript
SaysWhere); go vet ./internal/..., the darwin, GOOS=linux and GOOS=windows
builds pass.
The Routing page's group list followed the order the groups were made in, found ones after, so a group used every day couldn't be put first, though /v1/models lists the groups in that order and agents' pickers (Claude Desktop's 3P list among them) show it and some clients probe its first model. A group row's logos are now its handle, as an agent row's logo is (#57): dragged (dragRows, the Agents page's sorter) it moves the row, clicked, or the row right-clicked, it opens Move up and Move down (openRowMenu, with the Agents menu's arrows, now MOVE_UP/MOVE_DOWN), and Alt+↑/↓ moves it from the keyboard, which stays on it. The grip in the row's margin is drawn only while the row is under the pointer, dragged, or its handle is focused or its menu open, as on the Agents page. The list is drawn in its new order at once and put back if the save fails; a later move wins over an earlier one still saving.
The order is kept in providers.json as groupOrder (provider.SetGroupOrder, POST /api/groups/arrange), beside the providers' own order (#499), and applied in groupsIn, which every list of the groups is made from: the Routing page, Catalog and so /v1/models, magpie group ls and the TUI's routing page. A found group, which isn't stored until changed, can be put among the user's this way, which reordering the groups array couldn't do. A group not named (one made or found after) goes last; a renamed one keeps its place. Backup and sync carry the order as they do the providers' (MirrorGroupOrder).
TestGroupOrder (Groups and the catalog follow SetGroupOrder, providers.json has it, a new group last, a rename in place, bad orders refused, a synced order first) fails on the old code. group-arrange.test.cjs (WebKit, en and zh): the grip hidden with the pointer away and shown on the hovered row only, in its margin; right-click Move down with Move up off on the first; Alt+↓ and Alt+↑ with focus kept; the handle's click opens the menu, not the editor; a drag by the grip to the top; each posting every group's id with the removed one last; no click moving the page. It fails on the old code. agent-grip, click-scroll, account-arrange, usage-arrange, provider-order and the group-*/routing-* GUI tests pass in WebKit, but for group-from-model-add's 600x420 cases, which fail on main as well. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
* agent: Crush's models picked in magpie go to its data file, which it reads after crush.json
Crush saves what its own model picker chooses to its data file,
$XDG_DATA_HOME/crush/crush.json or ~/.local/share/crush/crush.json
(%LOCALAPPDATA%\crush\crush.json on Windows), and loads that file after
~/.config/crush/crush.json, so a models.large or models.small there wins.
On Linux and macOS magpie wrote its picks to ~/.config/crush/crush.json
only. Once somebody had picked a model inside Crush (its first run asks
for one), a model set in magpie never took effect, while magpie read it
back from the file it wrote and showed it as set.
The picks and the large model's effort are now read as Crush merges the
two files, the data file over crush.json, and written to the data file.
Unsetting one takes it out of both, and magpie's provider still goes
with the last pick on it. The provider stays in crush.json, beside the
library's MCP servers. On Windows the two paths are one file, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* agent: a model magpie picks for Crush replaces Crush's whole model object
Crush's picker saves the whole models.<type> object in its data file,
with its per-model settings (max_tokens, think, reasoning_effort,
sampling, provider_options), and applies them to whatever model the
object names. setPick only rewrote provider and model, so a max_tokens
picked for another model went upstream with magpie's, and magpie's
effort row showed an effort it never set.
Write the whole object instead. Only an effort magpie set itself is
kept: one on a large pick that is already magpie's and carries nothing
but provider, model and reasoning_effort. When magpie is first to make
Crush's data file, which holds API keys, make it 0600.
* agents: Crush's data file reaches crushAt, so WSL's Crush gets its picks where Crush reads them too
Main moved Crush's wiring into crushAt(at, path); the data-file path the
pick fields read and write is now passed in beside path: crush() works it
out as before (XDG_DATA_HOME or ~/.local/share/crush, LOCALAPPDATA's crush
on Windows), crushIn uses the WSL home's ~/.local/share/crush. The effort
test sandboxes XDG_DATA_HOME as it does the other XDG dirs, since the
package's TestMain shares one.
* agents: test that Crush in WSL picks in the distro's data file
A pick magpie makes for Crush in a WSL distro is read from and written to
<distro home>/.local/share/crush/crush.json, never this machine's data
file, whatever XDG_DATA_HOME says on it. The test fails when crushIn's
data path is the config file or follows the host's XDG_DATA_HOME.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>