Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/core/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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] }],
},
Expand Down
97 changes: 85 additions & 12 deletions src/core/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeInfo, "activity" | "detail" | "status"> | null {
Expand All @@ -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 (
Expand All @@ -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) {
Expand All @@ -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",
Expand Down Expand Up @@ -1254,6 +1323,7 @@ function shouldPreferCodexPreview(

function createCodexPreviewRuntime(
preview: Pick<RuntimeInfo, "activity" | "detail" | "status">,
session: SessionMatch | null = null,
): RuntimeInfo {
return createRuntimeInfo({
activity: preview.activity,
Expand All @@ -1262,7 +1332,7 @@ function createCodexPreviewRuntime(
strategy: "exact",
provider: "codex",
heuristic: true,
session: null,
session,
detail: preview.detail,
});
}
Expand Down Expand Up @@ -1321,7 +1391,10 @@ export async function buildInspectDebugInfo(pane: DiscoveredPane): Promise<Inspe
const matchedEntry = matchedState ? (index.entryByState.get(matchedState) ?? null) : null;
const preview = await loadCodexPreviewDebug(pane.pane.target);
const previewRuntime = preview.classification
? createCodexPreviewRuntime(preview.classification)
? createCodexPreviewRuntime(
preview.classification,
matchedState ? toCodexSessionMatch(matchedState) : null,
)
: null;
const hookRuntime = matchedState?.directory
? createRuntimeInfo({
Expand Down Expand Up @@ -1370,7 +1443,7 @@ async function classifyCodexPaneRuntime(
): Promise<RuntimeInfo> {
const preview = await loadCodexPreviewDebug(pane.target);
const previewRuntime = preview.classification
? createCodexPreviewRuntime(preview.classification)
? createCodexPreviewRuntime(preview.classification, state ? toCodexSessionMatch(state) : null)
: null;

if (state?.directory) {
Expand Down
188 changes: 188 additions & 0 deletions test/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ test("buildCodexHooksTemplate emits all hook events with the ingest command", ()
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"Stop",
]);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -256,6 +284,7 @@ test("updateCodexHooks merges managed hooks without dropping existing user hooks
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
]);
});
Expand Down Expand Up @@ -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
Expand Down
Loading