From 9cc35fa6d782c0a6515e80df9a7c940597079e49 Mon Sep 17 00:00:00 2001 From: mp Date: Sat, 24 Oct 2026 23:16:54 +0900 Subject: [PATCH 1/4] fix(chat): greet a fresh Codex started in a terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing `codex` in a Herdr terminal and opening Chat before the first message said "Conversation unavailable", while a Codex started from New workspace, New tab or New worktree showed its greeting (#990). Codex writes no session before the first message, and herdr reports none, so nothing tied the pane to a transcript. Chat now greets one only when every fact says it has begun none, and keeps the terminal otherwise: - the screen is Codex's start screen with nothing said: under the last banner line ("> _ OpenAI Codex (v…)", not an answer that mentions it) the composer is the first prompt line, no prompt or answer, warning or error cell follows it, and none is above the banner either (a command's card, or a second session in the same terminal); - no `resume` or `fork` is on the command line of the pane's Codex processes; - the pane's own CODEX_HOME holds no interactive thread of its folders (the shell's and the foreground process's, as given or as they resolve) with a first message, begun or written since those processes started (a lower bound of their start, less a second). Codex 0.163 makes no store before the first message at all. A store that cannot be read, or a CODEX_HOME the server cannot place (a relative one), says no; - the pane is not working or blocked. The answer is a conversation not begun, as for an app launch, with a history id of the pane and its Codex processes (no time: a start read on macOS moves between reads), so the first message's transcript replaces it whole. Fixes #1191 --- changelog.d/codex-fresh-terminal-greeting.md | 5 ++ server/codex.ts | 57 ++++++++++++++ server/conversation.test.ts | 80 ++++++++++++++++++++ server/conversation.ts | 16 +++- server/start-screen.test.ts | 25 +++++- server/start-screen.ts | 49 +++++++++--- 6 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 changelog.d/codex-fresh-terminal-greeting.md 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..ed639c13bc 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 : {}; @@ -1331,6 +1332,62 @@ 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; + // a CODEX_HOME the pane's Codex names but paneCodexHome cannot use (a relative one) leaves the store unknown + if (process.platform === "linux" && list.some((codex) => { + let named: string | undefined; + try { named = readFileSync(`/proc/${codex.pid}/environ`, "utf8").split("\0").find((entry) => entry.startsWith("CODEX_HOME="))?.slice(11); } + catch { return true; } + return named !== undefined && named !== "" && !isAbsolute(named); + })) return null; + 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..9684ca3333 100644 --- a/server/conversation.test.ts +++ b/server/conversation.test.ts @@ -2577,4 +2577,84 @@ 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 }); } + }); }); diff --git a/server/conversation.ts b/server/conversation.ts index 3ba6be1b65..ad5c2d0512 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 ?? ""], await paneCodexHome(paneId, 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. */ From c1b4e8ddd9beabf1e03db31d186585ff8003de3b Mon Sep 17 00:00:00 2001 From: mp Date: Mon, 26 Oct 2026 18:07:20 +0900 Subject: [PATCH 2/4] fix(chat): greet a terminal Codex only in a store its environment places The merge pass's gate found a false greeting on macOS: a pane whose Codex sets a relative CODEX_HOME had that value dropped, paneCodexHome fell back to the server's default store, and the Linux-only /proc guard did not refuse it, so a store with no conversation stood in for one that had a first message. codexFreshSession now places each Codex's store itself, on every platform (certainProcessCodexHome): the CODEX_HOME directory its environment names, ~/.codex when it names none, or no greeting. No greeting covers an environment that cannot be read (another user's process, a gone one, Windows), a CODEX_HOME that is relative or no directory, and on macOS a command line that spells CODEX_HOME= itself, which ps -E cannot tell from the environment, or a ps line that shows no environment. Every Codex of the pane must be placed; the first decides, as in paneCodexHome. An explicit server store (the codexHome option, which only tests set) still wins. --- server/codex.ts | 75 ++++++++++++++++++++++++++----------- server/conversation.test.ts | 55 +++++++++++++++++++++++++++ server/conversation.ts | 2 +- 3 files changed, 110 insertions(+), 22 deletions(-) diff --git a/server/codex.ts b/server/codex.ts index ed639c13bc..ec34e193fd 100644 --- a/server/codex.ts +++ b/server/codex.ts @@ -388,22 +388,51 @@ 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, ~/.codex when it names none, 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), and on macOS a command line that spells CODEX_HOME= itself, which + * `ps -E` cannot tell from the environment. + */ +async function certainProcessCodexHome(pid: number, argv: readonly string[]): Promise { + let named: string | null = null; + try { + if (process.platform === "linux") { + named = (await readFile(`/proc/${pid}/environ`, "utf8")).split("\0").find((entry) => entry.startsWith("CODEX_HOME="))?.slice(11) ?? ""; + } else if (process.platform === "darwin" && !argv.some((word) => word.includes("CODEX_HOME="))) { + const line = await psEnvironmentLine(pid); + // ps draws another user's process without its environment, and Codex always has a HOME in it + if (line !== null && /\sHOME=/.test(line)) named = codexHomeInPsLine(line) ?? ""; + } + } catch { return null; } + if (named === null) return null; + // Codex reads an empty CODEX_HOME as none + if (named === "") return join(homedir(), ".codex"); + 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; @@ -1344,22 +1373,26 @@ function titledCandidate(agent: Pick { +export async function codexFreshSession(paneId: string, cwds: readonly string[], configured: string | undefined, screen: string): Promise { 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; - // a CODEX_HOME the pane's Codex names but paneCodexHome cannot use (a relative one) leaves the store unknown - if (process.platform === "linux" && list.some((codex) => { - let named: string | undefined; - try { named = readFileSync(`/proc/${codex.pid}/environ`, "utf8").split("\0").find((entry) => entry.startsWith("CODEX_HOME="))?.slice(11); } - catch { return true; } - return named !== undefined && named !== "" && !isAbsolute(named); - })) 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 diff --git a/server/conversation.test.ts b/server/conversation.test.ts index 9684ca3333..ca56b555b0 100644 --- a/server/conversation.test.ts +++ b/server/conversation.test.ts @@ -2657,4 +2657,59 @@ describe("a Codex pane that reported no session yet", () => { 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[] }[] = []; + const started = (...homes: string[]) => { + codexes = homes.map((home) => { + const child = Bun.spawn([process.execPath, "-e", "setTimeout(() => {}, 60_000)"], { cwd: root, env: { ...process.env, CODEX_HOME: home } }); + children.push(child); + 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 + 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 db = new Database(join(relative, "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(); + started("rel"); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + // a CODEX_HOME that is no directory, or a second Codex whose home cannot be placed + started(join(root, "missing")); + expect(await ask()).toBeInstanceOf(ConversationUnavailable); + started(own, "rel"); + 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 ad5c2d0512..1a108f1a98 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -1327,7 +1327,7 @@ export async function paneConversation(paneId: string, codexHome?: string, page: // 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 ?? ""], await paneCodexHome(paneId, codexHome), text); + 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); From 11d7b71263f608287ed6dd540ce0b0f085f7f2f5 Mon Sep 17 00:00:00 2001 From: mp Date: Mon, 26 Oct 2026 18:18:34 +0900 Subject: [PATCH 3/4] test(chat): wait for the stand-in Codex to run before reading its home The new store test failed in CI ("a CODEX_HOME that is no directory" was greeted): it asked about each stand-in Codex right after Bun.spawn, and until the child's exec the OS shows the environment it was forked with, the test's own, whose CODEX_HOME is the empty fallback store. Locally it failed 2 runs in 30. Each stand-in now prints "ready" once it runs, and the test reads that line before asking: 0 failures in 60 runs. A real Codex has run for seconds before its pane is read, so the server is unchanged. --- server/conversation.test.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/server/conversation.test.ts b/server/conversation.test.ts index ca56b555b0..050391bb2b 100644 --- a/server/conversation.test.ts +++ b/server/conversation.test.ts @@ -2669,12 +2669,21 @@ describe("a Codex pane that reported no session yet", () => { 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[] }[] = []; - const started = (...homes: string[]) => { - codexes = homes.map((home) => { - const child = Bun.spawn([process.execPath, "-e", "setTimeout(() => {}, 60_000)"], { cwd: root, env: { ...process.env, CODEX_HOME: home } }); + // each one says it runs before it is asked about: until its exec, the OS shows the environment it was forked with + const started = async (...homes: string[]) => { + codexes = await Promise.all(homes.map(async (home) => { + const child = Bun.spawn([process.execPath, "-e", "console.log('ready'); setTimeout(() => {}, 60_000)"], { cwd: root, env: { ...process.env, CODEX_HOME: home }, 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" @@ -2686,7 +2695,7 @@ describe("a Codex pane that reported no session yet", () => { const ask = () => paneConversation(pane.pane_id).catch((error: unknown) => error); try { // a home its environment names, with no store yet: the greeting - started(own); + 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:"); @@ -2698,12 +2707,12 @@ describe("a Codex pane that reported no session yet", () => { 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(); - started("rel"); + await started("rel"); expect(await ask()).toBeInstanceOf(ConversationUnavailable); // a CODEX_HOME that is no directory, or a second Codex whose home cannot be placed - started(join(root, "missing")); + await started(join(root, "missing")); expect(await ask()).toBeInstanceOf(ConversationUnavailable); - started(own, "rel"); + await started(own, "rel"); expect(await ask()).toBeInstanceOf(ConversationUnavailable); } finally { if (serverHome === undefined) delete process.env["CODEX_HOME"]; else process.env["CODEX_HOME"] = serverHome; From d12ff6da961270052208d8e20ff39647b70105ae Mon Sep 17 00:00:00 2001 From: mp Date: Mon, 26 Oct 2026 18:20:40 +0900 Subject: [PATCH 4/4] fix(chat): place a terminal Codex's default store in its own HOME The merge pass's re-review found a false greeting: a Codex that names no CODEX_HOME had its store placed in this server's own home (homedir()), so one running with another HOME, whose .codex held a conversation begun since it started, was still greeted. Its store is now .codex in the HOME its own environment names, read like CODEX_HOME (/proc on Linux, ps -E on macOS). No HOME, a relative one, or on macOS a command line that spells HOME= itself means no greeting. --- server/codex.ts | 29 ++++++++++++++++++----------- server/conversation.test.ts | 32 +++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/server/codex.ts b/server/codex.ts index ec34e193fd..f0f63b8a08 100644 --- a/server/codex.ts +++ b/server/codex.ts @@ -410,26 +410,33 @@ async function psEnvironmentLine(pid: number): Promise { /** * 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, ~/.codex when it names none, 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), and on macOS a command line that spells CODEX_HOME= itself, which - * `ps -E` cannot tell from the environment. + * (#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") { - named = (await readFile(`/proc/${pid}/environ`, "utf8")).split("\0").find((entry) => entry.startsWith("CODEX_HOME="))?.slice(11) ?? ""; - } else if (process.platform === "darwin" && !argv.some((word) => word.includes("CODEX_HOME="))) { + 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); - // ps draws another user's process without its environment, and Codex always has a HOME in it - if (line !== null && /\sHOME=/.test(line)) named = codexHomeInPsLine(line) ?? ""; + 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 - if (named === "") return join(homedir(), ".codex"); + // 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; } diff --git a/server/conversation.test.ts b/server/conversation.test.ts index 050391bb2b..8567282bf4 100644 --- a/server/conversation.test.ts +++ b/server/conversation.test.ts @@ -2670,9 +2670,12 @@ describe("a Codex pane that reported no session yet", () => { 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 - const started = async (...homes: string[]) => { + // 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 child = Bun.spawn([process.execPath, "-e", "console.log('ready'); setTimeout(() => {}, 60_000)"], { cwd: root, env: { ...process.env, CODEX_HOME: home }, stdout: "pipe" }); + 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 = ""; @@ -2702,11 +2705,14 @@ describe("a Codex pane that reported no session yet", () => { // a relative CODEX_HOME: Codex places it under its folder, where a conversation has begun const relative = join(root, "rel"); mkdirSync(relative); - const db = new Database(join(relative, "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(); + 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 @@ -2714,6 +2720,18 @@ describe("a Codex pane that reported no session yet", () => { 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();