diff --git a/changelog.d/codex-fresh-terminal-greeting.md b/changelog.d/codex-fresh-terminal-greeting.md new file mode 100644 index 0000000000..ab7ae3463e --- /dev/null +++ b/changelog.d/codex-fresh-terminal-greeting.md @@ -0,0 +1,5 @@ +### Fixed +- Chat greets a Codex started by typing `codex` in a terminal, before its first message, as it + does one started from the app, instead of saying the conversation is unavailable. It does so only + when the screen shows Codex's start screen with nothing said, no `resume` or `fork` started + it, and no conversation of that folder was begun or written since it started. diff --git a/server/codex.ts b/server/codex.ts index 99d1531713..f0f63b8a08 100644 --- a/server/codex.ts +++ b/server/codex.ts @@ -15,6 +15,7 @@ import type { TranscriptParser } from "./transcript-records.ts"; import type { AgentInfo } from "../shared/herdr-api.generated.ts"; import { agentGet, paneProcessInfo, paneRead, sessionSnapshot } from "./herdr/client.ts"; import { resolveAgent } from "./pane-context.ts"; +import { codexStartScreenUnused } from "./start-screen.ts"; type RecordValue = Record; const record = (value: unknown): RecordValue => value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {}; @@ -387,22 +388,58 @@ async function readProcessCodexHome(pid: number): Promise { if (process.platform === "linux") { home = (await readFile(`/proc/${pid}/environ`, "utf8")).split("\0").find((entry) => entry.startsWith("CODEX_HOME="))?.slice(11) || null; } else if (process.platform === "darwin") { - const child = Bun.spawn(["/bin/ps", "-E", "-ww", "-p", String(pid), "-o", "command="], { stdout: "pipe", stderr: "ignore" }); - const timer = setTimeout(() => child.kill(), 3000); - try { - // the environment follows the arguments, space-separated: the last match is the - // environment's, and its value runs to the next `NAME=` (a path may hold spaces). - // ponytail: a value holding ` NAME=` is cut there, and with none in the environment an - // argument spelled CODEX_HOME=/path would be taken. /proc on Linux has neither problem - const text = await new Response(child.stdout).text(); - await child.exited; - home = codexHomeInPsLine(text); - } finally { clearTimeout(timer); } + // the environment follows the arguments, space-separated: the last match is the + // environment's, and its value runs to the next `NAME=` (a path may hold spaces). + // ponytail: a value holding ` NAME=` is cut there, and with none in the environment an + // argument spelled CODEX_HOME=/path would be taken. /proc on Linux has neither problem + home = codexHomeInPsLine((await psEnvironmentLine(pid)) ?? ""); } } catch { home = null; } return home !== null && (await isCodexHomeDir(home)) ? home : null; } +/** A process's `ps -E` line on macOS: its arguments, then its environment when it is this user's. Null when ps fails. */ +async function psEnvironmentLine(pid: number): Promise { + const child = Bun.spawn(["/bin/ps", "-E", "-ww", "-p", String(pid), "-o", "command="], { stdout: "pipe", stderr: "ignore" }); + const timer = setTimeout(() => child.kill(), 3000); + try { + const text = await new Response(child.stdout).text(); + return (await child.exited) === 0 ? text : null; + } finally { clearTimeout(timer); } +} + +/** + * The store a process's Codex writes to, only as far as its environment tells it for certain + * (#1191): the CODEX_HOME directory it names, else .codex in the HOME it names, or null. Null + * covers an environment that cannot be read (another user's process, a gone one, Windows), a + * CODEX_HOME that is relative or no directory (Codex places one itself, while paneCodexHome falls + * back to the default store for it), no HOME or a relative one, and on macOS a command line that + * spells CODEX_HOME= or HOME= itself, which `ps -E` cannot tell from the environment. Never this + * server's own home: the pane's Codex may run with another HOME. + */ +async function certainProcessCodexHome(pid: number, argv: readonly string[]): Promise { + let named: string | null = null; + let home: string | null = null; + try { + if (process.platform === "linux") { + const environment = (await readFile(`/proc/${pid}/environ`, "utf8")).split("\0"); + named = environment.find((entry) => entry.startsWith("CODEX_HOME="))?.slice(11) ?? ""; + home = environment.find((entry) => entry.startsWith("HOME="))?.slice(5) ?? null; + } else if (process.platform === "darwin" && !argv.some((word) => word.includes("HOME="))) { + // ps draws another user's process without its environment, and then names no HOME either + const line = await psEnvironmentLine(pid); + if (line !== null) { + named = codexHomeInPsLine(line) ?? ""; + home = [...line.matchAll(/(?:^|\s)HOME=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|\s*$)/g)].at(-1)?.[1] ?? null; + } + } + } catch { return null; } + if (named === null) return null; + // Codex reads an empty CODEX_HOME as none, and then keeps its store in the HOME it runs with + if (named === "") return home !== null && isAbsolute(home) ? join(home, ".codex") : null; + return (await isCodexHomeDir(named)) ? named : null; +} + /** An absolute path to a directory that is there: `ps` cannot tell an argument from the environment, a store can be checked. */ async function isCodexHomeDir(home: string): Promise { if (!isAbsolute(home)) return false; @@ -1331,6 +1368,66 @@ function titledCandidate(agent: Pick { + if (!codexStartScreenUnused(screen)) return null; + const { list, key } = await codexProcessesOf(paneId).catch(() => ({ list: [], key: "" })); + if (list.length === 0 || list.some((codex) => (codex.argv ?? []).some((word) => word === "resume" || word === "fork"))) return null; + // an explicit store wins, as in paneCodexHome. Else every Codex's home must be placed for + // certain, and the first decides: one paneCodexHome cannot place (a relative CODEX_HOME) falls + // back to the default store, where this pane's conversation is not + let home = configured; + if (home === undefined) { + const homes = await Promise.all(list.map((codex) => certainProcessCodexHome(codex.pid, codex.argv ?? []))); + const [first] = homes; + if (first == null || homes.some((known) => known === null)) return null; + home = first; + } + const starts = list.map((codex) => processStartLowerBound(codex.pid)); + if (starts.some((started) => started === null)) return null; + // whole seconds, and one more: a start read anew may land in the second before + const since = Math.floor(Math.min(...(starts as number[])) / 1000) - 1; + const store = join(home, "state_5.sqlite"); + let db: Database | undefined; + try { + // a store Codex has not made yet holds no thread + if (statSync(store, { throwIfNoEntry: false }) !== undefined) { + db = new Database(store, { readonly: true, create: false }); + // each folder the pane names (its shell's, its foreground process's), as given and as it + // resolves: Codex may have stored any of them + const named = cwds.filter((cwd) => cwd.length > 0); + if (named.length === 0) return null; + const folders = [...new Set(named.flatMap((cwd) => { + try { return [cwd, realpathSync(cwd)]; } catch { return [cwd]; } + }))].map((folder) => storedCwdCondition(folder)); + const said = db.query("SELECT 1 FROM pragma_table_info('threads') WHERE name = 'first_user_message'").get() !== null ? " AND COALESCE(first_user_message, '') <> ''" : ""; + const begun = db.query( + `SELECT 1 FROM threads WHERE (${folders.map((folder) => folder.where).join(" OR ")}) AND archived = 0 AND agent_role IS NULL${interactive(db)}${said} AND max(COALESCE(created_at, 0), COALESCE(updated_at, 0)) >= ? LIMIT 1`, + ).get(...folders.flatMap((folder) => folder.params), since); + if (begun !== null) return null; + } + } catch { return null; } + finally { db?.close(); } + return `unwritten:codex:${paneId}:${key}`; +} + /** `codex resume `: the thread a TUI was started on, straight from its command line. */ export function resumedThread(argvs: readonly (readonly string[])[]): string | null { for (const argv of argvs) { diff --git a/server/conversation.test.ts b/server/conversation.test.ts index ae5672c576..8567282bf4 100644 --- a/server/conversation.test.ts +++ b/server/conversation.test.ts @@ -2577,4 +2577,166 @@ describe("a Codex pane that reported no session yet", () => { expect((page as ConversationUnavailable).screen).toBeUndefined(); } finally { snapshot.mockRestore(); rpc.mockRestore(); read.mockRestore(); forgetTranscriptState(); rmSync(root, { recursive: true, force: true }); } }); + + // the process's start time is read from the OS: this test's own process stands in for Codex's + it.skipIf(process.platform === "win32")("greets a Codex started in the terminal on no conversation, and only that one", async () => { + const root = mkdtempSync(join(tmpdir(), "herdr-codex-greet-")); + const home = join(root, ".codex"); + mkdirSync(home); + const pane = { pane_id: "codex-greet", cwd: root, foreground_cwd: root, agent: "codex", agent_status: "done" } as HerdrPane; + let screen = FRESH; + let argv = ["codex"]; + const snapshot = spyOn(herdr, "sessionSnapshot").mockImplementation(async () => ({ panes: [pane] }) as SessionSnapshot); + const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method: string) => (method === "agent.get" + ? { agent: { agent: "codex" } } : { process_info: { foreground_processes: [{ pid: process.pid, argv }] } }) as never); + const read = spyOn(herdr, "paneRead").mockImplementation(async () => ({ text: screen }) as never); + const ask = () => paneConversation(pane.pane_id, home).catch((error: unknown) => error); + try { + // no store yet, as for a CODEX_HOME Codex has not written to + const greeting = await ask() as Awaited>; + expect(greeting).toMatchObject({ source: "codex-transcript", turns: [], cursor: null, screen: true, metadata: { model: "gpt-6.1-sol", reasoning_effort: "low" } }); + expect(greeting.history_id).toStartWith("unwritten:codex:"); + // a store whose threads of this folder all ended before this Codex started + const db = new Database(join(home, "state_5.sqlite")); + db.exec("CREATE TABLE threads (id TEXT, rollout_path TEXT, cwd TEXT, source TEXT, created_at INTEGER, updated_at INTEGER, archived INTEGER DEFAULT 0, agent_role TEXT)"); + db.query("INSERT INTO threads (id, rollout_path, cwd, source, created_at, updated_at) VALUES ('old', '/nowhere.jsonl', ?, 'cli', 1, 2)").run(root); + expect(await ask()).toMatchObject({ history_id: greeting.history_id }); + // a resume or a fork on the command line opens a conversation that exists + argv = ["codex", "resume"]; + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + argv = ["codex", "fork", "--last"]; + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + argv = ["codex"]; + // something said on screen, or a first turn at work + screen = FRESH.replace("\u203a Ask Codex", "\u203a hello\n\n\u2022 Hi there\n\n\u203a Ask Codex"); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + screen = FRESH; + pane.agent_status = "working"; + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + pane.agent_status = "done"; + // a thread of this folder written since this Codex started: perhaps its own, unread + const now = Math.floor(Date.now() / 1000); + db.query("UPDATE threads SET updated_at = ? WHERE id = 'old'").run(now); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + db.query("UPDATE threads SET updated_at = 2").run(); + db.query("INSERT INTO threads (id, rollout_path, cwd, source, created_at, updated_at) VALUES ('new', '/nowhere-2.jsonl', ?, 'cli', ?, ?)").run(root, now, now); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + // one in another folder, a subagent's or a `codex exec` run's says nothing about this one + db.query("UPDATE threads SET cwd = '/elsewhere' WHERE id = 'new'").run(); + db.query("INSERT INTO threads (id, rollout_path, cwd, source, created_at, updated_at, agent_role) VALUES ('sub', '/sub.jsonl', ?, 'cli', ?, ?, 'worker')").run(root, now, now); + db.query("INSERT INTO threads (id, rollout_path, cwd, source, created_at, updated_at) VALUES ('exec', '/exec.jsonl', ?, 'exec', ?, ?)").run(root, now, now); + expect(await ask()).toMatchObject({ history_id: greeting.history_id }); + // a thread with no message yet holds no conversation, whoever made it; one with a message does + db.exec("ALTER TABLE threads ADD COLUMN first_user_message TEXT"); + db.query("UPDATE threads SET cwd = ?, first_user_message = '' WHERE id = 'new'").run(root); + expect(await ask()).toMatchObject({ history_id: greeting.history_id }); + db.query("UPDATE threads SET first_user_message = 'hi' WHERE id = 'new'").run(); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + // the pane's folder named through a link: Codex stored the folder it resolves to + const alias = join(tmpdir(), `herdr-codex-greet-alias-${process.pid}`); + symlinkSync(root, alias); + try { + pane.cwd = alias; + pane.foreground_cwd = alias; + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + } finally { pane.cwd = root; pane.foreground_cwd = root; rmSync(alias, { force: true }); } + // Codex runs in another folder than the pane's shell: that folder's conversations count too + const elsewhere = mkdtempSync(join(tmpdir(), "herdr-codex-greet-fg-")); + try { + db.query("UPDATE threads SET cwd = ? WHERE id = 'new'").run(elsewhere); + expect(await ask()).toMatchObject({ history_id: greeting.history_id }); + pane.foreground_cwd = elsewhere; + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + } finally { pane.foreground_cwd = root; rmSync(elsewhere, { recursive: true, force: true }); } + db.close(); + // a store that cannot be read tells nothing, nor one that cannot even be looked at + writeFileSync(join(home, "state_5.sqlite"), "not a database"); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + rmSync(join(home, "state_5.sqlite")); + symlinkSync(join(home, "state_5.sqlite"), join(home, "state_5.sqlite")); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + } finally { snapshot.mockRestore(); rpc.mockRestore(); read.mockRestore(); forgetTranscriptState(); rmSync(root, { recursive: true, force: true }); } + }); + + // each Codex is a child started with the CODEX_HOME under test, whose environment the server reads from + // the OS: a Bun process, since macOS's ps shows no environment for a system binary such as sleep + it.skipIf(process.platform === "win32")("greets a terminal Codex only in the store its own environment places for certain", async () => { + const root = mkdtempSync(join(tmpdir(), "herdr-codex-home-")); + const own = join(root, "own"); + const fallback = join(root, "fallback"); + mkdirSync(own); + mkdirSync(fallback); + const pane = { pane_id: "codex-home", cwd: root, foreground_cwd: root, agent: "codex", agent_status: "done" } as HerdrPane; + const children: Bun.Subprocess[] = []; + let codexes: { pid: number; argv: string[] }[] = []; + // each one says it runs before it is asked about: until its exec, the OS shows the environment it was forked with + // a string is the CODEX_HOME it starts with; an object sets (or, as undefined, drops) its variables + const started = async (...homes: (string | Record)[]) => { + codexes = await Promise.all(homes.map(async (home) => { + const set = typeof home === "string" ? { CODEX_HOME: home } : home; + const env = Object.fromEntries(Object.entries({ ...process.env, ...set }).filter((entry): entry is [string, string] => entry[1] !== undefined)); + const child = Bun.spawn([process.execPath, "-e", "console.log('ready'); setTimeout(() => {}, 60_000)"], { cwd: root, env, stdout: "pipe" }); + children.push(child); + const reader = child.stdout.getReader(); + let said = ""; + while (!said.includes("ready")) { + const { value, done } = await reader.read(); + if (done) throw new Error("the stand-in Codex exited before it ran"); + said += new TextDecoder().decode(value); + } + reader.releaseLock(); + return { pid: child.pid, argv: ["codex"] }; + })); + }; + const snapshot = spyOn(herdr, "sessionSnapshot").mockImplementation(async () => ({ panes: [pane] }) as SessionSnapshot); + const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method: string) => (method === "agent.get" + ? { agent: { agent: "codex" } } : { process_info: { foreground_processes: codexes } }) as never); + const read = spyOn(herdr, "paneRead").mockImplementation(async () => ({ text: FRESH }) as never); + // the server's own default store holds nothing: a home it cannot place must not fall back to it + const serverHome = process.env["CODEX_HOME"]; + process.env["CODEX_HOME"] = fallback; + const ask = () => paneConversation(pane.pane_id).catch((error: unknown) => error); + try { + // a home its environment names, with no store yet: the greeting + await started(own); + const greeting = await ask() as Awaited>; + expect(greeting).toMatchObject({ source: "codex-transcript", turns: [], cursor: null }); + expect(greeting.history_id).toStartWith("unwritten:codex:"); + // a relative CODEX_HOME: Codex places it under its folder, where a conversation has begun + const relative = join(root, "rel"); + mkdirSync(relative); + const begun = (store: string) => { + const db = new Database(join(store, "state_5.sqlite")); + const now = Math.floor(Date.now() / 1000); + db.exec("CREATE TABLE threads (id TEXT, rollout_path TEXT, cwd TEXT, source TEXT, created_at INTEGER, updated_at INTEGER, archived INTEGER DEFAULT 0, agent_role TEXT, first_user_message TEXT)"); + db.query("INSERT INTO threads (id, rollout_path, cwd, source, created_at, updated_at, first_user_message) VALUES ('begun', '/nowhere.jsonl', ?, 'cli', ?, ?, 'hi')").run(root, now, now); + db.close(); + }; + begun(relative); + await started("rel"); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + // a CODEX_HOME that is no directory, or a second Codex whose home cannot be placed + await started(join(root, "missing")); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + await started(own, "rel"); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + // no CODEX_HOME: the store is .codex in the HOME the Codex runs with, never the server's own + const user = join(root, "user"); + mkdirSync(join(user, ".codex"), { recursive: true }); + await started({ CODEX_HOME: undefined, HOME: user }); + expect(await ask()).toMatchObject({ history_id: expect.stringMatching(/^unwritten:codex:/) }); + begun(join(user, ".codex")); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + // with no HOME, or a relative one, there is no telling where it is + await started({ CODEX_HOME: undefined, HOME: undefined }); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + await started({ CODEX_HOME: undefined, HOME: "user" }); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + } finally { + if (serverHome === undefined) delete process.env["CODEX_HOME"]; else process.env["CODEX_HOME"] = serverHome; + for (const child of children) child.kill(); + await Promise.all(children.map((child) => child.exited)); + snapshot.mockRestore(); rpc.mockRestore(); read.mockRestore(); forgetTranscriptState(); rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/server/conversation.ts b/server/conversation.ts index 3ba6be1b65..1a108f1a98 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -41,7 +41,7 @@ import nodePath, { type PlatformPath } from "node:path"; import type { ConversationMetadata, ConversationTurn, HerdrPane, SessionSnapshot } from "../shared/protocol.ts"; import { herdrRpc, paneProcessInfo, paneRead, sessionSnapshot, type ProcessInfo } from "./herdr/client.ts"; import { resolveAgent } from "./pane-context.ts"; -import { codexHistorySegments, createCodexTranscriptParser, codexOutputText, codexTranscriptPath, defaultCodexHome, forgetAllCodexState, forgetCodexStateFor, paneCodexHome, parseCodexTranscript, readRange } from "./codex.ts"; +import { codexFreshSession, codexHistorySegments, createCodexTranscriptParser, codexOutputText, codexTranscriptPath, defaultCodexHome, forgetAllCodexState, forgetCodexStateFor, paneCodexHome, parseCodexTranscript, readRange } from "./codex.ts"; import { CODEX_IMAGE_REF, codexTranscriptImage, MAX_IMAGE_BYTES } from "./codex-images.ts"; import { AmbiguousClaudeStore, claudeAttachSession, claudeProcessSession, claudeTranscriptFile, defaultClaudeConfigDir, forgetClaudeSessionFile, forgetClaudeSessions, isClaudeProcess, processClaudeConfigDir } from "./claude-store.ts"; import { forgetGjcPane, forgetGjcState, gjcPidUnderShell, gjcTranscriptForPane, isGjcProcess, storeRelative } from "./gjc-runtime.ts"; @@ -1285,10 +1285,10 @@ export async function paneConversation(paneId: string, codexHome?: string, page: const screen = await paneRead({ paneId, source: "visible", format: "text", timeoutMs: 2_000 }).then((read) => read.text, () => ""); return startScreenMetadata(source, screen); }; - const notBegun = async (source: RecognizedConversation["source"], id: string): Promise => { + const notBegun = async (source: RecognizedConversation["source"], id: string, read?: ConversationMetadata): Promise => { // what the screen states can change with the pane's output, with no file to watch: the chat re-reads on it const screen = readsStartScreen(source); - const metadata = screen ? await screenMetadata(source) : { model: null, reasoning_effort: null }; + const metadata = read ?? (screen ? await screenMetadata(source) : { model: null, reasoning_effort: null }); return { source, turns: [], metadata, ...(screen ? { screen: true as const } : {}), cursor: null, history_id: id, version: answerVersion(id, `${metadata.model}\0${metadata.reasoning_effort}`) }; }; @@ -1321,7 +1321,15 @@ export async function paneConversation(paneId: string, codexHome?: string, page: // a Codex that reported no session yet (one started in the terminal): the terminal stands in // for the chat, and Codex's live status line still states the model and effort it runs at if (pane.agent === "codex" && page.before === undefined && page.since === undefined && page.from === undefined) { - const screen = await screenMetadata("codex-transcript"); + const text = await paneRead({ paneId, source: "visible", format: "text", timeoutMs: 2_000 }).then((read) => read.text, () => ""); + const screen = startScreenMetadata("codex-transcript", text); + // unless it has begun no conversation at all: then it has its greeting, as one this server + // started does. At work or asking, its first turn may have begun. + if (error.message === "no_session_path" && screen.model !== null && pane.agent_status !== "working" && pane.agent_status !== "blocked") { + // every folder the pane names, and the screen this was decided on + const fresh = await codexFreshSession(paneId, [pane.cwd ?? "", pane.foreground_cwd ?? ""], codexHome, text); + if (fresh !== null) return await notBegun("codex-transcript", fresh, screen); + } if (screen.model !== null) throw new ConversationUnavailable(error.message, screen); } throw error; diff --git a/server/start-screen.test.ts b/server/start-screen.test.ts index 607b99fc70..6bb4ad1961 100644 --- a/server/start-screen.test.ts +++ b/server/start-screen.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { readsStartScreen, startScreenMetadata } from "./start-screen.ts"; +import { codexStartScreenUnused, readsStartScreen, startScreenMetadata } from "./start-screen.ts"; const CLAUDE = [ " ▐▛███▛█ Claude Code v2.1.296", @@ -62,6 +62,29 @@ describe("startScreenMetadata", () => { expect(startScreenMetadata("codex-transcript", `${header}\n GPT-6-Sol high · /tmp/project`)).toEqual({ model: null, reasoning_effort: null }); }); + it("tells a Codex start screen with nothing said yet from one with a conversation", () => { + const header = " >_ OpenAI Codex (v0.162.1)"; + expect(codexStartScreenUnused(CODEX)).toBeTrue(); + // a draft in the composer is not a message + expect(codexStartScreenUnused(CODEX.replace("\u203a Ask Codex to do anything", "\u203a fix the\n build"))).toBeTrue(); + // a message sent, its answer, an error; or a resumed conversation shown under its header + const said = (cells: string) => CODEX.replace("\u203a Ask Codex to do anything", `${cells}\n\n\u203a Ask Codex to do anything`); + expect(codexStartScreenUnused(said("\u203a hello\n\n\u2022 Hi! What should we work on?"))).toBeFalse(); + expect(codexStartScreenUnused(said("\u203a hello"))).toBeFalse(); + expect(codexStartScreenUnused(said("\u25a0 stream disconnected before completion"))).toBeFalse(); + expect(codexStartScreenUnused(CODEX.replace(header, `${header}\n\u2022 Explored\n \u2514 Read app.ts`))).toBeFalse(); + // no header, no footer, or a popup over the footer: nothing to tell by + expect(codexStartScreenUnused(CODEX.replace(header, ""))).toBeFalse(); + expect(codexStartScreenUnused(CODEX.replace(" \u2190 for agents \u00b7 ? for shortcuts", ""))).toBeFalse(); + expect(codexStartScreenUnused([header, " Select Model and Effort", "\u203a 1. GPT-6.1-Sol (default) (current)"].join("\n"))).toBeFalse(); + // a header drawn under a conversation (a command's card, a second session in the same terminal) + expect(codexStartScreenUnused(`\u203a hello\n\n\u2022 Hi there\n\n${CODEX}`)).toBeFalse(); + // an answer that only mentions the banner is no header, with the real one scrolled away + expect(codexStartScreenUnused(CODEX.replace(header, " the tail of an answer\n\u2022 OpenAI Codex (v0.163.0)"))).toBeFalse(); + // the banner boxed, as some versions draw it + expect(codexStartScreenUnused(CODEX.replace(header, "\u2502 >_ OpenAI Codex (v0.163.0) \u2502"))).toBeTrue(); + }); + it("reads gjc's status line, keeping a model it has no id for as gjc wrote it", () => { expect(startScreenMetadata("gjc-transcript", GJC)).toEqual({ model: "claude-opus-5-5", reasoning_effort: "xhigh" }); expect(startScreenMetadata("gjc-transcript", GJC.replace("opus-5.5", "kimi-k3"))).toEqual({ model: "kimi-k3", reasoning_effort: "xhigh" }); diff --git a/server/start-screen.ts b/server/start-screen.ts index 3b556e9273..e07c43c531 100644 --- a/server/start-screen.ts +++ b/server/start-screen.ts @@ -29,20 +29,51 @@ function claudeScreen(lines: string[]): Screen { return { model: `claude-${named[1]!.toLowerCase()}-${named[2]}${named[3] === undefined ? "" : `-${named[3]}`}`, reasoning_effort: null }; } -/** Only the live composer footer owns a Codex model. Text above it (an answer quoting the - * /model picker, a draft) never does, and while a popup hides the footer the model is unknown. */ -function codexScreen(lines: string[]): Screen { +const CODEX_PROMPT = /^\s*›(?:\s|$)/u; + +/** The lines under the last Codex session header down to its composer footer, the model and + * effort that footer states, or null when the screen does not show both. */ +function codexLayout(lines: string[]): { header: number; visible: string[]; end: number; model: RegExpExecArray } | null { let header = lines.length - 1; - while (header >= 0 && !lines[header]!.includes("OpenAI Codex (v")) header--; - if (header < 0) return NOTHING; + // the banner's own line, boxed or not: an answer that only mentions it is no header + while (header >= 0 && !/^[\s\u2502|]*>_ OpenAI Codex \(v\d[^)]*\)[\s\u2502|]*$/u.test(lines[header]!)) header--; + if (header < 0) return null; const visible = lines.slice(header + 1); const footer = new RegExp(`^\\s*(GPT-[\\w.-]+) (${EFFORT})(?: \\w+)? · `, "u"); let end = visible.length - 1; while (end >= 0 && visible[end]!.trim() === "") end--; - if (end < 1 || !/^\s*[←?].*(?:for agents|shortcuts)/u.test(visible[end]!)) return NOTHING; - const found = footer.exec(visible[end - 1]!); - if (!found || !visible.slice(0, end - 1).some((line) => /^\s*›(?:\s|$)/u.test(line))) return NOTHING; - return { model: found[1]!.toLowerCase(), reasoning_effort: found[2]! }; + if (end < 1 || !/^\s*[←?].*(?:for agents|shortcuts)/u.test(visible[end]!)) return null; + const model = footer.exec(visible[end - 1]!); + if (!model || !visible.slice(0, end - 1).some((line) => CODEX_PROMPT.test(line))) return null; + return { header, visible, end, model }; +} + +/** Only the live composer footer owns a Codex model. Text above it (an answer quoting the + * /model picker, a draft) never does, and while a popup hides the footer the model is unknown. */ +function codexScreen(lines: string[]): Screen { + const found = codexLayout(lines)?.model; + return found ? { model: found[1]!.toLowerCase(), reasoning_effort: found[2]! } : NOTHING; +} + +/** A cell Codex draws for something said or done: an answer or a tool call, a warning, an error. */ +const CODEX_CELL = /^\s*[•⚠■]/u; + +/** + * Whether a Codex screen is its start screen with nothing said in it yet: under the last session + * header, its card and start tips, then the composer (the first prompt line, `›`) with what it + * holds, then the footer. A message sent or a resumed conversation puts a prompt or an answer + * above the composer, and a long one pushes the header off the screen; either way this says no. + * So does one said above the header: the header may be a card a command drew (`/status`) after + * a conversation, or a session started after another one in the same terminal. + */ +export function codexStartScreenUnused(screen: string): boolean { + const all = screen.split("\n"); + const layout = codexLayout(all); + if (layout === null || all.slice(0, layout.header).some((line) => CODEX_PROMPT.test(line) || CODEX_CELL.test(line))) return false; + const lines = layout.visible.slice(0, layout.end - 1); + const composer = lines.findIndex((line) => CODEX_PROMPT.test(line)); + return composer >= 0 && !lines.slice(0, composer).some((line) => CODEX_CELL.test(line)) + && !lines.slice(composer + 1).some((line) => CODEX_PROMPT.test(line) || CODEX_CELL.test(line)); } /** ` ⬢ opus-5.5 · ◉ xhigh · 1.5% / …`, the status line over the composer. */