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.
This commit is contained in:
yetone committed 2026-10-10 10:05:10 +08:00
1 parent bb3b3f1486
commit e611fc65d6
12 files changed
+596 -14

No files matched your search

+8 -2
View File
@@ -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 ""
},
+122
View File
@@ -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}}
}
+66 -2
View File
@@ -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)
}
}
+7 -1
View File
@@ -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
}
+3
View File
@@ -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; }
+27
View File
@@ -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);
}
}
+3
View File
@@ -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.
+6 -3
View File
@@ -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:
@@ -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, []);
});
}
}
}
+159 -6
View File
@@ -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 <verb>`, 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() }
+138
View File
@@ -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)
}
}
+3
View File
@@ -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")