diff --git a/internal/agent/connect_stale_test.go b/internal/agent/connect_stale_test.go index a6134e73..a39cb550 100644 --- a/internal/agent/connect_stale_test.go +++ b/internal/agent/connect_stale_test.go @@ -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) + } +} diff --git a/internal/agent/connectinfo.go b/internal/agent/connectinfo.go index b4de49f6..948e639b 100644 --- a/internal/agent/connectinfo.go +++ b/internal/agent/connectinfo.go @@ -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. diff --git a/internal/gui/api.go b/internal/gui/api.go index 601521c6..ee257a80 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -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() diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 36efe503..2fb25119 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index d3635ae2..1ccaa9d1 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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)) { diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 60cd66c4..2147493c 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/tests/agent-stale-copies.test.cjs b/internal/gui/tests/agent-stale-copies.test.cjs new file mode 100644 index 00000000..524ce625 --- /dev/null +++ b/internal/gui/tests/agent-stale-copies.test.cjs @@ -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, []); + }); + } + } +}