Commit Graph
2500 Commits
Author SHA1 Message Date
yetone 3ee97def30 claude: a server or container without Claude Code can have magpie download Anthropic's own build, asked first and checked against its manifest (Jorben)
A Claude subscription signs in and answers through the local `claude`, so
on a server or in the Docker image, which has none, it couldn't be used
(Jorben on Discord: could it be downloaded on first use, as Bun is).

- internal/claudecode: Anthropic's native build, as claude.ai/install.sh
  takes it: downloads.claude.ai/claude-code-releases/latest names the
  version, its manifest.json the platform's SHA-256 and size (linux
  glibc/musl, darwin, win32). A build that doesn't match both is not kept.
  It goes to <cache>/claude-code/<version>/claude, one version kept. Find
  takes the machine's own claude first, then the download.
- magpie claude-code [install [--yes]|remove]: says the version, platform,
  size, the URL it comes from, where it goes, that its checksum is checked
  and that it is Anthropic's software under Anthropic's terms, then asks
  [y/N]. magpie accounts add claude offers it when there is no Claude Code
  (not on Windows, where one in WSL may be the one).
- the gateway's claudeBinary and the sign-in's claudeExecutable take
  claudecode.Find; their "not installed" errors name the command.
- Docker docs (en, zh, ja): Claude sign-in needs `docker exec -it magpie
  magpie claude-code install` first; kept in /config/cache.
  The Dockerfile is unchanged: distroless cc-debian12 has glibc.

Run:
- go test -tags nogui ./... under a temp HOME with pinned caches: all ok
- gofmt -l: none; go vet (darwin), GOOS=linux and GOOS=windows go vet
  -tags nogui ./...: clean; darwin, linux, windows builds
- TestClaudeSubscriptionRunsTheDownloadedClaudeCode fails on the old
  proc.FindTool lookup ("Claude Code is not installed"); taking the
  checksum compare out fails TestInstallRefusesABuildTheManifestDoesntName
- real: magpie claude-code install --yes downloaded Claude Code 2.1.296
  darwin-arm64 (230 MB) into a sandbox HOME, checked; it ran --version
- real, in gcr.io/distroless/cc-debian12:nonroot with the image's /config
  layout: install downloaded 2.1.296 linux-arm64 (245 MB) to
  /config/cache/magpie/claude-code, it ran `2.1.296 (Claude Code)` there,
  and magpie accounts add claude went on to print the claude.ai sign-in
  link and ask for the pasted address
- the claudecode tests built for linux/arm64 pass in debian:12-slim
2026-10-10 14:44:57 +08:00
yetone f9113626c7 docs: LESSONS.md gets the review of 10-09 and 10-10 — exemptions scoped by protocol position, a documented security gap is a bug, two sessions never share a checkout; user-set values, error cause and timing tests seen a second day
Reviewed 68aab298..c98160c0 (92 commits) plus the 10-09 ticks: security/privacy probe of every auth, network, telemetry and credential change, fix-again chains, tags vs green runs (all v0.1.1137-1154 on green, 16 assets), commit bodies and community merges. No lesson reached a third day, so nothing moves to the code standards. Filed #1469 for TestKimiHeldBurstTellsNoSpeed.
2026-10-10 14:24:31 +08:00
yetone c431225a08 gateway: a browser that reaches loopback under a web site's name (DNS rebinding) is refused unless it sends an enabled gateway key
What changed
- lanGuard refuses (403) a loopback request that carries a browser's
  marks (Origin, Sec-Fetch-Site/Mode, a Mozilla/ user agent) when its Host
  isn't this computer's: an IP, localhost, *.localhost, a container
  runtime's name for the host (host.docker.internal and kin),
  MAGPIE_PUBLIC_URL's or MAGPIE_ADDR's host, or the machine's own name.
  An enabled gateway key still lets it in.
- Why: a page whose DNS answers 127.0.0.1 after it loads is same-origin
  with the gateway. Its GET sends no Origin and its POST one equal to the
  Host, so corsGuard's foreignPage let it by, and on loopback no key is
  asked. The nightly security review reproduced it on origin/main: Host
  and Origin rebind.attacker.example:3425 from 127.0.0.1 got 200 on
  GET /v1/models, POST /v1/chat/completions (spends subscriptions) and
  GET /v1/magpie/quotas (account names, balances). The subsystem doc
  listed it as a known gap.
- Agents send none of a browser's marks and pass under any name; browsers
  and webviews calling localhost or an IP are unchanged.
- Tests: TestCORSKeyThroughTheServer and the Omarchy test now set a real
  Host (httptest's default is example.com). TestForeignPagesRefused's
  "own origin behind a port-443 name" row from loopback with no forwarding
  header now sends a key: without one it is the rebound page.
- docs/subsystems/gateway-routing.md describes the check in place of the gap.

Ran
- TestReboundPageRefused: fails on origin/main ("a rebound page's GET:
  200"), passes with the change.
- go test -count=1 ./internal/gateway/ ./internal/gui/ ./internal/access/
  (macOS, go1.27): ok. gofmt -l clean; GOOS=linux and GOOS=windows go vet
  ./internal/gateway/ clean.

Not tried: a real browser against a real rebinding DNS name.
2026-10-10 14:21:23 +08:00
yetone 4225ba1efb site: a partner's /go link and the home page's sponsor card say they came from magpie, as ref=magpie (yetone)
goTarget adds ref=magpie to the partner's key page or website, keeping the
address's own query and fragment; an address that names its own ref (a
referral code) keeps it as it is. The home page's sponsor card links to
yylx.io/?ref=magpie.

Run: node --test site/*.test.mjs (27 pass), with new cases for the query,
the fragment and a partner's own ref.
2026-10-10 14:18:05 +08:00
yetone a21a2b54f1 gateway: a Codex title the model was cut short on before any text goes back as response.incomplete with its reason and usage, not as a completed reply with nothing in it (#1466, Edward4226)
codexTitle read the title model's reply with compactReply, which kept
the output and usage of response.completed and response.incomplete alike
and dropped how it ended, and writeTitleReply always wrote status
"completed". A title cut short at max_output_tokens (all of it spent
reasoning) reached Codex as a finished reply with output [].

What changed:
- compactResult keeps the response's status and incomplete_details
  (from the terminal event's response, or its type).
- codexTitle: when no title can be read and the reply was incomplete,
  writeTitleEnd streams response.created then one response.incomplete,
  status "incomplete", the vendor's incomplete_details and usage. A
  title read from the reply still completes with it; Titles off still
  completes with nothing (writeTitleReply is writeTitleEnd "completed").
- Compaction (codexCompact) is left as it was: a summary cut short with
  text in it is still used.

Codex: codex-rs/codex-api/src/sse/responses.rs takes response.incomplete
with a reason other than interrupted/content_filter as a stream error
("Incomplete response returned, reason: max_output_tokens"), which
core/src/responses_retry.rs retries up to the provider's
stream_max_retries; then the hidden title turn fails and
tui/src/temporary_structured_request.rs sets no title, as it did for
the empty completed reply. This is what Codex does when its own backend
answers so.

Run:
- TestCodexTitleIncompleteStaysIncomplete (Responses upstream, #1466's
  two-field schema) and TestCodexTitleIncompleteFromChat (Chat upstream,
  finish_reason length) fail on the old code (one response.completed,
  status completed) and pass now; the same test's whole title under the
  same schema completes. Existing TestCodexTitle*/TestTitle* pass.
- gofmt -l clean; go vet -tags nogui ./internal/gateway on darwin,
  GOOS=linux, GOOS=windows.
- go test -tags nogui -count=1 ./... under a temp HOME: all ok (with
  ./internal/gateway and ./ rerun after this change).
- Real use: the built binary's gateway in a sandbox HOME with only the
  owner's DeepSeek provider, the issue's title request on
  deepseek-flash. max_output_tokens 16: old response.completed,
  output [], status completed; new one response.incomplete, reason
  max_output_tokens, usage 16 reasoning tokens. 2048: a title and
  description, response.completed, before and after.
2026-10-10 14:14:47 +08:00
yetone ea84c99c78 gateway: a DeepSeek model's tool call the client kept no thinking for goes with reasoning_content, so DeepSeek no longer turns the request away (#1462, Clear8Heart)
DeepSeek's Chat API in thinking mode refuses a whole request with 400
"The `reasoning_content` in the thinking mode must be passed back to the
API" when an assistant tool-call message since the user's last message
comes back without reasoning_content. OpenCode (and any client that
continues a turn another model made, or that dropped the thinking) sends
such turns, and magpie relayed them as they came.

What changed:
- chatReplaysReasoning(host, model) names the Chat upstreams that want
  reasoning back: a DeepSeek host, a model named deepseek wherever it is
  served (OpenCode Go's deepseek-v4.1-flash, OpenRouter), and Command
  Code's plugin. buildChat uses it as before.
- Passthrough (gateway.go, provider.Chat): for such an upstream, not
  Mistral (#494), withReasoningOnToolTurns gives every assistant
  tool-call message without reasoning_content one: its thinking from
  another field (reasoning, OpenRouter's) when it has some, else " ".
  The gate takes the member's model, so a group reaching a DeepSeek
  member is covered.
- Translated (buildChat, Responses/Anthropic -> Chat): a tool-call turn
  with no thinking gets " " too.
- Every tool turn, not only the last: DeepSeek also refuses an earlier
  tool turn of the current user turn missing it.
- " ", not "": DeepSeek's Chat takes "", but relays that omit empty
  fields drop it, and DeepSeek's Responses API refuses an empty
  reasoning_text. A vendor that refuses the field still gets the #494
  retry without it.

Checked against api.deepseek.com (owner's key, deepseek-flash, Chat):
last tool turn missing -> 400; "" -> 200; " " -> 200; real -> 200; an
earlier tool turn of the current user turn missing -> 400; a tool turn
before the latest user message missing -> 200; answers missing -> 200.

Not changed, found on the way: DeepSeek's Responses API refuses a
function_call with no reasoning item (and empty reasoning_text), and its
Anthropic API a tool_use with no thinking block; magpie relays those
natively as they come. OpenCode Go's deepseek-v4.1-flash could not be
tried for real: the owner's workspace answers "requires Global regions".

Run:
- New tests fail on the old code (TestChatToolTurnWithoutReasoningRelayed:
  400 "reasoning_content ... must be passed back"; TestTranslatedToolTurn
  WithoutReasoning: 400), pass now; TestChatToolTurnWithoutReasoning
  NotForMistral and the Mistral/MessageReasoning tests pass.
- gofmt -l clean; go vet -tags nogui ./internal/gateway on darwin,
  GOOS=linux, GOOS=windows.
- go test -tags nogui -count=1 ./... under a temp HOME: all ok.
- Real use: the built binary's gateway in a sandbox HOME with only the
  DeepSeek provider. Old: last tool turn missing 400, earlier one
  missing (stream) 400, plain answer 200. New: all three 200.
2026-10-10 14:14:47 +08:00
yetone 282c9feb23 agent: a packaged Claude Desktop gets magpie's gateway in the real %LOCALAPPDATA%\Claude-3p too, which 2.31226 reads, not only the package's (bg5eau on X)
bg5eau on X: Claude Desktop 2.31226.1, installed as an MSIX package on
Windows, never reached magpie. magpie wrote deploymentMode and its
configLibrary profile only into the package's
LocalCache\Local\Claude-3p, and that build reads the real
%LOCALAPPDATA%\Claude-3p. Copying configLibrary there and setting
deploymentMode 3p fixed it for them.

What the build does: 2.31226.1's asar (index.chunk-Cyt2WTko.js) resolves
the 3p userData with Ou() = path.join(LOCALAPPDATA, "Claude-3p"). 2.7032.0
has the same code (GY()). It reads deploymentMode from
Ou()/claude_desktop_config.json and the gateway from Ou()/configLibrary.
The path didn't change; the package stopped redirecting it into
LocalCache. An install's folders don't say which build it is, so:

- desktopdir.Sets returns every set of folders: Find's choice first, then
  the other one for an MSIX install. Everywhere lists every set's folders.
- Claude Desktop's switch writes and removes deploymentMode, the profile
  and _meta.json in each set. The package's set keeps its own stash keys
  (".package"). A stash that an older magpie wrote for the package's
  folders moves onto those keys, so turning it off still restores them.
- The check runs on each set, names the folder when there are two, and
  checks Claude-3p's deploymentMode as well.
- The siblings: the library's MCP servers go into every Claude-3p file
  that exists, and Cowork skills and Desktop sessions are read from every
  set.

Run:
- go test ./internal/{desktopdir,agent,library,sessions,usage}: green.
- The new tests (TestClaudeDesktopMSIX, TestClaudeDesktopMSIXStashBefore,
  TestClaudeDesktopMSIXBoth3p) and TestCallsCoworkMSIX are red against
  the old code. StashBefore also goes red with desktopAdoptStash disabled.
- gofmt; go vet for darwin, linux and windows.
- On the Windows box, Windows test binaries for the four packages:
  - desktopdir and library pass.
  - agent and sessions fail exactly the set that main's binaries fail
    there (#1388, and newly filed #1468).
  - Every Desktop test passes.
2026-10-10 14:13:05 +08:00
ggbdpq 74c48ac060 agent: the managed daemon's subcommand helpers are no stale Codex copy (#1461)
Codex keeps a long-lived helper next to the managed app-server (codex
app-server daemon pid-update-loop). It matches the codex startsWith
pattern, carries no --managed-daemon, sits under no app bundle or
Application Support folder, so codexCopyKind called it "cli": a helper
that started before magpie's last change to what Codex reads counted as
"A Codex in a terminal" forever, a daemon restart didn't end it, and the
only way to clear the row was killing a process the user can't name.

codexCopyKind now answers "helper" for an app-server daemon <sub>
command line, and StaleCopies skips it: it reads no list and there is no
Codex to quit. The daemon itself (--managed-daemon) is told apart
before, and a terminal Codex still counts.

Red/green: TestCodexDaemonHelperIsNoStaleCopy runs the helper alone from
before magpie's change — FAIL on the old code (one stale "cli" copy),
passes now; a terminal codex from before still counts 1.

Generated-by: GLM-5.3-Flash (ZCode)

Reviewed and landed by the maintainer (cherry-pick of b40a1dd0 from
ggbdpq:fix/codex-daemon-helper-1461, lychjck's report). Run on main at
c98160c0 with this commit on top, macOS arm64:
- On this Mac (ps, read only): the installed codex-cli 0.162.0 binary has
  the subcommand `app-server daemon pid-update-loop` ("[internal] Run the
  detached pid-backed standalone updater loop"): an updater, it reads no
  model list. The managed daemons running here are
  `codex app-server --listen unix:// --managed-daemon` and stay "daemon".
  No pid-update-loop helper was running on this Mac at review time, so the
  reporter's live row was not reproduced here; the fixture is the command
  line from the report. provider's isManagedDaemon already names the same
  helper as no daemon.
- TestCodexDaemonHelperIsNoStaleCopy with main's connectinfo.go: FAIL,
  "the daemon's helper counted a stale copy: [{Kind:cli ...}], want none";
  passes with the change.
- gofmt -l: clean. go vet -tags nogui ./internal/agent for darwin, linux
  and windows; GOOS=linux go vet -tags nogui ./... and GOOS=windows go vet
  ./...: clean. go build, GOOS=linux go build -tags nogui, GOOS=windows
  go build: ok.
- go test -tags nogui -count=1 ./... under a scratch HOME with GOPATH,
  GOMODCACHE and GOCACHE pinned: 44 packages ok, none failed.
- Not run: GUI tests (no GUI change), Windows or Linux hardware (the
  stale-copy check doesn't run on Windows; Linux uses the same ps path).
2026-10-10 14:07:55 +08:00
yetone c98160c045 gateway: a group's rule can pause one of its models at set hours, days or agents, leaving it out of the group then, not first, not a fallback, not kept by affinity (John on Discord)
John asked for a pause in a group's Rules, e.g. a model paused in its
vendor's peak hours. A time rule could only send those turns to another
member first: the paused-for model stayed behind it for failover, went
first when the other was unready, and was kept by affinity mid-turn.

What changed:
- provider: Rule.Pause (`"pause": true`, typed `pause=<model>`). It holds
  by time/days and agents only; cleanRules and ParseRule refuse any other
  condition, and refuse a pause with none. Pause rules are not send-to rules:
  MatchesBesidesIntent, ruledEntry and Ruled() leave them out.
  PausedOut(g, ms, agent, at) drops members a pause holds for, from the
  group's own rules and each nested group's (a group named = all its
  models; a model named = wherever a nested group has it, #625).
- gateway: serve and embeddings run PausedOut on ruleClock before planning,
  on every request. Every member paused -> 503 naming each rule
  (pausedError). The trace's GroupRef carries Paused.
- GUI: the rule editor's "send to" is now an app-menu action button (send
  to / pause); a pause hides the request conditions (clearing them),
  keeps hours, days and agents, and isn't saved without one. The group
  card's rule reads "→ pause <model>", and the Routing trace says which
  model was paused by which rule. Strings in zh, zh-TW, ja, de.
- CLI/TUI: `magpie group rule add <g> pause=<model> time=… days=…`.
- docs: gateway-routing.md step 2; start.html en/zh/ja "Hours and days".

Run:
- gofmt -l clean; go vet -tags nogui ./...; GOOS=linux and GOOS=windows
  go vet -tags nogui on gateway, provider, tui, root; go build ./...
- go test -tags nogui ./... under a temp HOME with pinned caches: all ok.
- rules_pause_test.go (3 tests) fails with the PausedOut call in serve
  removed (the paused model answers mid-turn, as a fallback, and the
  all-paused case returns 200). grouprule_pause_test.go covers clean,
  parse/Line, Ruled, Pauses and nested PausedOut.
- GUI: routing-pause.test.cjs in Chromium and WebKit, en/zh at 1100px
  and en/zh/zh-TW/ja/de at 420px: 14/14; all 7 Chromium cases fail on
  the old routing.js. routing-*, group-*, i18n-keys, gui-ja, gui-de,
  click-scroll: 618 pass, 4 fail under --test-concurrency=4
  (routing-blocked, -idle-frame, -side-calls, -wb-refused), all of which
  pass run alone; routing-side-calls failed 1 of 6 alone and passes
  with the old routing.js too, so it is a load flake not touched here.
2026-10-10 13:51:09 +08:00
yetone 34f2078db8 site: a partner's website and key links go through usemagpie.ai/go, which counts the click; fetches of the partner list are counted too
The app's own partner counts only come from users who leave the stats on,
so a sponsor's reach and clicks are now also counted where the server sees
them. /api/partners hands out each partner's website and keysUrl (and a
region's) as usemagpie.ai/go/<id>/site|keys[/<region>]; /go sends a
"magpie partner go" event to PostHog (partner, link, region, country) and
302s to partners.js's own URL. One in ten list fetches sends "magpie
partners fetch" with weight 10 while someone is listed. Events carry a
random id each, no install id or address. Ended entries leave the list but
/go still resolves them, since added providers keep the links. Privacy text
updated in README (en, zh), docs/reference.md and the site docs (en, zh, ja).

Run: node --test site/*.test.mjs (27 pass); a scratch copy with a fake
partner served rewritten links, 302'd /go to the real URLs and posted both
events; PostHog's /i/v0/e/ answered 200 to the payload shape.
2026-10-10 13:48:26 +08:00
yetone 4eb90b69f4 provider: after magpie switches Codex's account, Codex's accounts say the Codex app is still on the one before and to quit and reopen it (CavillZhang on X)
The Codex desktop app (ChatGPT.app 26.1007, bundled codex 0.162) reads
auth.json once, as its app-server starts. codex-rs AuthManager only
reloads it for the same account (reload_if_account_id_matches skips a
different one; a full reload is the app's own login/logout). So after
magpie's switch the running app keeps the spent account's token, its
webview reads /wham/usage with it (straight to chatgpt.com, which magpie
can't intercept), gets rate_limit.allowed=false, and hardBlocked stops
the composer in every thread with "You're out of Codex and Work usage".
Only quitting and reopening the app moves it.

magpie now notes, like the managed daemon, the app's own app-server
(…/CodexCLI.app/Contents/MacOS/codex … app-server, or the Windows Store
app's codex.exe) running at a switch and the account it holds, until that
process changes. Codex's accounts show "The Codex app is still signed in
as …; quit it and open it again" with Got it; `magpie accounts` and the
gateway log say the same.

Run: go test ./internal/provider ./internal/gui (TestCodexAppStale goes
red with the app not noted), the new GUI test in Chromium and WebKit, en
zh zh-TW ja de, 900 and 480px; findCodexApp against the owner's running
ChatGPT.app found its app-server; go vet darwin/linux(nogui)/windows,
go build.
2026-10-10 13:41:59 +08:00
yetone 61e296688e gateway: Codex's model list keeps codex-auto-review when the user picked models on the ChatGPT account, so auto-review runs on it again, not on the conversation's model at low (#1460)
10086gg reported that Codex's auto-review through magpie almost never
used codex-auto-review. It ran on the conversation's own model at low
effort, which costs more.

Cause: codexModels narrows the ChatGPT backend's list to the models the
user picked on the codex provider (CodexNativePicked). Hidden entries
(visibility "hide") never reach magpie's catalog (parseCodexModels), so
no one can pick them, and the narrowing always dropped them.
codex-auto-review is one of those hidden entries. Codex 0.162 picks the
reviewer this way (codex-rs/ext/guardian-reviewer/src/model.rs,
select_review_model):
1. the entry's auto_review_model_override;
2. else codex-auto-review, if the list has it;
3. else the conversation's model, at low effort.
So once the list was narrowed, every review fell to step 3. On a relay
model it was charged to the relay; on a ChatGPT model it ran on that
model. OpenCodex had the same bug: lidge-jun/opencodex#6712 recorded
direct ChatGPT login as codex-auto-review/low and their proxy as Sol/low.

Fix: the narrowing keeps hidden entries. No picker shows them, so the
user's picks still decide what Codex's /model lists. A user who wants
reviews on a relay model can still set Codex's auto-review model
(#938), and that setting still wins (step 1).

Which account pays changes for a user with picks whose conversation
runs on a relay model: auto-review moves from the relay to the ChatGPT
account's codex-auto-review. That is what Codex does without magpie.
OpenAI's help article "Using Codex with your ChatGPT plan" says:
"When you're signed in with a ChatGPT account, auto-review safety
checks are free and don't count toward your plan's usage limits."

New test TestCodexModelListPicksKeepHidden. It fails on the old code:
after the picks only gpt-6-sol is listed, with no codex-auto-review.
docs/subsystems/gateway-routing.md says this.

Run:
- gofmt -l internal/gateway
- go vet -tags nogui ./internal/gateway, on darwin, linux and windows
- go test -tags nogui ./... with a temp HOME and pinned caches: green
Not run: a real Codex signed in to ChatGPT through the gateway. The
reviewer choice was read from the Codex source.
2026-10-10 13:30:43 +08:00
yetone 259c71078c docs: gateway routing says reasoning state goes through same-protocol translation as the vendor wrote it (#1445, XYenon)
What changed: gateway-routing.md gains the #1445 contract (sealed parts,
the new IR events, each vendor's case, no cross-API envelope) and no
longer says a translated Responses reply never carries sealed reasoning.

Run: read against the code in the three commits before this one.
2026-10-10 13:29:45 +08:00
yetone d7186e51b9 gateway: Gemini's thoughtSignature stays on the call and text it signed through translation, and goes back instead of the skip value (#1445, XYenon)
What changed: parseGemini keeps a functionCall's and a text part's
thoughtSignature. geminiParts and buildCodeAssistSent write them back; a
later call of a signed step goes without one, and only an unsigned step
or Claude on Antigravity gets skip_thought_signature_validator. The Code
Assist decoder puts a call's signature in its id (signedID, as chat does
since #687) and a text's in KTextSig; the Gemini encoder and renderer
give them to a Gemini client where Gemini put them, with a clean id.
Chat, Messages and Responses renderers leave a signature-only text part
out.

Why: a Gemini client on a Code Assist or translated path had every call's
signature replaced by the skip value and text signatures dropped.

Run: the new tests in reasoning_state_gemini_test.go (Gemini's
documented thought-signature shapes) fail on the old code and pass now; a
Chat client's call id carries the signature back to Google, and no other
API's client gets the text's. gofmt -l; go vet -tags nogui on darwin,
linux and windows; the full internal/gateway tests in a scratch HOME: ok.
Not tried with a real account.
2026-10-10 13:29:45 +08:00
yetone 13356b9da7 gateway: a Responses reasoning item goes through translation to Responses with its id, summary and encrypted_content, both ways (#1445, XYenon)
What changed: parseResponses keeps a reasoning item that has
encrypted_content raw (Part.Sealed), and buildResponses sends it back as it
came when it has no reasoning text and DeepSeek replay is off
(sealedReasoning). The decoder says where an item begins (KThinkStart with
its id) and hands over the done item (KSealed). The encoder streams the
summary under the upstream's own id and gives the done item and the
completed response's output as the upstream wrote them; renderResponses
does the same for a reply that wasn't streamed.

Why: the old code made its own rs_ id, dropped encrypted_content and
left reasoning out of the next request, so a store:false conversation
through a stream-only account or web search lost its reasoning each turn.
Reasoning with text in it (another vendor's) is still left out for
OpenAI (#1411); withoutBareReasoning and the foreign-reasoning retry still
act on the built body.

Run: the new tests in reasoning_state_responses_test.go (OpenAI's
documented item and stream shapes) fail on the old code and pass now;
the seal isn't handed to Messages, Chat or Gemini clients. gofmt -l; go
vet -tags nogui on darwin, linux and windows; the full internal/gateway
tests in a scratch HOME: ok. Not tried with a real account.
2026-10-10 13:29:45 +08:00
yetone 64179e7383 gateway: Anthropic's redacted_thinking and a thinking block signed with no text go through translation to Messages as they came, each block apart (#1445, XYenon)
What changed: the IR carries a vendor's sealed reasoning (Part.Sealed,
SealedBy) and says where a thinking block begins (KThinkStart) and where a
sealed one is (KSealed). parseAnthropic keeps redacted_thinking raw and
buildAnthropic writes it back raw. decodeAnthropic, the Anthropic encoder,
the collector and renderAnthropic keep each thinking block apart, with its
own signature, including one signed with no text (aBlock still writes
"thinking": "", 12c0550a). Claude's subscription (Claude Code's stream,
whole and streamed) emits the same events. A resumed reply suppresses the
new kinds with the rest of the reasoning.

Why: on a same-protocol translated path (a non-streaming request to a
stream-only account, web search on the agent's own API, a plugin,
failover), the old code dropped redacted_thinking, dropped a signature
that came with no text, and merged two adjacent thinking blocks into one
with both signatures concatenated. Anthropic refuses the next turn for
each of these.

Run: the new tests in reasoning_state_anthropic_test.go (fixtures are
Anthropic's documented block and stream shapes; no captured bytes exist)
fail on the old code and pass now. A sealed block isn't handed to a
Responses client. gofmt -l; go vet -tags nogui on darwin, GOOS=linux and
GOOS=windows; go test -tags nogui ./internal/gateway/ with a scratch HOME
and pinned GOMODCACHE/GOCACHE: ok. Not tried with a real account.
2026-10-10 13:29:45 +08:00
yetone e85be9d6f1 readme: no stats strip under the hero screenshot (45+ agents, 4 wire APIs, 5 routing modes, 0 config files), in en and zh (the owner) 2026-10-10 13:22:39 +08:00
yetone f3d5d12ba3 readme: the intent-routing and nested-routing screenshots are back under the Routing view, in en and zh (the owner wants them) 2026-10-10 13:21:50 +08:00
yetone 793af76242 gui: Codex's subagents setting, unset, says the lead picks the model and only falls back to the main model, not just "same as model" (willz on Discord)
willz set Codex's "subagents" to 同主模型 (same as model), and a subagent
still ran on another model. Codex 0.162 lets the spawn_agent call's own
`model` argument win over `[agents] default_subagent_model`
(core/src/agent/child_config.rs:
requested_model.or(agent_default_subagent_model)). The default only
applies to a spawn that names no model, and the spawn_agent tool lists
up to 5 models the lead may name. So "same as model" was false whenever
the lead named a model.

This was reproduced with the real codex-cli 0.162.0 in a sandbox
HOME/CODEX_HOME, against a fake Responses server on port 3683 that logs
each request's model and x-openai-subagent header. The lead ran on
gpt-6.1-sol in every case:
  a. lead names astra, default unset  -> subagent on gpt-6-astra
  b. lead names none,  default unset  -> subagent on gpt-6.1-sol
  c. lead names astra, default sol    -> subagent on gpt-6-astra
  d. lead names none,  default astra  -> subagent on gpt-6-astra

magpie already holds every subagent to one model with the "subagent
model" setting (the gateway's codexSubagentPick, 18958661). So this
relabels Codex's unset "subagents" field rather than changing it:
- The field reads "the lead's pick, else same as model".
- Its picker opens on "Lead's pick", noted "{model} unless the lead
  names another".
- Its title points to the subagent model as the way to make every
  subagent use one model.
The local row and WSL rows both get this. zh, zh-TW, ja and de are
translated. Claude Code's and omp's "subagents" keep "same as model".
Claude Code's case was not checked here: its Explore runs on the haiku
tier, so its label may not be exact either.

The new codex-subagents-lead.test.cjs fails on the old app.js ("subagents:
same as model"). It passes in Chromium and WebKit, in en, zh, zh-TW, ja
and de, at 1100px and 560px.

Run:
- gofmt -l internal/agent
- go vet -tags nogui ./internal/agent, on darwin, linux and windows
- go test -tags nogui ./... with a temp HOME and pinned caches: green
- GUI, in Chromium and WebKit: codex-subagents-lead, i18n-keys, gui-de,
  gui-ja, gui-zh-tw, click-scroll, codex-subagent-model, omp-roles,
  subagent-effort, profile-details and desktop-tiers: 54/54 in each
2026-10-10 11:58:51 +08:00
yetone 338ea97909 provider: moved Cursor users get plugin 0.2.5: an account with a Grok Bot allowance shows it on its Usage card, and a model asked for by its bare name counts in its sized ids' pool (magpie-community/plugins#64, Morganwan90; Will on Discord)
Cursor's Grok Bot has an allowance apart from Cursor's monthly usage that
only cursor.com's dashboard showed. Plugin 0.2.5 reads it from
cursor.com/api/dashboard/get-sand-usage-status (the sign-in sent as the
dashboard's WorkosCursorSessionToken cookie, as CodexBar does) and adds a
"Grok Bot" window, or "Grok Bot (trial)": aside, so a spent Bot never stops
Cursor requests, placed before Total so it is among the card's three rings.
A missing or failed reading leaves Cursor's own windows as they were.
0.2.4 (not shipped to moved users until now) counts a model asked for by
its bare name (grok-4.7) in the pool its sized ids (grok-4.7@256k) are in.

The cursor mover's min goes from 0.2.3 to 0.2.5, so keepMovedCurrent
updates every moved Cursor user.

0.2.5 on npm (registry.npmjs.org, latest) has an index.mjs byte-identical
to plugins main 6bed2c1. The plugin PR was reviewed at 40d4df6 and merged:
- bun test in packages/cursor: 73 pass, 0 fail; whole repo 1033 pass,
  0 fail; bun scripts/check.mjs all 15 pass
- botUsage broken 16 ways: 12 turn usage.test.mjs red; no timeout, no
  Origin, no res.ok check (the catch covers it) and an added bearer stay
  green
- the request and parsing match CodexBar's CursorSandUsage.swift
- not tried with a real Cursor account on the review side (the author saw
  the Bot ring on a Free account linked to SuperGrok)

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. No test pins a mover's min, as for 0.2.3.
2026-10-10 11:52:49 +08:00
yetone 9bb2a467e4 agent: Snow CLI is an agent magpie sets up: a magpie profile in ~/.snow, made the active one, that Snow App takes in with "Sync Snow CLI API config" (#1457)
Snow CLI (npm snow-ai, MIT, MayDay-wpf/snow-cli) keeps one profile per
file under ~/.snow/profiles (or $SNOW_CONFIG_DIR), names the active one
in active-profile.json, and copies it over config.json at every start,
headless `snow --ask` included. Each profile is the whole snowcfg: base
URL, key, request method and models. Snow App (MayDay-wpf/snow-app)
reads ~/.snow/profiles/*.json when "Sync Snow CLI API config" is pressed.

magpie writes profiles/magpie.json from the profile the user was on
(their settings kept, Snow's OAuth sign-in left out): baseUrlMode base
at the gateway's /v1, requestMethod chat, the agent's key, the model as
advancedModel and basicModel, and the catalog's window and output
(200000 and 64000 when not known, the output cut to the window). It
makes that profile active and copies it to config.json. The profile the
user was on, whether there was an active-profile.json at all, and a
magpie.json of their own are stashed; Disconnect, `magpie snow
default`, or picking a model of one of the user's profiles puts those
back. A Snow from before profiles (config.json alone) gets the
default.json Snow itself would make, so its next start doesn't take
magpie's config for the user's. Check reports a base URL, key or request
method moved off magpie's; Sync rewrites the profile on a port move. A
WSL distro's ~/.snow is found and written the same way.

Registered: agents.go, the WSL kinds, SNOW_CONFIG_DIR in agentenv, the
npm update spec (snow-ai), the icon (Snow App's own, trimmed to 96px),
README, docs/reference.md, docs/subsystems/agent-wiring.md, the site's
agent list and start.html (en, zh, ja).

Run:
- go test -tags nogui ./... in a scratch HOME: 44 packages ok.
  New: TestSnow, TestSnowOwnProfile, TestSnowNoProfiles,
  TestSnowUsersMagpieProfile, TestSnowCheckSync, TestAgentSnowDefault;
  snow added to TestOwnProviderNamedAsMagpies, the WSL more-agents tests
  and TestMaxTokensWithinContextWindow. All fail before this commit
  (no agent "snow").
- go vet -tags nogui for darwin, linux and windows; gofmt -l clean.
- Real Snow CLI 0.9.2 in a sandbox HOME against a sandbox magpie
  (`magpie serve` on 127.0.0.1:3571, a fake OpenAI upstream on 3561):
  `magpie snow fake/pro` made the magpie profile active, `snow --ask`
  answered through the gateway (POST /v1/chat/completions, model pro,
  logged as agent snow), and `magpie snow default` left profiles,
  active-profile.json and config.json byte-identical to before.
- GUI: agent-layout, agent-connect, agent-models-picker,
  agent-field-names and models-add-and-icons in Chromium and WebKit;
  a Snow CLI row checked at 520-960px in en, zh, zh-TW, ja and de in
  both engines. No new GUI strings.
2026-10-10 11:46:44 +08:00
yetone 43cb6fe34e agent: Claude Code is told the window of Claude Desktop's models while Desktop is on magpie, so its Code tab no longer shows GPT 272K and Grok 256K as 200K (#1458)
Claude Desktop's Code tab runs Claude Code on ~/.claude/settings.json
and hands it the id Desktop lists a model by (mythos-magpie-<n>,
anthropic/magpie-<n>), with nothing of the model's window. Claude Code
2.1.293 (the one Desktop 2.31226.1 ships) gives an id it doesn't know
200000 unless the id has [1m] or CLAUDE_CODE_MAX_CONTEXT_TOKENS is set:
its catalog rows come from Anthropic's served catalog alone, and gateway
discovery keeps only id, name and description. Desktop sets that env
only for Cowork (yukon-silver-config contextWindowByModel), never for a
Code session, and reads the gateway's max_input_tokens only to add a
1M entry. magpie wrote the env from Claude Code's own main model alone,
and not at all on a [1m] or Claude main model, so GPT-6-Astra and Grok
4.7 ran as 200K. GLM 5.3 Flash is 1M, listed as [1m], so it ran at 1M
and showed magpie's auto-compact window, 300K.

While Claude Desktop is on magpie, the window written is now the least
of Claude Code's own model's and of the models Desktop is shown, leaving
out one listed by its 1M id (1M whatever the env says) and one named as a
Claude model Claude Code knows. Claude Code takes one value for every
model it doesn't know, so it is the least: never more than any of them
takes (an overflow is still turned into "prompt is too long" by the
gateway). A value the user set is still left alone. Sync writes it too,
so a model added or hidden for Desktop moves it, and turning
DesktopLongest on or off tells the agents again (catalog.Touched).
gateway.DesktopListed1M is the one rule desktopModels lists [1m] by, so
bb3b3f14's 1M label is unchanged.

Standalone Claude Code has the same limit: a magpie model picked in
/model runs on the one value; that is not changed beyond the Desktop
case.

Run:
- TestClaudeDesktopContextWindow, TestClaudeDesktopContextWindow1M fail
  on the old claude.go/desktop.go ("" want 256000; "" want 1000000), and
  each exclusion broken by hand turns them red.
- Real Claude Code 2.1.293 (from Desktop 2.31226.1's download), sandbox
  HOME/CLAUDE_CONFIG_DIR, against a fake gateway on 3611, with the
  settings.json magpie's agent code wrote (GPT 272K, Grok 256K, GLM 1M
  under DesktopLongest, main on GLM [1m]): /context shows 256k for
  GPT's and Grok's mythos-magpie ids (200k before), 272k for GLM's
  [1m] id (the compact window).
- gofmt -l; go vet, GOOS=linux and GOOS=windows go vet -tags nogui on
  internal/agent, internal/gateway, internal/gui; go test -tags nogui
  ./... under a temp HOME.
Not run: the Claude Desktop GUI itself.
2026-10-10 11:42:03 +08:00
yetone d7a1b02032 redact: more kinds of personal data, each with its own switch: SSNs, passports, IBANs, birthdays, MAC addresses and serial numbers on by default; home-folder names, user@host, public IPs and cloud buckets off (lc on Discord)
lc asked in #feedback (message 1558300896142696549) for magpie's masking
to cover more of what identifies a person or a machine. Under Settings >
Privacy > Mask personal data, each kind now has its own Off/On
(settings.redactKinds, redact.Options.Kinds). What each one masks:

- US SSNs: dashed numbers, except ones never issued (000, 666 and 9xx
  areas) and well-known examples. Undashed ones only next to "SSN" or
  "social security".
- Passport numbers, next to passport, Reisepass, 护照 or パスポート.
- IBANs: compact or in groups of four, with the country's length and the
  mod-97 check.
- Birthdays: a date only next to birthday, DOB, born, 生日 and the like.
  A date on its own stays.
- MAC addresses, but not documentation examples or all-decimal groups.
- Serial numbers, next to S/N, serial or 序列号.
- The user name in /Users/x, /home/x and C:\Users\x. The name is masked
  and the path is kept.
- User and host in a shell prompt or an ssh/scp/rsync target (user@host
  goes out as two placeholders, not as an email address), and on
  hostname/user lines whose value ends the line. Code such as
  `hostname = socket.gethostname()` is left alone.
- Public IPv4 and IPv6 addresses. Private, loopback, link-local, CGNAT,
  documentation, multicast and well-known resolver addresses stay, and so
  do version numbers.
- Cloud bucket names in s3://, gs://, oss://, cos://, abfss:// and
  storage URLs. The object path is kept.

The last four are off by default. Agents send their working folder, the
prompt and log lines in nearly every request, and the model then has to
copy the placeholder exactly inside a path or URL. The first six are
rare and sensitive, so they are on. Each placeholder is restored in
replies, in streamed text and in tool-call JSON. Context rules run only
in a window around their words (rule.matches), so masking with the
defaults on 400KB of app.js takes 136ms (112ms before; 222ms with every
kind on).

Tests, run at this head:
- gofmt -l is clean. go vet passes on darwin, GOOS=linux and
  GOOS=windows (-tags nogui) for internal/redact, gateway, gui and
  settings.
- go test -tags nogui ./... under a temp HOME with pinned caches passed
  everywhere except internal/proc's TestNoCommandBypassesProc. That
  failure came from 2da8fbe4, and b934d64a fixed it on main. After the
  rebase, the changed packages and internal/proc were run again and
  passed. New tests:
  - internal/redact TestMoreKinds, TestMoreKindsLeaveAlone (versions,
    timestamps, hashes, UUIDs, model ids, code, ordinary dates, URLs in
    code), TestCategoryDefaults, TestMoreKindsKnown, TestMoreKindsJSON.
  - internal/gateway TestRedactedKinds.
  - internal/gui TestRedactKindsAreRedacts.
- I broke the IBAN checksum by hand and TestMoreKindsLeaveAlone went
  red.
- GUI tests pass in Chromium and WebKit:
  - the new redact-kinds.test.cjs (en, zh, zh-TW, ja and de, at 1100
    and 640 px);
  - i18n-keys, gui-de, gui-ja, gui-zh-tw, redact-rule-row,
    privacy-hides-accounts, privacy-usage-stats, settings-groups,
    settings-sections and mobile-web;
  - 29 more files that open the Privacy page (252 tests).
  privacy-usage-stats now scrolls with the wheel to reach Count me as a
  user, which the new rows push below its 760px window. WebKit doesn't
  let a script scroll Settings.
- Real gateway check: a `magpie serve` built from this tree, under a
  sandbox HOME, on 127.0.0.1:3492, with a fake OpenAI upstream on 3491.
  An Anthropic /v1/messages request went to the upstream with each kind
  as a placeholder. Turning SSNs off and home folders on changed what
  was sent the same way. The streamed reply came back with every value
  restored.
2026-10-10 11:29:03 +08:00
yetone 5b008f5a55 i18n: Chinese says 子代理 for subagent everywhere, not 子 agent in some places and 子代理 in others (willz on Discord)
willz asked what the difference between 子 agent and 子代理 is. There is
none: the zh and zh-TW GUI strings used 子 agent for the Agents page's
subagent row and subagent effort ("Subagents", "subagents",
"subagent effort" and its hint), and 子代理 for every other subagent
string (Codex subagents, routing's sealed subagent task, subagent model).
Both locales now say 子代理 throughout; zh-TW already used 子代理 for the
rest, so it takes the same term. The zh site docs (intent.html,
start.html) said 子 Agent / 主 Agent and now say 子代理 / 主代理, matching
the GUI's 主代理. ja already says サブエージェント everywhere; the READMEs
don't name subagents. The four GUI tests that expected 子 agent are
updated.

Run: omp-roles, profile-details, subagent-effort, tier-effort,
codex-subagent-model, routing-sealed-task, agent-tweak-fold,
claude-signin, codex-memories(-settings), gateway-mode, mobile-web,
usage-ledger-detail, gui-zh-tw, gui-ja, gui-de and i18n-keys in Chromium
(148/148) and WebKit (79/79); subagent-effort also at 420px wide in both
engines, with the zh screenshot checked (子代理推理强度).
2026-10-10 11:24:31 +08:00
yetone 9db2ed3dc6 gateway: temperature, top_p and top_k are left out for the Claudes that refuse them, so Haiku 5.5 no longer answers auto mode's classifier "temperature is deprecated for this model" (#1454)
Claude Code's auto mode classifier asks at temperature 0, and magpie's
own classifiers do too. Through the aihubmix relay, claude-haiku-5-5
answered 400 "`temperature` is deprecated for this model." (and the same
for `top_p` and `top_k`). magpie relayed sampling parameters as they
came, and built them from a chat request's temperature or top_p.

samplingAsTaken, beside thinkingOffAsTaken in forwardOnce, so relayed and
built requests and retries all go through it, leaves the three out for
the models Anthropic documents as refusing them:
- Opus 4.7, 4.8, 5 and 5.5, Fable 5 and Mythos 5 refuse them at any
  value.
- Sonnet 5 and 5.5 refuse anything but the default.
- Haiku 5.5 takes only temperature 1 and top_p 0.99, never both, and no
  top_k.
Left out, each model samples at its default. The model is read as the
vendor names it, from the body or, for Bedrock and Vertex, the path.
Older Claudes (Haiku 4.5, Sonnet 4.6, Opus 4.6 and before), other
vendors' models and later lines go byte for byte as they came. A Claude
under a name these rules can't read, answering that a sampling parameter
is deprecated or not supported (samplingDeprecated), is asked again
without them, once.

aihubmix itself took every value for the other models and refused it
only on Haiku 5.5. The other models are left out by the docs' rule, for
direct keys and relays that pass the vendor's 400 on.

Verified:
- Tests:
  - TestSamplingAsTheModelTakesIt covers each refusing model's
    spellings, a Bedrock path, and byte-identical bodies for Haiku 4.5,
    Sonnet 4.6/4.5, Opus 4.6/4.1, Claude 3.7, Sonnet/Haiku 6, deepseek,
    glm, kimi and gpt.
  - TestHaikuFiveFiveSamplingOnTheWire uses a fake vendor answering
    with the relay's literal 400s. It covers the classifier relayed,
    top_p/top_k, magpie's own classifier built from chat, Haiku 4.5
    keeping temperature 0, and an unreadable name retried once.
- Without the forwardOnce call, three wire cases FAIL. Without the
  retry, the unreadable-name case FAILs.
- Live, through this build's `magpie serve` under a sandbox HOME with
  the aihubmix key, in the classifier's shape (temperature 0, stop
  </block>, thinking disabled), all 200:
  - claude-haiku-5-5 at default and max effort and with thinking
    omitted;
  - claude-haiku-4-5, claude-sonnet-5-5, claude-opus-5-5 and
    claude-sonnet-5;
  - chat to claude-haiku-5-5 at temperature 0 and at top_p 0.5.
  Straight to aihubmix, Haiku 5.5 at temperature 0, top_p 0.5 or
  top_k 5 gives the 400.
- gofmt -l, GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/gateway, and go test -tags nogui ./internal/gateway under a
  temp HOME with pinned caches.
2026-10-10 11:13:42 +08:00
yetone 4a3d7c6f04 gateway: an agent's thinking between_tools, Sonnet 5.5's thinking off, is read as thinking off on a model magpie translates for (#1454)
Sonnet 5.5 takes thinking turned off as {"type": "between_tools"}.
parseAnthropic read only "disabled" as off, so an agent that already
sends between_tools, routed to a model on another API (a group's
member, a fallback), had thinking neither off nor on, and was left to
that model's default. It is now read as ThinkOff, the same as disabled.

Verified: TestBetweenToolsReadAsThinkingOff fails without the change
("between_tools: ThinkOff = false"). gofmt -l, GOOS=linux and
GOOS=windows go vet -tags nogui ./internal/gateway, and go test -tags
nogui ./internal/gateway ./internal/agent all pass under a temp HOME
with pinned caches.
2026-10-10 11:02:38 +08:00
yetone a9cb0ef988 gateway: Claude Sonnet 5.5 is sent thinking between_tools in place of disabled, and Opus 5.5, Fable and Mythos no thinking, so Claude Code's auto mode classifier no longer gets a 400 (#1454)
911Liam's auto mode, on claude-sonnet-5.5 through a relay, got 400 "To
turn thinking off on this model, send "thinking": {"type":
"between_tools"} instead of {"type": "disabled"}". Claude Code turns
thinking off for its classifier and side queries unless it knows the
model refuses that, and a model reached through a gateway can fall
outside what it knows. magpie forwarded the disabled as it came, and
turns thinking off itself too (an effort of none fitted, a budget that
doesn't fit, thinkingOffUnlessAsked for other vendors' models).

thinkingOffAsTaken, in forwardOnce next to adaptiveThinking, so that
relayed requests, count_tokens, magpie's own thinking-off and retries
all go through it, follows Anthropic's rules for each model. It reads
the model's name as the vendor gives it, from the body, or from the path
for Bedrock and Vertex.
- Sonnet 5.5: between_tools with no other field, and xhigh or max asked
  as high (between_tools is a 400 above high).
- Opus 5.5, Fable 5.x and Mythos 5.x refuse disabled at every effort.
  Thinking is left out, effort is low unless one was asked, and
  classifierRoom is added to a reply limit below it, as Claude Code
  itself does for these models.
- Opus 5 and Haiku 5.5: disabled is kept, with xhigh or max asked as
  high.
- between_tools sent to any other Claude goes as disabled, or with no
  thinking where disabled is refused too.
Other vendors' models, Sonnet 5, Opus 4.8 and older, and lines later than
these go byte for byte as they came. A Sonnet 5.5 under a name these
rules can't read is asked again with between_tools, once, on the 400
that asks for it (asksBetweenTools). A refusal of disabled at any effort
(thinkingOffRefused) is asked again with thinking left to the model,
beside alwaysThinks.

TestAdaptiveOnlyModelsNeverGetABudget's "disabled stays" case pinned
disabled for claude-opus-5.5, which the vendor refuses. It now checks an
older Claude keeps disabled and Opus 5.5 is sent none.

Verified:
- Tests:
  - TestThinkingOffAsTheModelTakesIt and
    TestThinkingOffLeavesOtherModelsAlone (deepseek, glm-5.3, kimi,
    Sonnet 5, Opus 5 at high, Haiku 5.5, Opus 4.8 at max, Sonnet 4.6,
    Haiku 4.5, Claude 3.7, dated Sonnet 5 and Sonnet 6 unchanged).
  - TestClaudeFiveFiveThinkingOffOnTheWire: a fake vendor answers with
    the reporter's literal 400, covering the classifier body,
    count_tokens and the retry.
- Without the forwardOnce call, the wire test's Sonnet 5.5, Opus 5.5 and
  count_tokens cases and the Opus 5.5 adaptive case FAIL. Without the
  retry, its unreadable-name case FAILs.
- Live, through this build's `magpie serve` under a sandbox HOME with
  the aihubmix relay key, in the classifier's shape (max_tokens 64,
  temperature 0, stop </block>, thinking disabled):
  - claude-sonnet-5-5 answers 200 at default and xhigh effort (sent
    straight to aihubmix with disabled it gives the reporter's 400);
  - count_tokens 200;
  - claude-opus-5-5 200;
  - claude-haiku-5-5 at max 200;
  - claude-sonnet-5 200.
- gofmt -l, GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/gateway, and go test -tags nogui ./internal/gateway under a
  temp HOME with pinned caches.
2026-10-10 11:02:38 +08:00
yetone 16539c2ea9 imagemcp: generate_video and the Magpie Image card name every video model the gateway makes videos with, not a Grok subscription alone (#1399)
shen-joe (#1399) wants images and videos from magpie in PI-Desktop and
WorkBuddy. Neither app sends video generation to a custom provider: PI-Desktop
has no video generation at all, and WorkBuddy's VideoGen/ImageGen post to
copilot.tencent.com's /v2/videos|images/generations with the account token.
So the way those agents get videos is Library → MCP → Magpie Image
(`magpie mcp image`). Its generate_video tool told the agent it works only
"with a Grok subscription signed in to Magpie (it has no other video model
yet)", and the Library card said the same, while gateway.Videomakers also
makes videos with a preset plan's videos (Volcengine Ark Agent Plan's
Seedance), another magpie, and a custom provider whose model list marks video
models (#1446). An agent reading that can tell a Seedance user they need Grok.

- imagemcp.go: generate_video's description and the package doc name each
  kind of video model.
- library/market.go: the Magpie Image card says "a video model set up in
  Magpie (Grok Imagine, Seedance)"; zh, zh-TW, ja and de follow.
- Tests: TestVideoToolSaysEveryVideoModel and TestMagpieImageSaysVideoModels
  fail on the old text and pass now.

Run: go test -tags nogui ./internal/imagemcp ./internal/library ./internal/gui
./internal/gateway (ok) and ./... (only TestNoCommandBypassesProc red, from
2da8fbe4, fixed on main by b934d64a); gofmt -l; GOOS=linux and GOOS=windows
go vet of imagemcp and library. A built magpie's `mcp image` answered
tools/list with the new description. A sandbox gateway (scratch HOME,
127.0.0.1:3591, a real OpenRouter key) answered PI-Desktop's exact request,
POST /v1/images/generations {"model":"openrouter/google/gemini-2.5-flash-image",
"prompt":…,"n":1}, with 200 and data[0].b64_json, a PNG.
2026-10-10 10:58:31 +08:00
yetone f2611b7154 accounts: import ChatGPT accounts from Cockpit Tools' and Sub2API's exports, several files at once, from the window or magpie accounts import; the box says the source tool is signed out (#1453)
wlj521 asked for cockpit-tools' import: several auth.json files at once.
magpie's window already imported Codex's auth.json and CLIProxyAPI's
files; this brings it the rest of what Cockpit Tools exports and a command
for it.

What changed:
- login_import.go reads Sub2API's export ({accounts: [{platform, type,
  credentials}]}), so every Cockpit Tools export format comes in (its own,
  auth.json, CPA, Sub2API). An API key, another vendor's account and an
  entry with only an access token (a web session: it can't be renewed and
  would stop within days) are refused with the reason.
- ImportLogins and ImportGoogleAccounts no longer skip one file of several
  that holds no account: it is a failed row with its place
  (ImportedAccount.File), which the window and the command name by file.
- `magpie accounts import <codex|claude|antigravity|factory> <file>...
  [--yes]`: says first what importing does to the tool the file came
  from, imports nothing without a yes, only reads the files, and lists
  each account's outcome.
- The window's import box names Cockpit Tools and Sub2API and says
  plainly that the source tool is signed out of the account and the file
  is only read. Claude's box no longer claims a refresh it doesn't do.
  zh, zh-TW, ja and de for every new string; the retired strings removed.
- docs: reference.md and providers-accounts.md describe the import.

Decision: keep the refresh on import. It checks the file and makes magpie
the token's one holder at a moment the user chose. Storing the token
as-is, as Cockpit Tools does, only delays the rotation conflict and makes
which side gets signed out a race.

Run:
- go test -tags nogui ./... under a sandbox HOME (pinned caches)
- TestImportCockpitExports red on the old login_import.go (Sub2API rows
  unread, the garbage file passed over); TestAccountsImport red with the
  confirmation removed by hand; login-import.test.cjs red on the old app.js
- node --test login-import, factory-key, i18n-keys, gui-ja, gui-de,
  gui-zh-tw (Chromium and WebKit; en and zh, results at 380px)
- gofmt -l; go vet, GOOS=linux and GOOS=windows go vet -tags nogui on .
  and ./internal/provider; darwin, linux and windows builds
- no live ~/.codex: fixtures and a fake token endpoint only
2026-10-10 10:56:55 +08:00
yetone 99009b9333 provider: a plugin's model pool counts a bare id with its @sizes, so a routing group's cursor/grok-4.7 is weighed on Cursor Models, not a used-up Other Models (Will on Discord)
Will's routing group keeps cursor/grok-4.7. Cursor's plugin (0.2.2/0.2.3)
lists Grok 4.7 only at its context sizes, grok-4.7@256k and
grok-4.7@500k, and its usage() builds the Cursor Models window's models
and Other Models' notModels from those ids. quotaOfPlugin matched a model
against those lists by the whole string, so bare grok-4.7 (which the
plugin still runs, at its default size) fell into Other Models: used up,
so the Routing page showed it 0% left and weighRouted ranked it last,
while composer-2.5 showed ~84%.

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

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

Verified:
- TestPluginPoolCountsBareIDOfSizes (the reporter's literal ids) fails on
  the old code: "grok-4.7: used 100, want 16", "the usage page's holds
  count grok-4.7 in Other Models", "grok-4.8@1m: used 100, want 16";
  passes with the change.
- gofmt -l clean; go vet, GOOS=linux and GOOS=windows go vet -tags nogui
  on internal/provider and internal/plugin; go build for darwin, linux,
  windows.
- go test -tags nogui ./... green but TestNoCommandBypassesProc, red on
  main since 2da8fbe4 and fixed by b934d64a, which this is rebased on.
Not verified: against Will's real Cursor account.
2026-10-10 10:54:22 +08:00
yetone b934d64a4e tests: TestNoCommandBypassesProc leaves out internal/testenv, which only tests import and which can't import proc (its tests import testenv); main was red since 2da8fbe4's testenv.Bun ran bun with os/exec 2026-10-10 10:51:48 +08:00
yetone 54675767f1 sessions: a folder draws its latest 100 sessions and a Show more for the next page, and opening a session draws only that session again (ChildhoodAndy on X)
With thousands of sessions the Sessions page drew every row of the open
folder, 17,000 elements for 1,000 sessions, and drew them all again on
switching to the page or to another agent, on each keystroke in the filter
(every folder opens while filtering: 51,000 elements for 3,000) and on
opening a session. Switching and scrolling stuttered.

Now an open folder draws a page of 100 and a "Show N more" (data-unrolls)
that adds the next page under the rows already drawn. A filter or another
agent starts again at a page. A session opened from the Usage page is drawn
however far down its folder it is. Opening a session replaces only that
item and the one it closes. The folder box and the bar still pick every
session, drawn or not.

Measured with 3,000/2,500 sessions, before → after:
- open the page: WebKit 449 → 159 ms, Chromium 312 → 165 ms
- open a session: WebKit 287 → 67 ms
- filter keystroke: WebKit 546 → 64 ms
- elements drawn: 17,073 → 1,774

Run: sessions-manage-paging.test.cjs (new; Chromium + WebKit, en + zh,
1000px + 380px) and every sessions-*/session-* test, click-scroll and
old-webkit, all green.
2026-10-10 10:50:09 +08:00
yetone ca3394ff80 docs: the community's plugins update by themselves every hour, not every six hours (en, zh, ja)
internal/plugin/updates.go's KeepUpdated first runs 30 seconds after
magpie starts, then every updateEvery, which is time.Hour. #681 cut it
from six hours. The Plugins page (plugins.js) already says "within the
hour". site/public/docs/plugins.html, zh/ and ja/ still said six hours.

Other "six hours" mentions were checked and left alone, since the code
agrees with them:
- the registry.json fetch on the same pages (market.go: 6*time.Hour);
- the move retry in docs/subsystems/provider-plugins.md
  (migrate.go: moveRetry = 6*time.Hour).

Bun's own check (bunEvery) is six hours, but no doc states it. No README
or docs/ page states the plugin update interval.

Run: grep across README*.md, docs/ and site/ for six hours / 6 hours /
六小时 / 6 小时 / 6 時間. No code changed.
2026-10-10 10:28:49 +08:00
yetone e5f04f38e8 plugin: a plugin on a pre-release whose release is out shows the update on its row, as the Plugins dot already did; versions are ordered as semver orders pre-releases, in Go and in the page
The Plugins dot comes from update.Newer, and the Installed rows (chip,
Update, Update all) from plugins.js's own newer(). They disagreed:

- newer() read only x.y.z, so 2.0.0-beta.1 with 2.0.0 out had the dot
  and no row to update it from. Real case: opencode-antigravity-auth,
  whose latest is 1.6.0 after 1.6.0-beta.0..2.
- update.Newer compared pre-releases as strings, so 1.0.0-beta.10 came
  before 1.0.0-beta.9 (and 1.6.0-10 before 1.6.0-9): a missed update,
  and the other way round, an "update" to an older beta.
- update.Newer didn't parse build metadata, so 1.0.1+build.1 was never
  newer than anything.

Both now order as semver §11: no pre-release comes after any;
identifiers compared one by one, numbers as numbers, a number before a
word, and a shorter list before a longer one it starts. Build metadata
is ignored. A version that isn't x.y.z is never newer, as before.

Tests:
- internal/update TestNewer gains the pairs. On the old Newer 4 fail
  (beta.10/beta.9 both ways, 1.6.0-10/-9, +build).
- internal/gui/tests/plugin-prerelease.test.cjs: seven installed
  plugins, the dot's waiting list as update.Newer gives it, and each
  row's chip and Update button, plus the Update all count, must match.
  On the old plugins.js it fails at "v2.0.0 out" in both engines. No
  strings change, so it's English only.

Reproduced in a sandbox HOME with the real package:
`magpie plugin add opencode-antigravity-auth@1.6.0-beta.2`, then Check
for updates. Before: /api/plugins/updates listed it, and the Plugins
nav had the dot, but the row had no chip and no Update, and there was no
Update all. After: the row shows "v1.6.0 out", Update, and Update all (1).
Update all then installed 1.6.0 and the waiting list emptied.

Run: gofmt; go vet -tags nogui for darwin, linux and windows on
internal/update, internal/plugin, internal/gui and the root package; go
test -tags nogui of the root package, internal/{gui,plugin,provider,
stats,update} under a temp HOME; GUI tests plugin-prerelease,
plugin-updates, plugin-card-update, plugin-check-updates, plugin-market,
plugin-release, plugin-git and plugin-local in Chromium and WebKit.
2026-10-10 10:28:49 +08:00
yetone 2da8fbe488 tests: every test that runs plugins on the bun on PATH runs it once first, through testenv.Bun, so a bun that has never run here doesn't time out internal/provider's and internal/plugin's plugin tests (#1306)
65dcc573 (ggbdpq's patch 1) warmed the bun on PATH before the root
package's one-minute plugin contexts. The same lookup of the bun on PATH
is in 40 more places in internal/provider (28), internal/gateway (8),
internal/plugin (3) and internal/gui (1), and in the root package's
TestPluginListSaysClash. Many of them then open a one- or two-minute
context (internal/provider's sign-in, fetch and move tests,
internal/plugin's sandbox), and there macOS's first-exec check of a
just-installed or upgraded bun landed inside the deadline. The rest
are covered the same way, so no test has to remember which is which.

testenv.Bun now does what the root package's pathBun did: it finds the
bun on PATH (skipping the test when there is none) and runs
`bun --version` before the test opens its context, once per bun per test
binary. pathBun is gone; every `exec.LookPath("bun")` + skip block in
the tests uses testenv.Bun instead. Three tests reach for the bun some
other way and are left as they were: TestHostRunsAsMagpieBun (7ddea2c2
covers its copy), the plugin_git test's realBun and netproxy's
TestBunThroughBridge, none of which runs the plugin host under a
deadline on the PATH bun. migrate_test.go needed two `err` declarations
that the removed block used to provide.

What was run (macOS arm64, bun 1.4.2), with a wrapper bun first on PATH
whose first exec sleeps 61s (marker-gated, then execs the real bun):
- Red, old code:
  - internal/provider TestPluginSignInTakesAPastedCallback: FAIL at
    61.1s, "pluginsignin_paste_test.go:36: starting plugins: context
    deadline exceeded".
  - internal/plugin TestFakePlugin: FAIL at 62.0s, "plugin_test.go:66:
    Plugins = [], starting plugins: context deadline exceeded".
  - internal/gateway TestPluginFailover passed on the old code too (its
    calls have no deadline that short), so the gateway is not shown red.
- Green, this change, same cold wrapper: the three tests passed (61.1s,
  62.9s, 61.2s).
- go test -tags nogui -count=1 for the root package and for
  internal/testenv, gateway, gui, plugin and provider, under a temp HOME
  with GOMODCACHE, GOCACHE and GOPATH pinned: all ok.
- gofmt -l on the changed files: clean. go vet of those packages for
  darwin (-tags nogui), GOOS=linux and GOOS=windows (-tags nogui), and
  GOOS=windows without the tag: clean.

Not run: a really cold bun on a loaded machine; the sleep stands in for
the check. Not run on Linux or Windows, where the warm-up is one extra
`bun --version` per test binary.
2026-10-10 10:22:59 +08:00
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