Merge pull request #624 from Ark0N/feat/codex-blocked-detection

feat(codex): raise codex approval, question and trust dialogs as BLOCKED

Conflict resolved against #634 in src/session.ts _probePaneWorking():
#624's assignment (a dialog on screen is never work) is kept, and #634's
trust-dialog verdict block (log the hold once, remember the reading)
follows it, before _readWatching().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-16 02:00:29 +02:00
commit c76600315e
75 files changed
+2922 -112

No files matched your search

+57
View File
@@ -183,6 +183,63 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional blockedLine to the same guard', () => {
// Run over the foot of a capture at every settle and on a timer while a dialog is
// up, and a clis.json override can set it.
for (const blockedLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine,
};
},
`blockedLine ${JSON.stringify(blockedLine.slice(0, 20))} must be refused at LOAD time`
);
}
});
it('bounds the blockedLine window and refuses one with no pattern', () => {
for (const blockedLines of [0, 25, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLines: 4,
};
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
});
it('accepts a blockedLine with its window, and every shipped one', () => {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines: 24,
};
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
let shipped = 0;
for (const stock of STOCK_CLIS) {
const src = stock.capabilities.workDetect?.blockedLine;
if (!src) continue;
shipped++;
expect(compileVersionRegex(src), `${stock.id} declares a blockedLine the guard refuses`).not.toBeNull();
expect(CliEntrySchema.safeParse(JSON.parse(JSON.stringify(stock))).success).toBe(true);
}
expect(shipped).toBeGreaterThan(0);
});
});
describe('no shell text can reach the command line', () => {
+421
View File
@@ -0,0 +1,421 @@
/**
* A Codex session waiting on one of its own dialogs is BLOCKED, read off the screen.
*
* Codex is the one CLI Codeman launches with its approvals on by default, and it has no
* hook to say it stopped for one (`hooks: 'none'`), so a session parked on "Would you like
* to run the following command?" used to read as plainly idle, and one parked on a
* question (whose key hints carry `esc to interrupt`, codex's working line) as WORKING.
* The registry's `capabilities.workDetect.blockedLine` now names the dialog's chrome, the
* session reads it at every settle and on a timer while it is up, and the server raises
* exactly what a Claude `permission_prompt` hook raises, alert-only.
*
* The fixtures in `test/fixtures/codex-dialogs/` are live `tmux capture-pane -p` frames
* (no -J, what `capturePaneText()` reads) of codex-cli 0.162.0 on 2026-10-14, run with
* `-a on-request -s read-only` at 200, 80, 60, 44 and 36 columns; only trailing
* whitespace was trimmed and the scratch path replaced by `/tmp/codex-scratch/repo`.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import {
isBlockedScreen,
BLOCKED_TAIL_LINES,
BLOCKED_MAX_TAIL_LINES,
BLOCKED_RECHECK_MS,
BLOCKED_CLEAR_READS,
BLOCKED_QUIET_RECHECK_MS,
IDLE_SILENCE_MS,
PANE_PROBE_MIN_INTERVAL_MS,
} from '../src/session-activity.js';
import type { SessionMode } from '../src/types.js';
const DETECT = getCli('codex')!.capabilities.workDetect!;
const CODEX_BLOCKED = compileVersionRegex(DETECT.blockedLine!)!;
const CODEX_WINDOW = DETECT.blockedLines!;
const CODEX_WORKING = compileVersionRegex(DETECT.workingLine)!;
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
const blocked = (screen: string): boolean => isBlockedScreen(screen, CODEX_BLOCKED, CODEX_WINDOW);
/** Every dialog codex was measured blocking on. */
const DIALOGS = [
'exec-approval.txt',
'exec-approval-w36.txt', // the key hint wraps: `Press enter to confirm or esc to` / `cancel`
'exec-approval-w44.txt',
'exec-approval-long-command.txt', // a 14-row heredoc preview between the header and the options
'exec-approval-60x14.txt', // a 14-row pane: codex folds the preview to `[… 21 lines]`
'edit-approval.txt',
'network-escalation-approval.txt',
'question.txt',
'question-option2-selected.txt',
'question-1of2.txt', // `| ←/→ to navigate questions |` in the hint
'question-2of2-submit-all.txt', // `enter to submit all` on the last question
'question-w36.txt', // the hint one segment per row, the `›` row 16 rows up
'question-w60.txt',
'question-notes.txt', // the notes field open: `tab or esc to clear notes | enter to submit answer`
'question-notes-typed.txt',
'trust-dialog.txt', // drawn at the TOP of an otherwise blank screen at startup
'trust-dialog-w40.txt',
// The same prompt, reached through the update prompt (`2. Skip`) on 0.162.0 with 0.163.0
// out: `2. Back to Agent Command Center` over `enter continue · esc back`. Captured
// 2026-10-16 from Codeman's own launch flags in a fresh git repo, at 120 columns.
'trust-dialog-back.txt',
];
/** Screens with no dialog codex blocks on, several of them quoting the dialogs' words. */
const NOT_DIALOGS = [
'idle-start.txt',
'composer-typed.txt',
'working.txt',
'after-approve.txt',
'after-deny.txt',
'after-question.txt', // the transcript keeps `• Questions 1/1 answered` and the question
'slash-popup.txt', // `› /plan switch to Plan mode`: a `›` row with nothing after it
'transcript-quotes-dialog.txt', // an answer that printed the dialog's lines verbatim
'composer-typed-dialog-text.txt', // the user TYPED them into the composer
'composer-typed-dialog-text-w60.txt', // ... and at 60 columns, where the text wraps
'model-popup.txt', // menus the user opened at the keyboard: `enter select · esc back`
'permissions-popup.txt', // (with the quoted dialog from the earlier answer still above it)
'review-popup.txt',
'implement-plan.txt', // deliberately not blocked: a plan turn that ended, same footer as /model
];
describe('codex blockedLine on live captures', () => {
it('is a pattern the registry guard accepts, with a window inside the cap', () => {
expect(DETECT.blockedLine!.length).toBeLessThanOrEqual(200);
expect(CODEX_BLOCKED).not.toBeNull();
expect(CODEX_WINDOW).toBeGreaterThan(BLOCKED_TAIL_LINES);
expect(CODEX_WINDOW).toBeLessThanOrEqual(BLOCKED_MAX_TAIL_LINES);
});
it.each(DIALOGS)('reads %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(true);
});
it.each(NOT_DIALOGS)('does not read %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(false);
});
it('needs the whole window for the narrowest question, and no more than it declares', () => {
// At 36 columns the selected option sits 16 non-blank rows above the end; a window
// that stops short of it reads nothing, which is the pre-field behaviour, never a
// false alert.
const narrow = fixture('question-w36.txt');
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 16)).toBe(true);
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 15)).toBe(false);
});
it('sees the question dialogs that the working line used to claim', () => {
// Why a blocked screen has to WIN over the working line in the probe: the question's
// own key hints end in `esc to interrupt`.
for (const name of ['question.txt', 'question-w36.txt', 'question-1of2.txt']) {
expect(CODEX_WORKING.test(fixture(name)), name).toBe(true);
expect(blocked(fixture(name)), name).toBe(true);
}
});
});
describe('codex blockedLine against a screen the agent writes', () => {
// The foot of codex's screen while no dialog is up: composer, footer (whose model and
// path vary), hint row.
const composer = (typed = 'Ask Codex to do anything', footer = ' GPT-6-Luna default · ~/cases/x'): string[] => [
`› ${typed}`,
'',
footer,
' ? for shortcuts',
];
const forgedDialog = [
'• Here is what the dialog looks like:',
'› 1. Yes, proceed (y)',
' 2. No, and tell Codex what to do differently (esc)',
'',
' Press enter to confirm or esc to cancel',
];
it('refuses a forged dialog in the transcript, whatever follows it', () => {
expect(blocked([...forgedDialog, '', ...composer()].join('\n'))).toBe(false);
// While a prompt is typed the hint row goes away, so the forged hint sits only two
// rows above the end: the composer row (column 0, `›`) still breaks the run.
expect(blocked([...forgedDialog, ...composer('next thing').slice(0, 3)].join('\n'))).toBe(false);
// Even with a lowercase model and a short path, the footer row would have to end in
// one of the hint's own closing words.
expect(blocked([...forgedDialog, ...composer('x', ' gpt-5.6 default · ~/x').slice(0, 3)].join('\n'))).toBe(false);
});
it('refuses the working view with the dialog quoted right above the status line', () => {
const screen = [...forgedDialog, '', '• Working (3s • esc to interrupt)', '', ...composer()].join('\n');
expect(blocked(screen)).toBe(false);
});
it('refuses a hint row without the option row above it, and an option row without the hint', () => {
expect(
blocked(
[' Would you like to run the following command?', ' Press enter to confirm or esc to cancel'].join('\n')
)
).toBe(false);
expect(blocked(['› 1. Yes, proceed (y)', ' 2. No (esc)'].join('\n'))).toBe(false);
});
it('stays linear on a hostile window', () => {
// Many option rows and long rows, ending in something that is almost the hint: the
// pattern must give up quickly, since it runs on a timer while a dialog is up.
const row = `› 1. ${'x'.repeat(300)}`;
const screen = [
...Array(BLOCKED_MAX_TAIL_LINES - 1).fill(row),
` Press enter to confirm ${'or '.repeat(60)}`,
].join('\n');
const started = performance.now();
for (let i = 0; i < 50; i++) blocked(screen);
expect(performance.now() - started).toBeLessThan(250);
expect(blocked(screen)).toBe(false);
});
it('caps the window at BLOCKED_MAX_TAIL_LINES whatever the caller asks', () => {
const dialog = fixture('exec-approval.txt');
const padded = `${dialog}\n${Array(BLOCKED_MAX_TAIL_LINES).fill(' filler').join('\n')}`;
expect(isBlockedScreen(padded, CODEX_BLOCKED, 10_000)).toBe(false);
});
it('answers false for an empty or unreadable capture', () => {
expect(isBlockedScreen('', CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
expect(isBlockedScreen(null, CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
});
});
// ─── The session's blocked state ────────────────────────────────────────────
/** Codex's own composer repaint: carries the glyph that arms the idle confirmation. */
const CODEX_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/**
* What the pane shows, and how often the session has read it: every read is a synchronous
* `tmux capture-pane` on the live server.
*/
type FakeScreen = { text: string | null; captures?: number };
/** A session whose mux answers captures from `screen.text` (null = unreadable). */
function withFakePane(screen: FakeScreen, mode: SessionMode = 'codex'): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => {
screen.captures = (screen.captures ?? 0) + 1;
return screen.text;
},
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/**
* The pane changes the only way a real one can: tmux redraws it to the attached client,
* which reaches the session as output. A cursor move and an erase, with no composer glyph
* in it, so it marks activity without arming the idle confirmation.
*/
function redraw(session: Session, screen: FakeScreen, text: string | null): void {
screen.text = text;
feed(session, '\x1b[2;1H\x1b[K');
}
/** Output, then quiet: the moment the idle confirmation reads the screen. */
function settle(session: Session): void {
for (let i = 0; i < 3; i++) {
feed(session, CODEX_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
function recordChanges(session: Session): Array<{ blocked: boolean; frame: string | null }> {
const changes: Array<{ blocked: boolean; frame: string | null }> = [];
session.on('blockedChanged', (b: boolean, frame: string | null) => changes.push({ blocked: b, frame }));
return changes;
}
describe('Session.screenBlocked', () => {
afterEach(() => {
vi.useRealTimers();
});
it('raises once when the pane settles on an approval, and reads the session idle', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
expect(changes).toHaveLength(1);
expect(changes[0].blocked).toBe(true);
// The frame it was read from rides along, as the approval card's context.
expect(changes[0].frame).toContain('Would you like to run the following command?');
// The dialog staying up is one dialog: no second raise from the timer or a repaint.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
settle(session);
expect(changes).toHaveLength(1);
});
it('calls a question dialog blocked and idle, not working', () => {
// Its key hints carry `esc to interrupt`, which is codex's working line: before the
// field the probe called this pane busy for as long as the question stayed up.
vi.useFakeTimers();
const session = withFakePane({ text: fixture('question.txt') });
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
});
it('clears after the dialog has been gone for BLOCKED_CLEAR_READS reads, without waiting for the turn', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
// Answered: codex resumes the turn, and nothing settles while it runs. The timer is
// what notices, and one read alone is not enough.
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * (BLOCKED_CLEAR_READS - 1));
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
// Nothing keeps reading a screen with no dialog on it.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 10);
expect(changes).toHaveLength(2);
});
it('does not flap on one read that misses a dialog still on screen', () => {
// A capture inside a full-frame repaint can come back without the footer. Clearing on
// it would raise again a moment later: a second alert, push and webhook for one dialog.
vi.useFakeTimers();
const dialog = fixture('exec-approval.txt');
const screen = { text: dialog as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
redraw(session, screen, dialog);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 3);
expect(session.screenBlocked).toBe(true);
expect(changes).toHaveLength(1);
});
it('keeps its state through an unreadable capture, in either direction', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
settle(session);
// The pane moved, so the session looks, and tmux answers nothing it can read.
redraw(session, screen, null);
const before = screen.captures!;
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
expect(screen.captures! - before).toBeGreaterThanOrEqual(5);
expect(session.screenBlocked).toBe(true);
// ...and it is still watching: once the screen reads again without the dialog, it clears.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('does not re-read a quiet pane every interval while a dialog sits unanswered', () => {
// Each read is a synchronous `tmux capture-pane` (about 8 ms of blocked event loop,
// measured 2026-10-14), and a dialog can sit unanswered overnight. It leaves the
// screen only through a repaint, which reaches the session as output, so a pane that
// drew nothing since the last reading that saw the dialog is still showing it.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
expect(session.screenBlocked).toBe(true);
const quietMs = 10 * 60_000;
const before = screen.captures!;
vi.advanceTimersByTime(quietMs);
// The safety-net read only, never one per BLOCKED_RECHECK_MS (that would be 300 here).
expect(screen.captures! - before).toBeLessThanOrEqual(quietMs / BLOCKED_QUIET_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
// The answer repaints the pane, and the clear comes as fast as before.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('takes the confirming read even when no output follows the reading that missed the dialog', () => {
// tmux updates the pane before it sends this client the repaint, so a capture can see
// the dialog gone while the session has been sent nothing yet. Skipping a quiet pane
// is right only while the last reading SAW the dialog: once one reading missed it,
// waiting for output could leave the alert up for the whole safety-net interval.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
vi.advanceTimersByTime(PANE_PROBE_MIN_INTERVAL_MS);
screen.text = fixture('after-approve.txt'); // and not one byte of output, from here on
(session as unknown as { _probePaneWorking(): boolean | null })._probePaneWorking();
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(false);
});
it('raises again for the next dialog after a clear', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
screen.text = fixture('edit-approval.txt');
settle(session);
expect(changes.map((c) => c.blocked)).toEqual([true, false, true]);
});
it('never reads a dialog off the screen of a CLI that declares no blockedLine', () => {
// Claude's dialogs arrive as hooks; gemini declares nothing. Same screen, no state.
vi.useFakeTimers();
for (const mode of ['claude', 'gemini'] as const) {
const session = withFakePane({ text: fixture('exec-approval.txt') }, mode);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked, mode).toBe(false);
expect(changes, mode).toHaveLength(0);
}
});
it('drops the state when a new pane starts, announcing it', () => {
vi.useFakeTimers();
const session = withFakePane({ text: fixture('trust-dialog.txt') });
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
(session as unknown as { _resetBuffers(): void })._resetBuffers();
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
});
});
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• I’ll inspect calc.py, then use the file editing tool to add the function.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Working (5s • esc to interrupt)
└ Tip: Use /feedback to send logs to the maintainers when something looks off.
› Ask Codex to do anything
GPT-6-Luna medium · /tmp/codex-scratch/repo · Request approval to create file Plan mode
? for shortcuts
@@ -0,0 +1,23 @@
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to
ask me which animal to write in it, with 3 options. Ask
only that one question.
■ Conversation interrupted - use /feedback if something went
wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to
cancel tab to add notes | enter to submit answer | esc
to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo Plan mode
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
■ Conversation interrupted - use /feedback if something went wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to cancel tab to add notes | enter to submit answer | esc to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo · Ask for animal choice Plan mode (shift+tab to cycle)
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
GPT-6-Luna default · /tmp/codex-scratch/repo
+50
View File
@@ -0,0 +1,50 @@
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• I’ll inspect calc.py, then use the file editing tool to add the function.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
Would you like to make the following edits?
Description: Apply proposed file edits
Destination: /tmp/codex-scratch/repo/calc.py
› 1. Yes, proceed (y)
2. Yes, and don't ask again for these files (a)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+14
View File
@@ -0,0 +1,14 @@
Would you like to run the following command?
Environment: local
Reason: May I run the requested quoted heredoc with
write access to create lines.txt?
$ cat > lines.txt <<'EOF'
line 1
[… 21 lines] ^a view all
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -0,0 +1,50 @@
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
› Run this exact multi-line shell command with escalated permissions (ask me for approval): a bash heredoc that writes 12 lines 'line 1' through 'line 12' into lines.txt, using cat > lines.txt with a
quoted heredoc, each line written out literally.
• I’ll request approval to run the quoted heredoc that writes the 12 literal lines to lines.txt.
• Running cat > lines.txt <<'EOF' …
+ Show details
Would you like to run the following command?
Environment: local
Reason: May I run the requested quoted heredoc with write access to create lines.txt?
$ cat > lines.txt <<'EOF'
line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9
line 10
line 11
line 12
EOF
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+30
View File
@@ -0,0 +1,30 @@
› Run: touch second.txt (needs app…
access outside the read only
sandbox. I’ll request approval to
run that exact command.
• Running touch second.txt
Would you like to run the
following command?
Environment: local
Reason: May I run `touch
second.txt` outside the read
only sandbox to create the
requested file?
$ touch second.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for
commands that start with `touch
second.txt` (p)
3. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to
cancel
+30
View File
@@ -0,0 +1,30 @@
outside the sandbox)
• Creating second.txt requires write access
outside the read only sandbox. I’ll
request approval to run that exact
command.
• Running touch second.txt
Would you like to run the following
command?
Environment: local
Reason: May I run `touch second.txt`
outside the read only sandbox to create
the requested file?
$ touch second.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands
that start with `touch second.txt` (p)
3. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
• Running touch created.txt
Would you like to run the following command?
Environment: local
Reason: May I run `touch created.txt` outside the read only sandbox to create the requested file?
$ touch created.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `touch created.txt` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
⣀⣤⣤⣤⣀⣀
⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀
⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄
⣀⣾⣿⣿⠃ ⣀⣴⣾⣿⣿⡿⠟⠋ ⣀⡀ ⠈⠙⢿⣿⣿⣧
⣀⣶⣿⣿⣿⣿⡇ ⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇
⣰⣿⣿⡿⢿⣿⣿⡇ ⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷
⢰⣿⣿⡟⠁⢸⣿⣿⡇ ⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀
⣾⣿⣿⠃ ⢸⣿⣿⡇ ⢸⣿⣿⠋⠁ ⠈⠙⢻⣿⣿⣿⣶⣤⡀ ⠹⢿⣿⣷⡄
⢸⣿⣿⣇ ⠸⣿⣿⣷⣦⣼⣿⣿ ⢸⣿⣿⠻⢿⣿⣿⡇ ⠘⣿⣿⣿
⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀ ⢀⣠⣼⣿⣿ ⣿⣿⡇ ⣿⣿⣿⠇
⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿ ⣿⣿⡇ ⣠⣿⣿⣿
⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿ ⣿⣿⣷⣴⣿⣿⣿⠃
⠘⣿⣿⣷ ⠈⠛⢿⣿⣿⣿⠿⠋⠉ ⣠⣴⣿⣿⣿ ⣿⣿⣿⣿⣿⠟⠁
⠻⣿⣿⣿⣤⣀ ⠈⠉ ⢀⣤⣶⣿⣿⣿⠿⠟⠁ ⣰⣿⣿⡟⠋⠁
⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁ ⣠⣼⣿⣿⡟
⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏
⠈⠙⠛⠿⠿⠿⠿⠛⠉
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Questions 2/2 answered
• What should the greeting file be named?
answer: greeting.txt (Recommended)
• What greeting should it contain?
answer: Hello, world! (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Proposed Plan
# Add an English Greeting File
## Summary
Create greeting.txt in the repository root containing Hello, world!.
## Changes
• Add the greeting as plain text, followed by a newline.
• No public APIs or existing files need to change.
## Acceptance Check
• Confirm greeting.txt exists at the repository root and contains exactly Hello, world! on its first line.
## Assumptions
• Use the selected filename and greeting.
• Keep the file as plain text.
Worked for 1m 47s • 4:27 AM
Implement this plan?
› 1. Yes, implement this plan Switch to Default and start coding
2. Yes, clear context and implement Start a fresh thread (current context: 2% used)
3. No, stay in Plan mode Continue planning with the model
enter select · esc back
+50
View File
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Questions 2/2 answered
• What should the greeting file be named?
answer: greeting.txt (Recommended)
• What greeting should it contain?
answer: Hello, world! (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Proposed Plan
# Add an English Greeting File
## Summary
Create greeting.txt in the repository root containing Hello, world!.
## Changes
• Add the greeting as plain text, followed by a newline.
• No public APIs or existing files need to change.
## Acceptance Check
• Confirm greeting.txt exists at the repository root and contains exactly Hello, world! on its first line.
## Assumptions
• Use the selected filename and greeting.
• Keep the file as plain text.
Worked for 1m 47s • 4:27 AM
Select Model and Effort
› 1. GPT-6-Luna (current) Fast and affordable model for easier tasks.
2. GPT-5.6-Terra Older balanced model for straightforward work.
3. GPT-5.6-Luna Older fast and efficient model.
enter select · esc back
@@ -0,0 +1,50 @@
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
• Running curl -sS -o /dev/null -w '%{http_code}' https://example.com
Would you like to run the following command?
Environment: local
Reason: May I rerun curl with network access to fetch https://example.com and report its HTTP status?
$ curl -sS -o /dev/null -w '%{http_code}' https://example.com
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `curl -sS -o /dev/null -w '%{http_code}' https://example.com` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
› Run: touch second.txt (needs approval outside the sandbox)
• Creating second.txt requires write access outside the read only sandbox. I’ll request approval to run that exact command.
✗ You canceled the request to run touch second.txt
• Failed (exit 1) touch second.txt
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your question tool which color to write in it (offer 3 options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna default for Default mode.
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
Update Model Permissions
1. Ask for approval Read and edit workspace files and run commands, with approval required for internet access or edits outside the workspace
2. Approve for me Only ask for actions detected as potentially unsafe
3. Full Access Use with caution: Codex can edit files outside this workspace and access the internet without approval
› 4. Read Only (current) Read workspace files, with approval required for edits or internet access
enter select · esc back
+49
View File
@@ -0,0 +1,49 @@
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 1/2 (2 unanswered)
What should the greeting file be named?
› 1. greeting.txt (Recommended) Use a clear, conventional text filename.
2. README greeting Add the greeting as a separate README-style Markdown file.
3. hello.txt Use a short, friendly filename.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt
@@ -0,0 +1,49 @@
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 2/2 (1 unanswered)
What greeting should it contain?
› 1. Hello, world! (Recommended) Use a short, familiar English greeting.
2. Welcome! Use a concise welcoming message.
3. Good morning! Use a time-specific greeting.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit all | ←/→ to navigate questions | esc to interrupt
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
Question 1/1 (1 unanswered)
Which animal should I write in animal.txt?
› 1. Cat Write “cat” in the file.
2. Dog Write “dog” in the file.
3. Elephant Write “elephant” in the file.
4. None of the above Optionally, add details in notes (tab)
› abc
tab or esc to clear notes | enter to submit answer
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
Question 1/1 (1 unanswered)
Which animal should I write in animal.txt?
› 1. Cat Write “cat” in the file.
2. Dog Write “dog” in the file.
3. Elephant Write “elephant” in the file.
4. None of the above Optionally, add details in notes (tab)
› Add notes
tab or esc to clear notes | enter to submit answer
@@ -0,0 +1,49 @@
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 1/1 (1 unanswered)
Which language should the greeting file use?
1. English (Recommended) Write a simple greeting in English.
› 2. French Write a simple greeting in French.
3. Spanish Write a simple greeting in Spanish.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit answer | esc to interrupt
+29
View File
@@ -0,0 +1,29 @@
• Explored · 1 failed
└ List rg --files -g color.txt -g
AGENTS.md (exit 1)
+ Show details
Question 1/1 (1 unanswered)
Which color should I write in
`color.txt`?
› 1. Blue Write the
color
blue.
2. Green Write the
color
green.
3. Red Write the
color red.
4. None of the Optionally
above , add
details in
notes
(tab)
tab to add notes
enter to submit answer
esc to interrupt
+29
View File
@@ -0,0 +1,29 @@
› Run: touch second.txt (needs approval outside the sandbo…
■ Conversation interrupted - use /feedback if something went
wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your
question tool which color to write in it (offer 3
options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
Question 1/1 (1 unanswered)
Which color should I write in `color.txt`?
› 1. Blue Write the color blue.
2. Green Write the color green.
3. Red Write the color red.
4. None of the above Optionally, add details in notes
(tab)
tab to add notes | enter to submit answer
esc to interrupt
+49
View File
@@ -0,0 +1,49 @@
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 1/1 (1 unanswered)
Which language should the greeting file use?
› 1. English (Recommended) Write a simple greeting in English.
2. French Write a simple greeting in French.
3. Spanish Write a simple greeting in Spanish.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit answer | esc to interrupt
+50
View File
@@ -0,0 +1,50 @@
› Run: touch second.txt (needs approval outside the sandbox)
• Creating second.txt requires write access outside the read only sandbox. I’ll request approval to run that exact command.
✗ You canceled the request to run touch second.txt
• Failed (exit 1) touch second.txt
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your question tool which color to write in it (offer 3 options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna default for Default mode.
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
Select a review preset
› 1. Review against a base branch (PR Style)
2. Review uncommitted changes
3. Review a commit
4. Custom review instructions
enter select · esc back
+49
View File
@@ -0,0 +1,49 @@
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
› /plan switch to Plan mode
› /plan
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Model changed to gpt-6-luna default for Default mode.
› Run: touch second.txt (needs approval outside the sandbox)
• Creating second.txt requires write access outside the read only sandbox. I’ll request approval to run that exact command.
✗ You canceled the request to run touch second.txt
• Failed (exit 1) touch second.txt
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your question tool which color to write in it (offer 3 options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna default for Default mode.
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
└ Tip: NEW: Network proxy can now be enabled from /experimental. Restart Codex after enabling it.
Worked for 1s • 4:31 AM
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+12
View File
@@ -0,0 +1,12 @@
Folder access
/tmp/codex-scratch/repo
Trust this folder? Codex can read, edit, and run files here, subject to your permission settings. Folder settings
can run code automatically, even without a model request. Continue only if you trust these files. Your trust
decision will be saved.
› 1. Trust and continue
2. Back to Agent Command Center
enter continue · esc back
+17
View File
@@ -0,0 +1,17 @@
Folder access
/tmp/codex-scratch/repo
Trust this folder? Codex can read,
edit, and run files here, subject to
your permission settings. Folder
settings can run code automatically,
even without a model request.
Continue only if you trust these
files. Your trust decision will be
saved.
› 1. Trust and continue
2. Quit
enter continue · esc quit
+11
View File
@@ -0,0 +1,11 @@
Folder access
/tmp/codex-scratch/repo
Trust this folder? Codex can read, edit, and run files here, subject to your permission settings. Folder settings can run code automatically, even without a model request. Continue only if you
trust these files. Your trust decision will be saved.
› 1. Trust and continue
2. Quit
enter continue · esc quit
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
• Working (1s • esc to interrupt)
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · ⠧
? for shortcuts
+54 -1
View File
@@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie';
import { registerApprovalRoutes } from '../../src/web/routes/approval-routes.js';
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { raiseScreenBlocked } from '../../src/web/screen-blocked.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockSession } from '../mocks/index.js';
@@ -455,7 +456,6 @@ describe('approval routes', () => {
});
});
describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
let harness: RouteTestHarness;
let session: MockSession;
@@ -505,6 +505,59 @@ describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
});
});
describe('approval routes: dialogs read off a hook-less screen (codex) are alert-only', () => {
let harness: RouteTestHarness;
let session: MockSession;
const noopDeps = {
broadcast: () => {},
broadcastSessionStateDebounced: () => {},
sendPushNotifications: () => {},
};
beforeEach(async () => {
harness = await createEnvelopeHarness();
session = harness.ctx.sessions.get(SESSION_ID)!;
session.mode = 'codex';
});
afterEach(async () => {
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
await harness.app.close();
});
it('lists the item, and the page-load sweep keeps it', async () => {
raiseScreenBlocked(session as never, noopDeps, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
const approvals = await listApprovals(harness);
expect(approvals).toHaveLength(1);
expect(approvals[0]).toMatchObject({ sessionId: SESSION_ID, kind: 'permission', answerInTerminal: true });
});
it('refuses an answer with the terminal-only error, not "cannot have pending approvals"', async () => {
// The hooks gate would say a codex session cannot have an item at all, which is false
// about the one the caller holds; the alert-only check runs first.
raiseScreenBlocked(session as never, noopDeps, null);
const [item] = await listApprovals(harness);
for (const payload of [{ action: 'approve' }, { action: 'deny' }, { action: 'option', option: 1 }]) {
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/answer`, payload });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/^Codex approvals must be answered in the terminal/);
}
expect(session.writeBuffer).toEqual([]);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('can still be dismissed', async () => {
raiseScreenBlocked(session as never, noopDeps, null);
const [item] = await listApprovals(harness);
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/dismiss`, payload: {} });
expect(res.statusCode).toBe(200);
expect(await listApprovals(harness)).toHaveLength(0);
});
});
describe('approval routes: multi-user scoping', () => {
const saved: Record<string, string | undefined> = {};
+34 -1
View File
@@ -24,7 +24,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_WAIT_MS } from '../../src/config/agent-wait.js';
import { MAX_WAIT_MS, MIN_WAIT_MS } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
@@ -315,6 +315,39 @@ describe('POST /api/sessions/:id/input with wait', () => {
expect(body.data.status).toBe('busy');
});
it('a duplicate on a codex pane showing a dialog answers blocked, as GET .../wait does', async () => {
// A screen-read dialog is a level, not an edge: the original delivery may have raised
// it before the caller's connection dropped, and the retry must not wait out its
// timeout for an edge that already passed.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
mode: string;
status: string;
screenBlocked?: boolean;
writeBuffer: string[];
};
session.mode = 'codex';
await send(app, { input: 'first', clientId: 'c-codex', seq: 1 });
session.status = 'idle';
session.screenBlocked = true;
const replay = await send(app, {
input: 'first',
clientId: 'c-codex',
seq: 1,
wait: 'blocked',
waitTimeout: MIN_WAIT_MS,
});
const body = replay.json();
expect(body.data.duplicate).toBe(true);
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: true, timedOut: false });
// ...and only to a caller that asked for it: such a pane is idle too.
const idle = await send(app, { input: 'first', clientId: 'c-codex', seq: 1, wait: 'idle' });
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
});
it('gives the dedup seq back when a full waiter pool rejects the request', async () => {
// Otherwise the caller's retry is refused as a duplicate and the input vanishes.
const { app, ctx } = await harness();
+75
View File
@@ -34,6 +34,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { MAX_WAIT_MS, MIN_WAIT_MS, MAX_WAITERS_TOTAL, MAX_WAITERS_PER_OWNER } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
@@ -463,6 +464,80 @@ describe('GET /api/sessions/:id/wait: modes that install no hooks', () => {
});
});
describe('GET /api/sessions/:id/wait: blocked read off a codex screen', () => {
/** The listener deps the screen-blocked raise reaches; nothing else matters here. */
function stubDeps() {
return {
broadcast: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
getRunSummaryTracker: vi.fn(() => undefined),
};
}
afterEach(() => {
approvalInbox.resolveForSession(SESSION_ID, 'session_ended');
});
it('accepts until=blocked for codex, whose dialogs are read off its screen', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=1000` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['blocked']);
});
it('answers blocked at once for a dialog already on screen, and only to a caller who asked', async () => {
// A hook-raised blocked is an edge; this one is a level the server is still reading,
// so a caller who asks after the dialog appeared does not wait out its timeout.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
mode: string;
status: string;
screenBlocked?: boolean;
};
session.mode = 'codex';
session.status = 'idle';
session.screenBlocked = true;
const asked = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle,blocked` });
expect(asked.json().data.wait).toMatchObject({ signal: 'blocked', immediate: true });
// Such a pane is idle too, and a caller that asked for idle alone keeps getting idle.
const idle = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
});
it('wakes a parked until=blocked waiter when the session raises a screen-read dialog', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.mode = 'codex';
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
session.emit('blockedChanged', true, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
const body = (await pending).json();
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: false, timedOut: false });
expect(approvalInbox.getForSession(SESSION_ID)?.answerInTerminal).toBe(true);
// ...and the dialog leaving the screen resolves the item it opened.
session.emit('blockedChanged', false, null);
expect(approvalInbox.getForSession(SESSION_ID)).toBeUndefined();
});
it('still refuses until=stop for codex: nothing on a screen says a turn ended', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop,blocked` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Signal(s) stop never fire for codex');
});
});
describe('GET /api/sessions/:id/wait: liveness beats the reported status', () => {
it('answers exit for a session whose PTY is gone, not the idle its status claims', async () => {
// Session parks a dead PTY at status 'idle' and the object survives in the map,
+110
View File
@@ -0,0 +1,110 @@
/**
* What the server raises for a dialog read off a hook-less CLI's screen (codex), and what
* clears it: `web/screen-blocked.ts`. The session side, and the codex captures it reads,
* are in `codex-blocked-detection.test.ts`.
*
* The contract: exactly what a Claude `permission_prompt` hook raises (the `blocked` wait
* signal, an Approvals Inbox item, the `hook:permission_prompt` broadcast, push and
* webhook, a run-summary row), but ALERT-ONLY, since the dialog was read from text the
* agent writes: the item is `answerInTerminal`, push carries no `approvalId`, and the
* inbox never resolves or answers it on its own.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { raiseScreenBlocked, clearScreenBlocked, screenBlockedMessage } from '../src/web/screen-blocked.js';
import { approvalInbox } from '../src/web/approval-inbox.js';
import { sessionWaits } from '../src/web/session-wait-registry.js';
import { SseEvent } from '../src/web/sse-events.js';
import type { SessionMode } from '../src/types.js';
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
describe('raiseScreenBlocked / clearScreenBlocked', () => {
const SESSION = { id: 'screen-blocked-test', name: 'w9-scratch', mode: 'codex' as SessionMode };
function deps() {
const recordHookEvent = vi.fn();
return {
broadcast: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
getRunSummaryTracker: vi.fn(() => ({ recordHookEvent })),
recordHookEvent,
};
}
afterEach(() => {
approvalInbox.resolveForSession(SESSION.id, 'session_ended');
sessionWaits.cancelAll(SESSION.id);
});
it('raises what a permission_prompt hook raises, alert-only', async () => {
const d = deps();
const waiting = sessionWaits.waitForSignal(SESSION.id, { until: ['blocked'], timeoutMs: 5000 });
const itemId = raiseScreenBlocked(SESSION, d, fixture('exec-approval.txt'));
expect((await waiting).signal).toBe('blocked');
const item = approvalInbox.getForSession(SESSION.id)!;
expect(item.id).toBe(itemId);
expect(item.kind).toBe('permission');
expect(item.answerInTerminal).toBe(true);
expect(item.message).toBe('Codex is waiting for your answer in the terminal');
// The frame is the card's context, and nothing parsed it into answer buttons.
expect(item.context).toContain('Press enter to confirm or esc to cancel');
expect(item.options).toBeUndefined();
expect(d.broadcast).toHaveBeenCalledWith(
SseEvent.HookPermissionPrompt,
expect.objectContaining({ sessionId: SESSION.id, source: 'screen', approvalId: itemId })
);
expect(d.broadcastSessionStateDebounced).toHaveBeenCalledWith(SESSION.id);
// Push and webhook get the message and NO approvalId, so no Approve/Deny buttons.
const [event, push] = d.sendPushNotifications.mock.calls[0] as [string, Record<string, unknown>];
expect(event).toBe(SseEvent.HookPermissionPrompt);
expect(push).toMatchObject({ sessionId: SESSION.id, sessionName: 'w9-scratch', source: 'screen' });
expect(push.message).toBe(screenBlockedMessage('codex'));
expect(push.approvalId).toBeUndefined();
expect(d.recordHookEvent).toHaveBeenCalledWith('permission_prompt', expect.objectContaining({ source: 'screen' }));
});
it('never steers a keystroke: the inbox item cannot be answered', () => {
raiseScreenBlocked(SESSION, deps(), fixture('exec-approval.txt'));
const item = approvalInbox.getForSession(SESSION.id)!;
// No capture callback, so the inbox's Claude-shaped sweep never resolves it either:
// the session owns its lifetime.
expect(approvalInbox.verifyStillAnswerable(item.id)).toBe(true);
approvalInbox.resolveIfDialogGone(SESSION.id);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(item.id);
});
it('clears by resolving the item it opened, and only that one', () => {
const resolved: string[] = [];
const prev = approvalInbox.onResolved;
approvalInbox.onResolved = (info) => resolved.push(`${info.id}:${info.resolution}`);
try {
const itemId = raiseScreenBlocked(SESSION, deps(), null);
clearScreenBlocked(SESSION.id, itemId);
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
expect(resolved).toContain(`${itemId}:resolved_in_terminal`);
// An item it did not open (here a newer one) is left alone.
const newer = raiseScreenBlocked(SESSION, deps(), null);
clearScreenBlocked(SESSION.id, itemId);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
clearScreenBlocked(SESSION.id, null);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
} finally {
approvalInbox.onResolved = prev;
}
});
it('raises nothing for a session whose CLI reports blocked through hooks', () => {
const d = deps();
expect(raiseScreenBlocked({ ...SESSION, mode: 'claude' }, d, null)).toBeNull();
expect(d.broadcast).not.toHaveBeenCalled();
expect(d.sendPushNotifications).not.toHaveBeenCalled();
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
});
});
+36 -1
View File
@@ -11,6 +11,7 @@ import {
SessionWaitRegistry,
WaitCapacityError,
hooksAvailableForMode,
blockedSignalAvailable,
parseWaitSignals,
resolveWaitSignals,
signalForStatus,
@@ -100,6 +101,26 @@ describe('signalForStatus', () => {
});
});
describe('blockedSignalAvailable', () => {
it('is true where hooks report it, and for a CLI that declares a blockedLine (codex)', () => {
expect(blockedSignalAvailable('claude')).toBe(true);
expect(blockedSignalAvailable('codex')).toBe(true);
// Not keyed on hooks: codex has none, and its screen-read dialogs are still a source.
expect(hooksAvailableForMode('codex')).toBe(false);
});
it('is false for a CLI with neither (shell, gemini, opencode)', () => {
for (const mode of ['shell', 'gemini', 'opencode'] as const) {
expect(blockedSignalAvailable(mode)).toBe(false);
}
});
it('follows the per-session deepseek bridge, which is its only source', () => {
expect(blockedSignalAvailable('deepseek')).toBe(true);
expect(blockedSignalAvailable('deepseek', { deepSeekStatusReporting: false })).toBe(false);
});
});
describe('hooksAvailableForMode', () => {
it('is true only for claude', () => {
expect(hooksAvailableForMode('claude')).toBe(true);
@@ -144,8 +165,22 @@ describe('resolveWaitSignals', () => {
const result = resolveWaitSignals('stop', codex);
expect(result.until).toEqual([]);
expect(result.error).toContain('codex');
expect(resolveWaitSignals('blocked', codex).error).toBeTruthy();
expect(resolveWaitSignals('idle,stop', codex).error).toBeTruthy();
// gemini declares no blockedLine, so nothing can ever raise `blocked` for it.
expect(resolveWaitSignals('blocked', { mode: 'gemini' as SessionMode }).error).toBeTruthy();
});
it('accepts `blocked` on codex, whose dialogs are read off the screen, but still not `stop`', () => {
// capabilities.workDetect.blockedLine gives codex a source for `blocked`; nothing on a
// screen says a turn ended, so `stop` keeps its 400.
expect(resolveWaitSignals('blocked', codex)).toEqual({ until: ['blocked'], error: null });
expect(resolveWaitSignals('idle,blocked,exit', codex)).toEqual({ until: ['idle', 'blocked', 'exit'], error: null });
const stop = resolveWaitSignals('stop,blocked', codex);
expect(stop.until).toEqual([]);
expect(stop.error).toContain('stop');
expect(stop.error).not.toContain('Signal(s) stop, blocked');
// The suggestion names what does fire for codex.
expect(stop.error).toContain('idle, blocked or exit');
});
it('drops hook-only signals from the DEFAULT set instead of erroring', () => {
+18
View File
@@ -97,6 +97,24 @@ describe('sendPushNotifications -> webhook (the headless case)', () => {
expect(sendNotification).not.toHaveBeenCalled();
});
it('carries a screen-read dialog (codex) with its own message, and a hook message stays out', async () => {
// web/screen-blocked.ts sends the CLI's own label, built server-side, never the
// dialog's text. A Claude hook's `message` keeps the body it always had.
const { send } = await headlessServer({});
await send('hook:permission_prompt', {
sessionId: 's3',
sessionName: 'w3-app',
source: 'screen',
message: 'Codex is waiting for your answer in the terminal',
});
await send('hook:permission_prompt', {
sessionId: 's4',
sessionName: 'w4-app',
message: 'Claude needs your permission to use Bash',
});
expect(got.map((g) => g.body)).toEqual(['[w3-app] Codex is waiting for your answer in the terminal', '[w4-app]']);
});
it('dedupes an immediate repeat of the same event for the same session', async () => {
const { send } = await headlessServer({});
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });