39 Commits
Author SHA1 Message Date
yetone 99009b9333 provider: a plugin's model pool counts a bare id with its @sizes, so a routing group's cursor/grok-4.7 is weighed on Cursor Models, not a used-up Other Models (Will on Discord)
Will's routing group keeps cursor/grok-4.7. Cursor's plugin (0.2.2/0.2.3)
lists Grok 4.7 only at its context sizes, grok-4.7@256k and
grok-4.7@500k, and its usage() builds the Cursor Models window's models
and Other Models' notModels from those ids. quotaOfPlugin matched a model
against those lists by the whole string, so bare grok-4.7 (which the
plugin still runs, at its default size) fell into Other Models: used up,
so the Routing page showed it 0% left and weighRouted ranked it last,
while composer-2.5 showed ~84%.

The window's lists are now an idSet: a bare id is in it when one of its
id@size is, and an id@size when its bare id is. Only the trailing @size
is taken off, so distinct models stay apart: another @size of a model,
another family member (grok-4), and other suffixes (ZCode's -Trial) still
match by the whole id. Every route that asks a plugin window whether it
counts a model goes through w.matches -> Limit.applies (routing's For,
Pace, Count, Renewal, fallback's Pooled, CapHeld, QuotaWindow.Counts for
the usage page's holds), so the one change reaches them all. host.js's
contract comment says so for plugin authors.

The plugin's own lists are also fixed in magpie-community/plugins (cursor
0.2.4 adds each listed id's bare name to its pool), for magpie versions
before this one.

Verified:
- TestPluginPoolCountsBareIDOfSizes (the reporter's literal ids) fails on
  the old code: "grok-4.7: used 100, want 16", "the usage page's holds
  count grok-4.7 in Other Models", "grok-4.8@1m: used 100, want 16";
  passes with the change.
- gofmt -l clean; go vet, GOOS=linux and GOOS=windows go vet -tags nogui
  on internal/provider and internal/plugin; go build for darwin, linux,
  windows.
- go test -tags nogui ./... green but TestNoCommandBypassesProc, red on
  main since 2da8fbe4 and fixed by b934d64a, which this is rebased on.
Not verified: against Will's real Cursor account.
2026-10-10 10:54:22 +08:00
yetone 7d0e415e62 plugins: two plugins that sign in to one provider are both listed, each row says which serves it, and Use for picks the other (neiko on Discord)
neiko on Discord installed a third-party plugin beside a plugin of their
own, added by its folder. Only the third-party one could be seen. Both
plugins' auth hooks name the same provider id. The host's auths() keeps
the last plugin in plugins.json for an id, as OpenCode does, so the
user's own plugin silently lost it:
- its row read "Signs in to nothing magpie can use";
- its provider was gone from the Add sheet;
- keepUnloaded could list the id a second time, from the
  plugin-providers.json kept before.
A package-name clash and a display-name clash were tried as well. Both
plugins stay visible in those cases, because a folder plugin's name is
its path.

What changed:
- host.js:
  - init gives each loaded plugin the ids it signs in to (provides) and,
    for each id another plugin serves, that plugin's spec (servedBy).
  - auths() honours plugins.json's new prefer map (id -> spec).
  - Only the serving plugin's provider.models hook runs for the id.
- plugin:
  - Clashes() turns provides and servedBy into {id, by, with} per spec.
  - Prefer() records a pick and restarts the host.
  - Remove drops the removed plugin's picks.
  - Re-adding a plugin under a new spec carries its picks over.
  - remove, off, options and prefer find a plugin by its exact spec
    before its package name (indexOf), so acting on one plugin never
    reaches another of the same package.
  - keepUnloaded no longer re-adds a provider from before when another
    plugin serves its id now.
- GUI:
  - /api/plugins rows carry clashes, with the provider's listed name.
  - POST /api/plugins/prefer {spec, provider}.
  - The serving row says "{other} signs in to {name} too; this one
    serves it".
  - The other row says, in amber, "{name} is served by {other}, which
    signs in to it too", with a "Use for {name}" button.
  - New strings in zh, zh-TW, ja and de.
- CLI: `magpie plugin` prints the same lines, with the command that
  fixes it, and `magpie plugin use <name> <provider>` makes the pick.
- Docs: docs/subsystems/provider-plugins.md, docs/reference.md and
  site/public/docs/{,zh/,ja/}plugins.html.

What was run:
- Reproduced under a sandbox HOME with magpie web on 3562: a fake npm
  plugin and a folder plugin, both on id "acme".
  - Old binary: the local row had providers [] and /api/providers
    listed acme twice.
  - New binary: both rows carry the clash; prefer moves acme to the
    folder plugin; remove clears the pick and leaves the other plugin
    serving acme.
- New Go tests:
  - TestTwoPluginsOneProvider, a real Bun host: Prefer, restart,
    off/on and Remove.
  - TestClashes, TestPluginNamedExactlyFirst and
    TestKeepUnloadedLeavesAServedID.
  - TestPluginsClashRows (gui) and TestPluginListSaysClash (CLI).
  - Each fails when its piece is broken by hand: prefer ignored in
    host.js, the keepUnloaded id check removed, name before exact spec,
    Remove keeping picks, no provides, no clashes in the rows, no
    prefer route.
- plugin-clash.test.cjs, in Chromium and WebKit, in en, zh, zh-TW, ja
  and de, at 1100 and 560px: 22/22 pass. It times out on the old
  plugins.js.
- gui-de, gui-ja, gui-zh-tw, i18n-keys and every plugin-*.test.cjs:
  248 tests.
  - 245 passed in the 4-wide run.
  - plugin-moved-signin (webkit) failed once in that run, then passed
    3/3 alone and 26/26 in a smaller 4-wide rerun.
- gofmt -l; go vet ./...; GOOS=linux and GOOS=windows go vet -tags
  nogui ./...; go build ./...
- The full go test -tags nogui ./... under a temp HOME with the caches
  pinned: 44 packages ok, none failed.
- After rebasing onto 05917100 (plugins.css conflict with a3a8e257's
  chip rules, both kept): plugin, gui and the root plugin tests, vet on
  darwin, linux and windows, and the 248 GUI tests above, all green
  (248/248 this time).

Not tried: a real third-party plugin, neiko's own plugins, and Windows
beyond vet.
2026-10-10 00:55:38 +08:00
yetone baecbb1be0 provider, gateway: Cursor moved onto its 0.2.x plugin gets Fast back, in the agent picker (#954) and for a Routing group's member, on the models Cursor has a fast variant of (#1360, loosheng: 插件 0.2.x 后 Cursor 的快速模式不见了,保存 cursor/<model>:fast 报 "has no fast mode magpie can ask for")
Plugin 0.2.x lists Cursor's picker once per context size (claude-opus-5-5@300k, @1m) and no -fast model, so CanFast, which looked for a -fast sibling, said no for every model: FastFor in route.go, canFast in the group editor, SetFastPick and cleanFast all go through it. The plugin itself still ran service_tier priority as the fast variant. opencode-cursor-auth 0.2.2 (magpie-community/plugins fb253f9) now says fast on a model listed at a size Cursor has a fast variant at; host.js passes it on as plugin.Model.Fast, CanFast takes it, and a plugin before 0.2.2 is still read by its -fast sibling. The Cursor mover asks for 0.2.2. On the way a fast request translated for a moved Cursor (Anthropic, Responses, chat not streamed) lost its service_tier: buildChat sets it only for host "cursor", and a moved provider's host is empty; buildHost names it.

Tested: the fake plugin lists sized models with fast on one (FAKE_FAST=tier); TestCursorPluginGroupFast (CanFast per size, @1m:fast refused, a group's @300k:fast sends priority), TestCursorPluginGroupFastSibling (the old -fast list), TestCursorPluginFastPick (moved: picker fast on chat, chat streamed, Responses and Anthropic sends priority, codex's doesn't). Without groupfast.go, host.js's fast or buildHost they fail. go test -race -tags nogui on gateway, provider, plugin, agent, gui; go vet, GOOS=linux/windows vet and build. With the built binary under a temp HOME, the real npm 0.2.2 plugin and Cursor's API answered by a preload (a picker fixture, the agent API's Run caught): /api/state offers fastFor on @300k and composer-2.5, not @1m or grok; /api/groups canFast the same; saving @1m:fast is refused, @300k:fast saved; opencode's picked @300k reaches Cursor's Run with fast=true on chat, Anthropic and Responses, codex's with fast=false. origin/main's binary in the same HOME offers none and refuses @300k. Not tried with a real Cursor account.
2026-10-09 05:32:36 +08:00
yetoneandClaude cdf325f178 plugins: a package that is only a command (an MCP server like magpie-x-search) isn't listed under Unreviewed, isn't added as a plugin, and one added before says what it is instead of a host stack (#1327, congee949)
A repository tagged magpie-plugin whose npm copy (from that repository)
names no file to load (no main, no exports) was listed and installed from
npm without the entry check the GitHub route has; it then failed every
host start with "has no entry" and a stack. Now:

- the Unreviewed list checks npm's copy the same way: one that names no
  file needs the index.js the repository's own route needs (askTagged);
- `magpie plugin add` / the Plugins page refuse a package that names no
  file, has no index file and is neither pi's nor a middleware, take the
  package just installed for it out again, and say it isn't an OpenCode
  or magpie plugin, and, for one with a bin, to add an MCP server under
  Library → MCP servers (notPlugin);
- the host says the same, without a stack, for one already on the list.

Run: TestCommandOnlyPackageIsNoPlugin (magpie-x-search 1.2.0's real
package.json) fails without the change (Add = <nil>; with only host.js
reverted, the stack message), TestTaggedRepos with the real GitHub
package.json and npm latest of cyberElar/magpie-x-search fails without it
(listed), both pass with it; TestNotPluginKeepsPlugins (index.mjs, main,
exports, middleware, pi, a file stay plugins). go test -tags nogui
./internal/plugin under a temp HOME ok; go vet -tags nogui for darwin,
linux, windows. The built binary in a sandbox HOME: `magpie plugin add
magpie-x-search@latest` from real npm prints the new error, and
plugins/package.json no longer lists it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-08 23:09:25 +08:00
yetone 51d2cf88ea plugin host: a plugin's node:http2 connection goes through magpie's proxy like its fetch does (#1035, cataglyphis: Cursor opus-5-5 high ends after ~148s with "Cursor: 502 Bad Gateway" cursor_error) — the Cursor plugin runs agent.v1.AgentService/Run over node:http2, which Bun connects directly and ignores the proxy that host.js gives every fetch; the built-in sent the same request through s.client, so a user who needs magpie's proxy (setting, env or the system's) to reach Cursor had their sign-in, models and usage proxied but every Run went out directly. host.js now wraps http2.connect: for a URL proxyFor gives a proxy to (provider/account via, global, SOCKS bridged), it opens an HTTP CONNECT tunnel (Proxy-Authorization from the URL) and runs TLS with ALPN h2 over it; "direct", a caller's own createConnection and unproxied URLs keep Bun's connect; a tunnel that fails ends the session with "proxyconnect tcp: …". TestPluginHTTP2TakesTheProxy (an HTTP/2 vendor at an unresolvable host reached only through a CONNECT proxy; direct and a dead proxy fail with the reason) fails without the change ("h2: getaddrinfo ENOTFOUND h2vendor.invalid"; it carried 0) and passes -race -count=10. With the owner's Cursor account in a sandbox HOME and a logging proxy set, Claude Code's captured request on cursor/claude-opus-5-5 high: before, the proxy saw api2.cursor.sh only; after, it also sees agentn.global.api5.cursor.sh and the reply streams. The reporter's 502 wasn't reproduced here (direct runs answer in 4–20s). go test -race ./internal/plugin, the gateway/provider Plugin|Proxy|Cursor tests, gofmt and vet pass. 2026-10-06 22:16:24 +08:00
yetone 87487c5ef0 plugin, provider: a plugin can press its vendor's daily check-in itself (auth.checkin), and magpie runs it as its own check-ins (Lemon on Discord #feedback: provider 每日签到的方法能放到插件里面去做吗; ARNO earlier asked for Qoder's) — the check-ins magpie had (WorkBuddy, Trae CN, MiniMax Code, Qoder) are each written in magpie against the vendor's endpoint, even for a subscription that moved to its plugin, so a plugin author had no way to give their provider one. A plugin's auth hook may now have checkin(getAuth, provider) beside usage: the host lists the provider with checkin: true (plugin.Provider.Checkin) and answers checkin({provider, account}) (plugin.AccountCheckin), refreshing the account as usage does, with {outcome, credit, streak, message}; outcome is claimed, done, ineligible, inactive, captcha or failed, and a throw or an outcome outside these is failed with the reason. internal/provider/plugin_checkin.go runs these through checkin.go's checkiner, as the built-ins are: each signed-in account in use once a Beijing day (first look 2 minutes after start, then every 30), a failure asked again after wbCheckinRetry, every other outcome settled for the day, results kept in plugin-checkin.json by provider|account; a failure is recorded as failed and resets nothing. captcha is new (CheckinCaptcha): the vendor wants a captcha, which magpie never solves, so it is said as "check in in its own app", not as a failure, and not asked again that day. The results mark the account's Usage card (checkinBy "plugin:<provider>", WithCheckins), so the card's check-in row, its Auto check-in and Check in now work for it (POST /api/settings/plugin-checkin {provider, on}, POST /api/usage/plugin-checkin {provider}); Settings → Usage gains a Plugins tab, shown while such a plugin is signed in, a Daily check-in row per provider with Off/On and how each account went; magpie accounts checkin and c on the TUI's Usage page include them (the TUI's "no account" line names check-in plugins). The switch (settings.PluginCheckins, by provider) is off by default; for WorkBuddy, Trae CN, MiniMax Code and Qoder it follows that vendor's existing switch until set on its own, and magpie's own check-in through the plugin's fetch leaves a provider whose plugin checks in to it (pluginChecksIn), so no account is checked in twice. The Settings page's save carries PluginCheckins over (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). No plugin declares the hook yet; Qoder's check-in stays magpie's own here. TestPluginCheckinRunsAsTheBuiltinsDo (the fake plugin with FAKE_CHECKIN, five accounts answering claimed, done, captcha, a throw and an unknown outcome: off presses nothing; on, each outcome kept with its vendor; a minute on nothing pressed again; past the retry only the two failures; the next day all five; PluginCheckins and the cards marked; another provider's press asks none) and TestPluginCheckinTakesOverTheVendors (a qoder plugin without the hook leaves magpie's Qoder check-in its account, with it none; PluginCheckinOn's fallbacks) pass -race -count=10, and the latter fails with pluginChecksIn stubbed false; TestTUIChecksPluginsIn passes -race -count=20. plugin-checkin.test.cjs (the card's row with the captcha said, Auto check-in posting {provider, on}, no page move; the Settings Plugins tab at 440px with the row fitting, no sideways scroll, On posting) passes on Chromium and WebKit in en, zh, ja and de; usage-checkin, i18n-keys, gui-ja, gui-de and settings-* pass. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with a real plugin or account: no published plugin has the hook yet. docs/subsystems/provider-plugins.md (the hook's contract) and docs/reference.md say it. 2026-10-06 18:40:45 +08:00
yetone bc00ee1df2 plugin, provider: a program a plugin starts has magpie's proxy, and a CLI login with no link says why (𝕏 on Discord)
The Grok plugin's sign-in said only "grok login gave no link to open".
The plugin host has magpie's proxy only as MAGPIE_*_PROXY, so the
`grok login` the plugin runs went out with none, where the built-in's had
it (netproxy.Env); where x.ai is reached only through a proxy, it failed.
host.js's spawn wrapper now gives a started program HTTPS_PROXY and
HTTP_PROXY (the account's own proxy, none for direct, else magpie's) when
the plugin set none.

grok 1.0.46, unable to reach x.ai, prints "Error: error sending request
for url (https://auth.x.ai/oauth2/device/code): …", whose URL the
built-in's runCLISignIn took for the page to open; a line saying error
gives no link, and a login with none ends with the CLI's last line. The
plugin does the same from 0.1.8, which the grok mover now asks for.

plugin_cliproxy_test.go and TestCLISignInSaysWhyNoLink fail before this.
Checked with grok 1.0.46 itself through the plugin host and the published
plugin's code, given a proxy that refuses: the CLI goes through it and the
error names what it said.
2026-10-05 22:25:33 +08:00
yetone 4788cf2bbd plugin, gui: a plugin's account whose model list failed says why, rather than listing Auto alone (gnayiab on X) — with magpie in WSL, Cursor's plugin account offered only Auto: the plugin's models hook couldn't get the account's list and magpie, with no list kept from before, showed the plugin's defaults with nothing saying so, its editor even calling them "vendor list", and Refresh answered "1 models". The host now keeps why a models hook fell back (what it threw, else how its last fetch failed) for the provider and each account; plugin.Cached carries it with the accounts it reads again from the sign-ins; provider.ListError gives it for a plugin's account. The editor says "<provider> couldn't list its models: <why>" under the models (amber, no left border) and no longer calls the defaults the vendor's list, and Refresh fails with it. A list kept from before, or one that comes in, says nothing. en/zh/ja/de. Tried with the real Cursor plugin (0.1.9) in a sandbox: a key Cursor refuses shows "Cursor couldn't list its models: Cursor didn't take this API key" and Refresh says it; the owner's real account lists 72 models with nothing said, and Claude Code -p through the gateway ran a Bash tool on cursor/auto, composer-2.5 and claude-opus-5-thinking. The plugin itself, in WSL (magpie-desktop), listed 254 models and streamed chats with tools. TestFallenBackListSaysWhy (hook returns its defaults, throws, or hands back its own) fails without the change; plugin-list-error.test.cjs (Chromium and WebKit, en and zh) too; TestFallenBackListKeepsTheLastOne, account-models, plugin-signin and click-scroll pass. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. 2026-10-05 17:39:29 +08:00
Snowyy c38728145b plugin: a slow reader pauses its stream instead of racing a watchdog (#812)
* plugin: a slow reader pauses its stream instead of racing a watchdog

#664 gave a plugin reply a soft watermark (softQueuedBytes 4 MiB, softQueuedEvents 4096) and a 10-second idle watchdog: past the watermark, a reader that made no progress for stallIdle had its call failed with errStalledReader, and hardQueuedBytes/hardQueuedEvents (64 MiB / 65536) cut the reply outright. Both are guesses at how long a reader may take, and both end a reply that reader was still consuming.

The host now hands each fetch a credit window instead. Go grants streamWindow (512 KiB) up front and returns credit as the reader takes bytes, so the child stops producing at the window instead of being timed out; the window is charged per delivered byte plus frameOverhead (2048), so a run of tiny frames cannot fill it with metadata. streamBudget (64 MiB) and maxFrame (64 KiB) bound a whole reply and a single frame, and a child that sends past its window is a protocol violation (errProtocolOverrun) rather than a slow reader.

The plugin side grew the matching gate (makeGate, gate.take/spend/add, waitDrain): a fetch holds at the window until Go credits it, and its pending writes are cancellable. Cancellation and shutdown now have one owner per call - a five-state lifecycle (sending, active, draining, aborting, released) whose single transition helper disposes the queue, detaches the watcher, commits the terminal answer, creates the abort and returns capacity exactly once - so a host failure, a Close, a cancelled context and an already-delivered reply can no longer race each other over who cleans up. A reply the host already delivered keeps its bytes and its vendor outcome when the host dies.

Tests: the JS cancellation cases run the production sections with controlled stdout backpressure (stopped gate, split chunk, terminal suppression, acquired-body cancel, 30,000 cancelled bootstrap waits with no retained waiter or ready reaction); the Go cases drive lifecycle orderings directly (terminal-before-failure, failure-before-terminal, abort acknowledged before capacity returns, blocked enqueue), and the shutdown, body-close and mid-setup-cancel cases block on explicit channels instead of sleeping. TestFlowBigReplyProd reads 100 MiB non-SSE and SSE through the real host and checks lengths and SHA-256, including a 12 s reader pause. CI installs Bun 1.4.2 on Linux and runs that case in its own step, since the plugin sandbox skips when bun is not on PATH.

* plugin: the stream-head credit test waits for the credits before the reply ends (CI, macos-latest: TestHostStreamHeadChunksFinal failed on the macOS runner with "the credits were given back didn't happen", while the same package passed on ubuntu-latest and locally) — the test sent the child's final answer together with its 50 chunks, so the call could be marked stopped and its pending credits dropped before waitFor saw them; a completed fetch needs no credit, so waiting longer could not recover them and the expectation was a race, not a real accounting bug. The final answer is now withheld until io.ReadFull has taken the 140 payload bytes in order and exactly 140 + 49*frameOverhead credits have been observed with frameOverhead still owed, and only then does the child answer and the test assert a zero-byte EOF read. No production code changed.

Tests: go test -run '^TestHostStreamHeadChunksFinal$' -count=500 ./internal/plugin/ and -count=200 -race pass; go test -count=1 and -race -count=1 ./internal/plugin/... pass; gofmt -l and git diff --check clean.
2026-10-05 01:36:20 +08:00
yetone 67092380fb plugin: pi packages run as pi runs them — a program a login starts can't take the host's stdin, a select is answered with its option's id, and the extensions get pi's runner, pi's auth.json and session_start (owner: 都补上,用真实账号测) — tried with real accounts, five pi packages had problems that the fake one didn't show. pi-devin-plus runs devin auth login with stdio "inherit" when it finds no Devin sign-in, and the program read magpie's next requests from the host's stdin as its answer ("Login canceled") while writing over its answers; host.js now gives every program started through Bun.spawn and Bun.spawnSync (all of node:child_process goes through them) nothing to read and stderr to write. pi-zcode asks its region with onSelect and options { id, label }, pi's own shape, and checks the id ("bigmodel"); pi.js answered with the label, so the login said "Login cancelled". Packages read their sign-in back from pi's auth.json (readStoredCredential: pi-zcode for its plans, pi-cursor for its token, pi-provider-qoder through the registry, pi-antigravity syncs its accounts through it) and register what they find on session_start (pi-zcode its plans' models, pi-devin-plus and pi-xai-oauth refresh their catalogs), and a provider registered after loading went into a queue nothing read. Now each package is bound with pi's ExtensionRunner as pi's session binds it (bindCore: no UI, tools or session of magpie's), so a later registerProvider reaches the runtime; each pi provider's first account is kept in $PI_CODING_AGENT_DIR/auth.json as pi keeps it, written when it changes; and a provider newly signed in, and each one signed in when the host starts, is sent session_start, after which magpie lists its models again if they changed. A runner that can't be made leaves the packages registered as before. TestPiPackage now has a select answered with an id, a login that starts a program on the terminal, and a session_start that reads the sign-in from pi's auth.json and adds a model; it fails on the old code (the label answered; the model never listed, auth.json empty) and passes. With real accounts in a sandbox HOME: pi-zcode signed in through Z.ai and lists 11 models (none before); its requests reach ZCode but the account's Start Plan answers 200 {"code":1005,"msg":"exceed quota limit"}, which pi-zcode reads as an empty answer (its bug). pi-provider-qoder signed in through Google, 16 models, tool round trips on Kimi-K3 and Efficient. pi-devin-plus signed in, its catalog refreshed on session_start (53 models), tool round trips on swe-2 and claude-opus-5.5; pi-xai-oauth (grok-4.7) and pi-antigravity (gemini-3.8-flash) tool round trips; @rahularya01/pi-cursor answers text (its agent doesn't call a client's tools, upstream). pi-provider-kiro wasn't signed in: it needs pi's terminal, a Builder ID or an IdC login. go vet, builds (darwin, GOOS=linux -tags nogui, GOOS=windows) pass. 2026-10-03 22:51:24 +08:00
yetone 1baad0d2d4 plugin: pi packages are plugins as OpenCode's are — a pi coding agent package (its extensions) adds providers to magpie: their models, their sign-in, renewals and requests (owner: 我希望全面兼容 pi 插件!!!!!!!!!!) — a package is pi's when its package.json has a "pi" manifest, the "pi-package" keyword, or pi-coding-agent among its dependencies (IsPi, mirrored in host.js). Adding or updating one installs @earendil-works/pi-coding-agent beside it, and the plugin host loads every pi package through pi's own loader (pi.js: discoverAndLoadExtensions and ModelRuntime, with the credentials kept in plugin-auth.json), so an extension's registerCommand, registerTool and pi.on are taken and left unused, and its registerProvider calls become magpie providers. A provider's models keep their context, output, images and the reasoning levels their thinkingLevelMap allows; its OAuth login runs as pi runs it, each onPrompt asked in magpie's sign-in in turn, onAuth's page opened, a later prompt being the code pasted back; a provider with apiKey takes a key; magpie renews through refreshToken and saves the result. Requests arrive as Anthropic Messages and go to pi's stream: system and tools, images, tool results, thinking budgets and effort as the nearest level the model has (and the least it allows when it can't turn thinking off: Antigravity's gpt-oss refused requests without one), max_tokens held to the model's; the answer comes back as Messages SSE or JSON with thinking and its signature, redacted thinking, tool calls (their thought signatures kept for the next turn), usage, and a failure before anything is said as the vendor's status with Retry-After. The Plugins page's search also finds pi packages that bring a provider. Tried with the owner's real Antigravity account through pi-antigravity, next to the Grok OpenCode plugin in the same host: 14 models listed, Gemini streaming with usage, Claude Sonnet thinking with signatures, gpt-oss, tool round trips on gemini-3.8-flash, gemini-3.1-pro and claude-sonnet-4-6, a 400 and Antigravity's 429 passed on with their status. A Claude Code turn didn't get through: Antigravity answers 429 to any system prompt with Claude Code's billing header or its "Claude agent" line, through magpie's built-in Antigravity just the same. TestPiPackage (a fake pi package: models, an OAuth login asking twice with a wrong code then a right one, a key, streaming and JSON answers, thinking and tool signatures back, a 429, a renewal) and TestSearchFindsPiPackages pass. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestClaudeRunEndsWhenTheClientLeaves, TestProvidersAnswerWhileListsComeIn and TestSavedButSignedOutClaudeServed, timing tests that pass alone. 2026-10-03 19:12:19 +08:00
yetone 37809ac722 usage: WorkBuddy's credits are said beside their share — on the Routing page, the Usage page and the menu bar, used or left as the share is (#659)
Moody-Sin: the Routing page showed a WorkBuddy account as "已用 71%" only, which for a plan counted in credits says little. A quota window now carries its own count when the vendor counts in amounts — amount of limit, in unit (QuotaWindow, QuotaSpan for quota --json and /v1/magpie/quotas, and the routing trace's Weighed, from the window its share is of: Allowance.Count). WorkBuddy's built-in fills it from the resource summary, and @magpie-community/opencode-workbuddy-auth 0.1.8 tells it from auth.usage (amount / limit / unit, passed through host.js); the workbuddy mover's min is 0.1.8. The Routing page's stage and its list of what each account did, the Usage page's meter, the menu bar's tooltip and the TUI say "3,550 / 5,000 credits · 71% used", or "1,450 / 5,000 credits · 29% left" with Left on (积分 in Chinese); a window counted in shares alone reads as before.

quota-credits.test.cjs (WebKit, en/zh, used/left, Routing and Usage pages) fails on the old assets and passes; TestQuotaWindowCount, TestAllowanceCount, TestTraceTellsTheCount, TestPluginUsage (the count through the host) and TestWorkBuddyAccounts cover the Go side. Tried with the real WorkBuddy and WorkBuddy AI accounts in a sandbox magpie: moved onto the local plugin, /api/usage/quotas gives 33.29 / 3900 and 13.47 / 2250 credits and the Usage page reads "33.29 / 3,900 积分 · 已用 0.9%", "3,866.71 / 3,900 积分 · 剩余 99.1%" after a click on the count; not moved, the built-in gives the same for WorkBuddy. The Routing page with two real accounts of one provider wasn't tried (one account each). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-03 16:45:48 +08:00
yetone 123594489a plugin: a host stopped mid-renewal saves the new token first, and two hosts never write each other's sign-ins away — a stopped host (quit, plugin update, restart) was killed 2 s after stdin closed even while it renewed a sign-in through auth.refresh, after the vendor had spent the old refresh token, so the account was signed out; and while a host restarts the old one and the new one both rewrote all of plugin-auth.json from what each had read, so one host's save could drop the other's accounts or newer token. host.js now tells magpie how many renewals are under way (event "renewing"), and stop gives a host still renewing after the 2 s up to 15 s in all, until the renewal is saved; with none it stops as fast as before. Every change to plugin-auth.json (setAuth, removeAuth, settle, take, auth.set, a renewal's save, and magpie's SignOut when no host runs) is made under plugin-auth.json.lock (O_EXCL, broken when older than 10 s), reading the file afresh and changing only its own accounts; a renewal's save checks under the lock that the stored sign-in is still the one it renewed, so an older host never writes a stale token over a newer one. TestStopLetsARenewalEnd (a 4 s refresh, the host restarted in the middle: old code kept the spent token) and TestTwoHostsKeepEachOthersSignIns (two hosts saving 40 accounts each at once: old code lost 38 of 80) fail on the old code and pass; TestStopWithoutRenewalIsQuick keeps the quick stop. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut, timing tests that pass alone and in their packages rerun. Not tried with real plugin accounts. 2026-10-03 15:47:10 +08:00
yetone f0e4a79c06 plugins: auth.refresh — magpie renews a plugin's sign-ins before they run out, once per account at a time (owner: 按你建议的优先级,先做 1 和 2, from the pi plugin study) — each community plugin renewed its own tokens inside fetch, deduping by hand (factory's "two refreshes would spend it twice", minimax's renewing, cline's REFRESH_LEAD, workbuddy's EARLY_MS). A plugin can now give auth.refresh(auth, provider), returning the fields to keep over the old sign-in, and optionally auth.refreshLead (ms, 5 minutes by default). Before an account's loader options, provider.models and auth.usage, the host renews an OAuth account whose expires is that close: requests that find it due share one renewal (vendors that spend a refresh token once never see it twice), waiting up to 15s, and a renewal that takes longer is still saved when it ends (pending keeps the host up). What it gives is merged over the account and saved through setAuth, which clears the loader caches here and in Go, and the account is said renewed; a sign-in replaced or removed meanwhile stands. A throw leaves the account as it was and the request goes with the old token; signIn: "expired" on it marks the account and it isn't tried again until signed in again, any other error is retried after 30s. OpenCode ignores the hook, so plugins keep their in-fetch check for it. Documented in /docs/plugins (#renew, en and zh). TestPluginRenewsSignIns fails without the host change (no renewal). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass. 2026-10-03 03:08:15 +08:00
yetone 33acb6bca0 providers: each model's credit rate shows by it in the provider's model list and an agent's model picker, Qoder's 0.5× and WorkBuddy's 0.03×, a discount's price before it struck through (01huadalang on Discord: qcoder 或者 workbuddy 能不能实时抓取或者按一段时间抓取能展示列表模型的积分消耗倍率,这样不用打开软件查看,能选择到便宜的模型) — magpie read each model's price only to tell free from not: Qoder's price_factor (06584e15) and WorkBuddy's "credits" ("x0.03", wbFreeCredits), so the lists said FREE or nothing, and picking a cheap model meant opening Qoder or WorkBuddy. catalog.Model now keeps the price, Rate (the credits a request costs, as a multiple; 0 is not listed, or Free) and RateWas (the price before a discount running now), and carries both through provider.Entry, agent.Option and the providers page's models. Qoder (and Qoder CN): Rate is the price_factor; RateWas an active promotion's before_promotion_price_factor, else an original_price_factor above the price (Qwen3.8-Flash: free, 0.1× struck through), and a promotion's 0 that isn't free is its price before times its discount_factor. WorkBuddy and WorkBuddy AI (the CodeBuddy plan; both builds read the same /v3/config): Rate is the credits (wbCredits; x0.00 stays FREE, an unreadable or infinite rate is none). A plugin's model gives it as it gives free: rate and rateWas on the model its provider.models hook lists, a number (0.5) or as the vendor writes it ("x0.03", "0.5×"), which host.js reads (rateOf) into plugin.Model.Rate/RateWas and pluginCatalog keeps; the moved Qoder and WorkBuddy users get it once their plugins send it. The badge is small and grey (the context badge's colours, no uppercase), beside FREE where that shows, its title saying what a request costs (每次请求消耗 0.5× 积分;折扣前 / 限时免费,原价). Rates are as of the provider's last list: a built-in's is fetched at start-up when it has none, on Refresh and when its key changes, a plugin's when its plugins are asked again (start-up, a sign-in, a plugin change); nothing asks on a timer. Kiro, Cursor, ZCode and Devin list no per-model rate magpie reads, so they show none. TestParseModelsRate (qoder) and TestWBCreditsRate (provider) are new and don't build without the change; TestFakePlugin now checks a plugin's rate given as a number and as "x0.03" (0 for both with host.js not reading them). model-rate.test.cjs (WebKit, en and zh: the chips' badges and titles, struck-through prices, grey not green, no stripe, a click picks and scrolls nothing, the picker's badges) fails without the change and passes with it; click-scroll, models-free-only, model-pick and favorite-star pass in WebKit. No request was made to Qoder, WorkBuddy or any vendor. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME, go test ./internal/gui pass. 2026-10-02 22:48:45 +08:00
yetone 987d72c133 providers: Max concurrent requests — over it, an agent's requests wait in line and go out in the order they came (Lemon on Discord: 希望 provider 或者插件能够支持一个参数类似 max_concurrency,如果超过上限,放到无界队列里面依序请求; Memo: ChatGPT/Codex subscriptions' risk control trips at about 5-6 requests at once) — the gateway sent every request to its provider the moment it came, so a few agents (or one agent's subagents) on one subscription could have any number out at the vendor at once, and nothing in magpie could hold them to what an account takes. A provider now has maxConcurrency (providers.json; nil or 0 is no limit, a negative one is saved as 0), and a signed-in agent's accounts keep theirs with their picks. The limit is per key or account, as the gateway already tells them apart (candidate.who(): provider#keyid, provider@user, else the provider), so a subscription pooling several accounts lets each one have that many out. Before a try is forwarded it takes a slot in that lane (internal/gateway/concurrency.go); with none free it waits in a FIFO queue with no bound, and is let in, first come first served, as one before it ends. The slot is held for the whole try (attempt reads the vendor's reply to its end or until the agent hangs up, and its body is closed when it returns) and given back once whatever happens (sync.Once), and a lane with nothing out and no one waiting is dropped. An agent that hangs up while waiting leaves the queue at once, is never sent and takes no slot (a slot granted in the same instant is handed on), and goes the way of any canceled request (499, nothing recorded). Waiting is not a failure: a request in line stays for its provider and is not moved on to a fallback or another group member, the account isn't rested, and the time it waited is told in its route's try (Try.Queued, ms). A plugin can say how many each of its accounts takes, as its own field: the auth hook's maxConcurrency, else package.json's magpie.maxConcurrency (a whole number over 0, at most 1000); the user's setting on the provider goes over it. GET /v1/magpie/concurrency (local or shared callers, as quotas) gives each lane's out, waiting and limit. The provider editor (keys, custom, signed-in accounts) has "Max concurrent requests" / 最大并发请求数 under Proxy, "超出时排队,按顺序发出;0 或留空为不限制" below it (with a plugin's own number as its placeholder), refuses anything but a whole number from 0 to 1000 before anything is posted, and an empty field saves none (the plugin's, if it has one); a save that doesn't send the field keeps what the provider had. No coloured left border, and a click in the field leaves the page where it is. TestMaxConcurrency (limit 2, five streams: never more than 2 at the vendor, 3 waiting, let in a..e in order; one canceled while waiting leaves the queue, takes no slot and never reaches the vendor; slots given back when each stream ends and when an agent hangs up mid-stream; the next one served at once) fails without the change (all five reach the vendor at once, no lane), as do TestLanesLimitLifted, TestProviderConcurrency and the updated TestPluginOwnIconAndKeyHint (the auth hook's 3, package.json's 5 when the hook's is 2.5); provider-concurrency.test.cjs (chromium and webkit, en and zh: typed and saved, 2.5 refused with nothing posted, empty saved as null, an account opening on its 5 and saving 0, a plugin's 4 as the placeholder, every string translated, no stripe, no scroll on click) passes. No vendor was asked. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... (under a temp HOME) and go test -race on the new tests pass. provider-proxy.test.cjs's account test fails the same on HEAD without the change (its editor redrawn under it). 2026-10-02 13:38:23 +08:00
yetone 2a6ba1a20f workbuddy: an added account is told from the others by its uid, and the one WorkBuddy is signed in to stays once WorkBuddy moves on (#413: 添加已提前登录的 WorkBuddy 账号会顶掉默认第一个账号) — magpie matched a new sign-in to a listed account by its name, the nickname: another account of the same nickname replaced the one listed, or was taken for WorkBuddy's own and let go with "signed in". And WorkBuddy's own account is only read from WorkBuddy's file, so signing WorkBuddy in to the next account (to pick it on WorkBuddy's page from magpie) renamed that row, the last account gone, and the one picked, being WorkBuddy's own, was let go: one account at a time. A WorkBuddy or WorkBuddy AI sign-in is now matched by its uid (one of the same name whose uid isn't known is still taken to be it, #155); another account of the same name is added as "name (end of uid)"; the same account renews its sign-in where it is listed, and the one WorkBuddy is signed in to becomes magpie's own sign-in of it, first as it was, so it stays when WorkBuddy signs in to another. The sign-in says so: "该账号已在列表中,已更新登录" in the app, "is already listed — its sign-in was renewed" from magpie accounts add. On the plugin, the host no longer takes two sign-ins of one account id for one account when their uids differ. No request header or user agent changes. TestWorkBuddyAddSameNicknameAsOwn, TestWorkBuddyAddSameNicknameTwice, TestWorkBuddyAddWhatWorkBuddyIsSignedInTo, TestPluginSameNameOtherUID and signin-again.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; signin-again, click-scroll, old-webkit pass. 2026-10-01 19:38:36 +08:00
yetone 4f9dcb39e5 plugins: a plugin subscription goes through every path a built-in one does (the user: 要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — a SOCKS5 proxy on a plugin provider or account now carries its requests: Bun speaks http(s) proxies only, so magpie stands a loopback http proxy in front of the SOCKS one, and a proxy that refuses says so as Go's transport does. A plugin's model list and its loader are asked through the account's own proxy, as a built-in's list is fetched through it. The Grok plugin draws and makes videos at Grok's Imagine API through the plugin, and keeps Codex's namespaced tools, as the built-in does; an official plugin beside its built-in (kiro-plugin, zcode-plugin) counts tokens by estimate as the built-in and the moved plugin do. FetchNew no longer re-lists plugin providers every ten minutes, their list being told by the plugin host; a plugin account's usage is cached under one name whichever way it is asked for; and a fetch hook that throws on its sign-in (a refused refresh) marks the account lapsed, as a built-in's refused refresh does. TestPluginSOCKSProxy, TestPluginModelListThroughAccountProxy, TestGrokPluginDraws, TestPluginBesideBuiltinCountsByEstimate, TestFetchNewLeavesListedPlugins, TestPluginUsageReadOnceWhicheverName and TestPluginFetchSignInExpiredLapses fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (the known load flakes pass alone); internal/plugin, netproxy and the plugin/proxy tests of provider and gateway pass on Linux and Windows. 2026-10-01 19:07:12 +08:00
yetone 8cd520662f plugin: a plugin can say the list it hands back is a fallback, and magpie keeps the one told last, as a built-in does — Command Code's Go accounts and ZCode give a table of their own when their vendor's list can't be had, not the defaults they were given, so magpie couldn't tell it from a list read and a hiccup swapped the models for the table. A models hook's list marked Symbol.for("magpie.fellBack") (OpenCode never sees a symbol's key) now counts as fallen back; commandcode 0.1.7 and zcode 0.1.6 mark theirs. TestFallenBackListKeepsTheLastOne/throws=own fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/plugin ./internal/provider pass; internal/plugin passes on Windows and Linux. 2026-10-01 17:06:17 +08:00
yetone ca29a7badd plugin: a plugin whose models hook throws keeps the list it told last, as a built-in does — 9abf441d kept the last list for a hook that gave its defaults back, but Cursor's, Grok's and Devin's hooks throw when their vendor fails, and an account past the first whose hook threw was taken to have all the provider's models; a vendor's hiccup still swapped the list for the plugin's defaults. A hook that throws now marks its listing fallen back, the provider's and each account's, and magpie keeps the list told last in its place. TestFallenBackListKeepsTheLastOne/throws=1 fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; internal/plugin passes on Windows, internal/plugin and internal/provider on Linux. 2026-10-01 16:56:41 +08:00
yetone 9abf441dfa plugin: a plugin whose model list can't be fetched keeps the one it told last, as a built-in does (ARNO on Discord: the Command Code plugin lists no Pixel Canary, the built-in does) — a built-in whose fetch fails keeps catalog.Live, the list it fetched last, while Command Code's, Kiro's, Qoder's, WorkBuddy's and Zed's plugins catch the failure and give their short defaults back, so a vendor's hiccup shrank the models. The host now sees a models hook that asked its vendor, had no answer from its last ask and handed back the list it was given, and marks that listing; magpie keeps the provider's and each account's last told list in its place, in memory and from plugin-providers.json after a restart, and the vendor back, its list is told again. TestFallenBackListKeepsTheLastOne fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; internal/plugin and internal/provider pass on Windows and Linux. 2026-10-01 15:01:11 +08:00
yetone 7dd809002b accounts: on Linux an account put first is read first at once, and on Windows a file being replaced isn't read as none (goal: 插件订阅 provider 跟内置的所有逻辑都要一模一样, run on Windows and Linux) — the provider tests run on the Windows test box and in WSL found two bugs of magpie's own files. Linux stamps a file's time from a clock a tick (a few milliseconds) coarse, and filememo took same time and size for the same file: two accounts swapped (Devin, Grok, WorkBuddy, ZCode, Claude…) read back in the old order, so magpie kept using the account the user had just moved down, and a moved-back plugin's accounts read as before. A file written within the last two seconds is now also compared by its bytes. On Windows a file being renamed over refuses to open for a moment ("Access is denied"), and one held open refuses to be renamed over: plugin-auth.json, plugins.json and the plugin providers read as nothing (a usage fetch right after a move showed no cards), logins.json failed to save, and host.js, reading plugin-auth.json as {}, would have written the other accounts away on the next sign-in. New internal/steady waits such a read or rename out (half a second at most, Windows only); filememo, edit.WriteAtomic, writePrivate, Kiro's writes and the plugin store go through it, host.js retries its read and rename, and the plugin store writes its files by a rename. Tests: USERPROFILE beside HOME in the test homes, the fake devin (a shell script) skipped on Windows, plugin-auth.json's mode bits unchecked there. TestReadSameSizeSameTick fails on Linux and TestRenameOverAFileHeldOpen/TestReadAFileHeldOpen on Windows without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; on Linux (WSL) the provider and plugin packages pass whole (13 failed before), on Windows the plugin package and the move/plugin/Grok/Devin/usage tests pass 10 times over (TestMoveToPlugin failed one run in a few before); 15 built-in tests that assume Unix (mode bits, Keychain, shell-script CLIs) still fail there, as before. 2026-10-01 13:27:12 +08:00
yetone 4ba51bb627 plugin: a plugin names its own icon and the key field's title and hint (Lemon on Discord) — a plugin's provider took only the market's icon, and an api method's key field always read "<name> API key" with no hint. auth.icon (or package.json "magpie": {"icon"}) as an https URL, fetched once through the public-only icon path, or a data:image URI now comes before the market icon; an api method's label titles the key field as in OpenCode, and a new placeholder hints it, in the window and in magpie plugin login. README has a For plugin authors section. TestPluginOwnIcon, TestPluginOwnIconAndKeyHint and plugin-key-hint.test.cjs (Chromium, WebKit, en/zh) fail without the change. 2026-10-01 11:42:45 +08:00
yetone 77c2ad12fa migrate: a move that fails leaves nothing behind and says why, and a move only goes through on an account the plugin proved works (要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样 / 记得把整套迁移逻辑做得丝滑优雅精致优美简单方便易用) — a failed move left the plugin it had installed (a Plugins row saying signed out) and hidden plugin:<id> rows in logins.json, and a move checked only that the plugin loaded, so an account the vendor refused or couldn't be reached through moved as if it worked. The plugin a move installed now goes with a failed move, and with a move back when it serves nothing else; the stray rows go; the check reads each account's usage as well as its models, and an account the vendor refuses is marked and moves untried, as a lapsed one does, while one whose vendor can't be reached at all fails the move with nothing changed. A move with every account lapsed says so before installing anything. Failures carry a code (offline, install, lapsed, unserved, account) for the page to say in its language, with the models named, not their ids. A moved provider shows the built-in's API host (magpie providers showed the plugin's id), and Zed's accounts take their model list with them, so the plugin can serve it until it reads its own. TestMoveNeedsAWorkingAccount, TestMoveTidiesItsPlugin, TestMoveProvesEachAccount, TestMovedShowsBuiltinHost and TestZedMoveCarriesItsModels fail without the change. go vet, go build -tags nogui, go test -tags nogui ./... pass. 2026-10-01 11:15:23 +08:00
yetone 813fdcb2e5 migrate: a moved built-in keeps its models' reasoning levels, its own account's plan and one own row, and the move checks each account through its proxy (要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — Command Code's, Grok's and WorkBuddy's models their vendor gives no levels for lost the ones the built-in borrowed from their maker; moved, they borrow them again. Devin's own account moved without the plan the Devin CLI says, which the built-in showed. The agent signing in to another account while moved wrote a new own row for the built-in, which came back beside the one set aside as a second own account; nothing is written while moved, and going back keeps the one. And the move's check of an account asked the vendor directly, not through the proxy its requests take. Found walking Command Code, Grok, Devin, ZCode and WorkBuddy through sandbox moves. TestMovedKeepsEfforts, TestMovedOwnRowNotWritten, TestDevinOwnMovesWithPlan and TestPluginProxy fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass. 2026-10-01 07:04:40 +08:00
yetone e86a34fce1 migrate: moving back keeps an account's expired sign-in marked, and one refused while the move lists its models goes along marked (要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — Zed's, Factory's and MiMo's back write the plugin's sign-in as one just made, so an account the vendor had refused through the plugin came back to the built-in showing signed in until its next refusal; the plugin's mark now goes with it. And a models hook saying a sign-in expired failed the move's strict check, so one refused Zed account stopped the whole move where the built-in would mark it and carry on; it is marked and moves untried, as a lapsed one does. Found walking Zed, Factory and MiMo through a sandbox move and back. TestMoveBackKeepsLapse and TestMoveRefusedWhileListing fail without the change. go vet, go build -tags nogui (GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/provider ./internal/plugin ./internal/gateway pass. 2026-10-01 06:33:27 +08:00
yetone 78c32292e4 plugins: a models hook can say its account's sign-in expired, as a built-in whose model list the vendor refused marked the account (要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — a refusal in provider.models was only logged, so an account Zed or Qoder turned away while listing its models stayed unmarked where the built-in marked it. An error thrown with signIn: expired, kept or renewed now tells magpie, which reads it as it does an answer's X-Magpie-Sign-In. TestPluginModelsSayExpired fails without the change. go vet, go build -tags nogui (GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/provider ./internal/plugin ./internal/gateway pass. 2026-10-01 06:12:11 +08:00
yetone 1fa21850a3 plugins: a plugin can say its sign-in renewed, and what a usage read means for it (要保证插件跟内置的所有逻辑都要一模一样) — a built-in whose sign-in renewed took the lapse mark off whatever the request then met, and a plugin could only clear it with a success: X-Magpie-Sign-In: renewed now clears it on any status. A usage read marked a plugin account whenever its error said to sign in again and cleared it on any clean read, where Qoder's, Kiro's and others' built-ins marked none from usage: auth.usage may now say signIn: expired, kept or renewed, taken as the header is; one that doesn't say keeps the old reading. TestPluginSaysSignIn and TestPluginUsageLapse fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/gateway ./internal/provider ./internal/plugin pass. 2026-10-01 04:46:24 +08:00
yetone d4817bea56 plugin: a moved provider goes through its own proxy, and each account through its own (#237) — a plugin's fetches and usage went through magpie's global proxy whatever the provider or the account was set to. Go now hands the host the proxy ctx names, and host.js gives every fetch made for it Bun's proxy option; "direct" goes through none. Bun reads *_PROXY once and puts every fetch through them, so the host is started with them as MAGPIE_*_PROXY and applies magpie's itself to a fetch with no proxy of its own. A plugin sign-in's usage looks its proxy up under the provider's id, not "plugin:<id>". TestPluginProxy and TestMovedLoginProxy fail without the change. 2026-10-01 03:42:06 +08:00
yetone 8756ece70c plugins: a plugin's model is priced only when the plugin prices it, and says whether it takes images when the plugin says — OpenCode prices a model it has no price for at 0, as the plugins give their plans' models, and a 0 price made each plugin model the cheapest where magpie picks by price (a small model to search or describe images with, one to draw with) and showed it free. A price of 0 is none now. A model the plugin (or models.dev) says takes images, or doesn't, carries that answer (ImageInput), as a built-in's list did; one it said nothing of stays unknown. TestPluginCatalogPriceAndImages and TestFakePlugin fail without the change. 2026-10-01 03:41:46 +08:00
yetone d8f2e17dd7 plugins: a plugin's fetch gets its body as a string, as OpenCode hands it — the host passed a Buffer, and the plugins that reshape a request (Grok, WorkBuddy, MiMo) touch only a string body, so a moved provider's requests went out as the gateway built them. A UTF-8 body now goes as a string; one that isn't stays bytes. TestFakePlugin fails without the change. 2026-10-01 03:41:42 +08:00
yetone 566ae09db1 plugins: a moved provider's accounts keep what the built-in's had (你确保插件版的订阅 provider 和内置的一模一样了吗) — each account of a plugin provider has its own plan's models, not the first account's; a move carries each account's plan and a lapsed mark; a usage read that finds the sign-in gone marks the account and a clean one clears it, a network failure shows the last reading; a plugin that fails to load keeps its providers rather than taking them away; Kiro's API key is named and an OAuth account beside it stays off as in the built-in; Command Code Plan keeps its name. TestUnloadedPluginKeepsProviders, TestPluginUsageLapse, TestPluginAccountModels, TestMovedProviderName, TestKiroMover and the move test fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/provider ./internal/plugin ./internal/gateway pass. 2026-10-01 03:27:47 +08:00
yetone 1ce05f0270 plugins: a plugin's models show as the built-in's did (插件版…保持一模一样) — a model a plugin says its plan serves at no cost (WorkBuddy's x0.00 credits) is marked free, as the built-in marked it; Cursor's plugin models not named 1M hold what the catalog knows their base to (GPT-4o 128K, Kimi K2 131K), not Cursor's 200K, as the built-in's list; ZCode's own account moves marked as ZCode's, for the plugin (0.1.2) to read ZCode's own sign-in for each request and follow its switches, and comes back as ZCode's own with nothing saved. TestPluginUsage (free), TestCursorPluginContext and TestZCodeMover fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass. 2026-10-01 01:18:04 +08:00
yetone ee5b1b7d44 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. 2026-10-01 01:00:07 +08:00
yetone f5f10bda4d plugins: a plugin's accounts show their allowance as the built-ins' do (the user: 所有插件要完整支持显示额度!跟内置一模一样!) — a plugin's auth.usage now tells plan, user, until, renew, balance, resets (count, until, by window, five hours, weekly) and windows (used past 100 when overspent, resets at/in, display, span, the models a window counts or leaves out, aside), and magpie shows them on the usage page, in the menu bar, beside each account and to the gateway, which passes over an account used up for the model asked; a built-in moved onto its plugin keeps its card's name and icon. Also: Command Code's subscription endpoint answering 200 {"success":false} was read as "No plan" by the built-in (and the plugin), it is now unread; a moved Devin was listed twice, the built-in still finding the Devin CLI's own sign-in; and moving back left the plugin's rows in logins.json, listed as empty accounts. Checked in a sandbox HOME with real Command Code and Devin accounts moved onto the community plugins (0.1.1): GOAT, its period, 5 hours / Weekly / Credits for Command Code, Teams, balance and Daily/Weekly for Devin, the same on the built-in after moving back, no plugin rows left. TestPluginUsage, TestCommandCodeSubscriptionUnread and TestMove (the plugin's rows after moving back) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeSignedOut and TestUserPath timed out once at load 22, pass alone). 2026-09-30 22:58:56 +08:00
yetone 3bec6c0e40 providers: a built-in subscription moves onto its community plugin and back, its accounts with it (the user: 慢慢下掉内置的那些 Subscription Provider…不要让用户一升级完之后,突然发现他的所有服务都不可用了) — eleven built-ins (Zed, Qoder, Factory, MiMo, Devin, Grok, Command Code Plan, Cursor, Kiro, WorkBuddy, ZCode) each get a mover to their @magpie-community package. A move installs the plugin (updated when older than the mover needs), hands it every account in the plugin's shape, checks the plugin lists models for them, and only then lets the plugin serve the same provider id, so agents' model ids don't change; any failure rolls back and the built-in keeps serving. The built-in's accounts are kept as a backup, and "Run built-in again" (magpie plugin move-back <id>) takes the plugin's current sign-ins back, never copying a rotating token: they are taken out of the plugin in one step, and an account the built-in renewed meanwhile keeps its newer token. An account only the plugin can hold (Cursor's browser sign-in) stays with it. The provider editor shows "Runs on" with the button, a failed move's reason, and the CLI has magpie plugin move / move-back. Automatic moves (Retiring) are wired in but the list stays empty until the packages are on npm. Checked with real accounts in a sandbox HOME: Command Code Plan and Devin moved, answered a chat through the gateway on the plugin, moved back and answered again on the built-in. TestMove*, TestPutBackKeepsNewer, the movers' round-trip tests and plugin-move.test.cjs (Chromium, WebKit, en/zh) are new. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit pass. 2026-09-30 21:21:58 +08:00
yetone 52c8e838d2 providers, sign-in, tray, plugins: four of StringKe's reports on Discord — (1) Removing a provider left its editor open and listed, and a second Remove said there was no such provider: the removal had been made, but moving the agents off its models failed on one agent whose config couldn't be written, and Reseat returned that as the change's error; now such an agent is a move with its error ("still on X, not moved: …", a warning in the panel, a line in the CLI and TUI), the others are moved, and the panel, told a delete failed, re-reads the list and closes the editor when the provider is gone. (2) The sign-in waiting sheet shows the sign-in link itself, selected whole on a click, with a copy button, for a browser other than the one that opens. (3) A subscription with several accounts offers "Account in use" among the menu bar's usages, following the account the gateway goes to first. (4) The Antigravity plugin's sign-in never started: magpie called every plugin's authorize with an inputs object, the way OpenCode's CLI does, and opencode-antigravity-auth then asked its Project ID on the terminal — magpie's stdin — until the minute ran out; inputs now go only to a method with prompts, as OpenCode's TUI passes them (checked against the real package under Bun in a sandbox: the Google sign-in URL comes back at once). TestReseatUnmovable, TestTrayInUse, TestInUseOf, TestAuthorizeNoPrompts, provider-remove.test.cjs, signin-link.test.cjs and tray-inuse.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, tray-usages, plugin-signin, signin-callback, factory-sub pass. 2026-09-30 20:45:23 +08:00
yetone b63a0f1fd5 plugins: a plugin's provider takes more than one account, as a built-in subscription does (the user: 插件当然要支持多账号了) — a plugin kept one sign-in per provider, and signing in again replaced it. Now each sign-in goes beside the others: plugin-auth.json keeps the first under the provider's id, as OpenCode does (so what is signed in already stays the first account), the rest under id#slot; logins.json lists them as it lists the built-ins' accounts, so the editor shows them all, one first, the others ticked on behind it or off, any made first, any removed (which signs it out). The host runs every request in its account's scope, so a plugin's getAuth, client.auth.set (a refreshed token), loader and provider.models see that account alone; a sign-in to an account already there (the same id, else the same secret) replaces that one's rather than listing it twice. The gateway spreads and fails over across the accounts on, as for the built-ins. Accounts with no id are told apart by the end of their key ("API key …abcd"), else by their slot. Also on the Devin page: the header shows the plugin's icon, and Cognition's opencode-devin is off the market (its gateway answers 404 model_not_found for models and chat). TestPluginAccounts (plugin: per-account requests and refreshes, dedupe, sign-out; provider: first, on/off, switch, forget, labels), TestPluginProvider (gateway: a 429 on the first account goes on the second) and plugin-signin.test.cjs (add a second account in the editor, make it first; fails without the change) are new. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-signin, plugin-market, old-webkit, click-scroll pass. 2026-09-30 19:58:22 +08:00
yetone a31f97b30d plugin: OpenCode's provider plugins sign in to subscriptions in magpie — an npm package OpenCode users install for a plan (its auth hook: loader, methods, oauth authorize/callback, api keys, chat.headers) runs in a Bun host magpie downloads the first time a plugin needs it, speaking JSON-RPC over stdio; its sign-ins are kept in plugin-auth.json (0600). A provider a plugin signs in to is a provider like the others: its models the plugin lists, each spoken to on the API its AI SDK package speaks (anthropic → Messages, openai/azure → Responses, google → Gemini, Copilot's GPT-5+ → Responses, else chat completions), its requests carried by the plugin's fetch to the base URL its loader gives; an id magpie already has is <id>-plugin. CLI: magpie plugin [list|add|rm|update|on|off|login|logout]. App: Settings → Plugins adds, updates, switches off and removes them and says why one didn't load; Add provider → From plugins signs in with the plugin's methods, its questions (picks and checked text), the browser and a pasted code or a key, followed as the built-in sign-ins are; removing the account signs out. The built-in subscriptions stay. TestFakePlugin, TestPluginProvider (chat, Anthropic and Gemini models through the gateway) and TestPluginSignIn (a wrong code fails, the right one signs in, removing signs out, a key) run a fake plugin on Bun; plugin-signin.test.cjs (Chromium, WebKit, en/zh) covers the add sheet and Settings. signin-callback, add-sheet, zed-sub, account-proxy, own-signin-remove, routing-signin, click-scroll GUI tests pass; go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked, known flakes under load, pass alone). Not yet tried with a real plugin and account. 2026-09-30 16:44:32 +08:00