From 8063abd30d7603ed7eb41859d6a0e8a4c8d29251 Mon Sep 17 00:00:00 2001 From: v-openrig-build Date: Tue, 6 Oct 2026 06:33:34 +0000 Subject: [PATCH 1/3] fix(codex): recognize current update menu header --- .../src/adapters/codex-runtime-adapter.ts | 4 +-- .../daemon/src/domain/native-resume-probe.ts | 10 +++++-- .../daemon/test/codex-runtime-adapter.test.ts | 30 +++++++++++++------ .../daemon/test/codex-update-input.test.ts | 15 ++++++++-- .../test/fixtures/codex-update-0.160.0.txt | 25 ++++++++++++++++ .../daemon/test/native-resume-probe.test.ts | 11 +++++-- 6 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 packages/daemon/test/fixtures/codex-update-0.160.0.txt diff --git a/packages/daemon/src/adapters/codex-runtime-adapter.ts b/packages/daemon/src/adapters/codex-runtime-adapter.ts index 894d6905a..b54487ca7 100644 --- a/packages/daemon/src/adapters/codex-runtime-adapter.ts +++ b/packages/daemon/src/adapters/codex-runtime-adapter.ts @@ -24,7 +24,7 @@ import { readCodexThreadIdFromCandidateHomes, type ResolveHomeDirByPid, } from "../domain/codex-thread-id.js"; -import { assessNativeResumeProbe, buildCodexResumeCore, type NativeResumeProbeResult } from "../domain/native-resume-probe.js"; +import { assessNativeResumeProbe, buildCodexResumeCore, hasCodexUpdateHeader, type NativeResumeProbeResult } from "../domain/native-resume-probe.js"; import { unknownDaemonSupportMessage, type CodexDaemonSupportDetector } from "../domain/codex-daemon-support.js"; import { codexNetworkDefaultArg, type CodexNetworkDefaultReader } from "../domain/codex-network-default.js"; import { resolveCodexGitAddDirs, type CodexGitAddDirResolver } from "../domain/codex-git-add-dirs.js"; @@ -1560,6 +1560,6 @@ function commandLooksLikeCodex(command: string): boolean { } function isSkippableCodexUpdatePrompt(paneContent: string): boolean { - return paneContent.includes("Update available!") + return hasCodexUpdateHeader(paneContent) && /^\s*[›>]?\s*3\. Skip until next version\s*$/m.test(paneContent); } diff --git a/packages/daemon/src/domain/native-resume-probe.ts b/packages/daemon/src/domain/native-resume-probe.ts index ccfa0eb0b..73a8f4d23 100644 --- a/packages/daemon/src/domain/native-resume-probe.ts +++ b/packages/daemon/src/domain/native-resume-probe.ts @@ -223,7 +223,7 @@ export function assessNativeResumeProbe( detail: "Codex is waiting for workspace trust approval before the session can become interactive.", }; } - if (paneContent.includes("Update available!") || paneContent.includes("Updating Codex")) { + if (hasCodexUpdateHeader(paneContent) || paneContent.includes("Updating Codex")) { return { status: "inconclusive", code: "update_gate", @@ -336,6 +336,12 @@ function looksLikeClaudeMcpApprovalPrompt(paneContent: string): boolean { && paneContent.includes("Enter to confirm"); } +/** Header formats observed before and since Codex 0.160.0. */ +export function hasCodexUpdateHeader(paneContent: string): boolean { + return paneContent.includes("Update available!") + || /(?:^|\n)[ \t]*Update available · \S+ → \S+[ \t]*(?:\n|$)/.test(paneContent); +} + function looksLikeCodexTui(paneContent: string): boolean { const current = paneContent.slice(Math.max(0, paneContent.lastIndexOf("OpenAI Codex (v"))); if (/model:\s*loading\b/i.test(current)) return false; @@ -351,7 +357,7 @@ function looksLikeCodexTui(paneContent: string): boolean { // not a model mentioned somewhere in conversation prose. // A custom row must not make an unresolved trust/update panel disappear. const hasCustomModelFooter = !looksLikeCodexTrustPrompt(current) - && !current.includes("Update available!") && !current.includes("Updating Codex") + && !hasCodexUpdateHeader(current) && !current.includes("Updating Codex") && recentLines.split("\n").some((line) => { const fields = line.trim().split(" · "); return /^[ \t]{2,}\S/.test(line) && fields.length > 1 diff --git a/packages/daemon/test/codex-runtime-adapter.test.ts b/packages/daemon/test/codex-runtime-adapter.test.ts index c6623192d..613761c81 100644 --- a/packages/daemon/test/codex-runtime-adapter.test.ts +++ b/packages/daemon/test/codex-runtime-adapter.test.ts @@ -12,6 +12,10 @@ import type { TmuxAdapter } from "../src/adapters/tmux.js"; import { seedCodexThreads } from "./helpers/codex-state.js"; import { execFileSync } from "node:child_process"; +// Verbatim 250-byte Codex 0.160.0 menu capture, SHA-256 +// 45c70fc01c7d33f4652c9d42523f631734c62aac76f449ff0f2c15e99680ac9b. +const currentUpdatePrompt = fs.readFileSync(new URL("./fixtures/codex-update-0.160.0.txt", import.meta.url), "utf8"); + const CODEX_FLOOR_EFFECT = { runtime: "codex", axis: "sandbox", @@ -558,12 +562,14 @@ describe("Codex runtime adapter", () => { expect(sendText).toHaveBeenCalledWith("r01-qa", expectedFreshLaunchCommand({ queueRoot: null })); }); - it("launchHarness skips the Codex update prompt with one control key before capturing a fresh thread id", async () => { + it.each([ + ["legacy", false], ["current", false], ["legacy", true], ["current", true], + ] as const)("launchHarness skips the %s Codex update prompt exactly once (persistent=%s)", async (layout, persistent) => { const initialShell = [ expectedFreshLaunchCommand(), "admin@host project %", ].join("\n"); - const updatePrompt = [ + const updatePrompt = layout === "current" ? currentUpdatePrompt : [ "✨ Update available! 0.120.0 -> 0.121.0", "Release notes: https://github.com/openai/codex/releases/latest", "› 1. Update now (runs `npm install -g @openai/codex`)", @@ -579,7 +585,7 @@ describe("Codex runtime adapter", () => { .mockResolvedValueOnce(initialShell) .mockResolvedValueOnce(updatePrompt) .mockResolvedValueOnce(updatePrompt) - .mockResolvedValue("OpenAI Codex (v0.120.0)\n› Ask Codex to do anything"), + .mockResolvedValue(persistent ? updatePrompt : "OpenAI Codex (v0.120.0)\n› Ask Codex to do anything"), getPanePid: vi.fn(async () => 900), }); const adapter = new CodexRuntimeAdapter({ @@ -612,23 +618,29 @@ describe("Codex runtime adapter", () => { ]); }); - it("launchHarness does not choose a Codex update action unless skip-until-next-version is visible", async () => { + it.each(["legacy", "current"])("launchHarness does not choose a %s Codex update action without option 3", async (layout) => { const tmux = mockTmux({ - capturePaneContent: vi.fn(async () => [ - "✨ Update available! 0.120.0 -> 0.121.0", - "Press enter to continue", - ].join("\n")), + capturePaneScreen: vi.fn(async () => (layout === "current" ? currentUpdatePrompt + : "✨ Update available! 0.120.0 -> 0.121.0\n 2. Skip\n 3. Skip until next version") + .replace(/^.*3\. Skip until next version.*$/m, "")), + getPanePid: vi.fn(async () => 900), }); const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), - listProcesses: () => [], + listProcesses: () => [ + { pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" }, + { pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" }, + ], + readThreadIdByPid: () => "019d45bc-117d-78a3-a4ad-6fb186e5a86d", sleep: async () => {}, }); const result = await adapter.launchHarness(makeBinding(), { name: "dev-qa@test-rig" }); expect(result.ok).toBe(true); + // The shell launch gets Enter; no menu key or subsequent Enter is sent. + expect(vi.mocked(tmux.sendKeys).mock.calls).toEqual([["r01-qa", ["Enter"]]]); const sendText = tmux.sendText as ReturnType; expect(sendText.mock.calls).toEqual([ ["r01-qa", expectedFreshLaunchCommand()], diff --git a/packages/daemon/test/codex-update-input.test.ts b/packages/daemon/test/codex-update-input.test.ts index e70224ac8..a110b68ab 100644 --- a/packages/daemon/test/codex-update-input.test.ts +++ b/packages/daemon/test/codex-update-input.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { describe, expect, it, vi } from "vitest"; import { CodexRuntimeAdapter, type CodexAdapterFsOps } from "../src/adapters/codex-runtime-adapter.js"; import { TmuxAdapter } from "../src/adapters/tmux.js"; @@ -8,6 +9,7 @@ const MENU = [ "› 1. Update now (runs `npm install -g @openai/codex`)", " 2. Skip", " 3. Skip until next version", "Press enter to continue", ].join("\n"); +const CURRENT_MENU = fs.readFileSync(new URL("./fixtures/codex-update-0.160.0.txt", import.meta.url), "utf8"); const READY = "OpenAI Codex (v0.155.1)\n› Ask Codex to do anything"; const binding = { id: "b", nodeId: "n", tmuxSession: "checker@test", tmuxWindow: null, tmuxPane: null, @@ -43,7 +45,7 @@ const processRows = (shape: "wrapper" | "exec-wrapper" | "native"): ProcessRow[] { pid: 103, ppid: 102, command: native, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" }, ]; }; -function fixture(options: { delay?: number; beforeMenu?: number; failInput?: boolean; screen?: string; command?: string; panePid?: number; processes?: ProcessRow[]; shape?: "wrapper" | "exec-wrapper" | "native" } = {}) { +function fixture(options: { menu?: string; delay?: number; beforeMenu?: number; failInput?: boolean; screen?: string; command?: string; panePid?: number; processes?: ProcessRow[]; shape?: "wrapper" | "exec-wrapper" | "native" } = {}) { let selected = false; let ticks = 0; let updates = 0; @@ -52,7 +54,7 @@ function fixture(options: { delay?: number; beforeMenu?: number; failInput?: boo const commands: string[] = []; const leaked: string[] = []; const ready = () => selected && ticks >= (options.delay ?? 0); - const screen = () => options.screen ?? (ticks < (options.beforeMenu ?? 0) ? "Starting Codex..." : ready() ? READY : MENU); + const screen = () => options.screen ?? (ticks < (options.beforeMenu ?? 0) ? "Starting Codex..." : ready() ? READY : (options.menu ?? MENU)); const tmux = new TmuxAdapter(async (cmd) => { commands.push(cmd); if (cmd.includes("paste-buffer")) { @@ -97,6 +99,15 @@ const paths = [ { name: "fork", opts: { name: "checker@test", forkSource: { kind: "native_id" as const, value: "parent-thread" } } }, ]; +it.each(paths)("current update header sends one key through real tmux serialization: $name", async ({ opts }) => { + const f = fixture({ menu: CURRENT_MENU, delay: 4 }); + const result = await f.adapter.launchHarness(binding, opts); + expect(f.commands).toEqual(["tmux send-keys -t 'checker@test' '3'"]); + expect(f.counts()).toEqual({ updates: 0, dismissals: 1 }); + expect(f.leaked).toEqual([]); + expect(result.ok).toBe(true); +}); + describe.each(paths)("Codex update input: $name", ({ opts }) => { it.each([0, 4, 100])("sends one real key, no Enter or retry, with %i delayed ticks", async (delay) => { const f = fixture({ delay }); diff --git a/packages/daemon/test/fixtures/codex-update-0.160.0.txt b/packages/daemon/test/fixtures/codex-update-0.160.0.txt new file mode 100644 index 000000000..da3ec679b --- /dev/null +++ b/packages/daemon/test/fixtures/codex-update-0.160.0.txt @@ -0,0 +1,25 @@ + + + Update available · 0.160.0 → 0.160.1 + Release notes: https://github.com/openai/codex/releases/latest + +› 1. Update now (runs `npm install -g @openai/codex`) + 2. Skip + 3. Skip until next version + + enter continue · esc skip + + + + + + + + + + + + + + + diff --git a/packages/daemon/test/native-resume-probe.test.ts b/packages/daemon/test/native-resume-probe.test.ts index 8543616cb..eebb7dc16 100644 --- a/packages/daemon/test/native-resume-probe.test.ts +++ b/packages/daemon/test/native-resume-probe.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { describe, expect, it } from "vitest"; import { assessNativeResumeProbe, @@ -8,6 +9,8 @@ import { import { CLAUDE_BYPASS_CONSENT } from "./fixtures/claude-bypass-consent.js"; +const currentUpdatePrompt = fs.readFileSync(new URL("./fixtures/codex-update-0.160.0.txt", import.meta.url), "utf8"); + describe("default-path consent", () => { it.each([CLAUDE_BYPASS_CONSENT, CLAUDE_BYPASS_CONSENT.replace(" No, exit\n ❯ Yes, I accept", " ❯ No, exit\n Yes, I accept")])("recognizes the current consent menu", screen => { expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "claude", paneContent: screen, @@ -122,6 +125,7 @@ describe("native resume probe", () => { it.each([ ["Do you trust the contents of this directory?\n Yes, continue", "trust_gate"], ["Update available!", "update_gate"], + [currentUpdatePrompt, "update_gate"], ])("does not let a custom footer dismiss an unresolved gate: %s", (gate, code) => { expect(assessNativeResumeProbe({ runtime: "codex", paneCommand: "sh", paneContent: `› Earlier conversation prompt\n${gate}\n${reportedFooter}`, @@ -486,12 +490,15 @@ describe("native resume probe", () => { }); }); - it("classifies Codex update prompts as inconclusive", () => { + it.each([ + ["legacy", "✨ Update available! 0.117.0 -> 0.118.0\nPress enter to continue"], + ["current", currentUpdatePrompt], + ])("classifies %s Codex update prompts as inconclusive", (_layout, paneContent) => { expect( assessNativeResumeProbe({ runtime: "codex", paneCommand: "codex-aarch64-a", - paneContent: "✨ Update available! 0.117.0 -> 0.118.0\nPress enter to continue", + paneContent, }) ).toEqual({ status: "inconclusive", From 8ca3eef99c96b68ad7270fe2a75a31d98e03aa4a Mon Sep 17 00:00:00 2001 From: v-openrig-build Date: Tue, 6 Oct 2026 06:37:10 +0000 Subject: [PATCH 2/3] test(codex): keep custom footer discriminator within visible tail --- packages/daemon/test/native-resume-probe.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/daemon/test/native-resume-probe.test.ts b/packages/daemon/test/native-resume-probe.test.ts index eebb7dc16..d91b9a5b8 100644 --- a/packages/daemon/test/native-resume-probe.test.ts +++ b/packages/daemon/test/native-resume-probe.test.ts @@ -125,7 +125,7 @@ describe("native resume probe", () => { it.each([ ["Do you trust the contents of this directory?\n Yes, continue", "trust_gate"], ["Update available!", "update_gate"], - [currentUpdatePrompt, "update_gate"], + [currentUpdatePrompt.trimEnd(), "update_gate"], ])("does not let a custom footer dismiss an unresolved gate: %s", (gate, code) => { expect(assessNativeResumeProbe({ runtime: "codex", paneCommand: "sh", paneContent: `› Earlier conversation prompt\n${gate}\n${reportedFooter}`, From ab7b7141949c70772f6e98f2f13a8ccf3f95ad61 Mon Sep 17 00:00:00 2001 From: v-openrig-build Date: Tue, 6 Oct 2026 06:54:17 +0000 Subject: [PATCH 3/3] fix(codex): preserve a live composer after copied update text --- .../daemon/src/domain/native-resume-probe.ts | 21 ++++++++---- .../daemon/test/codex-update-input.test.ts | 33 +++++++++++++++++++ .../daemon/test/native-resume-probe.test.ts | 12 +++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/daemon/src/domain/native-resume-probe.ts b/packages/daemon/src/domain/native-resume-probe.ts index 73a8f4d23..b9c39955d 100644 --- a/packages/daemon/src/domain/native-resume-probe.ts +++ b/packages/daemon/src/domain/native-resume-probe.ts @@ -338,19 +338,26 @@ function looksLikeClaudeMcpApprovalPrompt(paneContent: string): boolean { /** Header formats observed before and since Codex 0.160.0. */ export function hasCodexUpdateHeader(paneContent: string): boolean { - return paneContent.includes("Update available!") - || /(?:^|\n)[ \t]*Update available · \S+ → \S+[ \t]*(?:\n|$)/.test(paneContent); + // Preserve legacy-header behavior. A current-format header may instead be + // quoted output above a later live composer. A footer alone does not end it. + if (paneContent.includes("Update available!")) return true; + const header = [...paneContent.matchAll(/^[ \t]*Update available · \S+ → \S+[ \t]*$/gm)].at(-1); + return !!header && !hasCodexComposer(paneContent.slice(header.index! + header[0].length)); } -function looksLikeCodexTui(paneContent: string): boolean { - const current = paneContent.slice(Math.max(0, paneContent.lastIndexOf("OpenAI Codex (v"))); - if (/model:\s*loading\b/i.test(current)) return false; - const recentLines = current.trimEnd().split("\n").slice(-20).join("\n"); - const hasPromptLine = recentLines.split("\n").some((line) => { +function hasCodexComposer(paneContent: string): boolean { + return paneContent.split("\n").some((line) => { const text = line.trimStart(); const hasPrompt = text.startsWith("›") || text.startsWith("»"); return hasPrompt && !/^\d+\.\s/.test(text.slice(1).trimStart()); }); +} + +function looksLikeCodexTui(paneContent: string): boolean { + const current = paneContent.slice(Math.max(0, paneContent.lastIndexOf("OpenAI Codex (v"))); + if (/model:\s*loading\b/i.test(current)) return false; + const recentLines = current.trimEnd().split("\n").slice(-20).join("\n"); + const hasPromptLine = hasCodexComposer(recentLines); const hasModelFooter = /(^|\n)\s{2,}gpt-[^\n]+ · [^\n]+(?:\n|$)/.test(recentLines); // Custom status lines can put the model's display name in any field. Keep // corroboration structural: an indented status row and a whole model field, diff --git a/packages/daemon/test/codex-update-input.test.ts b/packages/daemon/test/codex-update-input.test.ts index a110b68ab..0ef99b010 100644 --- a/packages/daemon/test/codex-update-input.test.ts +++ b/packages/daemon/test/codex-update-input.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { describe, expect, it, vi } from "vitest"; import { CodexRuntimeAdapter, type CodexAdapterFsOps } from "../src/adapters/codex-runtime-adapter.js"; import { TmuxAdapter } from "../src/adapters/tmux.js"; +import { assessNativeResumeProbe } from "../src/domain/native-resume-probe.js"; import type { NodeBinding } from "../src/domain/runtime-adapter.js"; const MENU = [ @@ -108,6 +109,38 @@ it.each(paths)("current update header sends one key through real tmux serializat expect(result.ok).toBe(true); }); +describe.each(paths)("copied current update text: $name", ({ opts }) => { + it.each([ + ["header", " Update available · 0.160.0 → 0.160.1"], + ["menu", CURRENT_MENU.trimEnd()], + ])("preserves the later live composer after a copied %s", async (_label, copied) => { + const screen = `The documentation shows this:\n${copied}\n› Continue\n 5h 71% left · GPT-6-Astra high · Context 81% left`; + const f = fixture({ screen }); + expect(assessNativeResumeProbe({ runtime: "codex", paneCommand: "codex", paneContent: screen })) + .toMatchObject({ status: "resumed", code: "active_runtime" }); + await f.adapter.launchHarness(binding, opts); + expect(f.commands).toEqual([]); + }); + + it("keeps a genuine menu with a custom footer and no later composer gated", async () => { + const menu = `${CURRENT_MENU.trimEnd()}\n 5h 71% left · GPT-6-Astra high · Context 81% left`; + expect(assessNativeResumeProbe({ runtime: "codex", paneCommand: "codex", paneContent: menu })) + .toMatchObject({ status: "inconclusive", code: "update_gate" }); + const f = fixture({ menu }); + await f.adapter.launchHarness(binding, opts); + expect(f.commands).toEqual(["tmux send-keys -t 'checker@test' '3'"]); + expect(f.counts()).toEqual({ updates: 0, dismissals: 1 }); + }); + + it.each([ + "› Continue", "» Continue", + ])("does not choose from copied menu text before the later marker: %s", async (marker) => { + const f = fixture({ screen: `${CURRENT_MENU.trimEnd()}\n${marker}` }); + await f.adapter.launchHarness(binding, opts); + expect(f.commands).toEqual([]); + }); +}); + describe.each(paths)("Codex update input: $name", ({ opts }) => { it.each([0, 4, 100])("sends one real key, no Enter or retry, with %i delayed ticks", async (delay) => { const f = fixture({ delay }); diff --git a/packages/daemon/test/native-resume-probe.test.ts b/packages/daemon/test/native-resume-probe.test.ts index d91b9a5b8..b4338caeb 100644 --- a/packages/daemon/test/native-resume-probe.test.ts +++ b/packages/daemon/test/native-resume-probe.test.ts @@ -490,6 +490,18 @@ describe("native resume probe", () => { }); }); + it("keeps a current update menu after an earlier composer gated", () => { + expect(assessNativeResumeProbe({ runtime: "codex", paneCommand: "codex", + paneContent: `› Earlier conversation prompt\n${currentUpdatePrompt}`, + })).toMatchObject({ status: "inconclusive", code: "update_gate" }); + }); + + it.each(["›", "»"])("ignores a copied current menu before a later %s composer and custom footer", (prompt) => { + expect(assessNativeResumeProbe({ runtime: "codex", paneCommand: "codex", + paneContent: `${currentUpdatePrompt.trimEnd()}\n${prompt} Continue\n 5h 71% left · GPT-6-Astra high · Context 81% left`, + })).toMatchObject({ status: "resumed", code: "active_runtime" }); + }); + it.each([ ["legacy", "✨ Update available! 0.117.0 -> 0.118.0\nPress enter to continue"], ["current", currentUpdatePrompt],