From 611dd52dff1c828e931867312eb215369a059481 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Sun, 7 Jun 2026 14:18:20 -0700 Subject: [PATCH] Fix Codex existing session status detection --- src/core/codex.ts | 8 ++ src/core/opencode.ts | 97 +++++++++++++++++++--- test/codex.test.ts | 188 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 12 deletions(-) diff --git a/src/core/codex.ts b/src/core/codex.ts index 2efbc3e..19eeeb1 100644 --- a/src/core/codex.ts +++ b/src/core/codex.ts @@ -220,6 +220,13 @@ function classifyHookPayload(payload: CodexHookPayload): { sourceEventType: eventName, status: "running", }; + case "PermissionRequest": + return { + activity: "busy", + detail: `Codex is waiting for permission to run ${payload.tool_name ?? "a tool"}`, + sourceEventType: eventName, + status: "waiting-input", + }; case "PostToolUse": return { activity: "busy", @@ -316,6 +323,7 @@ function buildManagedCodexHooks(command: string): CodexHooksDocument { SessionStart: [{ matcher: "startup|resume", hooks: [hook] }], UserPromptSubmit: [{ hooks: [hook] }], PreToolUse: [{ matcher: "Bash", hooks: [hook] }], + PermissionRequest: [{ hooks: [hook] }], PostToolUse: [{ matcher: "Bash", hooks: [hook] }], Stop: [{ hooks: [hook] }], }, diff --git a/src/core/opencode.ts b/src/core/opencode.ts index baeae87..aedb520 100644 --- a/src/core/opencode.ts +++ b/src/core/opencode.ts @@ -1119,6 +1119,50 @@ function getExactCodexState(index: CodexStateIndex, pane: TmuxPane): CodexStateF return null; } +function getCodexPromptText(line: string): string | null { + const match = line.match(/^[›>]\s+(?!\d+\.)(.+)$/); + return match?.[1]?.trim() || null; +} + +function isCodexPreviewChromeLine(line: string): boolean { + return ( + /^╭[─]+╮$/.test(line) || + /^╰[─]+╯$/.test(line) || + /^│.*│$/.test(line) || + line.startsWith("Tip:") || + /^gpt-[^·]+·/.test(line) || + /^[-\w.]+\s+·\s+/.test(line) || + /^─+$/.test(line) + ); +} + +function isCodexPermissionPromptLine(line: string): boolean { + return ( + line.includes("Hooks need review") || + /Approval Required/.test(line) || + /codex wants to (run|modify|use|access)/i.test(line) || + /Press enter to confirm or esc to (cancel|go back)/i.test(line) || + /^Yes, (proceed|just this once|and )/.test(line) || + /^\[[a-z]\]\s+/.test(line) + ); +} + +function hasCodexTranscriptBetween(lines: string[], startIndex: number, endIndex: number): boolean { + return lines.slice(Math.max(0, startIndex), Math.max(0, endIndex)).some((line) => { + if (isCodexPreviewChromeLine(line)) { + return false; + } + + return ( + getCodexPromptText(line) !== null || + line.startsWith("• ") || + line.startsWith("└") || + line.startsWith("… +") || + line.startsWith("↳") + ); + }); +} + function classifyCodexPreview( lines: string[], ): Pick | null { @@ -1128,14 +1172,7 @@ function classifyCodexPreview( .filter(({ line }) => /^\d+\.\s+\S/.test(line) || /^›\s+\d+\./.test(line)) .map(({ index }) => index); const latestPromptIndex = nonEmptyLines.reduce((latest, line, index) => { - if ( - (line.startsWith("› ") && !/^›\s+\d+\./.test(line)) || - (line.startsWith("> ") && !/^>\s+\d+\./.test(line)) - ) { - return index; - } - - return latest; + return getCodexPromptText(line) !== null ? index : latest; }, -1); const latestQuestionIndex = nonEmptyLines.reduce((latest, line, index) => { if ( @@ -1160,8 +1197,14 @@ function classifyCodexPreview( return latest; }, -1); + const latestPermissionPromptIndex = nonEmptyLines.reduce((latest, line, index) => { + return isCodexPermissionPromptLine(line) ? index : latest; + }, -1); const latestModelIndex = nonEmptyLines.reduce((latest, line, index) => { - return line.startsWith("model:") ? index : latest; + return line.startsWith("model:") || line.includes("│ model:") ? index : latest; + }, -1); + const latestHeaderIndex = nonEmptyLines.reduce((latest, line, index) => { + return line.includes("OpenAI Codex") ? index : latest; }, -1); if (latestQuestionIndex >= 0 && latestQuestionIndex > latestPromptIndex) { @@ -1175,7 +1218,33 @@ function classifyCodexPreview( }; } + if ( + latestPermissionPromptIndex >= 0 && + latestPermissionPromptIndex > latestPromptIndex && + latestPermissionPromptIndex > latestHeaderIndex + ) { + return { + activity: "busy", + detail: "Codex is waiting for permission input", + status: "waiting-input", + }; + } + if (latestPromptIndex >= 0 && latestPromptIndex > latestTrustIndex) { + const hasTranscript = hasCodexTranscriptBetween( + nonEmptyLines, + latestHeaderIndex >= 0 ? latestHeaderIndex + 1 : 0, + latestPromptIndex, + ); + + if (hasTranscript) { + return { + activity: "idle", + detail: "Codex is idle between turns", + status: "idle", + }; + } + return { activity: "idle", detail: "Codex is ready for a new prompt", @@ -1254,6 +1323,7 @@ function shouldPreferCodexPreview( function createCodexPreviewRuntime( preview: Pick, + session: SessionMatch | null = null, ): RuntimeInfo { return createRuntimeInfo({ activity: preview.activity, @@ -1262,7 +1332,7 @@ function createCodexPreviewRuntime( strategy: "exact", provider: "codex", heuristic: true, - session: null, + session, detail: preview.detail, }); } @@ -1321,7 +1391,10 @@ export async function buildInspectDebugInfo(pane: DiscoveredPane): Promise { const preview = await loadCodexPreviewDebug(pane.target); const previewRuntime = preview.classification - ? createCodexPreviewRuntime(preview.classification) + ? createCodexPreviewRuntime(preview.classification, state ? toCodexSessionMatch(state) : null) : null; if (state?.directory) { diff --git a/test/codex.test.ts b/test/codex.test.ts index e16fede..90b2596 100644 --- a/test/codex.test.ts +++ b/test/codex.test.ts @@ -97,6 +97,7 @@ test("buildCodexHooksTemplate emits all hook events with the ingest command", () "SessionStart", "UserPromptSubmit", "PreToolUse", + "PermissionRequest", "PostToolUse", "Stop", ]); @@ -133,6 +134,33 @@ test("persistCodexHookState classifies multiple-choice prompts as waiting-questi } }); +test("persistCodexHookState classifies permission prompts as waiting-input", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "coding-agents-tmux-codex-state-")); + const restoreEnv = setEnv({ + CODING_AGENTS_TMUX_CODEX_STATE_DIR: stateDir, + TMUX_PANE: undefined, + }); + + try { + await persistCodexHookState( + JSON.stringify({ + hook_event_name: "PermissionRequest", + cwd: "/tmp/codex-project", + session_id: "codex-session", + tool_name: "Bash", + }), + ); + + const states = readCodexStates(); + + assert.equal(states[0]?.status, "waiting-input"); + assert.equal(states[0]?.activity, "busy"); + assert.equal(states[0]?.detail, "Codex is waiting for permission to run Bash"); + } finally { + restoreEnv(); + } +}); + test("readCodexStates supports env override and default state root", () => { const preferredStateDir = mkdtempSync(join(tmpdir(), "coding-agents-tmux-codex-state-")); writeFileSync( @@ -256,6 +284,7 @@ test("updateCodexHooks merges managed hooks without dropping existing user hooks "SessionStart", "UserPromptSubmit", "PreToolUse", + "PermissionRequest", "PostToolUse", ]); }); @@ -315,6 +344,165 @@ exit 1 } }); +test("Codex preview classifies visible permission prompts as waiting-input", async () => { + const fakeTmux = installFakeTmux(` +if [ "$1" = "capture-pane" ]; then + printf '╭──────────────────────────────────────────────╮\n' + printf '│ >_ OpenAI Codex (v0.137.0) │\n' + printf '│ model: gpt-5.5 medium /model to change │\n' + printf '╰──────────────────────────────────────────────╯\n' + printf '\n' + printf 'Command Approval Required\n' + printf 'codex wants to run:\n' + printf 'npm install express\n' + printf '[a] Accept once\n' + printf '[d] Decline\n' + printf 'Press enter to confirm or esc to cancel\n' + exit 0 +fi +printf 'unexpected args: %s\n' "$*" >&2 +exit 1 +`); + const stateDir = mkdtempSync(join(tmpdir(), "coding-agents-tmux-codex-state-")); + const restoreEnv = setEnv({ + PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}`, + CODING_AGENTS_TMUX_CODEX_STATE_DIR: stateDir, + }); + + try { + const summaries = await attachRuntimeToPanes([createDiscoveredCodexPane()], { + provider: "auto", + }); + + assert.equal(summaries[0]?.runtime.status, "waiting-input"); + assert.equal(summaries[0]?.runtime.activity, "busy"); + assert.equal(summaries[0]?.runtime.source, "codex-preview"); + assert.equal(summaries[0]?.runtime.detail, "Codex is waiting for permission input"); + } finally { + restoreEnv(); + } +}); + +test("Codex preview classifies stale permission prompts before the active session as idle", async () => { + const fakeTmux = installFakeTmux(` +if [ "$1" = "capture-pane" ]; then + printf 'Hooks need review\n' + printf '› Review hooks\n' + printf ' Trust all and continue\n' + printf 'Press enter to confirm or esc to go back\n' + printf '╭──────────────────────────────────────────────╮\n' + printf '│ >_ OpenAI Codex (v0.137.0) │\n' + printf '│ model: gpt-5.5 medium /model to change │\n' + printf '╰──────────────────────────────────────────────╯\n' + printf '\n' + printf '› Tell me about this project\n' + printf '\n' + printf '• This project tracks coding-agent panes in tmux.\n' + printf '\n' + printf '› Improve documentation in @filename\n' + exit 0 +fi +printf 'unexpected args: %s\n' "$*" >&2 +exit 1 +`); + const stateDir = mkdtempSync(join(tmpdir(), "coding-agents-tmux-codex-state-")); + const restoreEnv = setEnv({ + PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}`, + CODING_AGENTS_TMUX_CODEX_STATE_DIR: stateDir, + }); + + try { + const summaries = await attachRuntimeToPanes([createDiscoveredCodexPane()], { + provider: "auto", + }); + + assert.equal(summaries[0]?.runtime.status, "idle"); + assert.equal(summaries[0]?.runtime.activity, "idle"); + assert.equal(summaries[0]?.runtime.source, "codex-preview"); + assert.equal(summaries[0]?.runtime.detail, "Codex is idle between turns"); + } finally { + restoreEnv(); + } +}); + +test("Codex preview classifies existing sessions with a visible last prompt as idle", async () => { + const fakeTmux = installFakeTmux(` +if [ "$1" = "capture-pane" ]; then + printf '╭──────────────────────────────────────────────╮\n' + printf '│ >_ OpenAI Codex (v0.137.0) │\n' + printf '│ model: gpt-5.5 medium /model to change │\n' + printf '╰──────────────────────────────────────────────╯\n' + printf '\n' + printf '› Tell me about this project\n' + printf '\n' + printf '• This project tracks coding-agent panes in tmux.\n' + printf '\n' + printf '› Write tests for @filename\n' + printf '\n' + printf ' gpt-5.5 medium · ~/Developer/coding-agents-tmux\n' + exit 0 +fi +printf 'unexpected args: %s\n' "$*" >&2 +exit 1 +`); + const stateDir = mkdtempSync(join(tmpdir(), "coding-agents-tmux-codex-state-")); + const restoreEnv = setEnv({ + PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}`, + CODING_AGENTS_TMUX_CODEX_STATE_DIR: stateDir, + }); + + try { + const summaries = await attachRuntimeToPanes([createDiscoveredCodexPane()], { + provider: "auto", + }); + + assert.equal(summaries[0]?.runtime.status, "idle"); + assert.equal(summaries[0]?.runtime.activity, "idle"); + assert.equal(summaries[0]?.runtime.source, "codex-preview"); + assert.equal(summaries[0]?.runtime.detail, "Codex is idle between turns"); + } finally { + restoreEnv(); + } +}); + +test("Codex preview classifies existing sessions without a draft as idle", async () => { + const fakeTmux = installFakeTmux(` +if [ "$1" = "capture-pane" ]; then + printf '╭──────────────────────────────────────────────╮\n' + printf '│ >_ OpenAI Codex (v0.137.0) │\n' + printf '│ model: gpt-5.5 medium /model to change │\n' + printf '╰──────────────────────────────────────────────╯\n' + printf '\n' + printf '› Tell me about this project\n' + printf '\n' + printf '• This project tracks coding-agent panes in tmux.\n' + printf '\n' + printf '› Find and fix a bug in @filename\n' + exit 0 +fi +printf 'unexpected args: %s\n' "$*" >&2 +exit 1 +`); + const stateDir = mkdtempSync(join(tmpdir(), "coding-agents-tmux-codex-state-")); + const restoreEnv = setEnv({ + PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}`, + CODING_AGENTS_TMUX_CODEX_STATE_DIR: stateDir, + }); + + try { + const summaries = await attachRuntimeToPanes([createDiscoveredCodexPane()], { + provider: "auto", + }); + + assert.equal(summaries[0]?.runtime.status, "idle"); + assert.equal(summaries[0]?.runtime.activity, "idle"); + assert.equal(summaries[0]?.runtime.source, "codex-preview"); + assert.equal(summaries[0]?.runtime.detail, "Codex is idle between turns"); + } finally { + restoreEnv(); + } +}); + test("Codex preview classifies visible numbered choices as waiting-question", async () => { const fakeTmux = installFakeTmux(` if [ "$1" = "capture-pane" ]; then