* 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.
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.