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")