2515 Commits
Author SHA1 Message Date
yetone ba0e2a8aa9 plugin: Update all and magpie plugin update bring a plugin pinned to an older version to npm's newest, so the update prompt clears (ARNO on Discord)
ARNO: 非官方插件安装后就算更新到最新版,magpie依然提示有更新. A third-party
plugin added as name@1.2.3 is pinned. The Plugins page still counts it
under "Update all (n)", its row says "vX out" and the Plugins dot is on.
Update all (and `magpie plugin update`) reinstalled the pinned version,
reported "Plugins updated", and the prompt stayed. The row's own Update
already installs the newest version and unpins the spec (name@latest).

Update now does the same for a pinned plugin when npm has a newer version
than the one installed: it installs that version through add, which keeps
the plugin's options and the provider picked for it. A pinned plugin that
is already at npm's newest keeps its pin. Git plugins, folders and plugins
npm didn't answer for behave as before. The plugin docs (en, zh, ja) now
say that updating a pinned plugin yourself unpins it.

Reproduced in a sandbox HOME with the real opencode-gemini-auth from npm
(magpie web on a spare port, China mirror switch on and off). Pinned at
1.4.15 with 2.0.1 out: before the fix, Update all and `magpie plugin
update` left it at 1.4.15 and /api/plugins/updates still listed it. After
the fix it is at 2.0.1 as opencode-gemini-auth@latest, the waiting list
is empty and Check for updates reports "current". The row's Update
(upgrade) path was checked too: unpinned opencode-gemini-auth, and scoped
@gitlab/opencode-gitlab-auth 1.3.1 -> 1.3.3 with the mirror on. Both
cleared the prompt before the fix as well.

TestUpdateAllUpdatesAPinnedPlugin (real bun, fake registry) fails without
the change: "Update all left opencode-someones-auth at 1.0.0, want 2.0.0".
gofmt -l, go vet (darwin, GOOS=linux, GOOS=windows) on internal/plugin,
and go test -tags nogui of internal/plugin, internal/gui and
internal/provider under a temp HOME with pinned caches all pass.
2026-10-10 10:19:42 +08:00
yetone 47a313f618 library: Pi's MCP servers go to ~/.pi/agent/mcp.json, the file Pi 0.99+ loads, unless pi-mcp-adapter is really loaded; an adapter left installed but not listed no longer sends them to mcp-adapter.json (sydney on Discord)
sydney reported that the Library wrote Pi's MCP servers to
~/.pi/agent/mcp-adapter.json while Pi 1.0 loads ~/.pi/agent/mcp.json.

Pi's own source (npm @earendil-works/pi-coding-agent):
- 0.99.0 to 1.1.0 have built-in MCP: dist/extensions/mcp/config.js
  loadMcpConfig reads <agentDir>/mcp.json (and a trusted project's
  .pi/mcp.json). An extension registering /mcp (pi-mcp-adapter) replaces
  it; "extensions": ["-builtin:mcp"] turns it off.
- 0.87.1 and earlier have no MCP; pi-mcp-adapter 3.x/4.x reads
  mcp-adapter.json only, adapter 5.0.0+ also reads Pi's mcp.json on a Pi
  with registerMcpServer.
- core/package-manager.js resolve() loads only settings.json "packages",
  "extensions" entries and <agentDir>/extensions/. npm/node_modules, git/
  and the global npm root are install places, not loaded on their own.

magpie counted any pi-mcp-adapter folder (npm/node_modules, git, global
root) as loaded, so an adapter left behind with "packages": [] made it
write mcp-adapter.json, which nothing reads, and move mcp.json away
(heliar-k's #1097 is the same case). It also fell back to the adapter file
when Pi's version was unknown.

Now:
- piDetect follows Pi's loader: a package is loaded only when listed (an
  object entry with "extensions": [] loads none), "-"/"!" entries turn one
  off, and <agentDir>/extensions/<name> loads; the other folders only give
  the version.
- An unknown Pi version is taken as one with built-in MCP.
- With no adapter loaded, servers in mcp-adapter.json are moved into
  mcp.json (both backed up first); nothing in either file is dropped.
- With adapter 5+ loaded on a Pi with built-in MCP, mcp.json is left
  where it is (the adapter reads it) and new servers go to the adapter's
  file; with adapter 3/4, or on Pi before 0.99, the old move is kept.

Tests: TestPiMCPUnloadedAdapter, TestPiDetectLoaded and
TestPiMCPAdapter5KeepsPiFile fail on the old pimcp.go and pass now;
older tests that relied on an unlisted adapter now list it.

Run: gofmt -l; GOOS=linux and GOOS=windows go vet -tags nogui
./internal/library/; go test -tags nogui ./internal/library/
./internal/agent/ under a temp HOME. Real Pi in a sandbox HOME:
- Pi 1.0.4, reporter's layout (adapter 5.2.0 in npm/node_modules,
  "packages": []): `magpie library mcp add` wrote mcp.json and `pi mcp
  list` shows both the user's server and the new one connected. The old
  binary wrote mcp-adapter.json and Pi listed no servers. `magpie library
  sync` with pi off PATH (version unknown) moved that home's
  mcp-adapter.json back into mcp.json, with backups, and Pi connected both.
- Pi 1.0.4 with `pi install npm:pi-mcp-adapter@5.2.0`: mcp.json is kept,
  the new server goes to mcp-adapter.json, and `pi-mcp-adapter doctor`
  connects both.
- Pi 0.87.1: still mcp-adapter.json, as before.
Not run: Windows or WSL Pi, project-level .pi/settings.json, a real
interactive Pi session.

Reported by sydney on Discord; same cause as #1097 (heliar-k).
2026-10-10 10:18:16 +08:00
yetone 666c0fa4fd routing: the context window holds still while requests stream in, and the prompt-cache card shows the last request's hit, dimmed, or a dash with the reason instead of "——命中" and an empty bar (dumplings on Discord)
dumplings reported that on Routing, every new request made the context
window jitter and flash, and that the prompt-cache card read "——命中",
glued together, with an empty bar under it.

Causes, measured with a frame sampler while requests stream through
start/read/done:
- A live request's story is shorter than a finished one's. "Is
  answering…" becomes "answered in 4.0 s…", which wraps onto two lines
  at phone width. The log head also gains View usage and Replay once the
  request is done. So the card under it moved up and back down on every
  request: 45px at 420px wide, 6px at 900px.
- The state pill ("Live" or "Counted") changed width, which moved the
  model name beside it by about 11px.
- While a request was under way the cache card dropped to "—" with a
  0% bar, then came back. That was the flash.
- A flex item's leading space is dropped, so " hit" and " / 272K" were
  glued to the figure before them.

Fix:
- routing.js holdStory keeps the log head and steps at the tallest
  height seen while following live, at this width and language. A
  picked request, a replay or a past day is drawn at its own height. A
  ResizeObserver on the steps measures again as soon as the width
  changes. Without it, a scrollbar appearing before the first request
  left a stale height and a 6px shift (seen in WebKit under load).
- context.js stacks all three state words in one grid cell and shows
  only the current one, so the pill keeps one width.
- While the next request is under way, the cache card keeps the last
  request's figure, dimmed (.was), with "The last request's · this one
  is on its way". When there is nothing to show, it gives a faint "—"
  and the reason ("Read once the vendor answers" or "The vendor didn't
  say"). The bar stays hidden but keeps its space. The remembered
  figure resets when the agent or session changes.
- .ctx-big gets a gap. Width and opacity transitions are turned off
  under reduced motion.
- The three new strings are added in zh, zh-TW, ja and de.

Verified:
- The new tests/routing-ctx-still.test.cjs streams two requests and
  checks that the card, model, state, used, cache, figure, bar, grid,
  legend and foot rects never move by more than 0.5px. It also checks
  the figure-to-word gap and both empty states.
- It runs in Chromium and WebKit, at en 420/1100, zh 420/1100,
  zh-TW 900, ja 900 and de 420. It narrows the viewport by 10px after
  load.
- On the old assets it fails 16 of 16 (the card moves 6–52px). It also
  fails with only the ResizeObserver removed.
- routing-live-patch now expects the held figure ("97%") on live steps
  instead of "—".
- Under --test-concurrency=4 in both engines: 578/578 pass across
  routing-*, context-*, click-scroll, group-context, panel-routing and
  gui-de/ja/zh-tw.
- go test -tags nogui ./internal/gui passes.
- GOOS=linux and GOOS=windows go vet -tags nogui ./internal/gui are
  clean.
2026-10-10 10:13:43 +08:00
yetone e3bd766fae providers: a saved provider's editor keeps the add form's Fetch models button, "Fetch models again" once a list was fetched, to re-fetch after a key moves to another group (耍赖天都爱 on Discord)
耍赖天都爱 on Discord: 「provider中模型拉取一次就没有这个按钮了 能不能加上 有
的provider换一个分组要从新拉取模型」. The add form offers "Fetch models"
(获取模型); once saved, the editor's same action was a generic "Refresh"
(刷新) in the models footer, so after the first fetch the button looked
gone, while a relay key moved to another group serves another list.

The editor's button now says "Fetch models", and "Fetch models again"
(重新获取模型) when a vendor list was fetched before. Its title says it asks
with the key typed here and what happens to the picks. The fetch itself is
unchanged: it sends the typed form (asTyped), and Refetch replaces the list,
drops picks the new list no longer has and keeps ids added by hand. The
hints that named Refresh ("from models.dev · …", "magpie's list · …",
"Jev's names · …", Forget's title, the empty-list hint) name the button now,
in zh, zh-TW, ja and de. The TUI's "m fetch models" and `magpie provider
refresh` already re-fetch at any time; the CLI help names the app button.

Verified:
- new internal/gui/tests/provider-fetch-again.test.cjs (Chromium + WebKit;
  en and zh at 900 and 420px, zh-TW and ja at 900, de at 420): a never
  fetched relay shows Fetch models and its hint names it; a fetched one
  shows Fetch models again; with group B's key typed it asks with that key,
  shows group B's list, drops the pick B lacks, keeps an id added by hand,
  the button is still there and asks again; nothing scrolls. On the old
  app.js/i18n.js it fails: "waiting for ... getByRole('button', { name:
  'Fetch models', exact: true }) to be visible" (en), "again has its zh".
- provider-typed, provider-picks-stay (labels updated), gui-zh-tw, gui-ja,
  gui-de, click-scroll: 51 pass, 0 fail, both engines.
- go test -tags nogui . ./internal/tui ./internal/gui under a temp HOME: ok.
  go vet -tags nogui . ./internal/tui, GOOS=linux and GOOS=windows: ok.
2026-10-10 10:13:43 +08:00
ggbdpq 65dcc57369 tests: the root package's plugin tests run the bun on PATH once before their one-minute context, so a bun that has never run here doesn't time them out (ggbdpq, #1306)
TestAccountsOfAPlugin and the plugin login tests (fakeCoHome) point
MAGPIE_BUN at the bun found on PATH and do all their plugin work inside
a one-minute context. 7ddea2c2 kept macOS's first-exec check of the
copied magpie-bun out of that minute, but not the PATH bun's own: when
bun was just installed or upgraded, its check landed inside the minute
and the test failed with "starting plugins: context deadline exceeded".

Both now take their bun from pathBun, which looks it up as before and
runs `bun --version` once before the context opens. This is patch 1 from
ggbdpq's comment on #1306, with the warm-up in one helper beside
fakeCoHome instead of copied into both tests. Patch 2 (quota_served's
stand-ins) is left out: it only saves wall-clock time, and that test has
no deadline.

What was run (macOS arm64, bun 1.4.2):
- Red, old code: a wrapper bun first on PATH whose first exec sleeps
  61s (marker-gated, then execs the real bun). TestAccountsOfAPlugin:
  FAIL at 62.0s, "accounts_plugin_cli_test.go:38: starting plugins:
  context deadline exceeded". TestPluginLoginTakesTheLapseOff: FAIL at
  62.0s, "plugin_login_cli_test.go:46: starting plugins: context
  deadline exceeded".
- Green, this change, the same cold wrapper with a fresh marker:
  TestAccountsOfAPlugin PASS (65.2s), TestPluginLoginTakesTheLapseOff
  PASS (63.4s); the 61s is spent before the context opens.
- go test -tags nogui -count=1 . under a temp HOME with GOMODCACHE,
  GOCACHE and GOPATH pinned: ok (22.8s); with a warm bun the warm-up
  adds nothing noticeable.
- gofmt -l on both files: clean. go vet -tags nogui . for darwin,
  GOOS=linux and GOOS=windows: clean.

Not covered: the same shape (LookPath("bun") as MAGPIE_BUN, then a
one-minute context) is in internal/provider (pluginsignin_paste_test,
plugin_fetch_lapse_test, signin_removed_test) and internal/plugin
(plugin_test). Not run on a genuinely cold bun on a loaded machine; the
sleep stands in for the check.
2026-10-10 10:12:59 +08:00
yetone e611fc65d6 codex: magpie restarts the codex app-server a change left on the old model list when no codex session is on it, and the Agents page offers Restart when one is (luci on Discord)
luci (Codex CLI 0.162, magpie 0.1.1150): after Codex was wired back to
magpie, codex in a new terminal listed Codex's own models although
config.toml was magpie's.

Cause, reproduced with real codex 0.162.0 in a sandbox CODEX_HOME:
codex sessions attach to a background app-server (the managed daemon)
that is per CODEX_HOME, not per terminal (socket under
$CODEX_HOME/app-server-control). It is detached, has no idle exit and
builds its model list once, at start. A daemon started while Codex was
off magpie keeps the official list for every later session, in this
terminal or a new one, until it restarts. magpie writes only PATH to
shell rc files and doesn't unwire Codex on quit, so nothing
per-terminal is involved. luci's "a new terminal fixed it" isn't
explained by the daemon. Codex's hourly updater restarting it is a
guess, not proven.

What changed:
- provider.KeepCodexDaemonCurrent(ctx, home, changed) compares the
  daemon's start time with when magpie last changed what Codex reads at
  start. The start time is the mtime of a pid file in that home that
  names the running daemon's pid, so another home's daemon (ChatGPT.app,
  other sandboxes) is never acted on.
  - With no codex session attached, it runs
    `codex app-server daemon restart` with CODEX_HOME set to that home.
  - Otherwise it reports Behind and how many sessions are attached.
  - isCodexClient leaves out apps' and editors' own codex, exec-server,
    mcp-server and the daemon's own processes. It reads the whole ps line,
    since ps leaves a path's spaces unquoted ("Codex Computer
    Use.app/...").
- agent: Codex's Notice after a change runs this check first. It says
  either that magpie restarted the daemon, or that the daemon is behind
  and how to restart it (Agents page or the command).
- The GUI backend and `magpie serve` re-check every 15s. A session that
  was open at the change is usually closed later, and that is when the
  daemon can be restarted.
- Agents page: the stale "daemon" copy line has a Restart button that
  POSTs /api/codex/daemon/restart (timeout now 2 min, as a running turn
  is let finish). It reuses translated strings. On Windows, where
  processes aren't listed on each look, the row shows the last check.
- Restarts are serialized by one mutex.
- WSL's Codex is not handled automatically. Its row keeps the advice
  text, which names the restart command.

What was run:
- Real codex 0.162.0 in a sandbox CODEX_HOME, with a fake gateway, on
  luci's sequence: the daemon started on the official config, then
  magpie's config and catalog were written.
  - With a TUI open, /model listed GPT-6.x. The check said Behind with
    a session attached and did not restart.
  - With the TUI quit, the check restarted the daemon. A new codex in
    the same terminal listed Magpie Alpha and Beta.
- Earlier in the sandbox: a daemon restart under an idle attached 0.162
  TUI reconnects, and /model then shows magpie's models. A running turn
  is waited for up to 60s.
- New tests:
  - TestKeepCodexDaemonCurrent(Fails), TestIsCodexClient (with the
    space-in-path case from the real process list) and
    TestCodexNoticeKeepsTheDaemonCurrent. They don't build or fail on
    the old code. The space case failed before the ps-line fix.
  - The GUI test "the daemon's line restarts it" times out on the old
    app.js.
- Results:
  - agent-stale-copies.test.cjs: 60/60 in Chromium and WebKit, in en,
    zh, zh-TW, ja and de, at widths 980 and 440.
  - go test (temp HOME) for ./internal/agent, ./internal/provider,
    ./internal/gui and the root package: ok.
  - gofmt -l: clean.
  - GOOS=linux and GOOS=windows go vet -tags nogui: ok.
- The Windows path is checked only by vet and not tried on a Windows
  box.
2026-10-10 10:05:10 +08:00
yetone bb3b3f1486 gateway: with "only each model's largest window" on, Claude Desktop shows a model of 1M or more as its 1M entry, "1M context window", not as what looked like the plain model (杰多夫 on Discord)
The cause: under settings.DesktopLongest, /v1/models lists such a model by
its "[1m]" id alone (#1272). Claude Desktop 2.7032 (app.asar, read only)
marks an entry as the 1M one only on the entry it adds itself: JLt emits
`${id}[1m]` with description WLt = "1M context window" and
supports_1m_context, and only for a model with supports1m. PIt gives
supports1m to a bare id alone (LC(id) === id), and OIt folds a "[1m]" row
only into a bare one listed beside it. So the lone "[1m]" row was shown by
its name with no description, exactly like the plain entry it replaced: with
the switch off the menu had "DeepSeek V4 Pro" plus "DeepSeek V4 Pro · 1M
context window"; on, only "DeepSeek V4 Pro". The reporter read that as the
switch keeping the plain model and dropping the 1M one.

What changed: such a row now carries description "1M context window", the
words Desktop gives its own 1M entry, and Desktop shows a row's description
(PIt keeps it; JLt prefers it to its fallback). The id is unchanged: the
"[1m]" is what gives a Claude Code session the 1M window, and magpie serves
it as the plain model. Rows under 1M, and every row with the switch off, are
unchanged (no description).

What was run:
- TestClaudeDesktopLongestOnly now checks each row's description, on and
  off. With the description line removed it fails: "on: vend/deepseek-v4-pro
  (...[1m]) described "", want "1M context window"".
- Sandbox HOME gateway on 127.0.0.1:3561 with real providers, /v1/models
  as Claude Desktop asks for it, fed through a script that follows
  PIt/OIt/JLt: on, each 1M model is one entry "<name> · 1M context window";
  off, the plain entry plus Desktop's own 1M one, as before.
- gofmt -l; GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/gateway; go test -tags nogui ./internal/gateway under a temp
  HOME: ok. No GUI or string change.
2026-10-10 09:58:30 +08:00
yetone d9bf7c1ccf zcode: the API format the user picks for magpie's provider in ZCode's settings stays theirs; a sync no longer puts Anthropic Messages back on every start (杰多夫 on Discord)
What changed: zcodeRules wrote api.type "anthropic-messages" into
provider_config.json, and zcodeProviderJSON wrote kind "anthropic" into
config.json, on every sync (magpie's start, any catalog change). A user who
switched the provider to OpenAI Chat Completions or Responses in ZCode had it
switched back the next time magpie started. zcodeFormat now reads the format
already there and keeps it, in both files: provider_config.json's api.type
(ZCode 3.14 on) or config.json's kind (older ZCode), the one written last
where they differ, mapped as ZCode maps them (anthropic-messages/anthropic,
openai-chat-completions/openai-compatible, openai-responses/openai). A new
provider, or a format ZCode doesn't have, is Anthropic Messages as before.

Why it's safe: magpie's gateway answers all three at the same base address.
Checked with ZCode 0.16.9's own agent CLI (glm/zcode.cjs -p, run from a
copy under a sandbox HOME, never ~/.zcode) against a sandbox gateway with
the DeepSeek provider: anthropic-messages asks /v1/messages,
openai-chat-completions /chat/completions, openai-responses /responses, and
a tool-using turn (Read a.txt) answered 200 on each, to deepseek-flash.

What was run:
- TestZCodeKeepsUserFormat (new): a format picked in provider_config.json,
  for each of the three, survives two syncs and is carried to config.json's
  kind; one picked in config.json is carried to provider_config.json; an
  unknown one isn't kept. On the old zcode.go it fails: "picked
  openai-chat-completions: kind "anthropic", api "anthropic-messages"".
- Sandbox, real binaries: provider_config.json set to openai-responses,
  then `magpie zcode magpie`: the old build wrote anthropic-messages back,
  this one kept openai-responses (config.json kind openai). Then
  openai-chat-completions, resynced, and the real ZCode CLI ran a turn on the
  file magpie wrote, served over /chat/completions.
- gofmt -l; GOOS=linux and GOOS=windows go vet -tags nogui ./internal/agent;
  go test -tags nogui ./internal/agent under a temp HOME: ok.

The "bcww: Field required" 400 on Anthropic Messages is not part of this
commit: ZCode's real requests (captured from its CLI, plain and with web
search) to deepseek/deepseek-flash through magpie returned 200, and DeepSeek
answers schema errors in serde's words, not "Field required".
2026-10-10 09:58:30 +08:00
yetone eb035e29e1 sessions: a session seen only through the gateway can be deleted: its row and recorded conversation go to magpie's trash, and Restore brings them back (lc on Discord)
lc ("只可列出?不可删除?agent claude code") saw Claude Code sessions named by
uuid under "No folder" at "0 B", with "These Claude Code sessions are read
only; magpie can list them, but cannot resume or delete them." Those rows are
gateway projections from the usage ledger: Claude Code ran with its own files
somewhere magpie can't see (another computer, a container, another config
folder), so nothing native won on the same agent+id. An agent with only such
rows was sent as not deletable, and every row was read_only, so the page had
no Delete at all.

What changed:
- sessions.DeleteGateway moves the session's recorded text folders
  (gateway-conversations/<date>/<identity hash>) into
  trash/sessions/gateway/<ts>-<hash>/files/<date> with a note marked gateway,
  and records {agent, id, last} in gateway-sessions-hidden.json.
  /api/sessions and /api/sessions/manage leave it out up to that last
  request; a newer request lists it again. The usage ledger is untouched, so
  Usage, /api/sessions/one and stats keep what it spent. A session with a
  request in the last minute is refused as active.
- Restore moves the text back (skipping what expired or was cleared, refusing
  rather than overwriting a same-day re-recording), checks each item is this
  session's identity folder inside the store, and unhides it. Delete forever
  keeps it hidden.
- The recording's promises hold in the trash: the hourly prune erases
  trashed gateway text past 7 days, "Delete saved conversations…" erases it
  too, and HasGatewayConversations counts it.
- The manage route marks an agent with gateway rows deletable and sends
  those rows deletable, with gateway: true and the size of their recorded
  text; the delete route sends gateway ids to DeleteGateway and native ones
  to Delete as before.
- Sessions page: gateway rows group under "Seen through the gateway"
  instead of "No folder", show "{size} recorded" only when something was
  recorded (no "0 B"), and the note says why they can't be resumed and what
  Delete takes and keeps. The delete dialog says the same for gateway rows
  instead of "Their files are moved…"; the Trash marks them and notes the
  7-day expiry. New strings in zh, zh-TW, ja and de.
- docs/subsystems/gateway-sessions.md describes the delete, hidden list,
  restore, expiry and clear.

Verified:
- go test -tags nogui ./... (sandbox HOME) green; gofmt -l clean; go vet
  ./...; GOOS=linux and GOOS=windows go vet -tags nogui ./internal/sessions
  ./internal/gui; go build, GOOS=linux -tags nogui and GOOS=windows builds.
- TestSessionsDeleteGatewaySession fails on the old code with "Claude Code
  with only gateway sessions isn't deletable: [{Agent:claude
  Deletable:false}]"; sessions-gateway-delete.test.cjs fails there too.
  TestGatewayDeleteTrashExpires, TestGatewayPurgeKeepsItHidden,
  TestGatewayDeleteNothingRecorded and TestGatewayRestoreRefusesForeignItems
  cover expiry, purge, an unrecorded session and a forged note.
- GUI: sessions-gateway-delete.test.cjs in Chromium and WebKit, en, zh,
  zh-TW, ja, de, at 1000 and 390px (in-app dialog, Cancel sends nothing,
  click doesn't scroll, no left-border accents, no page overflow); all
  sessions-*, gateway-conversations, checkbox-look, old-webkit,
  sess-agent-strip, i18n-keys, gui-ja, gui-de and gui-zh-tw tests pass.
- Real try: magpie web from this tree on a sandbox HOME seeded with three
  gateway-only Claude Code sessions (two recorded): deleted a recorded and an
  unrecorded one, checked the store, trash and hidden list on disk, restored
  the recorded one (its transcript reads again), purged the other (stays
  hidden). That try found the Trash page breaking on an unrecorded session's
  null items; DeleteGateway now writes [] (TestGatewayDeleteNothingRecorded
  fails without it) and the page reads a missing list as empty.
2026-10-10 09:53:38 +08:00
yetone 0d5fdbb2de provider: moved Cursor users get plugin 0.2.3: where a proxy or network blocks HTTP/2 ("h2 is not supported"), a Cursor chat goes over HTTP/1.1 as RunSSE + BidiAppend on api2 instead of failing (magpie-community/plugins#59, kazecreator)
Behind TLS inspection or a network that drops HTTP/2, every moved Cursor
chat failed with 502 "Cursor: h2 is not supported" or never answered, while
sign-in, models and usage worked. 0.2.3 falls back to Cursor's own HTTP/1.1
transport; a failure that says HTTP/2 itself can't be had keeps later Runs
on HTTP/1.1 for 10 minutes. The cursor mover's min goes from 0.2.2 to
0.2.3, so keepMovedCurrent updates every moved Cursor user.

0.2.3 on npm (registry.npmjs.org) has an index.mjs byte-identical to
plugins main 5b67ff4. The plugin PR was reviewed at f5c11b7 and merged:
- bun test in packages/cursor: 66 pass, 0 fail, three runs
- whole repo: 1026 pass, 0 fail; bun scripts/check.mjs all 15 pass
- each guarded piece broken by hand turns http1.test.mjs red
- not tried with a real Cursor account on the review side

go test -tags nogui ./internal/provider passes (temp HOME, pinned caches).
go vet -tags nogui ./internal/provider passes for darwin, linux and windows.
gofmt -l is clean.
2026-10-10 09:38:24 +08:00
yetone 83c963c466 gateway: a gateway MAGPIE_ADDR puts on the network asks callers from other machines, a container's host among them, for an enabled gateway key, as sharing does
What changed
- lanGuard: a request whose peer isn't loopback (or that carries a
  forwarding header) is remote. While the gateway is shared, or
  MAGPIE_ADDR names a host other than loopback/localhost (OnNetwork:
  0.0.0.0 in the Docker image, a server's address), a remote request is
  answered only with an enabled gateway key, on every route behind the
  guard; otherwise it is refused (403) as before. Before, a MAGPIE_ADDR on
  the network with sharing off took remote requests with any key.
  A remote request let in with a key is marked as shared-mode ones are,
  so quotas, quota cards, refresh and prices answer it.
  callerGuard follows the same condition. OpenToAnyone is replaced by
  OnNetwork.
- Loopback is unchanged: any key still works on the gateway's own
  machine and inside the container (HEALTHCHECK included).
- CLI keyNote and the console's Connect note no longer describe an open
  gateway; on the network they say other machines need a gateway key
  (`magpie gateway-key add <name>`). The console shows the Gateway keys
  block and network addresses when OnNetwork (gatewayJSON.onNetwork
  replaces open); the "Open to the network" strings are removed.
- Docs: docker.html (en/zh/ja) and remote.html (en/zh/ja) say callers
  outside the container need a gateway key, how to make one, that
  sharing isn't needed for it, and what an upgrade means for agents that
  sent `magpie`; docs/reference.md and providers-accounts.md likewise.
  A MAGPIE_ADDR pinned to one interface's address now asks a key of this
  computer's agents calling that address too (documented).

Tests
- New TestNetworkGatewayGuardsEveryRoute: from 172.17.0.1 and
  192.168.1.9 every registered route (model APIs, count_tokens,
  systemone, the Responses websocket GET, MCP, Codex backend and
  catalog, /v1/magpie/*, images, embeddings, rerank, videos, Gemini)
  gets 401 without a valid key; with one, models and quotas answer 200.
- TestNetworkGatewayNeedsAKey (was TestExplicitOpenGatewayAccepts...),
  TestLANGuard, TestProxiedLoopbackNeedsTheKey, TestQuotasOverLAN,
  TestMCPProxyStaysHere, session route and remote prices tests, TestOnNetwork,
  serve_cli and gui public_url tests updated. With the old lanGuard
  restored, TestNetworkGatewayNeedsAKey, TestNetworkGatewayGuardsEveryRoute,
  TestLANGuard and TestProxiedLoopbackNeedsTheKey fail.

Run
- go test -tags nogui -count=1 ./internal/gateway ./internal/gui
  ./internal/provider . under a temp HOME: ok.
- gofmt -l clean; GOOS=linux and GOOS=windows go vet -tags nogui on
  those packages: clean.
- GUI: public-url and gateway-caller-keys tests, Chromium and WebKit: 22/22.
- Locally built image (OrbStack), -p 127.0.0.1:3481:3425: from the host,
  /v1/models with no key, `magpie` and another made-up key -> 401;
  count_tokens and /mcp/x -> 401; a key from `docker exec ... magpie
  gateway-key add Host` -> models 200, quotas 200; inside the container
  wget /v1/models -> 200; healthcheck exits 0. `web --addr 0.0.0.0:3430`:
  /api/state 401 without the web key, 200 with its cookie.
2026-10-10 09:26:23 +08:00
yetone c4476d5658 library: skills from GitHub are checked as the skills page opens, and one changed here is marked and asked about before an update replaces it (#1449)
YangZhiqiang98 asked (#1449) that the Library find which skills have
updates and update them. magpie already checked skills from GitHub (a
market install among them) by hand and could update them, but an update
deleted the library's folder, so a skill the user had changed (by hand,
through an agent's link, or in an agent's copy not yet taken in) was lost
without a word, and nothing checked unless the user pressed Check.

- A skill is "changed here" when its library folder no longer hashes to
  what was fetched (Finder's .DS_Store aside), or an agent's copy was
  edited since its mark and not yet taken in (editState, skill_changed.go).
  The view says so (SkillView.edited) and the row shows "Changed here".
- UpdateSkill refuses such a skill with *EditedError unless told to
  replace it. The row's Update asks in the page first (Keep mine / Update;
  Update sends replace). Update N, the group's Update N and the CLI's
  update-all leave it as it is and say so. The CLI takes
  `magpie library skill update <name> --replace-edits`.
- The version an update replaces is moved to the backups when it was
  changed, or when it can't be told (a skill with no hash from before).
- View.checkDue: no check since magpie started, or none for 12 hours. The
  page then runs the check once by itself as the skills open, and says
  only what there is to update or add.

Not checked, by design: skills the skills CLI manages (`npx skills
update` updates them; magpie only groups them), linked local folders,
skills imported from an agent with no source, and From-only skills have
no remote; CC Switch-installed skills can still be updated by hand.

Verified:
- go test -tags nogui ./... (sandbox HOME); new TestUpdateKeepsASkillChangedHere,
  TestUpdateKeepsACopyEditedInAnAgent, TestUpdateKeepsWhatItCantTell,
  TestCheckDue, TestCheckSkillsMarksItDone, all from httptest. With
  editState forced to "not changed" the first three fail.
- gofmt -l, go vet, GOOS=linux and GOOS=windows go vet -tags nogui on
  ./internal/library ./internal/gui ./internal/tui .
- GUI: new skill-update-edited.test.cjs (Chromium and WebKit, 860 and
  400px, en and zh; no scroll on click; fails with the row's ask removed),
  plus the 22 other library skill tests, gui-ja, gui-de, gui-zh-tw,
  i18n-keys and click-scroll: 262 pass.
- Real use in a sandbox HOME with github.com/anthropics/skills: installed
  pdf and docx, the real check said both current; an edit through Claude
  Code's link was refused by `skill update pdf`, skipped by `skill update`
  (docx updated), replaced by `--replace-edits` with the edit in
  backups/<time>/library/skills/pdf; docx edited later shows edited, a
  .DS_Store doesn't.
2026-10-10 09:14:35 +08:00
yetone d86a4e0568 site: the docs have Docker and Remote magpie pages in English, Chinese and Japanese, listed in every docs page's contents and footer and linked from the READMEs (HuanCheng Bai on Discord)
Until now, both were only in docs/reference.md. The new pages are at
/docs/docker, /docs/remote and their zh/ and ja/ versions.

- docker: the image, its tags, ports, the /config volume and the nonroot
  user. How to run it with the browser UI (`web --addr 0.0.0.0:3430
  --no-open`, MAGPIE_WEB_KEY, the ?k= cookie). Signing in through the
  Callback URL paste or `docker exec ... magpie accounts add`. What
  /config keeps, the env vars, sharing with gateway keys, and a Compose
  file for a NAS. Healthcheck and updates.
- remote: sharing the main magpie, then adding it as a Remote magpie
  from the app, the TUI (a/w/m) or `magpie provider add remote-magpie
  <key> url=... id=office`. What comes over (models, groups, APIs,
  images, System One, embeddings, prices, quotas) and the errors magpie
  shows, quoted from remote_quotas.go and lan.go.

The facts were checked against the code, not reference.md:
- the Dockerfile ENV, VOLUME, HEALTHCHECK and EXPOSE;
- docker.yml's tags;
- web_cli.go;
- the lanGuard in internal/gateway/lan.go;
- the remote-magpie preset.

The Docker page's warning says a Docker gateway answers anyone until
sharing is on, because MAGPIE_ADDR is 0.0.0.0 there.

The new links in the top nav bar carry a toc-only class:
- docs.js still lists them in the contents;
- the bar keeps today's width, since nine links overflowed it at
  1024-1280px.

`.steps li` gets min-width: 0, so a long inline code no longer widens
the grid past a phone's width, where `overflow: hidden` clipped the
text.

Run:
- Real containers from ghcr.io/yetone/magpie:latest (v0.1.1150):
  - the documented `docker run ... web` came up healthy, and its log
    output is what the page shows;
  - with "lan": true, `Bearer magpie` got 401 and a gateway key got 200;
  - a second container ran `provider add remote-magpie <key>
    url=http://<a>:3425 id=office` and listed office/test-relay/... models.
- `node --test site/*.test.mjs`: 23 pass.
- Playwright, in Chromium and WebKit, at 1280, 1024 and 390, light and
  dark:
  - no horizontal scroll and no nav overflow on all 20 docs pages;
  - every internal link and #anchor resolves. /zh/ and /ja/ are routed
    by the worker and return 200 live.
2026-10-10 09:08:03 +08:00
yetone c65d9fb473 codex: magpie-models.json keeps the keys the user added by hand to a model, and says supports_reasoning_summaries itself, so Codex before 0.145 sends the effort and shows thinking (heng91, #1450)
What was wrong: every write of ~/.codex/magpie-models.json rendered the
catalog from magpie's models alone, so a key the user had put into an
entry was gone after the next model switch, catalog sync (Refresh, a
provider added, a gateway start) or a switch to Codex's own default,
which also deleted the file outright. The key heng91 added,
supports_reasoning_summaries, is one Codex before 0.145 needs: its
client.rs build_reasoning sends no `reasoning` (no effort, no summary,
so no thinking shown) for a model whose entry doesn't say true, until
openai/codex#32206 (first in 0.145). 0.144 also refuses to load a
catalog an entry of which lacks the key at all.

What changed:
- codexcat: every entry says supports_reasoning_summaries, true for a
  model with effort levels, false otherwise. Later Codex ignore it.
- codexcat.Keep: before a write, the keys of an entry on disk magpie
  doesn't write are carried over to the entry of the same slug, and a
  true of supports_reasoning_summaries stays over magpie's false. A key
  magpie writes keeps magpie's value; a model magpie no longer serves
  drops out; an unreadable file keeps nothing.
- agent/codex.go: the write when magpie becomes the provider and the
  catalog sync go through Keep; the three places that removed the file
  remove it only when nothing of the user's is in it.

Run:
- New tests TestCodexCatalogKeepsWhatTheUserAdded,
  TestCodexCatalogRemovedWhenNothingAdded (agent, from the file magpie
  writes, edited by hand) and TestCodexCatalogReasoningSummaries,
  TestKeepUserKeys (codexcat). Each fails on origin/main's code.
- Real Codex in a sandbox CODEX_HOME against a capture server:
  0.144.0 fails to load the old catalog ("missing field
  supports_reasoning_summaries"), loads the new one and sends
  reasoning {effort: high, summary: auto}; with the user's keys kept
  it sends summary "detailed". 0.162.0 loads both and sends the same.
- gofmt -l clean; go vet, GOOS=linux and GOOS=windows go vet of
  ./internal/agent ./internal/codexcat; linux and windows builds;
  go test -tags nogui ./... under a temp HOME, all ok.
2026-10-10 09:07:44 +08:00
yetone 229ce19acc gateway: the loop guard's end-to-end tests bound how soon the loop was cut by what reached the agent, not by how far the fake vendor wrote ahead of a slowed gateway (#1443)
Cause: loopCut failed when the fake vendor had sent more than 2000 lines
by the time it saw its request let go. That count isn't the guard's: the
cut is made in holdWriter.Write, on the write that trips it, and cancels
the vendor's request there. But the vendor's writes succeed as long as the
sockets' and transports' buffers take them, so it runs ahead of a gateway
that reads slowly. Under load (CI macOS -race, a full run at load ~35) it
got ~2,800-3,200 lines ahead of a cut the agent saw at the 259th line.

Fix (test only, the guard was already deterministic): loopingVendor sends
at most 2000 lines of its loop, then holds its stream open until its
request is let go (20s, else it says it wasn't). Being let go therefore
means the guard found the loop within those 2000 lines, however the test
is scheduled. How soon it cut is now checked on what reached the agent:
the loop lines in the stream are at most the guard's window (256) plus one
32K read of the vendor's stream, which the cut's write sends whole.

Run:
- Reproduced by injecting a 300us sleep into holdWriter.Write (not
  committed): the old test failed 140 of 180 subtests (-race -count=5
  -cpu 1,2,4, 16 busy loops beside it), every route and protocol but chat,
  "let go only after 2754..3082 lines", the vendor up to 8322. The agent
  got ~259 loop lines in each.
- The new test with the same sleep and load: -race -count=50 -cpu 1,2,4,
  1800/1800 subtests pass (built-in and plugin, chat/anthropic/responses/
  gemini, and TestLoopEndsTheReplyOnEveryProtocol). At most 470 loop lines
  reached the agent (the bound is 583; 256 + 32768/141 is ~490).
- Without the sleep, under load: -race -count=20 -cpu 1,2,4 of these plus
  TestLoopGuardOff and TestLongReplyThatDoesNotLoopGoesThrough, all pass.
- The test goes red when the guarantee breaks: with cutLoop's h.stop()
  disabled ("the vendor's request was not let go", built-in anthropic,
  responses, gemini; chat is still let go when the failed write ends its
  copy), and with the guard's trips dropped for its first 800 writes ("the
  loop was cut only after 1038 of its lines reached the agent").
- gofmt -l, go vet (darwin, GOOS=linux, GOOS=windows) ./internal/gateway,
  go test -race ./internal/gateway under a temp HOME.
2026-10-10 08:36:30 +08:00
yetone 62d7cd7be3 sessions: recording gateway conversations is a setting card over the list, with what it keeps and where, and a labelled delete that shows only when text is kept (lee on Discord)
What changed: c9b82633 (#1355) put a bare "Record gateway conversations"
checkbox and an unlabelled trash icon on lines of their own under the agent
tabs, on every agent. They are now one card in the list's column, as wide
as the list: a switch (the app's lib-switch), its title, and a line that
says what is kept and where ("on this computer only, for up to 7 days
(256 MiB)", worded for on and off). "Delete saved conversations…" sits in
the card, says what it deletes, and asks in magpie's own dialog as before.
The card shows only where it applies: on an agent whose list has gateway
sessions, in gateway mode, or while recording is on or text is kept; not on
an agent without gateway sessions, nor in the Trash. The delete shows only
while some text is kept.

To know that, /api/sessions/manage marks gateway-only rows `gateway` and
says `recorded` (sessions.HasGatewayConversations: any file under
gateway-conversations; a folder that can't be read counts as holding some,
so it can still be cleared). New strings in zh, zh-TW, ja and de.
docs/subsystems/gateway-sessions.md says where the switch is and when it
shows.

Why: lee (Discord feedback, 1558248607751344220) said the controls' look
and place need a redesign: they read as stray, unaligned controls, and the
icon didn't say what it deletes.

Run: gofmt -l (nothing); go vet, GOOS=linux go vet -tags nogui,
GOOS=windows go vet (with and without -tags nogui) on ./internal/gui/...
./internal/sessions/...; go test ./internal/gui/... and go test -tags nogui
./internal/gui/... ./internal/sessions/... under a sandbox HOME: ok.
GUI: gateway-conversations (both tests, en/zh/zh-TW/ja/de, 1000/1400 and
390px) plus checkbox-look, click-scroll, gateway-mode, i18n-keys,
old-webkit, sess-agent-strip, every sessions-* test and
usage-sessions-agents-fit: 221/221 (default run) and 114/114 with
BROWSER=webkit. Not run: the whole GUI suite.
Fails without the change: with origin/main's sessions_manage.go,
TestGatewayConversationAPI fails "listing: …" (no gateway/recorded); with
origin/main's sessions.js/.css, every gateway-conversations case fails
(no .sm-record switch; "no bare checkbox bar under the tabs 2 !== 0").
2026-10-10 07:40:03 +08:00
yetone 9b51428e16 provider: a Claude model models.dev lists after the Claude account's last fetch, Haiku 5.5 among them, is offered at once, not after the next Refresh (wakaka on Discord)
The Claude subscription's models are magpie's own list, the models.dev
catalog's anthropic rows. Its fetch kept a copy of that list in
models/claude.json, and available() served that copy over the catalog
from then on. FetchNew fetches an account only while it has no list, so
the copy was never taken again unless the user pressed Refresh: the
hourly models.dev sync brought Claude Haiku 5.5 (listed from 2026-10-07)
into the catalog, while the Claude account kept offering the list as it
was at its first fetch. The owner's own copy is from 2026-10-03 and has
no claude-haiku-5-5, though the synced catalog beside it has.

An account whose fetch asks no vendor (magpieList: Claude's, and
Factory's compiled-in droid list, which had the same hold-out after an
update) is now always served its list as it is now. A vendor's own list
(Codex, Copilot, Kiro, ...) is served as fetched, as before.

Haiku 5.5's facts come from the catalog: models.dev's row (1M context,
128K output, low..max efforts, $0.10/$0.50 with $0.50/$2.50 over 100K)
matches Claude Code 2.1.296's own model catalog (window 1e6, native 1M,
max_output 128000, effort/xhigh_effort/max_effort, default medium). So
no static table needed a row: claudeEffortsFor gives an unknown newer
Claude all five levels, and claudeTopEffort rightly leaves it out
(2.1.296 reads the top effortLevel only for the models up to Fable 5.1).
With the model listed, the haiku tier follows the newest Haiku
(claudeLight), as Claude Code's own default haiku on the Anthropic API
is claude-haiku-5-5 since 2.1.293.

Verified:
- TestClaudeAccountServesModelsListedSinceItsFetch (models.dev's real
  rows) fails without the models.go change: "Haiku 5.5 not served on the
  Claude account: [claude-opus-5-5]". TestFactoryServesModelsAddedSince
  ItsFetch fails without Factory's flag. Both pass with the change.
- gofmt -l clean; go vet, GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/provider; go build, GOOS=linux and windows build -tags nogui.
- go test -tags nogui ./internal/provider ./internal/agent
  ./internal/catalog ./internal/gateway ./internal/gui under a temp HOME:
  all ok.
- Not tried against a running magpie with a real Claude sign-in: the
  owner's gateway on 3425 wasn't running. The stale models/claude.json
  and synced models.json were read from the owner's cache (read only).
2026-10-10 07:37:24 +08:00
yetone 1134c7a8af codex: the restart advice after a model or catalog change names the app-server codex sessions share ("codex app-server daemon restart"), on Windows and WSL too (TJHHHH, luci on Discord)
TJHHHH and luci (magpie 0.1.1137, codex-cli 0.162.0): the Codex app
listed magpie's models while the codex CLI showed only the official
list, on and off. Codex 0.162's TUI attaches to a background
`codex app-server --managed-daemon` (feature daemon_auto_start, stable
and on by default on every OS; skipped on Windows only when elevated or
detached launch is restricted, on WSL only when CODEX_HOME is on drvfs).
That daemon builds its model list once, when it starts, and restarts
itself only when its binary or version changes. So closing every codex
session and opening a new one keeps the list from before magpie wrote
model_catalog_json, while the desktop app's own app-server, restarted
with the app, has magpie's.

Reproduced with the real codex-cli 0.162.0 in a sandbox CODEX_HOME: a
daemon started with no catalog kept listing Codex's 8 models in model/list
after config.toml switched to a magpie catalog and every client closed;
`codex app-server daemon restart` made it list the catalog's models.
Nothing since v0.1.1137 changed this.

magpie's advice only said "restart the Codex app (and open codex
sessions)", which doesn't reach the daemon. On macOS and Linux the
Agents row already flags a stale daemon copy (StaleCopies), but that
returns nil on Windows and WSL, where the reporters may be. The Notice,
and the WSL codex kind's restart text, now name the shared app-server
and provider.CodexDaemonRestart. magpie still doesn't restart it itself:
that ends every session attached to it (as 03dbde12 decided).

Test: TestCodexAdviceNamesTheSharedAppServer (codexRunning stubbed)
fails on the old texts and passes now; it also checks nothing is said
when no codex runs.

Run: gofmt -l (clean); go vet -tags nogui on darwin, GOOS=linux and
GOOS=windows for ./internal/agent ./internal/gui ./internal/tui and the
main package; go test -tags nogui of those under a scratch HOME (all
ok). Not run on Windows or a real WSL distro, and the reporters haven't
confirmed their codex had a daemon running from before the switch.
2026-10-10 06:27:40 +08:00
yetone 5aca7d0924 codex: effort left at Default stays Default and says the level Codex takes then ("default (medium)"), and isn't written over with medium when a model is picked or the catalog synced (lgtm on Discord)
lgtm set Codex · WSL Ubuntu's 推理强度 to 默认 and it came back as 中.
Two things did that, on every platform (the WSL row is the same codexIn
code as the local one):

- The effort field's Get answered an unset model_reasoning_effort on a
  magpie model with codexcat.DefaultEffort (medium), so right after the
  pick the slider and the row read 中, not 默认.
- settle() counted an unset effort as one the model doesn't take and
  wrote DefaultEffort into config.toml: on every Sync for a magpie
  model, on Join, and on a model pick, Codex's own models included. So
  Default was pinned as medium, and on Codex's own models whose default
  is low (gpt-6.1-sol, gpt-6-astra, gpt-5.6-sol in 0.162's models.json)
  magpie overrode Codex's own default.

With model_reasoning_effort unset, Codex takes the catalog entry's
default_reasoning_level (core/src/client.rs build_reasoning). Checked
for real with codex-cli 0.162.0 in a sandbox CODEX_HOME: a
model_catalog_json entry with default_reasoning_level "low" and no
model_reasoning_effort sent {"reasoning":{"effort":"low"}} to a stand-in
Responses endpoint.

Now settle replaces only a set effort the model doesn't take, Get
returns the unset value, and the effort control's options start with
Default carrying Takes: the level Codex then takes, from the entry it
reads (codexcat.TakesEffort: Codex's own models_cache entry for its
models and a ChatGPT account's codex/ ones, else the one Entries writes).
The GUI shows it as "default (medium)" / 默认(中) / 預設(中) /
デフォルト(中) / Standard (mittel). A stopped WSL distro's options get the
same Default (codexEfforts).

Tests: TestCodexDefaultEffortStaysDefault fails on the old settle
("Default written over: model_reasoning_effort = \"high\"", "Default
shows as \"high\"") and passes now. TestCodexCustomProviderModelHasEfforts
now expects Default first with Takes "high" and an unset value read as
"". codex-default-effort.test.cjs (window at 560 and 360px, tray panel;
en, zh, ja, de; Chromium and WebKit) fails on the old effortName and
passes now.

Run: gofmt -l (clean); go vet -tags nogui on darwin, GOOS=linux and
GOOS=windows for ./internal/agent ./internal/codexcat ./internal/gui
./internal/tui and the main package; go test -tags nogui of those and
./internal/catalog under a scratch HOME (all ok); the GUI effort, gui-ja,
gui-de, gui-zh-tw and i18n-keys tests (all pass), and the whole GUI suite
in both engines: 4711 pass, 8 fail, all in library-servers-bulk
(chromium, ".lib-everyon moved from 290 to 288") and purpose-filter
(webkit), which fail the same way run alone on origin/main c1c88779.
Not run on a real WSL distro.
2026-10-10 06:27:40 +08:00
yetone c1c887798b provider, docs: a bare model id several providers serve goes by the Providers order, said and tested (MOMO on Discord)
MOMO's {"model":"glm-5.3"} was answered by opencode-go though a6api
serves glm-5.3 too, and they asked for a 409 or for the response to say
which provider was used. The pick was already deterministic, but
Resolve's comment said "exactly one provider" and no doc said what
happens.

Decision: keep serving bare ids, no 409. magpie writes only
provider/model ids into agents' configs (agent/route.go magpieModels),
so a bare id comes from a user's own config, and a 409 would break every
such client the day a second provider adds the model. The rule is now
written down and pinned:
- found groups on (the default): GroupFor takes the bare id to its found
  group auto-<slug>, members in the Providers order, smart routing and
  fail-over to the next provider; a conversation stays with the member
  that answered it while its cache is worth it (affinity).
- found groups off: Resolve takes the first provider in the Providers
  order that exposes it, alone.
- every reply already carries X-Magpie-Provider / X-Magpie-Model
  (member.go), and the usage log has the provider.

What changed: Resolve's comment; gateway-routing.md model resolution
row; reference.md, the ways a model is named and the headers;
bare_slug_test.go: two providers serving glm-5.3, both modes: the first
in the Providers order answers every new conversation, reordering moves
new ones, an answered one stays in the found group, and a 503 from the
first falls over only in the found group. It passes on the old code
too: it pins behaviour that was already there, now documented.

Run: gofmt -l; go vet -tags nogui on darwin, linux, windows for provider
and gateway; go test -tags nogui ./internal/gateway ./internal/provider
in a sandbox HOME.
2026-10-10 05:56:07 +08:00
yetone e50a958014 gateway: an unknown model's 404 names the nearest ids, display names first, and how a model is named (MOMO on Discord)
MOMO asked for "Zen-Space-Bunny", the model's name as a picker shows
it, and the 404 said only "magpie knows no model" and then every id. It
named neither the id meant (opencode-zen-free/space-bunny-free, "Space
Bunny Free") nor the forms a model id can take.

What changed:
- provider/closest.go: Closest(asked, n) splits asked into lowercase
  letter and digit words and scores every catalog entry: a word in the
  display name (the user's or the vendor's) counts 4, in the model id 3,
  the provider's id or name 1 and only alongside one of the others; the
  whole of asked found in the name or id adds 4.
- gateway/unknown_model.go: unknownModel puts "did you mean …?" (up to
  3), then the forms (provider/model, a routing group's id or name, a
  bare model id and where an ambiguous one goes), then the ids as
  before. Used by the chat 404 and the embeddings 404. Switched-off,
  disabled-group and empty-group 404s are unchanged. Draw and video keep
  their own 404: their models are a different list, and the chat
  catalog's nearest ids would point at models that can't draw.
- docs: gateway-routing.md model resolution row.

Tests: unknown_model_test.go: "Zen-Space-Bunny" against
opencode-zen-free serving space-bunny-free named "Space Bunny Free" is
told that id first and the forms; a provider's name alone suggests
nothing; a display name ranks before an id with the same words. The
404 test fails with the gateway hooks reverted.

Run: gofmt -l; go vet -tags nogui on darwin, linux, windows for provider
and gateway; go test -tags nogui ./internal/provider ./internal/gateway
and the root package in a sandbox HOME.
2026-10-10 05:46:48 +08:00
yetone 0311c720c1 gateway, provider: a model its vendor answers is deprecated leaves that provider's lists until it is answered again, and the next member is asked (MOMO on Discord)
MOMO's /v1/models listed opencode-zen-free/exo-free, and a request for it
got 410 "Model exo-free has been deprecated.". OpenCode Zen's own
/zen/v1/models still lists exo-free (checked 2026-10-10), and models.dev
gives it no deprecated status, so the zen-free plugin's discovery keeps it
and a refresh of the list would bring it straight back. No preset
hardcodes it.

What changed:
- fallback.go: retiredWords / modelRetired: a 400, 404, 410 or 422 saying
  the model is deprecated/retired (Zen's "ModelDeprecated", OpenAI's
  "The model ... has been deprecated"), without quota words. It is
  retryable (410 wasn't), and modelRefused, so only that model rests.
- gateway.go: such an answer calls provider.Retire(provider, model);
  routing.go: servedCandidate calls Unretire.
- provider/retired.go: retired.json beside providers.json, re-read by
  mtime; Retire/Unretire touch the catalog so agents' lists are written
  again. buildEntries leaves a retired model out, for that provider only,
  for at most a week. provider/model requests are still sent.
- docs: gateway-routing.md failure handling, reference.md.

Tests: retired_test.go with Zen's literal 410 body: the model leaves
/v1/models while the provider's other model stays; a group falls over to
the next member and only the 410's provider is marked; modelRetired
cases. All three fail with the hooks reverted.

Run: gofmt -l; go vet -tags nogui on darwin, linux, windows for provider
and gateway; go test -tags nogui ./internal/provider ./internal/gateway
./internal/agent ./internal/catalog in a sandbox HOME.
2026-10-10 05:40:28 +08:00
yetone 52f6b3a3ab plugins: the GitHub list waits up to 3s for a repository's https icon fetched the first time (yetone: 非官方插件显示不出来 logo)
RepoIcons starts every fetch, then waits for those not in yet, so the
list's first answer has them; one slower than the wait shows the next
time. The fetch takes fetchPluginIcon before its goroutine starts.

Ran: go test ./internal/provider ./internal/gui; the new test -race
-count=10, and red with a 1ns wait. A sandbox build (fresh HOME, real
GitHub): the first /api/plugins/github answer has all 8 icons the 13
tagged repositories give, screenshot in light and dark.
2026-10-10 05:22:07 +08:00
yetone 3e5bc3d8fe gateway, usage: conversation text is kept only under a session the client named, and a client's "request-…" session counts like any other (#1355)
What changed: with recording on, a request that names no session used to
get a made-up "request-…" id and its text saved under it. Nothing listed
that id and GatewaySessionByID turned it away, so the text could never be
opened. magpie now makes up no id: such a request isn't recorded, and the
X-Magpie-Session reply header only echoes the client's own session.

With no made-up ids left, the "request-" prefix checks in appendUsage,
OTel, Usage's sessions, Routing's vias and the gateway Sessions projection
only dropped sessions clients named themselves (appendUsage wiped them from
the ledger). They are gone. The tests that held the old rule now say the
new one, and gateway-sessions.md says it.

Run: TestGatewayConversationNeedsTheClientsSession and
TestGatewayConversationClientRequestPrefixedSession fail on the PR's code
(a made-up id is returned; the ledger keeps 0 of 2 calls) and pass here;
putting appendUsage's prefix check back turns the second red again.
gofmt -l clean; go vet; GOOS=linux and GOOS=windows go vet -tags nogui on
gateway, gui, sessions, settings, usage; go test -race -timeout 900s on
those five: ok (gateway's TestCORSKeyThroughTheServer rerun after its fix).
GUI: gateway-conversations, gateway-mode and all sessions-* tests, 216
cases in Chromium and WebKit: pass.
2026-10-10 04:24:18 +08:00
木子不是木子狸 c9b8263378 sessions, gateway: Sessions lists gateway sessions, and can keep their text on this computer when turned on (#1355, mu-zi-lee)
What changed: the Sessions page lists sessions seen only through the
gateway (from the usage ledger, read-only, a native session of the same id
wins), and shows in gateway mode. A Sessions switch, off by default and
confirmed before it turns on, records each gateway exchange's text under
the config dir (scrubbed, at most 7 days and 256 MiB, cleaned hourly).
Settings sync and restore can't turn it on; POST /api/sessions/recording
turns it on or off and can clear what was kept.

Why: users of magpie as a gateway (gateway mode, LAN clients) had no way
to see their conversations; the owner chose this design on the PR.

Squashed from PR head 6a343ba1 onto origin/main. Conflicts: gui-shell.md
(kept main's placeMenu text, took the PR's Sessions in gateway mode) and
settings_keep_test.go (main's fields plus GatewayConversations).

Run: go build ./..., go vet on gateway, gui, sessions, settings, usage;
go test -race -timeout 900s on those five packages: all ok.
2026-10-10 04:24:18 +08:00
yetone 12c0550ae7 gateway: a signed thinking block with no text goes to Messages with its empty thinking, not without the field (#1447, Edward4226)
The second refusal in #1447: OpenCode Go's Claude models answer a
thinking block with a signature and no text,
{"type":"thinking","signature":"abc123"}, with 400 "messages.1.content.0.
thinking.thinking: Field required". Messages requires the field, but
aBlock's Thinking was omitempty, so buildAnthropic dropped it whenever a
signed part had no text: Claude Code's own thinking when its display is
omitted ("thinking": "" with a real signature), and Gemini's signed
parts, translated to an Anthropic upstream.

aBlock now always writes "thinking" on a thinking block (MarshalJSON);
every other block keeps leaving its empty fields out. Only aBlock had
this: the Anthropic reply encoders already write the field as a map.
A separate change from the prefill fix (af1e477d): a different field
and a different refusal.

TestSignedThinkingWithNoTextKeepsTheField fails on the old code ("thinking
block = map[signature:abc123 type:thinking]") and passes now.

Run: gofmt -l (clean), go vet -tags nogui ./internal/gateway on darwin,
GOOS=linux and GOOS=windows, go test -tags nogui ./internal/gateway
./internal/provider ./internal/plugin under a scratch HOME (all ok).
Not run for real: the owner's OpenCode Go subscription is inactive (403).
Which request produced the reporter's messages.25 error from Codex isn't
known: Responses input carries no signatures, so it wasn't this path for
a Codex client's own turns.
2026-10-10 04:19:56 +08:00
yetone 916c1cc7ea gateway: a custom provider's video models, marked "kind": "video" by its own model list, are started, polled and fetched at its OpenAI Videos API (#1446, Edward4226)
What changed
- Videomakers: a custom provider (no account, not a remote magpie, no
  preset plan's videos, a Chat or Responses base) makes videos with the
  models its fetched /v1/models marks "kind": "video"
  (catalog.LiveVideomakers). Nothing is guessed from a name: sora-like-2
  without the kind, a chat model and grok-imagine-video named on it all
  still get the 400 and reach no upstream video route.
- /v1/videos, /v1/videos/{id} and /v1/videos/{id}/content forward to
  {base}/videos the way Remote magpie's already do (filmsOpenAI), where
  base is the Chat base, else the Responses base (videoBase). The vendor
  gets its own User-Agent and no caller headers; passOnCaller stays
  remote-magpie only.
- Custom video ids base64 the vendor's id (its own may hold dots) and end
  with the key's fingerprint (customVideoID, markedKey), so a poll and its
  download use the key that started the video, and a key turned off since
  answers 404 "no longer on". Remote magpie's and Volcengine's id formats
  are unchanged.
- Gateway key holds on all three routes: account/key holds before any
  upstream call (videoAsker), model holds once the model is known
  (videoModelHeld). Grok's and Volcengine's polls and downloads ignored a
  key's model holds before; they now apply them too.
- videoObject's done/succeeded mapping is untouched: the custom path passes
  the vendor's OpenAI object through (asOurs), so its "completed" reaches
  the client as sent.
- docs/subsystems/gateway-routing.md says so under Remote magpie APIs.

Why
- Edward4226's provider serves OpenAI's Videos API, and magpie answered
  400 "can't make videos" because only Grok, Volcengine and Remote magpie
  were allowed.

What was run
- New tests in video_custom_test.go (httptest OpenAI Videos fake whose id
  holds a dot; start, two polls, content bytes; exact upstream request log;
  Responses-only base; two keys with holds; Grok poll/content held by key
  models). On the old video.go 4 of 5 fail (videomakers [], 400 "can't
  make videos", held Grok poll 200); the name-guard test passes on both,
  as it should. Breaking markedKey by hand, or the content model check,
  turns TestCustomProviderVideoKeys red.
- go test -tags nogui -count=1 ./internal/gateway/ ./internal/catalog/
  ./internal/provider/: ok. gofmt -l clean; go vet -tags nogui
  ./internal/gateway/ on darwin, GOOS=linux and GOOS=windows: clean.
- End to end: the built magpie (nogui) under a scratchpad HOME, `magpie
  provider add Studio url=http://127.0.0.1:3561/v1 key=…`, `magpie serve`
  on 127.0.0.1:3562, against a Go fake of the Videos API on 3561. curl
  start -> queued, poll -> in_progress, poll -> completed, content ->
  video/mp4 whose 200000 bytes cmp equal to what the fake served. The fake
  saw POST /v1/videos with model vid-1, the polls and /content, each with
  the provider's key and no X-Magpie-Agent.
- No real video vendor was used. Multipart uploads to a real OpenAI
  upstream weren't tried. The GUI lists no video models per provider, so
  it has nothing to check.
2026-10-10 04:15:28 +08:00
yetone af1e477d83 gateway: a resumed reply to a model that takes no prefill is answered after it, not refused (#1447, Edward4226)
OpenCode Go's Claude models (over Anthropic Messages) refuse a
conversation ending with the assistant's message, "This model does not
support assistant message prefill. The conversation must end with a user
message.", and Volcengine's Agent Plan does too, "The last message cannot
be from the assistant for a model that does not support prefill".
magpie took every Anthropic upstream to go on from a prefill, so both a
client's own resume (Codex sends what it has of a cut reply as the last
assistant message) and magpie's own continuation of a cut reply (#853)
ended with that refusal.

The refusal is about the model, so it is remembered for that provider,
model and API in unfit (prefillRefused); the provider's other models,
and every upstream that takes a prefill, are unchanged.

- A Chat or Responses client's conversation ending with the assistant's
  message, which those APIs read as a turn already said, is asked again
  with a user turn after it ("Continue."), the conversation the refusal
  asks for, and so from then on: translated (forwardTranslated, whole
  and streamed) and relayed as is (passthrough, Chat and Responses: the
  Volcengine case).
- An Anthropic client's prefill is its own ask and still gets the
  refusal as the vendor sent it.
- magpie's own continuation is not asked of that model again, and the
  try it refused ends the reply with what cut it, as a reply that can't
  go on did before #853, not with the refusal of an ask the client never
  made. No user-turn continuation there: a re-answer spliced onto the
  part the client has is the garble continuation.go avoids.

The thinking-block refusal in the same report (a signed thinking block
with no text) is a separate fix, in its own commit.

Tests (prefill_test.go), from the report's literal messages: the
issue's Responses repro and a streamed Chat request to an Anthropic
upstream (TestTrailingAssistantToNoPrefillModel, also checks another
model keeps its prefill), Volcengine's refusal relayed on Responses and
Chat (TestTrailingAssistantRelayedToNoPrefillModel), a cut reply
(TestStreamCutOnNoPrefillModel), an Anthropic client's prefill
(TestAnthropicPrefillToNoPrefillModel), userLastBody. With the refusal
unrecognised (the old code's behaviour) the first three fail with the
report's "UP: Upstream request failed: [invalid_request_error] This
model does not support assistant message prefill…" / "…the reply didn't
end with what cut it"; with the remembering removed they fail with
"calls 4 … asked one again" and "calls = 2 … asked to go on again".

Run: gofmt -l (clean), go vet -tags nogui ./internal/gateway on darwin,
GOOS=linux and GOOS=windows, go test -tags nogui ./... under a scratch
HOME with pinned caches (all ok). Not run for real: the owner's OpenCode
Go key answers 403 "An active OpenCode Go subscription is required", and
there is no Volcengine Agent Plan account.
2026-10-10 04:14:30 +08:00
yetone 8c21a41846 gateway: auto mode's server review reaches Claude alone, and its verdict comes back on every route (lc on Discord)
lc saw Claude Code's notice that the session isn't eligible for the free
server-side auto mode classifier "because your requests go through
127.0.0.1:3425". Claude Code 2.1.295 sends the dangerous-tool-use-2026-09-03
beta and a `safeguards` field, and reads `safeguard_results` from every reply
(a stream's message_delta, a whole reply's top level). A reply without them
latches the session to the local classifier and shows the notice.

What changed:
- serverReview (automode.go), called from forwardOnce, so passthrough,
  count_tokens and the translated path alike: a body naming a model that
  isn't Claude (DeepSeek's, Kimi's Anthropic APIs) goes without the beta and
  without safeguards. AiHubMix answers them with 400 "safeguards: Extra
  inputs are not permitted"; DeepSeek ignores them. A provider that turned the
  beta away (remembered by s.betas, e.g. Bedrock's "Unexpected value(s)")
  gets no safeguards either. Claude, a body with no model (Bedrock's path)
  and api.anthropic.com keep both as sent.
- anthropicWholeEvents: a whole reply streamed after keepalives put
  safeguard_results in message_start, where Claude Code doesn't read it. It
  now goes in message_delta.delta.
- buildAnthropic sends safeguards to a claude-* model; decodeAnthropic
  carries delta.safeguard_results to the encoder, which already wrote them.
  Tool ids are left as they were on every path.
- magpie does not write CLAUDE_CODE_AUTO_MODE_SERVER=0 for other vendors'
  models: /model can move the same session to a Claude model behind the
  gateway, and the setting would hide its eligibility there. Said in
  automode.go's header comment. How magpie presents itself is unchanged.

Tests use Claude Code 2.1.295's captured beta header, an object
classifier_context and the status.available.tool_uses verdict shape.
TestAutoModeReviewOnlyForClaude, TestAutoModeReviewGoesWithItsBeta,
TestAutoModeReviewOfAWholeReplyStreamed and TestAutoModeReviewTranslated
each fail with the source changes reverted (400 from the vendor, verdict in
message_start, safeguards dropped to Claude / verdict not given back).

Run: gofmt -l; go vet, GOOS=linux and GOOS=windows go vet -tags nogui
./internal/gateway; go test -tags nogui ./internal/gateway (ok, 191s)
under a scratchpad HOME. End to end: real Claude Code 2.1.295 -p
--permission-mode auto under a sandbox CLAUDE_CONFIG_DIR, through this
build's gateway on 127.0.0.1:3563, to a fake Anthropic API answering with
safeguard_results: on ant/claude-sonnet-5 the beta and safeguards reached
the fake, Bash ran on the server verdict and no notice was shown; on
ds/deepseek-flash neither reached the fake and Claude Code classified
locally with the notice, as designed. Not run against Anthropic's real API:
no first-party Anthropic key was available.
2026-10-10 03:56:01 +08:00
a998a36367 provider, gateway: remote magpie serves decision and retrieval APIs (#1282)
* provider, gateway: remote magpie serves decision and retrieval APIs

Configure System One from the remote address and discover explicitly marked
decision models, including Clef and custom names. Keep exposed retrieval
models when importing the remote catalog, without treating decisions as
chat or image generators. Refresh removes stale decisions and the editor
receives an explicit empty decision list when none remain.

Forward caller/session attribution for decision, retrieval and media APIs.
Preserve TUI model-list errors for a remote that cannot be reached. Document
the HTTP API surface and old-peer compatibility.

Verified with the full nogui Go suite, focused race checks, go vet, native
macOS and Linux/Windows cross-builds, and the Remote magpie browser tests in
Chromium and WebKit in English and Chinese. The new System One integration
test fails on the old code with an empty model list, and the retrieval test
fails because the remote embedding model is filtered out. Integration tests
exercise two gateways and simulated upstreams; live vendor accounts were
not used.

* gateway, provider: remote forwards keep official client headers

Claude Code and Codex keep their User-Agent through both gateways, so a
relay behind the remote that admits only official clients accepts their
requests. Only decision, retrieval and media calls set magpie's User-Agent
at their own call sites. The two-hop test covers Messages, Responses, Chat
Completions and a Codex originator with another User-Agent.

Keep entryFor limited to remote decision providers. Test rejection of
unlisted remote decisions and remove the redundant resolveDecideModel
early return: the explicit model marker and list-only Jev already exclude
vendor aliases. Document retrieval entries remaining in the shared agent
catalog and TUI list errors being shown for other mixed providers too.

Verified with the Remote magpie provider/gateway tests. Before the fix,
TestRemoteMagpieClientHeaders fails all four cases with the relay receiving
magpie/dev and returning 403 official client required; all pass after it.
Follow-up to yetone's review of #1282.

* provider, gateway, gui: remote discovery reports only the decisions it lists (#1282)

Show CLI list failures and catalog counts for mixed providers. Derive the
remote decision endpoint from its one editable address, name custom decisions
by their ids, and reuse successful discovery in endpoint tests without an
empty decide OK row. Bare-provider decision selection is documented.

Simplify decision classification around the explicit remote list and reuse
model markers while building Available, Catalog and Deciders, avoiding a
whole-list copy and scan for every model. Other providers keep their rules.

The regression tests failed before the fixes on swallowed CLI errors, blank
labels, extra endpoint fields, empty decide results and repeated discovery.
Deliberately removing retrieval/video User-Agent or System One caller
attribution makes all three receiving-side tests fail with lost agent/via.

Verified with sandboxed Go tests, targeted race checks, Linux GUI/nogui and
Windows vet/build, Chromium/WebKit en/zh browser tests and zh-TW/ja/de checks.
The two-hop tests use real gateway handlers with simulated vendor replies;
real vendor accounts and native Windows/macOS runs were not used.

Co-authored-by: XYenon <register@xyenon.bid>
Amp-Thread-ID: https://ampcode.com/threads/T-01a11cd6-93aa-767c-9d6e-f0db2a9c5bab

* gui, gateway tests: remote decision refresh leaves only its current model choices (#1282)

The empty-decision browser fixture cleared deciders but kept decision
entries in models, so its screenshot still showed the same chips. Remove
those entries and assert the visible choices in both states. Check the
real providerInfo model list after refresh too, with Jev Router retained
as chat, and verify an unnamed remote decision reaches the local picker
with its id and provider label.

No production code changes. Verified all Remote magpie tests in root,
provider, gateway and gui under a sandbox HOME with -race; the new
assertions in gateway/gui pass. Chromium and WebKit pass all six browser
cases in English and Chinese; the corrected screenshot run passes 3/3.
Targeted Linux and Windows nogui vet and gofmt are clean.

Amp-Thread-ID: https://ampcode.com/threads/T-01a11cd6-93aa-767c-9d6e-f0db2a9c5bab
Co-authored-by: XYenon <register@xyenon.bid>

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-10-10 03:55:45 +08:00
yetone 566760147a provider: moved Factory users get plugin 0.1.22, whose Claude Code restored-context turn after /compact no longer reaches Factory unadapted (plugins#68, Vigilans)
After /compact in Claude Code 2.1.280, every turn on factory/claude-opus-5-5
was 403 "Factory: Forbidden". The reporter's bisect found that Factory
refuses each fixed fragment of the turn Claude Code sends with the files it
restores: the "Note: <path> was read before the last conversation was
summarized…" notes, "Called the Read tool with the following input:", the
"# Environment\nYou have been invoked in the following environment:"
paragraph and the model line. Plugin 0.1.21 recognized none of them in
that combined, unwrapped block. Plugin 0.1.22 (magpie-community/plugins
0437abb, on npm) recognizes the complete block and applies the existing
compat spellings and the lossless Read-result encoding to each fixed
paragraph, the rest byte-for-byte. The mover's min is raised so
keepMovedCurrent updates moved users. The built-in, which has none of the
plugin's Claude Code adaptations, is unchanged.

Plugin: its two new tests use the issue's literal fragments; the adapting
one fails on 0.1.21; bun test (1010 pass) and check.mjs pass. Here:
go test -tags nogui -count=1 ./internal/provider/, go vet -tags nogui for
darwin, linux and windows, gofmt -l pass. Stubbed fetch only; not tried
against Factory itself (no Factory account here).
2026-10-10 03:39:45 +08:00
yetone 599d784f59 library: magpie-image runs this computer's magpie, whatever path another computer or an old install saved (#1439, dawn1095)
magpie-image was kept in library.json with one computer's magpie by full
path (os.Executable at install). A WebDAV sync carried
C:\Users\...\Downloads\magpie-windows-amd64.exe to a Mac (and a Mac's app
to Windows), and a magpie moved out of Downloads left its old path behind:
the Library page said it couldn't start, and agents were given a program
that isn't there.

- heal (self_here.go): a server that is magpie's own -- `mcp ...` run by a
  program named magpie (magpie, magpie.exe, magpie-<os>-<arch>[.exe]) --
  whose program isn't an existing absolute path here is pointed at this
  magpie, as the library is read (load) and after every change (so a
  bundle Put from another computer is healed before it is written into
  the agents). Every agent writer, project MCP files, the WSL side and
  CheckServers read through load, so all of them get it. A magpie program
  that does exist here (another build) and any server run by another
  program are left as they are.
- Collect carries magpie's own server as plain "magpie": two computers
  with the same library carry the same bundle, so davsync's library hash
  matches and a sync doesn't write one's path over the other's.
- The path given is update.Program (exported cliProgram): the AppImage
  rather than its mount, Homebrew's opt link rather than the Cellar.
- TestWSLMagpieImage now says which magpie this (simulated Windows)
  computer runs.

Verified:
- New tests: TestOwnServerFromElsewhereRunsThisMagpie (Windows path read
  on macOS, a Mac path, an exe moved out of Downloads, a bare "magpie"),
  TestUsersOwnServerStaysAsItIs, TestCarriedOwnServerIsEachComputersMagpie.
  Broken by hand five ways (heal off; no heal in change; no marker in
  Collect; ownServer matching every server; existing magpie builds not
  kept) -- each went red with a FAIL naming the behaviour, green restored.
- gofmt -l clean; go vet -tags nogui ./...; GOOS=linux and GOOS=windows
  go vet (library, update, davsync); go build, GOOS=linux -tags nogui and
  GOOS=windows builds; go test -tags nogui ./... green in a sandbox HOME.
- Sandbox app (own HOME, ports 3561/3562) seeded with a library.json
  holding the reporter's Windows path for magpie-image plus a user's own
  missing Windows tool: on start library.json, Codex's config.toml and
  Claude Code's .claude.json got the running binary; /api/library/mcp/check
  answered magpie-image ok with 3 tools, the user's server unchanged and
  notfound.
Not verified: on the Windows box, or a real two-computer WebDAV sync.
2026-10-10 01:30:40 +08:00
yetone 7f62dbf3ed gateway, agents, gui: a model's reply limit is published within the window beside it, on /v1/models, Cursor's and Muse's lists, the Gateway page and magpie model output (#1438, yhong91)
#1438 (yhong91): GET /v1/models listed grok/grok-4.7 with context_window
256000 (the Grok backend's own) and max_output_tokens 500000 (models.dev's
x-ai entry, which catalog.Decorate fills in when the backend gives no
output). A client that reads both sizes its replies past what the window
holds. The agents' files already kept output within the window (maxTokens,
#338/#347), but the gateway's lists, the GUI and the CLI did not.

What changed:
- provider.OutputWithin(window, output) and Entry.PublishedOutput(): the
  output is kept within the window; an unknown window or output (0) is left
  as it is.
- Every place that publishes the pair uses it: modelObject (/v1/models),
  cursorLocalModel (Cursor Private Inference), Muse Code's list, the
  Gateway page's models and groups (providerInfo, providersState), the
  agents' maxTokens, and `magpie model output` (which also says
  "500k, kept within its 200k window" when it lowers one).
- Entry.Output stays the model's own limit. The request-path cap
  (outputLimit/withMaxOutput) and the user's Max output setting are
  unchanged, so no request is lowered further than before. This is why the
  clamp is not in replyLimit/entryFor as the issue suggests, and why the
  commenter's patch (modelObject only) was not taken as is: it was right
  but missed Cursor, Muse, the GUI and the CLI.
- docs/subsystems/gateway-routing.md: the rule and its routes.

Tests (each fails without the change):
- gateway TestModelListsKeepOutputWithinTheWindow: models.dev 500000 over a
  backend 256000 window, plus output within the window, unknown window and
  unknown output, on /v1/models, Cursor's capabilities and Muse's limit; a
  group request with max_tokens 300000 still reaches upstream as 300000.
- provider TestOutputWithin, gui TestProviderModelOutputWithinTheWindow,
  TestModelOutputCmdWithinTheWindow.
Breaking it by hand: OutputWithin returning output unclamped turns all of
them red, plus agent TestMaxTokensWithinContextWindow; gateway sites back
on e.Output turn the gateway test red on all three lists; moving the clamp
into Entry.Output turns the fixture and the group-request guard red.

Real data: an offline dump of the owner's catalog (copies of
~/.config/magpie and its caches, 203 entries) has 3 models with output
over their window, grok/grok-4.5, 4.6 and 4.7 (256000 vs 500000), now
published as 256000; 0 are not Grok. The live gateway on 3425 was not
running, so /v1/models itself was not read, and no real Grok request was
made.

Run: gofmt -l; go vet -tags nogui ./... on darwin, linux and windows;
go test -tags nogui ./... -count=1: all green except
TestLoopEndsWorkBuddysReply/plugin/anthropic ("let go only after 3207
lines", bound 2000) at load average ~35 with other suites running; it
passes 5 of 5 alone and does not touch the output limit or request path.
2026-10-10 01:30:29 +08:00
yetone b4bdd59f72 gui: a long app menu stays in the window and scrolls inside itself, so the last removed group can be brought back (#1437, Hipple)
With 28 found groups removed, Routing's "28 found groups removed" menu ran
off the bottom of a ~717px WebView2 window and couldn't be scrolled.
openRowMenu had no height cap (max-height: none, and .pop clips with
overflow: hidden), it only flipped up when the whole menu fit above, and any
scroll, even one inside the menu, closed it.

What changed:
- placeMenu (app.js) places every app menu: under its anchor, over it when
  the window runs out below, and one that fits neither way takes the larger
  room with its height held to it. openProtoMenu's placement moved into it
  unchanged; openRowMenu (agent rows, model chips, Routing's removed groups
  and its other row menus) and openSessCombo (the Sessions/Usage filters,
  which could run off the bottom the same way) now use it too.
- .row-menu scrolls inside itself (overflow-y: auto, overscroll-behavior:
  contain), and openRowMenu closes only on a scroll outside the menu, as
  openProtoMenu and openSessCombo already did.
- docs/subsystems/gui-shell.md names placeMenu.

Verified:
- New removed-groups-menu-scroll.test.cjs: 30 removed groups, 1100x700 and
  560x700, en and zh, Chromium and WebKit, 8/8 pass. It wheels the page to
  the line, clicks it where it is, checks the menu is inside the window and
  scrolls, wheels the menu until the last entry shows inside it, clicks it
  (posts groups/show auto-gone-30), and checks the page never scrolled and
  has no sideways scroll.
- Broken by hand three ways, each red in every case: the old app.js/app.css
  ("the menu stays in the window": bottom 1494 of 700), the old
  scroll-closes-menu handler ("scrolling the menu doesn't close it"), and no
  overflow-y on .row-menu ("the last entry is scrolled into view inside the
  menu").
- The 63 GUI test files that open app menus or guard click-scroll, both
  engines: 595 pass, 1 fail: search-api.test.cjs webkit zh, at page load
  (the row is redrawn between waitFor and scrollIntoViewIfNeeded) before any
  menu opens; it passes 5/5 alone. Not run: the rest of the GUI suite, and
  gui-zh-tw/ja/de (no new strings).
- gofmt (no Go changed), go vet ./internal/gui (darwin, GOOS=windows),
  GOOS=linux and GOOS=windows go vet -tags nogui ./internal/gui, and
  go test -tags nogui ./... under a sandbox HOME: all pass.
- Not tried in a real Windows WebView2 window.
2026-10-10 01:26:57 +08:00
yetone 5b46b6142f gui: Names in agents' lists shows the saved choice on Routing and in the provider editor before Settings is opened (#1433, ObsidianArch02)
With plainNames: true, plainOwnNames: false saved, a restart that opened
Routing before Settings showed "Names in agents' lists" as On. suffixMode()
read only `prefs`, Settings' own read of the settings, which stays null
until the Settings page loads, though startup had already read the same
settings into state.settings. The provider editor's Names & levels
(Provider in model names, and its "Agents' lists show ..." line) and a
group editor's label read the same thing, and were wrong the same way.

- suffixMode() reads state.settings, the copy every page draws from. A
  plain-names pick and Settings' own read (loadSettings) now set it as
  well as prefs, as savePrefs and the other cross-page writes already do,
  so the two copies agree.
- Routing's groups can answer before /api/state, so the page can be
  drawn before any settings are in. Each suffix choice records the mode
  it was drawn with, and load() redraws one whose mode the incoming state
  changes (redrawSuffix), with its place's own redraw (Routing's groups,
  the editor's line).

Siblings checked: every other read of `prefs` outside the Settings page.
loadFonts already falls back to bootPrefs, the update row's "Automatic
updates are off" is drawn only on Settings, and the other cross-page
settings (agent order, usage order, panel cards, quota left, desktop
longest, codex credits, auto-reset, check-ins, update pill) already read
state.settings. suffixMode was the only one.

Verified:
- new internal/gui/tests/name-suffix-saved.test.cjs: Routing opened first
  with Off saved (then Settings and back), Routing drawn before a late
  state with "Not on names I set" saved, and the provider editor opened
  first with Off saved; no settings write, no page error. Chromium and
  WebKit, en/zh/zh-TW/ja/de, 1100 and 560px: 30/30 each.
- On the old app.js it fails 30/30 in each engine (expected 'Off',
  actual 'On'; expected 'Not on names I set', actual 'On'). With the
  fix but without redrawSuffix() in load(), the late-state case fails
  10/10 in each engine.
- 86 related GUI files (name-suffix-*, model-suffix-own, plain-names,
  names-fold, gateway-mode, group-*, panel-routing, settings-*, routing-*,
  currency, webdav-settings-refresh, codex-memories-settings,
  click-scroll, i18n-keys): WebKit 376/376; Chromium 375/376, the one
  being routing-blocked en, which fails the same on the old app.js
  (5 of 8 runs of a traced copy): the view scrolls 0 -> 42 -> 201px in
  the frames after the day click and the test takes its "before"
  position during that. Not caused by this change.
- 186 more GUI files that open Settings or Providers: Chromium 943/943,
  WebKit 935/935.
- go build/vet -tags nogui ./internal/gui (embedded assets). No Go
  source changed.
2026-10-10 01:20:25 +08:00
yetoneandtimonwong ca0867e412 gateway: a Grok model's 2500.0 in a call's arguments reaches the agent as 2500, on every route and protocol, deltas and done alike (#1431, timonwong)
Grok's models (grok-4.6, grok-4.7) write a whole number in a tool call's
arguments with a fraction of zeros. Codex reads exec_command's
yield_time_ms and max_output_tokens as u64/usize and write_stdin's
session_id as i32, though their schemas say "number", and serde turned the
whole call away ("failed to parse function arguments: invalid type:
floating point `2500.0`, expected u64 at line 1 column 77"; `17277.0`,
expected i32), so the turn stopped at its first command. Reported 11 of 11
turns through a sub2api relay in front of grok-4.6 and grok-4.7.

What changed (grok_integral.go):
- grokModel/grokServed: a Grok model is one whose id, as magpie names it
  or as it is sent upstream, has a last `/` segment beginning "grok"
  (relay/grok-4.6, x-ai/grok-4, the plugin's grok-4.7).
- wholeNums: a streaming scanner over the arguments' JSON text. A number
  written with a fraction of zeros and no exponent loses the fraction
  (2500.0 -> 2500, -3.00 -> -3, [1.0,2.0] -> [1,2]). Strings, keys, other
  fractions (2.5, 2.50) and exponents (1.0e3) stay as written. It holds
  only a run of ".0…" until the next byte, so arguments cut into deltas
  anywhere come out the same as whole, and the deltas add up to what the
  done events say.
- Relayed (passthrough): intTidy, beside nsTidy and built like it, on
  Responses (function_call items, argument deltas and done, the completed
  response, a whole reply), Chat (tool_calls deltas and messages) and
  Messages (tool_use input and input_json_delta), streamed or whole. A
  line that carries no call passes as it came. Plugin-served Grok and the
  built-in subscription go through the same passthrough.
- Translated: translate sets Request.Grok; encoder() wraps the client's
  encoder in wholeEncoder (outside usageEncoder) and render() rewrites the
  Result's calls (wholeResult), so every client protocol is covered at
  those two choke points.
- Any other model's reply is relayed byte for byte, as before.
- gateway-routing.md says so, and lists the tests.

Scope, against the reporter's 9fd6855b: that commit rewrote only fields a
tool schema typed "integer" or a fixed list of Codex's (codexInts), only
on Responses, and left deltas as they came, so the deltas disagreed with
the done events, and the list missed Codex's other integers (shell's
timeout_ms, sleep's duration_ms, tool_search's limit, the multi-agent
tools' timeout_ms). Codex's own schemas say "number", and JSON has one number type, so 2500 is the same value as
2500.0 for every client; this drops the zero fraction everywhere in a
Grok model's call arguments instead. Its rollout fixtures are kept
verbatim (testdata/grok_integral_args) and its test helpers adapted.

Tests: TestWholeNumsAnyCut (every split point and byte by byte),
TestGrokCallsGiveCodexIntegers (relayed Responses, streamed with deltas
cut whole, byte by byte and at every '.'/zero, and whole),
TestGrokCallsGiveCodexIntegersTranslated (Chat upstream -> Codex),
TestGrokCallsGiveIntegersOnEveryProtocol (Chat relayed, Messages relayed,
Messages translated from Chat), TestGrokCallsByUpstreamName,
TestGrokPluginCallsGiveCodexIntegers (fake plugin, $FAKE_GROK),
TestOnlyGrokCallsGetIntegers (gpt-5.5 byte for byte on Responses, Chat
and Messages relayed; translated keeps 2500.0), TestGrokModel. Each
recorded case checks that the field Codex's own error names is now an
integer of the Rust type it named, wherever Codex reads the call.
With grokModel forced false, every Grok test fails; with the delta
rewriting turned off (intTidy and wholeEncoder), every streamed case
fails on deltas != done.

Run: gofmt -l (clean); go vet -tags nogui ./... on darwin, GOOS=linux
and GOOS=windows; go build ./... and GOOS=linux/windows -tags nogui
builds; go test -tags nogui ./... under a temp HOME; -race -count=3 on
the new tests. End to end: the built binary (`serve`, sandbox HOME, port
47812) in front of a fake upstream streaming 2500.0 a byte per delta on
Responses and Chat; a client reading /v1/responses and
/v1/chat/completions, streamed and whole, got integers in deltas,
arguments.done, output_item.done, completed and output for grok-4.6
(relayed) and grok-4.7 (translated from Chat), and 2500.0 unchanged for
gpt-5.5. The same client against a binary of the parent commit failed
all 12 Grok checks.

Not verified: a real Grok model or a real Codex turn; Messages and Chat
Grok replies have not been seen in the field; a relayed Messages
tool_use input that is rewritten has its keys re-marshalled in sorted
order; exponent forms (2.5e3) are left as written.

Co-authored-by: timonwong <timon86.wang@gmail.com>
2026-10-10 01:09:31 +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 059171006b library: DeepSeek Harness is given a server's ${NAME} as a !!js expression it reads from its environment (#1435, CnsMaple)
A Library MCP server whose headers or env named a variable as ${NAME}
(#1250) was refused for dsh, because dsh had no reference syntax in
refsOf. dsh does have one: a YAML !!js value is evaluated as the row
activates (dsh 0.2.0-rc.2: cordis-plugin-include's entryListSchema
reads the tag, quoted or not; cordis-plugin-loader's interpolate
evaluates it across the whole config), and dsh-mcp-client's README uses
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN. A stdio server needs it so:
it inherits dsh's environment without credential-shaped names
(scrubbedParentEnv).

What magpie writes now (refsOf fmtDsh -> refDsh, dshRef):
- a whole ${NAME}: !!js process.env.NAME (bare, as the README has it)
- text mixed with variables: the text as JSON string literals joined
  with (process.env.NAME ?? ""), YAML-quoted. The user's text is only
  ever inside a JSON string literal, so nothing in it runs as code, and
  NAME is the envRef pattern [A-Za-z_][A-Za-z0-9_]*. An unset variable
  inside a mixed value is "", not "undefined".
dshUnref reads exactly those forms back as ${NAME}, so an entry magpie
wrote reads as same and a sync changes nothing; any other !!js still
keeps the entry the user's (hasJS), as before.

The reporter asked for the value to be resolved into a literal when the
file is written. This writes a reference instead, keeping #1250's rule
that the token never lands in an agent's file as plain text. dsh
evaluates it from its own environment at load time. Their side note
that a quoted !!js isn't evaluated is not what dsh 0.2.0-rc.2 does: its
own --dump-config prints !!js quoted, and the quoted forms evaluated in
the run below.

Since AgentView.NoEnvRefs comes from refsOf, dsh's chip is no longer
greyed and the GUI notice drops it with no string change.
docs/subsystems/library.md says what dsh gets.

Run:
- real dsh 0.2.0-rc.2 `dsh headless` in a sandbox (HOME and DSH_HOME
  under a scratch dir, never ~/.dsh), on a cordis.patch.yml written by
  magpie's mcpFile: a stdio fixture got T1435_A=<token> (whole) and
  "Bearer <token>" (mixed); an injection string with quotes, backticks,
  ${, backslashes and "'); process.exit(3); ('" arrived verbatim; an
  unset variable in a mixed value gave "pre--post"; a streamable-http
  fixture received Authorization: Bearer <token>, the whole-variable
  header and the literal header. magpie read every entry back as same.
  A whole !!js process.env.UNSET makes dsh leave the server out as an
  invalid config.
- go test -tags nogui ./... (temp HOME, pinned caches): green.
- break by hand: refsOf without fmtDsh, encode writing raw values, and
  decode not unref'ing each make TestEnvRefsDsh fail (decode also
  TestDshImportKeepsJS).
- gofmt -l; go vet ./...; GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/library; go build for darwin, linux and windows (nogui).

Not run: dsh on Windows (the reporter's system), and a real LLM turn
calling a tool through the server (no dsh account in the sandbox; the
MCP servers had connected before dsh stopped for its missing LLM
credential). No GUI test was run, as no GUI file changed.
2026-10-10 00:43:26 +08:00
yetone a3a8e25792 plugins: a repository tagged magpie-plugin shows the icon its package.json gives (yetone: 非官方的插件能否支持 icon)
magpie.icon, an https URL or a data:image URI, is read with the rest of
package.json and kept through pluginOwnIcon as an installed plugin's is;
the card shows it, else the puzzle piece.

Ran: go test ./internal/plugin ./internal/gui ./internal/provider; the
plugin GUI tests in Chromium and WebKit (en/zh/ja/de, 980 and 440).
2026-10-10 00:42:37 +08:00
yetone 849faea3a3 plugins: a card's kind and Unofficial chips without fills, provider and middleware each their own hue (yetone)
Unofficial is a hairline with a small amber dot; what a plugin is reads as
its glyph and word, periwinkle for a provider and teal for middleware,
apart from the community badge's green.

Ran: plugin-github, plugin-middleware and plugin-check-updates GUI tests in
Chromium and WebKit (en/zh/ja/de, 980 and 440), light and dark looked at.
2026-10-10 00:42:37 +08:00
yetone 2c1b593675 library: Zed takes MCP servers, in its settings.json context_servers (lc on Discord)
lc on Discord ("agent未识别到zed"): the MCP server editor's agent chips
had no Zed. magpie already edits Zed's settings.json for its model, and
Zed reads its MCP servers from context_servers in that same file, so Zed
is now an MCP target like the other agents.

What Zed takes, from its own source (zed-industries/zed 2c99f547):
- crates/settings_content/src/project.rs ContextServerSettingsContent is
  untagged: a command as command/args/env (enabled, remote, timeout
  beside it), a url as url/headers (enabled, timeout, oauth), or an
  extension's entry as settings. docs/src/ai/mcp.md writes the same.
- crates/context_server/src/transport/http.rs only POSTs (and DELETEs):
  streamable HTTP, no SSE. So an SSE server is refused for Zed and its
  chip is greyed with the existing reason (AgentView.NoSSE).
- stdio_transport.rs hands command.envs over as written and http.rs sends
  headers as written: no ${NAME}. Zed is refNone, so a server naming a
  variable is refused and its chip greyed (AgentView.NoEnvRefs).

Changes:
- targets.go: "zed" gets mcpFile{agent.zed's settings.json, fmtZed}; the
  path is agent.zed's for every OS (~/.config/zed, $XDG_CONFIG_HOME/zed,
  %APPDATA%\Zed, $MAGPIE_ZED_CONFIG_DIR).
- mcp.go: fmtZed under "context_servers", encoded as Cursor/Desktop are;
  magpie owns url/headers/command/args/env only, so enabled, remote,
  timeout and oauth the user sets on magpie's entry stay. The file is
  JSONC and edited in place (edit.SetJSON/DelJSON), so comments, other
  settings and the user's own servers are kept.
- mcp.go, sync.go: an entry Zed's extensions configure (settings, no
  command or url) is never written over: a library server by that name
  is a Problem for Zed (errZedExtension), asking for another name.
- envref.go: a comment citing why Zed has no syntax.
- docs/subsystems/library.md: Zed's file, format, no SSE, no ${NAME},
  the extension guard, and the new tests.

Tests:
- internal/library/zed_mcp_test.go TestZedMCP, on a Zed-style JSONC
  settings.json with comments, trailing commas, a user stdio server
  (enabled:false, timeout), a user remote server and an extension's
  entry, at the default path and under MAGPIE_ZED_CONFIG_DIR: found
  servers, the page's chips, written entries, SSE/${NAME}/extension
  refusals, user keys kept through an edit, uncheck and remove leaving
  only the user's, comments kept, a Sync rewriting nothing.
- internal/gui/tests/library-zed-mcp.test.cjs: Zed's chip on every row
  and in the editor, a click gives it the server, greyed with the reason
  for SSE and ${NAME}; Chromium and WebKit, en/zh/zh-TW/ja/de, 1100 and
  560px, no sideways scroll. No new strings.

Broken by hand, each went red and was restored: no zed target, no
extension guard, SSE allowed, enabled owned by magpie, wrong key (Go);
Zed without noSSE (GUI).

Run (temp HOME, Go caches pinned): gofmt -l internal/ (clean); go vet
-tags nogui ./... on darwin, GOOS=linux and GOOS=windows; go vet
./internal/library ./internal/agent; go build ./..., GOOS=linux go build
-tags nogui ./..., GOOS=windows go build ./...; go test -tags nogui ./...
(all ok); node --test library-zed-mcp.test.cjs (22/22, both engines).

Not verified: a real Zed reading the written file (the live
~/.config/zed was never touched); Linux and Windows beyond vet/build;
Zed found in WSL (not in ownFolder, so no MCP target there); a
project's .zed/settings.json (not added).
2026-10-10 00:37:32 +08:00
yetone 189586612e agents, gateway: Codex's subagents go on a model set in its row, a ChatGPT account's (willz on Discord)
Codex's lead names each subagent's model itself in spawn_agent, and
Codex's [agents] config can't overrule it. The Agents page's Codex row
now has a "subagent model" square (the app's model picker, Default
first). Its options are only models a ChatGPT account in magpie serves,
because a subagent's task is sealed for ChatGPT accounts.

- settings.CodexSubagentModel holds it. It is an agent Field
  (subagent_model), so the TUI/CLI, profiles, davsync's "agents" part
  and WSL twins carry it too. The setter refuses another provider's
  model. It keeps a ChatGPT account's model that isn't served now, so a
  backup restored on another computer still applies (ApplyFields stops
  at the first failed Set). POST /api/settings keeps it.
- The gateway (codexSubagentPick) moves a collab_spawn request onto it.
  codexBackend does this for Codex's own bare models; serve() does it
  for any other model. When no ChatGPT account on in magpie serves it,
  or a held gateway key may not use it, the request stays on the model
  asked for. Route.Subagent records asked/to/kept. The lead's turns,
  titles, guardian and the rest are never moved. Off (the default)
  changes nothing.
- Routing page: kindWhy says the subagent was put on the model set, or
  stayed on its own and why. "only account on for" names the model it
  went on. Strings added in zh, zh-TW, ja and de.
- docs/subsystems: gateway-routing.md and agent-wiring.md describe it.

Run (darwin, rebased on origin/main):
- gofmt -l internal/: clean.
- go vet -tags nogui on gateway, provider, agent, gui and settings:
  darwin, GOOS=linux and GOOS=windows.
- go build ./...
- HOME=<temp> go test -tags nogui ./...: all ok.
  TestSettingsSaveKeepsWhatItDoesNotSend flagged the new field; it is
  now set there.
- GUI tests, Chromium + WebKit:
  - codex-subagent-model.test.cjs: 20/20 (en, zh, zh-TW, ja, de at
    1100 and 560 px).
  - Also green: routing-kind, subagent-effort, codex-memories,
    codex-memories-settings, i18n-keys, gui-ja, gui-de, gui-zh-tw and
    click-scroll.

Failing without the change (codexSubagentPick returning nil):
- TestCodexSubagentPutOnTheModelSet
- TestCodexSubagentOnAnotherProviderPutOnTheModelSet
- TestCodexSubagentModelNotServedKeepsTheAsked
- TestCodexSubagentModelHeldKeyKeepsTheAsked
TestCodexSubagentModelLeavesOtherRequests guards the other side: the
lead's turns, titles and guardian stay untouched.

Not tried with a real Codex CLI and ChatGPT account. A subagent spawn
needs a signed-in Codex home, and this session may not touch the live
~/.codex. The fake ChatGPT backend tests cover both routes.
2026-10-09 23:38:50 +08:00
YAYOI27andClaude aa82288fb6 usage: Volcengine Ark's Coding Plan and Agent Plan windows, read with the account's access key (#1427, yayoinoyume)
Ark tells a Coding Plan's or Agent Plan's 5-hour, weekly and monthly
windows only to the account's control plane (GetCodingPlanUsage,
GetAFPUsage on open.volcengineapi.com), signed with the account's
AccessKey ID and Secret, never to the plan's inference key. An Ark
provider's editor now takes that access key, and the Usage page shows the
plan's windows as a card.

From yayoinoyume's feat/volcengine-usage, reviewed and reworked:
- Signing is volc-sdk-golang's (base/sign.go GetSignRequest, 06f9ef66):
  signed headers sorted (content-type;host;x-content-sha256;x-date), only
  Action and Version in the query. The branch signed them unsorted and put
  Region in the query. TestVolcSignMatchesSDK pins two signatures the SDK
  made.
- The card is in PlanQuotas beside the Step Plan's (its cache, last
  reading, refresh and `magpie quota wait`), not in the subscription
  cards. One per access key and plan.
- The plan is the endpoint's: /api/coding asks GetCodingPlanUsage,
  /api/plan GetAFPUsage, pay-as-you-go none, in the host's region. The
  branch asked both and showed whichever answered.
- Dropped guessed reply fields; the Coding Plan reply is the one
  pi-ark-usage, dsh-ark-quota and ArkCodingPlanUsage read. AFP buckets are
  read in the forms dsh-ark-quota and arkcli read (Used of Quota or Total,
  or Percent), plus Tier; none was seen from a real account.
- An Ark provider is known by its host, not the preset id, so a copy or a
  custom provider takes the key too.
- The Secret is never sent to the page (accessKey {id, secretSet}); a
  blank Secret keeps it, a save without the ID keeps both, Remove
  (clearAccessKey) drops both, an ID with no Secret is refused.
- The pair goes where keys go: AddCopy, Restore, Mirror, backups and
  davsync carry it only with keys (withoutKeys), and a keyless restore or
  sync keeps this machine's. CLI: access.key, access.secret.
- A changed access key drops the cached cards (ForgetPlanQuotas).
- UI strings in zh, zh-TW, ja and de.

Nothing was tried against the real Volcengine API: there was no access
key. A local fake checked the real request PlanQuotas makes against
volc-sdk-golang's own GetSignRequest (both actions, two regions); it
lives outside the repo, so go.mod is unchanged.

Run: gofmt -l; go vet (darwin), GOOS=linux and GOOS=windows go vet
-tags nogui on provider, gui, backup, davsync and the root; go test
-tags nogui ./internal/provider ./internal/gui ./internal/backup
./internal/davsync . under a temp HOME; node --test
volc-access-key, zhipu-team, gui-ja, gui-de, i18n-keys in Chromium and
WebKit. Fail without the change: TestVolcSignMatchesSDK (unsorted
headers), TestAddCopy, TestBackupBalanceToken, TestNoKeys,
TestTakeBalanceToken, TestSyncBalanceToken (pair not carried),
TestProviderSaveKeepsVolcengineSecret (blank Secret dropped),
volc-access-key (Remove not posted).

Co-authored-by: YAYOI27 <111715843+yayoinoyume@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 23:25:04 +08:00
yetone ea1c5d0457 gateway: Antigravity's 429 to a Claude Code / Agent SDK system prompt is the prompt turned away, not a quota, everywhere, and the agent gets 400 (#1425)
Antigravity answers the system prompt of Claude Code and the Claude Agent
SDK (Claude Desktop's chats) with 429 "Resource has been exhausted" at
whatever quota is left (#666). The detail text already said it was not a
quota, but when Antigravity was the last one left (a direct model, the
reporter's case) the route still fell through to failureOf: the try was
counted 额度用尽 on the Routing page and in the account's tally, and the
429 went to the client, so Claude Desktop asked ten times over.

What changed:
- codeassist.go: antigravityTurnedAway(p, system, said) needs the
  Antigravity account, a Claude Code / SDK system prompt and the
  "resource has been exhausted" words. A real quota 429 ("You have
  exhausted your capacity ... reset after ...", QUOTA_EXHAUSTED) stays a
  quota and rests the account as before. turnedAwayStatus = 400 (the
  Anthropic and OpenAI SDKs retry 408, 409, 429 and 5xx, not 400);
  ledger ErrType "prompt_turned_away".
- gateway.go: such a try is failPrompt (was failRefused in a group, and
  failQuota as the last), no rest, no usage re-asked, no passing retry,
  no auto reset. The last one left answers the agent 400 with the
  vendor's words and the hint; in a group the next member is asked, and
  if it fails only on a quota the agent is told the turned-away 400. The
  in-reply (stream error event) form is told the same way.
- continuation.go / translate: the hint is appended to the in-reply form
  too, and the ledger row carries the ErrType.
- routing.js: the hint joins HINTS, so the Routing page shows Antigravity's
  words as "It said: ..." and the explanation on its own line, translated
  in zh, zh-TW, ja and de (i18n.js).

Tests (each fails on the old gateway.go/continuation.go/routing.js):
- TestAntigravityTurnedAwayLastIsNoQuota (stream and not): 400, asked once,
  failPrompt, no rest, ledger 400 prompt_turned_away.
- TestAntigravityTurnedAwayInsideTheReplyLast, TestAntigravityTurnedAwayThenAQuota,
  TestAntigravityQuotaOnAClaudeCodeTurnStaysQuota (scope: a real quota
  429 still rests and reaches the agent as 429),
  TestAntigravityTurnedAwayHintIsTheRoutingPages.
- gui/tests/routing-ag-turned-away.test.cjs: row status word, account
  tally, "It said" line and the hint line, in en/zh/zh-TW/ja/de.
The real-quota fixture's words are from public reports, not captured from
a real account.

Run: gofmt -l internal/ (clean); go vet ./internal/gateway/ and
GOOS=linux / GOOS=windows go vet -tags nogui ./internal/gateway/;
go build ./..., GOOS=linux and GOOS=windows go build -tags nogui ./...;
go test -tags nogui ./internal/gateway/ ./internal/gui/... under a
scratch HOME with pinned caches (ok); GUI node tests (new test,
routing-*, gui-de, gui-ja, gui-zh-tw, i18n-keys) in Chromium and WebKit.
2026-10-09 23:21:41 +08:00
yetone c3e673b061 codex: the account Codex is signed in to goes by the name it is saved under, so two Team seats of one email are weighed apart (#1424, lingqiqi5211)
One email in two Team workspaces is saved as "me@example.com · Team" and
"me@example.com · Team · <workspace>" (codexName). codexAccount still named
the account Codex is on by codexUser alone, "me@example.com · Team". With
Codex signed in to the second seat, its candidate used the second seat's
credentials under the first seat's name. Everything keyed on Account.User
then read the first seat's records: Smart's allowance (98% used, so the
seat with 2% left was ranked last), rests and sticks (restKey/who), the
usage cap, per-account proxy and model list, a gateway key's account list
(LoginID), OwnPaused, and the Routing page's name.

- codexLiveName (logins.go): codexName over the saved logins, shared by
  liveLogin and codexAccount, so the provider's Account.User is the saved
  name.
- SessionIdentities: a saved Codex identity goes by its saved name, and the
  one from auth.json by that of the saved one of its workspace and member,
  so a session of the second seat is told as that seat's.
- providers-accounts.md: the Codex naming rule.

Stored records: nothing keyed under a name that goes away. The first seat
keeps "email · Team"; the second seat's rests, caps, proxy, model list and
usage are already under its saved name, written while it wasn't the one in
use. Records written under the first seat's name while the second was in
use can't be told apart now. Rests run out and readings are read again, so
none is migrated.

Tests: TestCodexTeamSeatsOfOneEmailWeighedApart (gateway): Team A 98%,
Team B (Codex on it) 2% with its week renewing first, another email 20%.
On the old code it fails with "sent to ws-other; want the seat Codex is on"
(and, with its name check, "goes by \"me@example.com · Team\""). With
session_identity.go reverted, TestCodexWorkspaceSessionIdentities (provider)
fails with "the seat Codex is on: [me@example.com · Team]".

Run: gofmt -l (clean); go vet, GOOS=linux and GOOS=windows go vet -tags
nogui on provider, gateway, usage; go build -tags nogui ./... on darwin,
linux, windows; go test -tags nogui ./internal/provider ./internal/gateway
./internal/usage under a temp HOME with pinned caches: all ok. Not tried
with real accounts: no two Team seats of one email are available here.
2026-10-09 23:16:53 +08:00
yetone 319b72c7a5 gateway: the loop guard's long-reply test streams 1000 lines, not all of gateway.go, and waits a minute (69c21cdd's macOS -race CI red)
TestLongReplyThatDoesNotLoopGoesThrough/gemini failed on macOS -race in
run 37946485821: "the stream never ended". It streamed all of gateway.go
(199KB, ~20k events of 11 bytes) through the gateway on each protocol, and
askStream waited 20s. Locally under -race each protocol took ~5s with the
guard and ~5s without it (5.0s off, 6.0s on for gemini), so the time is
the gateway translating 20k events, and CI's runner is ~4x slower: a
bound with no room, not a slow guard.

- The test streams gateway.go's first 40KB, cut at a line end (1001
  lines, several times the guard's 256-unit window).
- askStream waits a minute.
- TestLoopGuardLeavesOrdinaryRepliesAlone feeds the first 256KB of each
  file (app.js and i18n.js run to megabytes): 72s -> 37s under -race.

Ran: gofmt -l, go vet (darwin, GOOS=linux, GOOS=windows), go test -race
-run 'TestLong|TestLoop' ./internal/gateway (long reply 3.9-4.7s per run
for all 4 protocols).
2026-10-09 23:14:39 +08:00
yetone ab72c8eb85 gui: TestCoveredWindowStaysUp hides magpie and checks the window in one step, so the activation its open asked for can't unhide magpie in between (macos-latest, CI run 37942751214)
Cause: on macos-latest the helper exited 1 with "window up: timed out
waiting until magpie is hidden" (6.06 s: the whole 5 s wait). It passed
on 3cd0b5f7's own run and the ten green runs after it, so it is a flake,
not a missing window server: the open and covered steps before it
passed on the same machine. Opening the window (openMain -> showHere ->
Focus) asks the Mac to activate magpie, which it does through the
window server and the run loop, not at once. On a loaded CI machine that
activation can still be pending when the test hides magpie a moment
later; it lands right after the hide and unhides magpie, which puts the
window back, so every poll found it open. NSApp hide: itself is
synchronous here (hidden with no window ordered in, in the same
main-thread block), active or not, and under full CPU load. That the
CI log has no line between is inferred from this: the only thing that
puts a hidden app's windows back is unhiding it, and the helper's only
activation is the open's.

Change: the test's hide step hides magpie and reads whether the window
counts as up in one block on the main thread, and hides again when it
finds magpie shown again. No production code changes: a user can't hide
magpie with Command-H before it is active. Not skipped: the test needs
nothing CI lacks.

Run:
- The old step with a late activation injected (queued from another
  goroutine while the hide block runs, not committed): FAIL 3 of 3,
  "window up: timed out waiting until magpie is hidden" in 5.8-6.3 s,
  the CI failure. The new step with the same injection: ok, 3 of 3.
- Guarantee still checked: windowUp with its "open as magpie was hidden"
  clause removed fails the new test, "hidden with magpie: the window
  counts as up=false, want true".
- go test -count=10 -run TestCoveredWindowStaysUp ./internal/gui: ok.
  Before the change, load alone didn't reproduce it here: three copies
  at a time, 15 runs, with a busy loop on every core, all passed.
- gofmt -l: nothing. go vet ./internal/gui (darwin), GOOS=linux and
  GOOS=windows go vet -tags nogui ./internal/gui, GOOS=windows go vet
  ./internal/gui: clean. go test ./internal/gui (darwin, under a temp
  HOME with pinned caches): ok.
2026-10-09 23:07:44 +08:00
yetone 570b8fdb2a provider: swapping LoginUsage's or Claude /usage's stand-in waits for the readings still running through the old one (data race in CI run 37942751214)
Cause: TestClaudeAccountBackOnceItsWindowRenews (a2c39053) failed the
ubuntu race shard of run 37942751214. Its Cleanup's LoginUsageVia(nil)
wrote the package-level stand-in while a reading Allowances had started
(routing.go:419) was still reading it in loginUsageAt. Allowances reads
in the background on purpose and returns without waiting (only the very
first ask waits, and not for long), so a reading can outlast the caller,
and the test. The window: Allowances deletes c.loading before it tells
the renewed hooks, so the test's next poll can start another reading
just as the rest it waits on is lifted; the test ends with that reading
in flight. In production the stand-in is never set, so there is no
production race; the goroutine outliving its caller is the design (it is
bounded by its 30 s context).

Change: the stand-in is held for reading while it runs, and
LoginUsageVia takes it for writing, so it returns once no reading is
running through the one it replaces and none starts through it after.
That fixes every test that swaps it (gateway claude_renewed and
allowance_stale, provider routing, allowance_renewed,
codex_reset_deadline, window_renewed), not only the one that failed.
UsageClaudeVia's claudeCLIUsage is the same shape (tests swap it, and
Allowances' background reading of a Claude account reaches it through
readClaudeUsage), so it gets the same treatment; claude_usage_test's
restore goes through UsageClaudeVia. ProbeClaudeVia is left: a Claude
account test calls it synchronously, nothing reads it in the background.

Tests: login_usage_via_test.go holds a call through each stand-in and
checks the swap waits for it.

Run:
- Red before: with a 30 ms delay injected into loginUsageAt and before
  Allowances tells the renewed hooks (not committed),
  go test -race -count=20 -run TestClaudeAccountBackOnceItsWindowRenews
  ./internal/gateway/ printed 7 "WARNING: DATA RACE", the write at
  login_usage.go:43 from the test's Cleanup and the read at
  login_usage.go:63 from Allowances.func1, as on CI. Same injection with
  this change: ok, no race. (-race -count=50 without the injection
  passed on the old code too; the window is too narrow on this Mac.)
- The new tests with the swap made unsynchronised again: both FAIL
  "swapped while a call was still running through the stand-in it
  replaced"; with the change: PASS, -race -count=5.
- gofmt -l: nothing. go vet (darwin) ./internal/provider ./internal/gateway
  ./internal/gui; GOOS=linux and GOOS=windows go vet -tags nogui on the
  same: clean.
- Under a temp HOME with pinned caches: go test -tags nogui
  ./internal/provider ./internal/gateway ok; go test -race
  ./internal/provider ok; go test -race -count=20 -run
  '^(TestClaudeAccountBackOnceItsWindowRenews|TestAllowance.*)$'
  ./internal/gateway ok.
- go test -race -tags nogui ./internal/gateway (whole package, temp HOME):
  ok in 635 s, no race.
2026-10-09 23:07:44 +08:00
yetone d7f3c20be3 grok: a Codex subagent's plain agent_message goes to Grok as the user's message, not refused with 422 "unknown item type" (plugins#61, opencode-grok-auth 0.1.11)
Grok's Responses backend refuses a request carrying Codex MultiAgentV2's
agent_message item (a subagent's task, or its reply kept in the lead's
history) with 422 unknown item type "agent_message". plugins#61 counted
512 such 422s on a moved Grok subscription from 10-03 to 10-09, and fixed
it in @magpie-community/opencode-grok-auth 0.1.11 (on npm).

What changed:
- the grok mover's min goes from 0.1.10 to 0.1.11, so keepMovedCurrent
  updates moved users' plugin, and its comment says the fix.
- grokBody (the built-in's request shaper, which also runs for the Grok
  plugin's provider, plugins.go) does what the plugin does: an
  agent_message whose content is a non-empty list of input_text /
  input_image parts goes as {"type":"message","role":"user"}, with
  "From <author> to <recipient>\n\n" (or From / To alone, or nothing)
  as its first part, in its place among the input. One with
  encrypted_content on the item or a part, an empty or null content, or a
  part of another kind is left byte for byte, so magpie reads no sealed
  task and the gateway's sealed-task guard still sees it. A body with no
  agent_message is returned as it came.

Tests:
- provider: TestGrokBodyAgentMessageGoesAsUser (the plugin's task, reply
  with an image between a call and its output, sender alone / recipient
  alone / none) and TestGrokBodyLeavesSealedAgentMessage (the plugin's
  seven sealed or malformed items, and an ordinary history byte for byte).
- gateway: TestGrokGetsAgentMessageAsUser (built-in, streamed and not,
  through the handler) and its parity TestGrokPluginGetsAgentMessageAsUser
  (the Grok plugin's provider, streamed, relayed).
- With origin/main's grok.go back, TestGrokBodyAgentMessageGoesAsUser,
  TestGrokGetsAgentMessageAsUser and TestGrokPluginGetsAgentMessageAsUser
  FAIL ("the agent_message went as" the untouched item); with the change
  they pass.

Run: gofmt -l (nothing); go vet, GOOS=linux and GOOS=windows go vet
-tags nogui on ./internal/provider ./internal/gateway (clean);
go test -tags nogui ./internal/provider ./internal/gateway under a temp
HOME with pinned caches (ok). Not tried against the real Grok backend:
no live Grok request was made.
2026-10-09 22:48:55 +08:00
yetone acdaa28aa1 codex: a conversation that searched the web declares cached search to the ChatGPT backend, so its compaction and turns aren't refused "response protection is unavailable" (#1270, #1099)
The ChatGPT backend answers HTTP 200 and only `event: error` "response
protection is unavailable" to any request whose input replays one of its
own web_search_call items while the request doesn't declare the web_search
tool. Probed on a real ChatGPT Pro account (gpt-6.1-sol, 2026-10-09) with
a real searched turn: refused with tools [], with function tools only and
with a compaction_trigger; answered once {"type":"web_search",
"external_web_access":false} is declared. A Responses Lite request refuses
a top-level web_search (400) but takes it in an additional_tools item. So
xjdata's case (Codex compacting a custom provider's conversation itself, a
plain /responses with tools [] that magpie's codexCompact never sees) failed
every time once the session had searched, and congee949's thread
descriptions too.

What changed:
- provider.DeclareSearch / declareSearch: when the input holds a
  web_search_call and nothing declares search, add cached-only web_search to
  the tools, or for Lite (header, reasoning.context all_turns, or an
  additional_tools item present) to the additional_tools item (prepended
  when there is none). With no tools offered, tool_choice becomes "none" so
  the model can't search; a forced choice is kept. Otherwise the body is
  unchanged (bytes untouched when no web_search_call is in it).
- codexBody (every ChatGPT account route: serveAgent, codexCompact, warmup,
  magpie's codex/* models) calls it; codexUpstreamOn calls it for Codex's
  own sign-in relayed to the backend's /responses.
- Not changed: siwc, API-key passthrough to api.openai.com, the legacy
  /responses/compact endpoint (no evidence they are refused so; not probed).
- docs/subsystems/gateway-routing.md names the cause and the fix.

Tests: internal/gateway/search_history_test.go replays the backend's real
items from that probe against a fake that refuses as the backend did.
TestSearchedConversationCompactsOnAnAccount, ...FunctionTools and
...OnCodexSignIn fail on the old code with "event: error"; ...CompactionTrigger
fails with "asked 2 times, not once". TestUnsearchedConversationUnchanged
and provider TestDeclareSearch cover what's left alone.

Run: gofmt -l (clean); go vet, GOOS=linux go vet -tags nogui, GOOS=windows
go vet -tags nogui on internal/provider and internal/gateway; HOME=<temp>
go test -tags nogui ./internal/provider ./internal/gateway (ok). Real
account, sandbox magpie on 127.0.0.1:3491 under a scratch HOME, replaying
the real searched history: old binary -> /v1/responses codex/gpt-6.1-sol
text compaction with tools [] and /backend-api/codex/responses (standard
and Lite compaction_trigger) all "response protection is unavailable";
new binary -> all response.completed, Lite with a compaction item.
Codex CLI 0.161.0 end to end was not done: on a custom provider it
offered no web_search tool, so no search could get into its history.
2026-10-09 22:40:53 +08:00