From e611fc65d6bd9cdba414ae4d239aebf142adec18 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 10 Oct 2026 10:01:49 +0800 Subject: [PATCH] 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. --- internal/agent/codex.go | 10 +- internal/agent/codex_daemon.go | 122 +++++++++++++ internal/agent/codex_daemon_notice_test.go | 68 +++++++- internal/agent/connectinfo.go | 8 +- internal/gui/assets/app.css | 3 + internal/gui/assets/app.js | 27 +++ internal/gui/backend.go | 3 + internal/gui/providers.go | 9 +- .../gui/tests/agent-stale-copies.test.cjs | 54 ++++++ internal/provider/codex_daemon.go | 165 +++++++++++++++++- internal/provider/codex_daemon_test.go | 138 +++++++++++++++ providers_cli.go | 3 + 12 files changed, 596 insertions(+), 14 deletions(-) create mode 100644 internal/agent/codex_daemon.go diff --git a/internal/agent/codex.go b/internal/agent/codex.go index 3c59c18a..24a968b4 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -987,10 +987,16 @@ func codexIn(at place) *Agent { // codex session keeps the old list: a new one shows Codex's own // models until that daemon is restarted, while the desktop app's // own app-server, restarted with the app, has magpie's (TJHHHH, - // luci). magpie doesn't restart it: that ends its sessions. + // luci). With no codex session on it magpie restarts it itself + // (codexDaemonNotice); with one, the user says when. Notice: func() string { + if at.spell == nil { + if n, ok := codexDaemonNotice(path); ok { + return n + } + } if codexRunning() { - return "Codex builds its model list at start-up — restart the Codex app, open codex sessions and the app-server they share (" + provider.CodexDaemonRestart + ") to see this." + return codexRestartAll } return "" }, diff --git a/internal/agent/codex_daemon.go b/internal/agent/codex_daemon.go new file mode 100644 index 00000000..94f7113d --- /dev/null +++ b/internal/agent/codex_daemon.go @@ -0,0 +1,122 @@ +package agent + +import ( + "context" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +// Codex's background app-server (the daemon codex sessions attach to) +// builds its model list once, as it starts, and runs on per CODEX_HOME, +// across terminals, with no idle exit. So a change magpie makes to what +// Codex reads at start — wiring it in, taking it out, a new catalog — +// reaches no codex session started after it until the daemon restarts: +// luci (Discord, Codex 0.162) took Codex off magpie, opened codex, which +// started the daemon on Codex's own models, and after wiring Codex in +// again every new codex showed the official list. With no codex session +// attached magpie restarts it itself, right after the change and every +// so often after (a session open then is one the user closes later); +// with one attached the Agents page offers the restart. + +// codexRestartAll is what is said after a change while copies of Codex +// run that magpie leaves to the user. +const codexRestartAll = "Codex builds its model list at start-up — restart the Codex app, open codex sessions and the app-server they share (" + provider.CodexDaemonRestart + ") to see this." + +// keepCodexDaemon is provider.KeepCodexDaemonCurrent; a var so tests can +// see what is asked of it without a codex. +var keepCodexDaemon = provider.KeepCodexDaemonCurrent + +// codexDaemonSeen is the last check of this machine's daemon, for the +// Agents row where its processes aren't listed on each look (Windows, +// where listing them is a PowerShell run). +var codexDaemonSeen struct { + sync.Mutex + at time.Time + check provider.CodexDaemonCheck +} + +// codexDaemonCheck checks the daemon of the Codex whose config is path +// against when magpie last changed what Codex reads at start, and +// restarts it when no codex session is on it. +func codexDaemonCheck(path string) provider.CodexDaemonCheck { + a := &Agent{ID: "codex", Path: path} + _, files, reads := a.startsWith() + changed := a.changedAt(files, reads) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + c := keepCodexDaemon(ctx, filepath.Dir(path), changed) + if c.Err != nil { + log.Println("codex app-server:", c.Err) + } + codexDaemonSeen.Lock() + codexDaemonSeen.at, codexDaemonSeen.check = time.Now(), c + codexDaemonSeen.Unlock() + return c +} + +// codexDaemonNotice is what is said of the daemon after a change: ok is +// false when there is none behind it. +func codexDaemonNotice(path string) (string, bool) { + c := codexDaemonCheck(path) + switch { + case c.Restarted: + return "magpie restarted the app-server codex sessions share, which none was on, so new codex sessions have this; restart the Codex app, if it is open, to see it there.", true + case c.Behind && c.Attached > 0: + return "Codex builds its model list at start-up, and the app-server open codex sessions share started before this: restart it (Restart on magpie's Agents page, or " + provider.CodexDaemonRestart + ") and the Codex app to see this.", true + } + return "", false +} + +// codexDaemonEvery is how often KeepCodexDaemonCurrent looks. +const codexDaemonEvery = 15 * time.Second + +// KeepCodexDaemonCurrent restarts this machine's Codex daemon whenever it +// is behind what Codex reads at start and no codex session is on it, +// looking every so often until ctx ends. +func KeepCodexDaemonCurrent(ctx context.Context) { + home, err := os.UserHomeDir() + if err != nil { + return + } + path := filepath.Join(home, ".codex", "config.toml") + t := time.NewTicker(codexDaemonEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + if _, err := os.Stat(path); err == nil { + if c := codexDaemonCheck(path); c.Restarted { + log.Println("codex app-server: restarted, it had the model list from before", c.Since.Format(time.RFC3339)) + } + } + } +} + +// CodexDaemonRestarted forgets the daemon last found behind, once the user +// has restarted it. +func CodexDaemonRestarted() { + codexDaemonSeen.Lock() + codexDaemonSeen.check = provider.CodexDaemonCheck{} + codexDaemonSeen.Unlock() +} + +// codexDaemonBehind is the daemon as last checked, while that check is +// recent: a copy left on the old list, for StaleCopies where processes +// aren't listed on each look. +func codexDaemonBehind() []StaleCopy { + codexDaemonSeen.Lock() + defer codexDaemonSeen.Unlock() + c := codexDaemonSeen.check + if !c.Behind || c.Restarted || time.Since(codexDaemonSeen.at) > 3*codexDaemonEvery { + return nil + } + return []StaleCopy{{Kind: "daemon", Since: c.Since}} +} diff --git a/internal/agent/codex_daemon_notice_test.go b/internal/agent/codex_daemon_notice_test.go index e158e6d3..d0c61529 100644 --- a/internal/agent/codex_daemon_notice_test.go +++ b/internal/agent/codex_daemon_notice_test.go @@ -1,8 +1,12 @@ package agent import ( + "context" + "os" + "path/filepath" "strings" "testing" + "time" "github.com/yetone/magpie/internal/provider" ) @@ -17,9 +21,10 @@ import ( // row can't tell a stale copy. func TestCodexAdviceNamesTheSharedAppServer(t *testing.T) { home, _ := codexHome(t, "", "") - was := codexRunning + was, wasKeep := codexRunning, keepCodexDaemon codexRunning = func() bool { return true } - t.Cleanup(func() { codexRunning = was }) + keepCodexDaemon = func(context.Context, string, time.Time) provider.CodexDaemonCheck { return provider.CodexDaemonCheck{} } + t.Cleanup(func() { codexRunning, keepCodexDaemon = was, wasKeep }) if n := codex(home).Notice(); !strings.Contains(n, provider.CodexDaemonRestart) { t.Errorf("Codex's advice leaves out its app-server: %q", n) } @@ -34,3 +39,62 @@ func TestCodexAdviceNamesTheSharedAppServer(t *testing.T) { t.Errorf("advice with no Codex running: %q", n) } } + +// luci (Discord, Codex 0.162): after Codex was wired to magpie every new +// codex showed Codex's own models, since the background app-server they +// attach to was started before, on the official config. Said after a +// change (the GUI's and the CLI's), the advice first checks that +// app-server against magpie's change in this Codex's home: restarted by +// magpie when no session is on it, named with the Agents page's Restart +// when one is. +func TestCodexNoticeKeepsTheDaemonCurrent(t *testing.T) { + home, _ := codexHome(t, "", "") + dir := filepath.Join(home, ".codex") + cfg, list := filepath.Join(dir, "config.toml"), filepath.Join(dir, "magpie-models.json") + os.WriteFile(cfg, []byte("model_provider = \"magpie\"\nmodel_catalog_json = \""+list+"\"\n"), 0o600) + os.WriteFile(list, []byte(`{"models":[{"slug":"magpie/a"}]}`), 0o644) + then := time.Now().Add(-2 * time.Hour) + os.Chtimes(cfg, then, then) + os.Chtimes(list, then, then) + wasRunning, wasKeep := codexRunning, keepCodexDaemon + t.Cleanup(func() { codexRunning, keepCodexDaemon = wasRunning, wasKeep }) + codexRunning = func() bool { return true } + var asked []string + var changed time.Time + answer := provider.CodexDaemonCheck{} + keepCodexDaemon = func(_ context.Context, h string, c time.Time) provider.CodexDaemonCheck { + asked, changed = append(asked, h), c + return answer + } + + // nothing behind: the advice as before + if n := codex(home).Notice(); n != codexRestartAll { + t.Fatalf("no daemon behind: %q", n) + } + if len(asked) != 1 || asked[0] != dir || changed.Sub(then).Abs() > time.Second { + t.Fatalf("asked %v at %v, want %s at %v", asked, changed, dir, then) + } + // magpie's list changes: the daemon is checked against now + os.WriteFile(list, []byte(`{"models":[{"slug":"magpie/a"},{"slug":"magpie/b"}]}`), 0o644) + answer = provider.CodexDaemonCheck{Behind: true, Restarted: true, Since: then} + n := codex(home).Notice() + if time.Since(changed) > time.Minute { + t.Fatalf("checked against %v, not magpie's change", changed) + } + if !strings.Contains(n, "magpie restarted") { + t.Fatalf("restarted: %q", n) + } + // a session on it: the user is told where to restart it + answer = provider.CodexDaemonCheck{Behind: true, Attached: 1, Since: then} + if n := codex(home).Notice(); !strings.Contains(n, "Agents page") || !strings.Contains(n, provider.CodexDaemonRestart) { + t.Fatalf("session attached: %q", n) + } + // as last checked, for the row on Windows + if got := codexDaemonBehind(); len(got) != 1 || got[0].Kind != "daemon" || !got[0].Since.Equal(then) { + t.Fatalf("behind: %+v", got) + } + CodexDaemonRestarted() + if got := codexDaemonBehind(); got != nil { + t.Fatalf("after a restart: %+v", got) + } +} diff --git a/internal/agent/connectinfo.go b/internal/agent/connectinfo.go index 97620d3e..14e35744 100644 --- a/internal/agent/connectinfo.go +++ b/internal/agent/connectinfo.go @@ -177,7 +177,8 @@ func (a *Agent) changedAt(files []string, reads func() string) time.Time { // Stale is how many copies of the agent are running that started before // magpie last changed what it reads at start: they still have the list -// they started with until reopened. Zero where it can't be told (Windows). +// they started with until reopened. On Windows only the Codex daemon is +// told. func (a *Agent) Stale() int { return len(a.StaleCopies()) } // StaleCopy is a copy of an agent still running on the list it started @@ -206,6 +207,11 @@ type StaleCopy struct { // files, and isn't counted. func (a *Agent) StaleCopies() []StaleCopy { pats, files, reads := a.startsWith() + // Windows lists its processes slowly and without their start: only + // the Codex daemon, as KeepCodexDaemonCurrent last found it + if a.ID == "codex" && a.WSL == "" && runtime.GOOS == "windows" { + return codexDaemonBehind() + } if len(pats) == 0 || a.WSL != "" || runtime.GOOS == "windows" { return nil } diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 8e4e4156..4e086ca4 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -3270,6 +3270,9 @@ body.window .row + .row.agent.conn > .who { min-height: 41px; } /* the line abov .ag-longest-say { color: var(--fg-2); } /* which copy is left on the old list: a sentence that wraps as one, under the warning's words */ .ag-vl.ag-stale-copy { display: block; padding-left: 14px; font-size: 12px; line-height: 1.6; color: var(--fg-2); } +/* Codex's daemon: restarted from its line, a button that sits in the sentence */ +.ag-stale-copy .ag-quiet { height: 20px; margin-left: 4px; padding: 0 6px; vertical-align: middle; } +.ag-stale-copy .ag-quiet.busy { opacity: .6; cursor: progress; } .ag-hint { font-size: 12px; color: var(--muted); } .ag-tier { width: 70px; flex: none; font-size: 12px; color: var(--fg-2); } .ag-vl .field.ag-pick { width: 220px; justify-content: flex-start; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index a0433485..37f85a34 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1318,6 +1318,29 @@ function desktopLongestLine() { return l; } +// daemonRestart: the button that restarts Codex's background app-server +// left on the old list, then shows the row as it is after +function daemonRestart() { + const go = el("button", "ag-quiet accent", t("Restart")); + go.type = "button"; + go.title = "codex app-server daemon restart"; + go.onclick = async () => { + go.disabled = true; + go.classList.add("busy"); + try { + await api("codex/daemon/restart", {}); + state = await api("state"); + renderAgents(); + status(t("Codex's background service restarted")); + } catch (e) { + go.disabled = false; + go.classList.remove("busy"); + status(e.message, "err"); + } + }; + return go; +} + // connectPanel: a connected agent's row, opened function connectPanel(a, { fields, fieldBtn }) { const box = el("div", "ag-exp"); @@ -1365,6 +1388,10 @@ function connectPanel(a, { fields, fieldBtn }) { const [pre, post] = t(how[0], { agent: a.name, when: ago(c.since), app: c.app || "" }).split("{cmd}"); const l = line(pre, ...(how[1] ? [code(how[1]), post || ""] : [])); l.classList.add("ag-stale-copy"); + // Codex's daemon, which magpie restarts itself while no codex + // session is on it: with one on, here, when the user says (open + // sessions of Codex 0.162 reconnect to the new one) + if (c.kind === "daemon" && a.id === "codex") l.append(daemonRestart()); parts.push(l); } } diff --git a/internal/gui/backend.go b/internal/gui/backend.go index d55fad1d..0e745927 100644 --- a/internal/gui/backend.go +++ b/internal/gui/backend.go @@ -40,6 +40,9 @@ func startBackend() (gw *gateway.Server) { gateway.Window = true // the routing this process serves is shown on its page gw = serveGateway() go watchGateway(backendCtx, gatewayWatch) + // Codex's background app-server, restarted when it has the list from + // before a change and no codex session is on it (luci) + go agent.KeepCodexDaemonCurrent(backendCtx) // Model lists are fetched, never compiled in: whatever the agents can see // comes from the models.dev catalog plus each vendor's own /models answer. // Keep both halves warm without making the user click anything. diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 6779f403..400ed7bb 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -1611,17 +1611,20 @@ func providerRoutes(mux *http.ServeMux, w Windows) { st.Moved = moved writeJSON(rw, st) }) - // Codex's background app-server, left on the account before a switch: - // restarting it (which ends the Codex sessions on it), or letting it be. + // Codex's background app-server, left on the account before a switch + // or on the model list before a change (the Agents row's Restart): + // restarting it (Codex 0.162's sessions on it reconnect, after a turn + // running, for up to a minute; an older Codex's end), or letting it be. mux.HandleFunc("POST /api/codex/daemon/{action}", func(rw http.ResponseWriter, r *http.Request) { switch r.PathValue("action") { case "restart": - ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() if err := provider.RestartCodexDaemon(ctx); err != nil { fail(rw, err) return } + agent.CodexDaemonRestarted() case "dismiss": provider.DismissCodexDaemon() default: diff --git a/internal/gui/tests/agent-stale-copies.test.cjs b/internal/gui/tests/agent-stale-copies.test.cjs index 5bff7976..a7cf9cee 100644 --- a/internal/gui/tests/agent-stale-copies.test.cjs +++ b/internal/gui/tests/agent-stale-copies.test.cjs @@ -138,3 +138,57 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", } } } + +// luci on Discord (Codex 0.162): every new codex showed Codex's own models +// after Codex was wired in, since the daemon they attach to had the list +// from before. magpie restarts it itself while no codex session is on it; +// with one on, the daemon's line has a Restart, which restarts it, and +// the row as it is after takes the line away. Only the daemon's line has +// it. Every language, both widths. +const restarted = { en: "Codex's background service restarted", zh: "Codex 的后台服务已重启", "zh-TW": "Codex 的後臺服務已重啟", ja: "Codex のバックグラウンドサービスを再起動しました", de: "Hintergrunddienst von Codex neu gestartet" }; +const restartSays = { en: "Restart", zh: "重启", "zh-TW": "重啟", ja: "再起動", de: "Neu starten" }; +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh", "zh-TW", "ja", "de"]) { + for (const width of [980, 440]) { + test(`${engine} ${lang} ${width}px: the daemon's line restarts it`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width, height: 800 } }); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + let agent = { ...codex, stale: 2, staleCopies: [{ kind: "app", since: days(3) }, { kind: "daemon", since: days(1) }] }; + const asked = []; + const fake = serve(lang); + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/codex/daemon/restart") { + asked.push(route.request().method()); + agent = { ...agent, stale: 1, staleCopies: [agent.staleCopies[0]] }; + return route.fulfill({ json: { providers: [], presets: [], excluded: [], gateway: { running: true, window: true } } }); + } + if (url.pathname === "/api/state") return route.fulfill({ json: { agents: [agent], profiles: [], settings: { lang, theme: "light" } } }); + return fake(route); + }); + await page.goto("http://magpie.test/?view=agents"); + await page.locator(`${row} .ag-conn`).waitFor(); + await page.locator(`${row} .ag-link`).click(); + const copies = page.locator(`${row} .ag-stale-copy`); + await copies.first().waitFor(); + assert.equal(await copies.nth(0).locator("button").count(), 0, "the app's line has no Restart"); + const go = copies.nth(1).locator("button"); + assert.equal((await go.textContent()).trim(), restartSays[lang]); + // in the line, inside the window + const box = await go.boundingBox(); + assert.ok(box.x + box.width <= width, "past the window"); + await go.click(); + await page.waitForFunction((r) => document.querySelectorAll(r + " .ag-stale-copy").length === 1, row); + assert.deepEqual(asked, ["POST"]); + assert.equal(await page.locator("#status").textContent(), restarted[lang]); + assert.equal(await page.locator(`${row} .ag-stale-copy button`).count(), 0); + if (process.env.ARTIFACT_DIR) await page.locator(row).screenshot({ path: path.join(process.env.ARTIFACT_DIR, `daemon-restart-${engine}-${lang}-${width}.png`) }); + assert.deepEqual(errors, []); + }); + } + } +} diff --git a/internal/provider/codex_daemon.go b/internal/provider/codex_daemon.go index e8916350..72d47b7d 100644 --- a/internal/provider/codex_daemon.go +++ b/internal/provider/codex_daemon.go @@ -219,10 +219,21 @@ func DismissCodexDaemon() { // RestartCodexDaemon has Codex restart its app-server, which reads the // sign-in again: `codex app-server daemon restart`, run with the codex CLI -// magpie finds and the Codex home it signs Codex in through. The Codex -// sessions attached to it are ended. +// magpie finds and the Codex home it signs Codex in through. Codex 0.162's +// sessions attached to it reconnect to the new one, a turn running is let +// finish first (for up to a minute); an older Codex's are ended. func RestartCodexDaemon(ctx context.Context) error { - cmd, err := codexDaemonCommand(ctx, "restart") + codexRestarting.Lock() + defer codexRestarting.Unlock() + if err := restartCodexDaemon(ctx, codexHome()); err != nil { + return err + } + DismissCodexDaemon() + return nil +} + +func restartCodexDaemon(ctx context.Context, home string) error { + cmd, err := codexDaemonCommand(ctx, home, "restart") if err != nil { return err } @@ -232,21 +243,163 @@ func RestartCodexDaemon(ctx context.Context) error { } return errors.New("codex app-server daemon restart: " + err.Error()) } - DismissCodexDaemon() return nil } // codexDaemonCommand is `codex app-server daemon `, with CODEX_HOME // set to the home magpie switches, and nothing to read on stdin (Codex // waits on a stdin left open). -func codexDaemonCommand(ctx context.Context, verb string) (*exec.Cmd, error) { +func codexDaemonCommand(ctx context.Context, home, verb string) (*exec.Cmd, error) { exe := codexExecutable() if exe == "" { return nil, errors.New("the codex CLI isn't installed, or magpie can't find it") } cmd := proc.CommandContext(ctx, exe, "app-server", "daemon", verb) env := slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "CODEX_HOME=") }) - cmd.Env = append(env, "CODEX_HOME="+codexHome()) + cmd.Env = append(env, "CODEX_HOME="+home) cmd.Stdin = nil // os/exec reads it from the null device return cmd, nil } + +// The daemon also builds Codex's model list once, as it starts, from +// config.toml and the catalog it names (codex-rs app-server's models +// manager): a codex session started after magpie wires Codex in, or takes +// it out, attaches to the daemon left running and shows the list from +// before — Codex's own models, or magpie's — until it restarts (luci on +// Discord, Codex 0.162). It runs per CODEX_HOME, not per terminal, and +// has no idle exit. Restarting it with no codex session attached ends +// nothing, so magpie does that itself (KeepCodexDaemonCurrent); with one +// attached, the Agents page offers it. + +// A CodexDaemonCheck is what KeepCodexDaemonCurrent found and did. +type CodexDaemonCheck struct { + // Behind: a daemon runs that started before what it reads changed + Behind bool + // Since is when that daemon started + Since time.Time + // Attached is how many codex sessions may be on it, which magpie + // doesn't restart it under + Attached int + // Restarted: magpie restarted it, and new sessions get the new list + Restarted bool + Err error +} + +// codexRestarting keeps one restart of the daemon at a time. +var codexRestarting sync.Mutex + +// KeepCodexDaemonCurrent restarts the managed app-server of the Codex home +// home when it started before changed (when magpie last changed what Codex +// reads at start) and no codex session is attached to it. One that can't +// be found or timed, or a restart already under way, is left as it is. +func KeepCodexDaemonCurrent(ctx context.Context, home string, changed time.Time) CodexDaemonCheck { + var c CodexDaemonCheck + if changed.IsZero() { + return c + } + ps, err := listProcesses(ctx) + if err != nil { + return c + } + pid := findCodexDaemon(ps, home) + if pid == 0 { + return c + } + since, ok := daemonStarted(home, pid) + // a second's slack: the pid file and the config are written by + // different programs, whose clocks round differently + if !ok || !since.Before(changed.Add(-time.Second)) { + return c + } + c.Behind, c.Since, c.Attached = true, since, codexClients(ps, home) + if c.Attached > 0 || !codexRestarting.TryLock() { + return c + } + defer codexRestarting.Unlock() + if c.Err = restartCodexDaemon(ctx, home); c.Err == nil { + c.Restarted = true + } + return c +} + +// daemonStarted is when the daemon pid started: the time Codex wrote its +// pid file, which it does once, as the daemon comes up, on every OS. ok is +// false when no pid file of home's names pid. +func daemonStarted(home string, pid int) (time.Time, bool) { + for _, name := range []string{"app-server.pid", "daemon.pid"} { + p := filepath.Join(home, "app-server-daemon", name) + b, err := os.ReadFile(p) + if err != nil { + continue + } + if id, ok := parsePIDFile(b); !ok || id != pid { + continue + } + if st, err := os.Stat(p); err == nil { + return st.ModTime(), true + } + } + return time.Time{}, false +} + +// codexClients counts the codex sessions that may be attached to home's +// daemon: codex run in a terminal (its TUI, exec, resume, an app-server +// proxy); not the daemon, its helpers and the commands that drive it, nor +// an app's or an editor's codex, which serve themselves. One started for +// another CODEX_HOME is left out where that can be read. +func codexClients(ps []proc.Process, home string) int { + n := 0 + for _, p := range ps { + if !isCodexClient(p.Args) { + continue + } + if h, ok := processCodexHome(p.PID); ok && h != "" && filepath.Clean(h) != filepath.Clean(home) { + continue + } + n++ + } + return n +} + +// isCodexClient reports whether a command line is a codex session that +// may attach to the daemon. +func isCodexClient(args string) bool { + exe, rest := splitExe(args) + base := strings.ToLower(exe[strings.LastIndexAny(exe, `/\`)+1:]) + if base != "codex" && base != "codex.exe" { + return false + } + // an app's (ChatGPT.app's CodexCLI.app) or an editor extension's; ps + // leaves a path's spaces unquoted, so "…/Codex Computer Use.app/ + // Contents/MacOS/…" reads as a program named Codex: the whole line + // is looked at + if strings.Contains(args, ".app/Contents/") || strings.Contains(exe, "/extensions/") || strings.Contains(exe, `\extensions\`) { + return false + } + f := strings.Fields(rest) + if slices.Contains(f, "exec-server") || slices.Contains(f, "mcp-server") { + return false + } + if i := slices.Index(f, "app-server"); i >= 0 { + // the daemon, its helpers, `daemon restart` and other apps' + // app-servers serve; a proxy is a client of the daemon + return i+1 < len(f) && f[i+1] == "proxy" + } + return true +} + +// splitExe parts a command line into the program and its arguments, the +// program quoted as Windows lists it (`"C:\Program Files\…\codex.exe" …`). +func splitExe(args string) (exe, rest string) { + args = strings.TrimSpace(args) + if q, ok := strings.CutPrefix(args, `"`); ok { + if exe, rest, ok = strings.Cut(q, `"`); ok { + return exe, rest + } + } + exe, rest, _ = strings.Cut(args, " ") + return exe, rest +} + +// CodexHome is the Codex home magpie wires and signs Codex in through. +func CodexHome() string { return codexHome() } diff --git a/internal/provider/codex_daemon_test.go b/internal/provider/codex_daemon_test.go index e200839b..990417d1 100644 --- a/internal/provider/codex_daemon_test.go +++ b/internal/provider/codex_daemon_test.go @@ -259,3 +259,141 @@ func TestSwitchLoginNotesCodexDaemon(t *testing.T) { t.Fatalf("got %q, want work@example.com", got) } } + +func TestIsCodexClient(t *testing.T) { + for args, want := range map[string]bool{ + // the TUI as ps lists it (argv[0] alone), and run by path + "codex": true, + "/opt/homebrew/bin/codex -m gpt-6": true, + "codex exec hello": true, + "codex resume --last": true, + "codex app-server proxy": true, + `"C:\Program Files\Codex\codex.exe"`: true, + `C:\Users\me\AppData\Roaming\npm\node_modules\@openai\codex\vendor\x86_64-pc-windows-msvc\codex\codex.exe`: true, + // the daemon, its helpers, the commands that drive it + "/Users/me/.codex/packages/app-server-daemon/releases/0.162.0-aarch64-apple-darwin/bin/codex app-server --listen unix:///x --managed-daemon": false, + "codex app-server daemon pid-update-loop": false, + "codex app-server daemon restart": false, + // apps' and editors' own, which don't use the daemon + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server": false, + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex exec-server --remote x": false, + "/Users/me/.vscode/extensions/openai.chatgpt-1.0/bin/darwin-aarch64/codex app-server": false, + "codex mcp-server": false, + `"C:\Program Files\WindowsApps\OpenAI.Codex_1.0\app\resources\codex.exe" app-server`: false, + // other programs + "/Users/me/.codex/plugins/cache/chrome/extension-host ChatGPT for Chrome": false, + // a path with a space, as the user's own ps showed it + "/Users/me/.codex/computer-use/Codex Computer Use.app/Contents/MacOS/SkyComputerUseService": false, + "codex-code-mode-host": false, + "node /x/codex.js": false, + "": false, + } { + if got := isCodexClient(args); got != want { + t.Errorf("isCodexClient(%q) = %v, want %v", args, got, want) + } + } +} + +// writeDaemonPID writes the pid file Codex keeps for its daemon, dated at. +func writeDaemonPID(t *testing.T, home, body string, at time.Time) string { + t.Helper() + p := filepath.Join(home, "app-server-daemon", "daemon.pid") + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, at, at); err != nil { + t.Fatal(err) + } + return p +} + +// luci (Discord, Codex 0.162): the daemon codex sessions attach to was +// started on Codex's own models; wired to magpie after, every new codex +// showed the official list. magpie restarts a daemon started before the +// change while no codex session is on it, and only then. +func TestKeepCodexDaemonCurrent(t *testing.T) { + pid := fakeDaemon(t) + out := fakeCodex(t, 0) + home, _ := os.UserHomeDir() + home = filepath.Join(home, ".codex") + clients := 0 + listProcesses = func(context.Context) ([]proc.Process, error) { + if *pid == 0 { + return nil, nil + } + ps := []proc.Process{{PID: *pid, Args: home + "/packages/app-server-daemon/releases/0.162.0/bin/codex app-server --listen unix:///x --managed-daemon"}} + for i := range clients { + ps = append(ps, proc.Process{PID: 100 + i, Args: "codex"}) + } + return ps, nil + } + ran := func() bool { + b, err := os.ReadFile(out + ".args") + os.Remove(out + ".args") + return err == nil && strings.TrimSpace(string(b)) == "app-server daemon restart" + } + ctx := context.Background() + started := time.Now().Add(-time.Hour).Truncate(time.Second) + *pid = 50 + pidFile := writeDaemonPID(t, home, `{"pid":50,"processStartTime":"x"}`, started) + + // started before the change, no session on it: restarted, in home + c := KeepCodexDaemonCurrent(ctx, home, time.Now()) + if !c.Behind || !c.Restarted || c.Err != nil || !ran() { + t.Fatalf("idle daemon behind: %+v", c) + } + if b, _ := os.ReadFile(out + ".home"); strings.TrimSpace(string(b)) != home { + t.Errorf("CODEX_HOME %q, want %q", b, home) + } + // a codex session on it: said, left to the user + clients = 1 + c = KeepCodexDaemonCurrent(ctx, home, time.Now()) + if !c.Behind || c.Restarted || c.Attached != 1 || ran() { + t.Fatalf("session attached: %+v", c) + } + if !c.Since.Equal(started) { + t.Errorf("since %v, want %v", c.Since, started) + } + clients = 0 + // started after the change: it has the list + if c := KeepCodexDaemonCurrent(ctx, home, started.Add(-time.Minute)); c.Behind || ran() { + t.Fatalf("daemon newer than the change: %+v", c) + } + // nothing changed yet + if c := KeepCodexDaemonCurrent(ctx, home, time.Time{}); c.Behind || ran() { + t.Fatalf("no change: %+v", c) + } + // a pid file naming another process: when it started isn't known + writeDaemonPID(t, home, `{"pid":51}`, started) + if c := KeepCodexDaemonCurrent(ctx, home, time.Now()); c.Behind || ran() { + t.Fatalf("pid file of another: %+v", c) + } + // no pid file: the same + os.Remove(pidFile) + if c := KeepCodexDaemonCurrent(ctx, home, time.Now()); c.Behind || ran() { + t.Fatalf("no pid file: %+v", c) + } + // no daemon + writeDaemonPID(t, home, "50", started) + *pid = 0 + if c := KeepCodexDaemonCurrent(ctx, home, time.Now()); c.Behind || ran() { + t.Fatalf("no daemon: %+v", c) + } +} + +// A failed restart is said, not taken for one done. +func TestKeepCodexDaemonCurrentFails(t *testing.T) { + pid := fakeDaemon(t) + fakeCodex(t, 2) + home, _ := os.UserHomeDir() + home = filepath.Join(home, ".codex") + *pid = 50 + writeDaemonPID(t, home, "50", time.Now().Add(-time.Hour)) + c := KeepCodexDaemonCurrent(context.Background(), home, time.Now()) + if !c.Behind || c.Restarted || c.Err == nil { + t.Fatalf("%+v", c) + } +} diff --git a/providers_cli.go b/providers_cli.go index 78c6ddf0..cc4e5863 100644 --- a/providers_cli.go +++ b/providers_cli.go @@ -1009,6 +1009,9 @@ func serve() error { s := gateway.New() go stats.Run(version, "serve") go catalog.KeepFresh() // new models' prices, in a gateway left running + // Codex's background app-server, restarted when it has the list from + // before a change and no codex session is on it + go agent.KeepCodexDaemonCurrent(context.Background()) public := advertisedURL() fmt.Println(green.Render("●"), "magpie gateway on", bold.Render(gateway.URL())) fmt.Println(muted.Render(" OpenAI "), public+"/v1/chat/completions", muted.Render("·"), public+"/v1/responses")