agents: a Codex left on the old list is named, with how to reopen it — the Codex app (⌘Q: closing its window keeps it running on macOS), an editor's Codex, the CLI's background app-server, a terminal's codex (miaopasi on Discord: "已接入 · 重开 Codex 后生效" stayed after restarting Codex)

Agent.Stale counted every process matching (^|/)codex( |$) that started
before magpie's last change, and the row only said "reopen Codex". Several
of those don't end when Codex is "reopened": ChatGPT.app's main process
registers window-all-closed so a packaged app on darwin doesn't quit
(read from /Applications/ChatGPT.app/Contents/Resources/app.asar), so
closing the window leaves its app-server (CodexCLI.app/.../codex
app-server) running; an editor extension's codex app-server, the CLI's
--managed-daemon (ppid 1) and a codex in another terminal run on through
a reopened app. One app also counted twice: its app-server and its
`codex exec-server --remote` both match (this Mac's ps).

Agent.StaleCopies gives each copy with its kind (app / ide / daemon /
cli, from the command line) and start, one app's processes as one copy;
Stale is its length. /api/state carries staleCopies, and the opened row
under "N running Codex still have the old list" says, per copy, what it is,
when it started and how to end it: ⌘Q for the app, the editor's window
reload, `codex app-server daemon restart` for the daemon, quit for a
terminal's. Strings in zh, zh-TW, ja, de.

TestCodexStaleCopiesSayWhichIsLeft (an app with its exec-server, VS
Code's Codex, this home's daemon, an old and a new terminal codex: app,
ide, daemon, cli; Stale 4) fails on the old logic (5 copies, no kinds).
agent-stale-copies.test.cjs (Chromium and WebKit, en/zh/zh-TW/ja/de,
980 and 440px) fails on the old app.js (no such lines).

Run: go vet -tags nogui (darwin, GOOS=linux, GOOS=windows) ./internal/agent
./internal/gui; go test -tags nogui ./internal/agent ./internal/gui (temp
HOME); node --test internal/gui/tests/agent-*.test.cjs gui-ja gui-de
gui-zh-tw i18n-keys codex-model-order: 202 pass, both engines. Not tried
with the reporter's Codex: which copy is left on their Mac isn't known.
This commit is contained in:
yetone committed 2026-10-08 10:40:45 +08:00
1 parent 40c5b92a93
commit 2f74a963d7
7 files changed
+249 -8

No files matched your search

+56
View File
@@ -3,6 +3,7 @@ package agent
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -64,3 +65,58 @@ func TestCodexStaleOnlyForMagpiesChange(t *testing.T) {
t.Fatalf("asked again: %d stale, want 1", n)
}
}
// miaopasi on Discord: the Codex row stayed "已接入 · 重开 Codex 后生效"
// after Codex was restarted. On macOS closing the Codex app's window
// leaves the app running, its app-server with it; an editor's Codex and
// the CLI's managed daemon run on through a reopened app too. Each copy
// left is told apart, so the row can say which one and how it is reopened,
// and one app's processes are one copy, not one per process.
func TestCodexStaleCopiesSayWhichIsLeft(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("CODEX_HOME", "")
dir := filepath.Join(home, ".codex")
os.MkdirAll(dir, 0o755)
cfg := filepath.Join(dir, "config.toml")
os.WriteFile(cfg, []byte("model = \"gpt-6.1-sol\"\nopenai_base_url = \"http://127.0.0.1:3425/backend-api/codex\"\n"), 0o600)
then := time.Now().Add(-2 * time.Hour)
os.Chtimes(cfg, then, then)
was := running
t.Cleanup(func() { running = was })
app := "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"
running = func(string) []runningProc {
return []runningProc{
// the Codex app, its window closed and opened again: the app
// and its processes from three days ago
{72 * time.Hour, app + " -c features.code_mode_host=true app-server --analytics-default-enabled"},
{72*time.Hour - 10*time.Second, app + " exec-server --remote https://codex-cloud-environments.chatgpt.com/api"},
// VS Code's Codex
{5 * time.Hour, home + "/.vscode/extensions/openai.chatgpt-26.1003.0-darwin-arm64/bin/macos-aarch64/codex app-server --analytics-default-enabled"},
// the CLI's daemon for this home
{4 * time.Hour, dir + "/packages/app-server-daemon/releases/0.160.0-aarch64-apple-darwin/bin/codex app-server --listen unix:// --managed-daemon"},
// a codex in a terminal from before, and one opened since
{3 * time.Hour, "/opt/homebrew/bin/codex"},
{time.Minute, "/opt/homebrew/bin/codex"},
}
}
a := codex(home)
got := a.StaleCopies()
var kinds []string
for _, c := range got {
kinds = append(kinds, c.Kind)
}
if want := []string{"app", "ide", "daemon", "cli"}; strings.Join(kinds, ",") != strings.Join(want, ",") {
t.Fatalf("copies: %v, want %v", kinds, want)
}
if d := time.Since(got[0].Since); d < 72*time.Hour-time.Minute || d > 72*time.Hour+time.Minute {
t.Fatalf("the app's copy started %v ago, want the app-server's 72h", d)
}
if n := a.Stale(); n != 4 {
t.Fatalf("Stale: %d, want 4 (one app, not one per process)", n)
}
}
+68 -7
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
@@ -165,32 +166,92 @@ 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).
func (a *Agent) Stale() int { return len(a.StaleCopies()) }
// StaleCopy is a copy of an agent still running on the list it started
// with: what kind of copy it is, which says how it is reopened, and when
// it started.
type StaleCopy struct {
// Kind, for Codex: "app" (the ChatGPT or Codex desktop app, its
// app-server and helpers), "ide" (an editor extension's), "daemon"
// (the background app-server the CLI leaves running) or "cli"; ""
// for other agents
Kind string `json:"kind,omitempty"`
Since time.Time `json:"since"`
}
// StaleCopies are the copies Stale counts. Reopening one kind doesn't end
// another: on macOS the Codex app keeps running, its app-server with it,
// when its window is closed (its window-all-closed doesn't quit a packaged
// app on darwin), an editor's Codex runs until the editor's window is
// reloaded, and the CLI's managed daemon runs on with ppid 1 until it is
// restarted itself. So each is told apart for the row to say which one is
// left. One app's processes (its app-server, its exec-server) are one copy.
// One run from another home's agent dir (an app-server daemon Codex left
// running for a CODEX_HOME of its own, under its packages) reads other
// files, and isn't counted.
func (a *Agent) Stale() int {
func (a *Agent) StaleCopies() []StaleCopy {
pats, files, reads := a.startsWith()
if len(pats) == 0 || a.WSL != "" || runtime.GOOS == "windows" {
return 0
return nil
}
changed := a.changedAt(files, reads)
if changed.IsZero() {
return 0
return nil
}
dir := filepath.Dir(a.Path)
others := "/" + filepath.Base(dir) + "/"
n := 0
var out []StaleCopy
apps := map[string]int{}
for _, pat := range pats {
for _, p := range running(pat) {
if strings.Contains(p.cmd, others) && !strings.Contains(p.cmd, dir+"/") {
continue
}
if time.Now().Add(-p.up).Before(changed.Add(-time.Second)) {
n++
since := time.Now().Add(-p.up)
if !since.Before(changed.Add(-time.Second)) {
continue
}
c := StaleCopy{Since: since}
if a.ID == "codex" {
var app string
c.Kind, app = codexCopyKind(p.cmd)
if app != "" {
if i, ok := apps[app]; ok {
if since.Before(out[i].Since) {
out[i].Since = since
}
continue
}
apps[app] = len(out)
}
}
out = append(out, c)
}
}
return n
return out
}
// codexCopyKind tells what runs a Codex process from its command line, and
// for one of a desktop app the app's bundle.
func codexCopyKind(cmd string) (kind, app string) {
exe := cmd
if i := strings.Index(cmd, " -"); i > 0 {
exe = cmd[:i]
}
switch {
case slices.ContainsFunc(strings.Fields(cmd), func(f string) bool {
return f == "--managed-daemon" || strings.HasPrefix(f, "--managed-daemon=")
}):
return "daemon", ""
case strings.Contains(exe, "/extensions/"):
return "ide", ""
case strings.Contains(exe, ".app/Contents/"):
// the outermost bundle: ChatGPT.app's codex runs from a
// CodexCLI.app inside it
return "app", exe[:strings.Index(exe, ".app/")+len(".app")]
}
return "cli", ""
}
// elapsed reads ps's etime, [[dd-]hh:]mm:ss.
+5 -1
View File
@@ -196,6 +196,9 @@ type agentJSON struct {
// started with (agent.Stale), for the line under its name
Source string `json:"source,omitempty"`
Stale int `json:"stale,omitempty"`
// StaleCopies: those copies, each with what it is and when it
// started, for the opened row to say which is left to reopen and how
StaleCopies []agent.StaleCopy `json:"staleCopies,omitempty"`
// Joined: connected with its own models still in its list (Codex
// signed in with ChatGPT, agent.Agent.Join)
Joined bool `json:"joined,omitempty"`
@@ -1690,7 +1693,8 @@ func state() stateJSON {
aj.Drift = a.Drift()
aj.Wired = a.Wired()
if aj.Wired {
aj.Stale = a.Stale()
aj.StaleCopies = a.StaleCopies()
aj.Stale = len(aj.StaleCopies)
aj.Joined = a.Joined != nil && a.Joined()
} else {
aj.Source = a.Source()
+2
View File
@@ -3187,6 +3187,8 @@ body.window .row + .row.agent.conn > .who { min-height: 41px; } /* the line abov
:where(html[data-fonts]) .ag-code, :where(html[data-fonts]) .ag-diff code, :where(html[data-fonts]) .ag-path { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.ag-code { padding: 1px 5px; border-radius: 4px; background: var(--pill); color: var(--fg); }
.ag-warn { gap: 8px; font-size: 12px; }
/* 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); }
.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; }
+20
View File
@@ -999,6 +999,15 @@ function newModels(a) {
}
const staleNow = (a) => a.wired && a.stale > 0 && staleSeen[a.id] !== a.stale;
// a running copy left on the old list (agent.StaleCopy's kind): what it
// is, since when, and how it is reopened — the words, and the command or
// key in them as code
const STALE_HOW = {
app: ["The {agent} app, started {when}: closing its window keeps it running. Quit it with {cmd} and open it again.", "⌘Q"],
ide: ["{agent} in an editor (VS Code, Cursor…), started {when}: reload that editor's window."],
daemon: ["{agent}'s background app-server, started {when}: restart it with {cmd}.", "codex app-server daemon restart"],
cli: ["A {agent} in a terminal, started {when}: quit it and start it again."],
};
// connectLine: the dot and the words under an agent's name
function connectLine(a, kind) {
@@ -1263,6 +1272,17 @@ function connectPanel(a, { fields, fieldBtn }) {
later.onclick = () => { staleSeen[a.id] = a.stale; renderAgents(); };
w.append(later);
parts.push(w);
// which copy is left, and how it is reopened: a reopen of one kind
// doesn't end another (the Codex app keeps running on macOS when its
// window is closed; an editor's Codex and the CLI's daemon run on)
for (const c of a.staleCopies || []) {
const how = STALE_HOW[c.kind];
if (!how) continue;
const [pre, post] = t(how[0], { agent: a.name, when: ago(c.since) }).split("{cmd}");
const l = line(pre, ...(how[1] ? [code(how[1]), post || ""] : []));
l.classList.add("ag-stale-copy");
parts.push(l);
}
}
kv(t("In {agent}", { agent: a.name }), ...parts);
} else if (menuFromList(a)) {
+16
View File
@@ -395,6 +395,10 @@ const I18N = {
"Pick any model in {agent}'s {cmd}.": "在 {agent} 的 {cmd} 里选任何一个模型。",
"1 running {agent} still has the old list": "1 个正在运行的 {agent} 还是旧列表",
"{n} running {agent} still have the old list": "{n} 个正在运行的 {agent} 还是旧列表",
"The {agent} app, started {when}: closing its window keeps it running. Quit it with {cmd} and open it again.": "{when}启动的 {agent} 应用:关掉窗口它仍在运行。用 {cmd} 退出后再打开。",
"{agent} in an editor (VS Code, Cursor…), started {when}: reload that editor's window.": "{when}启动的编辑器(VS Code、Cursor…)里的 {agent}:重新加载那个编辑器的窗口。",
"{agent}'s background app-server, started {when}: restart it with {cmd}.": "{when}启动的 {agent} 后台 app-server:用 {cmd} 重启它。",
"A {agent} in a terminal, started {when}: quit it and start it again.": "{when}在终端里启动的 {agent}:退出后重新启动。",
"Got it": "知道了",
"In {agent}": "在 {agent} 里",
"also for small background tasks": "也用于后台小任务",
@@ -4123,6 +4127,10 @@ const I18N = {
"Pick any model in {agent}'s {cmd}.": "在 {agent} 的 {cmd} 裡選任何一個模型。",
"1 running {agent} still has the old list": "1 個正在執行的 {agent} 還是舊列表",
"{n} running {agent} still have the old list": "{n} 個正在執行的 {agent} 還是舊列表",
"The {agent} app, started {when}: closing its window keeps it running. Quit it with {cmd} and open it again.": "{when}啟動的 {agent} 應用:關掉視窗它仍在執行。用 {cmd} 結束後再打開。",
"{agent} in an editor (VS Code, Cursor…), started {when}: reload that editor's window.": "{when}啟動的編輯器(VS Code、Cursor…)裡的 {agent}:重新載入那個編輯器的視窗。",
"{agent}'s background app-server, started {when}: restart it with {cmd}.": "{when}啟動的 {agent} 背景 app-server:用 {cmd} 重新啟動它。",
"A {agent} in a terminal, started {when}: quit it and start it again.": "{when}在終端機裡啟動的 {agent}:結束後重新啟動。",
"Got it": "知道了",
"In {agent}": "在 {agent} 裡",
"also for small background tasks": "也用於後臺小任務",
@@ -7816,6 +7824,10 @@ const I18N = {
"Pick any model in {agent}'s {cmd}.": "{agent} の {cmd} で任意のモデルを選んでください。",
"1 running {agent} still has the old list": "実行中の {agent} 1 件はまだ古い一覧のままです",
"{n} running {agent} still have the old list": "実行中の {agent} {n} 件はまだ古い一覧のままです",
"The {agent} app, started {when}: closing its window keeps it running. Quit it with {cmd} and open it again.": "{when}に起動した {agent} アプリ:ウィンドウを閉じても動き続けます。{cmd} で終了してから開き直してください。",
"{agent} in an editor (VS Code, Cursor…), started {when}: reload that editor's window.": "{when}に起動したエディタ(VS Code、Cursor…)の {agent}:そのエディタのウィンドウを再読み込みしてください。",
"{agent}'s background app-server, started {when}: restart it with {cmd}.": "{when}に起動した {agent} のバックグラウンド app-server:{cmd} で再起動してください。",
"A {agent} in a terminal, started {when}: quit it and start it again.": "{when}にターミナルで起動した {agent}:終了して起動し直してください。",
"Got it": "了解",
"In {agent}": "{agent} 内",
"also for small background tasks": "小さなバックグラウンドタスクにも使用",
@@ -11527,6 +11539,10 @@ const I18N = {
"Pick any model in {agent}'s {cmd}.": "Ein beliebiges Modell in {cmd} von {agent} wählen.",
"1 running {agent} still has the old list": "1 laufende {agent}-Instanz hat noch die alte Liste",
"{n} running {agent} still have the old list": "{n} laufende {agent}-Instanzen haben noch die alte Liste",
"The {agent} app, started {when}: closing its window keeps it running. Quit it with {cmd} and open it again.": "Die {agent}-App, gestartet {when}: Wenn ihr Fenster geschlossen wird, läuft sie weiter. Mit {cmd} beenden und wieder öffnen.",
"{agent} in an editor (VS Code, Cursor…), started {when}: reload that editor's window.": "{agent} in einem Editor (VS Code, Cursor…), gestartet {when}: das Fenster dieses Editors neu laden.",
"{agent}'s background app-server, started {when}: restart it with {cmd}.": "Der Hintergrund-App-Server von {agent}, gestartet {when}: mit {cmd} neu starten.",
"A {agent} in a terminal, started {when}: quit it and start it again.": "Ein {agent} im Terminal, gestartet {when}: beenden und neu starten.",
"Got it": "Verstanden",
"In {agent}": "In {agent}",
"also for small background tasks": "auch für kleine Hintergrundaufgaben",
@@ -0,0 +1,82 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// miaopasi on Discord: the Codex row stayed "已接入 · 重开 Codex 后生效"
// after Codex was restarted. On macOS closing the Codex app's window keeps
// the app running, and an editor's Codex or the CLI's daemon runs on
// through a reopened app. Opened, the row says which copy is left on the
// old list, since when, and how that one is reopened: ⌘Q for the app, the
// daemon's restart command. Every language, wide and narrow; "Got it"
// takes it away. No backend, the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const days = (n) => new Date(Date.now() - n * 86400e3).toISOString();
const codex = {
id: "codex", name: "Codex", icon: "generic", path: "/fixture/codex", wired: true,
fields: [{ key: "model", label: "model", value: "gpt-6.1-sol", options: [{ value: "gpt-6.1-sol", label: "GPT-6.1 Sol", ref: "group/auto-gpt-6-1-sol", note: "Group · via magpie" }] }],
stale: 2, staleCopies: [{ kind: "app", since: days(3) }, { kind: "daemon", since: days(1) }],
};
function serve(lang) {
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [codex], profiles: [], settings: { lang, theme: "light" } });
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const row = '.row.agent[data-id="codex"]';
// as each engine's Intl says "3 days ago"
const ago3 = { en: /3 days ago/, zh: /3 ?天前/, "zh-TW": /3 ?天前/, ja: /3 ?日前/, de: /vor 3 Tagen/ };
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 opened row says which Codex is left and how to reopen 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));
await page.route("**/*", serve(lang));
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.count(), 2);
const [app, daemon] = await copies.allTextContents();
assert.equal(await copies.nth(0).locator("code").textContent(), "⌘Q");
assert.equal(await copies.nth(1).locator("code").textContent(), "codex app-server daemon restart");
assert.match(app, ago3[lang], "the app's start");
for (const s of [app, daemon]) {
assert.ok(s.includes("Codex"), s);
assert.doesNotMatch(s, /\{\w+\}/, "a placeholder left: " + s);
}
if (lang !== "en") assert.doesNotMatch(app, /closing its window/, "not translated: " + app);
// nothing runs past the row
const over = await page.evaluate((r) => [...document.querySelectorAll(r + " .ag-stale-copy, " + r + " .ag-stale-copy *")].filter((e) => e.scrollWidth > e.clientWidth + 1 || e.getBoundingClientRect().right > document.documentElement.clientWidth).map((e) => e.className || e.tagName), row);
assert.deepEqual(over, []);
// told: the lines go with the warning
await page.locator(`${row} .ag-warn button`).click();
await page.waitForFunction((r) => !document.querySelector(r + " .ag-stale-copy"), row);
assert.deepEqual(errors, []);
});
}
}
}