Changing Goose's model or effort could insert a key inside a multiline flow
collection or append a duplicate when the existing key was quoted. Locate
entries through parsed keys, keep complete values together, and validate the
result before writing it. Read and delete use decoded key names too.
Keep other entries, document markers, comments and block-scalar blank lines
in place. Reject invalid input, multiple documents and layouts that cannot
be edited safely as whole lines, with the file path in the error.
Regression tests reproduce the flow-value and quoted-key failures and cover
multiline values, unchanged surrounding content and failed writes.
* agents: omp's subagents and small role pick their own models, through magpie too
* agents: omp keeps a role's thinking level and the model it had before magpie, and offers the slow and designer roles
A role set to "anthropic/claude-opus-5:max" and switched to a magpie model lost its ":max", and resetting it deleted the key rather than putting the user's model back. The level now goes with the new model (omp clamps one the model lacks, never fails), the model the role had before magpie is stashed and restored, and the reviewer's @slow and the designer's @designer roles are fields beside subagents and small.
* gui: omp's slow and designer roles follow the model, as its subagents do
Unset, both roles take the session's model, yet the picker offered them omp's Default and no typed model. They are now squares beside subagents, with Same as model first and a model typed in taken as it is.
* agents: omp's thinking level no longer hides a role's model from magpie, and the model alone clears it — magpie omp magpie/x/y:high is taken, a role moved back to omp's own is drift, a list of models comes back on reset, and designer (gone in omp 18.1.5) goes
A role on "magpie/deepseek/pro:max" matched the picker only through the
option offered for its level beside the model, and only for the value in
the file now. So the CLI refused a model typed at a level, a recorded
value at a level the current one didn't share wasn't known for magpie's
(no "replaced" drift), and a level could never be taken away: a model
picked without one took the old one on.
An agent may now say what it keeps after the model (Agent.SplitSuffix;
omp's is its level). Drift, Reseat, RenameProvider, Spell and the CLI's
field-for-value match the model and put the suffix back after the one
they move to; agents without it are unchanged. The option at the level
stays: it keeps the level. Another model keeps it too; the same model
picked alone clears it.
A role omp falls back through (a YAML list, or the comma list omp joins
it into, settings.ts modelRoleValueFromUnknown) read as nothing, so it
wasn't stashed and a reset deleted it. It reads as that comma list now,
is stashed and comes back; a list has no level of its own.
omp 18.1.5 removed the designer role (CHANGELOG), so its field goes. The
smol role, like task and slow, gives its agents the session's model when
unset; its field is labelled smol, as omp names it, so the GUI can show it
as the others without touching other agents' "small".
* gui: omp's smol role follows the model as its subagents and slow role do, and designer goes
smol unset gives omp's scout and sonic the session's model, so it is a
square after the pickers with "Same as model" first, as subagents and
slow are (a feather). Its label is smol, not small: OpenCode's, Crush's
and OpenChamber's small model stays a picker. designer, a role omp 18.1.5
removed, leaves FOLLOWS_MODEL, the glyphs and i18n.
* agents: the note on omp's fallback chains says their keys are roles, and from omp 18 models too
ompRefKeys' comment had retry.fallbackChains keyed by model. omp 16.3.5 takes only a role's name there (session/agent-session.ts: "a mapping of role names to selector arrays"); omp 18.4.4 takes a role's name or a model selector (session/retry-fallback-chains.ts). Walking the keys stays: a role's name is no magpie model and is passed over. Follow-up to the review of #340.
* agents: omp reads whether it is on magpie one way, and a role at a level that a fallback chain names too is renamed once in each
Rebased on #340, one scan over omp's config (ompRefs) answers both questions: magpie's provider stays in models.yml while anything under ompRefKeys is on magpie, as #340 has it, and Check looks at the wiring while any role under modelRoles is, as this PR had it; a fallback chain or enabledModels alone keeps the provider without being checked.
A provider renamed moves a role at a thinking level through SplitSuffix first, then RenameRefs the rest, which finds no old name left in the roles: each place moves once, the level kept.
* agents: a fallback chain alone on magpie has omp's wiring checked, and a role set off magpie mends it
Check now reads the same keys dropMagpie does (ompRefKeys: every role, retry.fallbackChains, enabledModels, task.agentModelOverrides): whatever keeps magpie's provider in models.yml has its baseUrl checked, so a chain on magpie whose wiring something else moved no longer fails its fallback silently. Applying again sets the model role, which may be omp's own; a role set off magpie while something else still names it now writes magpie's provider afresh instead of leaving it as it is, so that mends the wiring. Review of #325.
* agents: an omp role that is a list of models stays the user's own, even starting on one of magpie's, and comes back as it was written
A role omp falls back through ([magpie/deepseek/flash, openai/gpt-6], or the same as a comma-separated string) was taken for one of magpie's models because it started with magpie/: magpie taking the role over stashed nothing, and a reset deleted it. Only one model, no comma, is magpie's now: SplitSuffix answers whether a value is one model, and Spell, drift (magpieValue), Reseat, RenameProvider and the CLI's fieldForValue leave a list alone. The stash keeps a YAML list as the YAML it was written in (edit.GetYAMLText), and a reset puts it back so, flow or block; a comma-separated string comes back a string. A provider renamed moves the names inside a list through RenameRefs, once, and in the lists the stash keeps too. Review of #325.
* gui: a test holds omp's subagents, smol and slow roles as squares that follow the model, and OpenCode's small model a picker
omp-roles.test.cjs, in Chromium and WebKit, English and Chinese, API faked: the three roles are squares after the model's picker, unset named "same as model" (「同主模型」) and set naming their model; smol's picker opens on "Same as model" with the model beside it, and a pick sends it and shows it. OpenCode's small model stays a picker. With smol and slow taken out of FOLLOWS_MODEL it fails at "small is a square". Review of #325.
* gateway: a Claude Code run whose subagent ended on StructuredOutput is let go, and runs left waiting on tool calls are bounded (#345) — a workflow's subagents end on StructuredOutput, which the client answers itself, so the bridge's claude -p waited 30 minutes for a result that never came, one process per subagent. Such a run now waits 30s (for a schema error the client sends at once) and ends; of other runs waiting 5 minutes or more on their caller's tools, 16 are kept, the longest waiting let go first (one that then gets its results starts anew with the whole conversation). TestClaudeRunEndsAfterStructuredOutput and TestClaudeParkedRunsAreBounded fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
* plugins: a built-in moved onto its plugin looks and works as it did (你确保插件版的订阅 provider 和其对应的内置的…都保持一模一样了吗?) — moved onto its plugin, a built-in lost pieces: its tile's name, icon, plans and risk note went to the plugin's own, "Add account" still opened the built-in's sign-in (in the app, on the server and in `magpie accounts add`), an agent's own sign-in the built-in still found showed a second usage card of the same account, the account's plan badge went, WorkBuddy's daily check-in stopped, the provider's icon and site were the plugin's, WorkBuddy AI's card was named WorkBuddy, and Devin, which had no card of its own, lost the plugin's. Now a moved built-in keeps its tile and risk sheet and signs in through its plugin; its cards take the built-in's place and name, and it has none of its own; the plan the plugin tells stays with the account; WorkBuddy checks in through the plugin, which signs the call; a card is left out only where its built-in's slot showed it. TestMovedBuiltinQuiet, TestWorkBuddyCheckinThroughPlugin, TestMoveToPlugin (Devin-like card) and TestPluginUsage (plan kept) fail without the change. Command Code and Devin moved and back in a sandbox HOME: one card each, plan, name. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-move, plugin-signin, commandcode-risk, old-webkit pass; click-scroll's WebKit fails the same on origin/main under this load.
* provider: the Providers and Gateway pages no longer wait on `claude --version` (deluxe on Discord: on Omarchy the two pages never loaded) — on Omarchy `claude` is a mise wrapper that installs or updates Claude Code before it answers, which on a slow network takes minutes or never ends; magpie asked it with no limit and with a lock held, so /api/providers (fetching the Claude account's models) and every Claude request waited with it. The CLI is now asked in the background, given a minute, and a caller waits at most 3s before the version it has stands in; a late answer is taken once it comes. Reproduced on an Omarchy VM (/api/providers unanswered after 100s on v0.1.516, 3.7s with the change). TestClaudeVersionDoesNotWaitForAStalledCLI and TestClaudeVersionTakenWhenItComes fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
* plugins: a plugin's account behaves as its built-in's (the user: 插件版的订阅 provider 和内置的…保持一模一样了吗) — a plugin's failed sign-in now says why ("the sign-in failed: the sign-in page expired") instead of a bare failure; a plugin account the provider refuses with 401 is marked lapsed, "<user>'s <provider> sign-in has expired — sign in again", beside the account and on its usage card as a built-in's is, and the mark goes when it answers again or is signed in anew; a Devin or Command Code account taken from the agent's CLI moves to the plugin marked as the CLI's and back as the agent's own, so the one it follows stays the one the CLI is signed in to. TestPluginProvider (lapsed), TestMoveSideState (cli) and TestPluginSignIn (reason) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
* agents: "Hide all" in an agent's model list keeps its line, and an uninstalled Gemini CLI goes from the Agents page (#356, #230) — the line under an agent ("Showing 0 / N models") came only when a picker still listed one of magpie's models, so hiding them all took away the one way back; it now stays while any is hidden. Gemini CLI was taken as present from what it leaves in ~/.gemini (settings.json, .env, installation_id…), which stays after it is uninstalled, so its row stayed, offering to set up a CLI that can't run; it is now its gemini binary. TestAgentModelLineAllHidden and TestGeminiNotAntigravity fail without the change; agent-models-all-hidden.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/agent ./internal/gui ./internal/library ./internal/provider pass; agent-models, click-scroll, old-webkit pass.
* gateway: Codex's Responses Lite tools reach SuperGrok and every other backend, and a group moves on when a member can't read a request (#350) — Codex sends a Lite model's tools as an additional_tools input item, its own functions in a "functions" namespace; magpie relayed the item to xAI, which 422s on it (unknown item type), and translated routes (Cursor, Anthropic, Chat…) dropped it, so the model had no tools. Outside OpenAI the item's tools now go in tools, the functions namespace opened into plain tools and earlier calls un-namespaced; OpenAI and the ChatGPT account still get Codex's own form. A 400/422 over the request's shape (failed to deserialize, unknown item type/parameter…) now moves to the group's next member, each asked once, the member not resting; the Routing page says so. TestAdditionalToolsRelayed, TestAdditionalToolsTranslated and TestGroupFailsOverARequestShape fail without the change; routing-shape.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, routing-wb-refused pass.
---------
Co-authored-by: yetone <yetoneful@gmail.com>
* edit: a config that is a hard link stays one when magpie saves or clears it — WriteAtomic renamed a temp file over the path even when the file had other names, so a models.yml that Orca keeps hard-linked into its overlay split in two: omp's name got the new text, the overlay kept the old one, and each later write drifted further; clearing an instructions file unlinked only magpie's name and left the old text under the others. Now a regular file with more than one link (st_nlink on unix, NumberOfLinks from GetFileInformationByHandle on Windows, one elsewhere) is written in place: the new text first goes to a temp file beside it and is synced, then is written over the file, cut to length and synced, and the temp file goes; a crash part way leaves the whole new text beside it, and a failed write keeps it and names it in the error. Mode, owner and every name stay. Remove empties such a file instead of unlinking one name, as it already did for a symlink, and a failed edit rolled back by Atomically goes back in place too. A file with one link is still replaced by rename, atomically, as before.
* edit: a read-only hard-linked config is still written, and temp files a crashed write left beside a config go at its next write
Review follow-up on #328:
- A hard-linked file that can't be opened for writing (0444 on unix,
read-only on Windows) fell over with "permission denied" where main's
rename replaced it. WriteAtomic now opens it first and, on
fs.ErrPermission, falls back to temp file + rename as before, which
splits that name from the others as main always did. On Windows main
couldn't rename over a read-only file either, so nothing changes there.
- writeInPlace says why it writes over and then cuts to length: a reader
can briefly see the new text with the old one's tail, but emptying first
would show an empty or half file instead, which a tool may load as a
config with nothing set; a mangled one fails to parse.
- writeInPlace names the trade with hard-link backups (rsnapshot, cp -al,
rsync --link-dest): they change with the file, as with vim's
backupcopy=auto.
- Remove says that emptying every name is meant: with AGENTS.md and
CLAUDE.md one file, clearing one empties both, as #323 does for a
symlink's target, since deleting one name would leave the text magpie is
taking back under the other.
- After a write goes through, both paths remove .<name>.<digits>.tmp
beside the file older than ten minutes: the file then holds newer text
than any of them, a fresh one may be another write's still under way,
and the digits keep other files' temps (.models.yml.bak.1.tmp) out.
omp names models beyond modelRoles.default: its other roles, retry.fallbackChains, enabledModels and task.agentModelOverrides. magpie looked only at modelRoles' scalar roles before dropping its provider from models.yml, and a rename moved only the default, so a chain on magpie/<p>/x:max was left pointing at a model omp no longer had, which it skips with a warning.
The product, module path (github.com/yetone/magpie), binaries, env vars
(MAGPIE_ADDR, MAGPIE_DEV_UI, MAGPIE_THEME, MAGPIE_DEBUG), config and cache
folders (~/.config/magpie, ~/.cache/magpie), bundle id (com.yetone.magpie),
gateway provider id and token, and every UI string now say magpie.
On first run magpie copies ~/.config/dial and ~/.cache/dial to the new
folders when they don't exist yet (copy, not move, so a still-running dial
keeps working; the old folders can be deleted by hand later).
The logo is a small round magpie in profile: one path for the header and
the tray, and the app tile adds the white belly and the blue-green tail.
The header mark is drawn in ink rather than the accent colour, which
fought the text in light mode.
Setting a field to nothing now means the agent's own default: dial's
wiring (gateway provider, model catalog, env) comes out and the key is
removed, so Codex is back on OpenAI's catalog and Claude Code on
Anthropic's. The picker lists it first; the CLI takes `dial <agent>
default`. Also the vendor icon inside a field pill was 22px, not 14px:
the row icon rule outranked it.
Wails v3 tray panel and window, TUI and CLI. Switches model, effort and
provider for Claude Code, Codex, Gemini CLI, OpenCode, Pi, Goose, Cursor,
Copilot CLI and Crush with format-preserving config edits. Providers page
and registry, stored keys, live vendor model lists, profiles.
Co-Authored-By: Claude <noreply@anthropic.com>