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.
iamyhzhao's moved Grok account reads as signed out after nearly every
reboot. The plugin host (Bun, host.js) reads MAGPIE_*_PROXY once, at its
start, and nothing restarted it when the proxy changed. After a reboot
magpie can start before Clash or another proxy app sets the system proxy,
so the host had no proxy for its life; the Grok plugin renews by running
`grok models`, which got no proxy from the spawn wrapper, couldn't reach
auth.x.ai, and token() said the sign-in had expired. Moving to the
built-in and back installs and removes the plugin, each of which restarts
the host, which is why that worked.
Now the host remembers the proxy it was started with (hostProxy of
env()), and get() replaces a live host whose proxy is no longer what a
new host would get (proxyMoved, looked at no more than every 15s).
Run: new TestPluginHostTakesANewProxy (real host on the cliproxy
fixture: no proxy, then HTTPS_PROXY set, the CLI gets it; unchanged
proxy keeps the same host) passes, and fails with the check disabled.
go test -tags nogui ./internal/plugin ./internal/provider
./internal/gateway in a sandbox HOME: ok. gofmt -l clean; go vet for
darwin, linux and windows on ./internal/plugin: clean.
Not verified: the reporter's proxy setup (Clash with system proxy is the
likely case, not confirmed), a real reboot, a real Grok account.
* 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.
* plugin,gui: a stalled stream can't wedge the host, stale reads can't draw, and the browser suite runs in CI
internal/plugin: the host has one reader of the child's stdout, and it handed
each message to its call by sending on that call's channel. A caller that
stopped draining a streamed body (a slow HTTP client on a plugin-backed chat
stream) filled the channel; the reader then blocked, and every other call
through the host -- all plugin providers and accounts, moved built-ins
included -- stopped being dispatched until the stalled stream was drained. A
second call's answer arrived only after the first was drained.
Each call now queues its head and chunks in a bounded backlog (4 MiB of
chunks, 4096 of them) drained by its own reader, so the host's reader never
waits on one consumer; a call whose consumer stops reading is given up on
alone, with the limit as why. Aborts go through a goroutine too (abortLoop),
so a child that stopped reading its stdin can't make an abort wait either.
host_test.go covers the stalled consumer, the bounds and the abort path.
internal/gui: loadUsage() and refreshUsage() both read the Overview's
usage?period=, and neither checked after the await that its answer was still
the newest: a 24 h answer landing after a 7 d one drew the old period's
figures under the new period's name, and leaving the tab could still write the
Overview's cost into the shared header. Both now claim one shared read
generation before the request and draw only when it is still the newest and
the page, tab and period are unchanged. providersWhileFetching() also stopped
for good after one failed poll or reload; it now asks again.
usage-stale-answer.test.cjs and providers-fetching-retry.test.cjs lift the
real functions out of app.js and drive them with a faked api/page/timers, so
they need no browser; usage-refresh-flight.test.cjs gains the browser case for
the same race.
Makefile/.github: `make test-ui` runs every internal/gui/tests/*.test.cjs
through Node's test runner -- auto-discovered, UI_TEST_CONCURRENCY files at a
time (default 2) -- and a new `ui` job in Test runs it on ubuntu-latest with
Playwright's Chromium and WebKit. A missing browser fails the job; it never
skips to a pass. internal/gui/tests/README.md points at `make test-ui` instead
of a hand-kept command line.
Verified: node --test on the two new files 21/21; go test -count=1
./internal/plugin/ ok; go test -race -count=1 ./internal/plugin/ ok; go vet
./internal/plugin/ and go build ./... clean; node --check on the changed JS;
actionlint on the workflow.
Not verified: the full browser suite is not green yet. `make test-ui` is 1641
passed / 70 failed of 1711 across 225 files, and 12 of the 19 failing files
fail the same way on a pristine main (a known baseline: a fixture boots the
page in web mode while asserting the desktop window's narrow behaviour). The
`ui` job will be red until those baselines are dealt with -- nothing is
skipped or masked -- and the job has not yet run on a GitHub-hosted runner.
* plugin: distinguish stalled readers from large responses
* plugin: clarify best-effort abort delivery