feat(codex): read Codex's own background-terminal row

Codex states background work too, and it says so in a different place. Claude
writes `· 1 monitor ·` on the last row of the screen; Codex pins
`1 background terminal running · /ps to view · /stop to close` ABOVE its
composer, which puts that row third from the bottom once the status line and
the composer are counted.

So how far up the screen to look is now per-CLI data as well:
`capabilities.workDetect.watchingLines`, bounded to 1..8 by the schema, and
defaulting to Claude's two. That bound is the point. The window is half the
injection guard, since every row it adds is another row the agent itself may be
able to write, and the label is what silences an idle alert. The other half is
the anchor, and Codex's is ` · /ps to view`: chrome naming a slash command only
the CLI can offer, so a session that writes "I left 1 background terminal
running for you" into its own output matches nothing.

Measured against a live codex-cli 0.154.0 pane rather than read out of a
binary. The row appears when the terminal starts, follows the composer down as
the conversation grows, and is gone after `/stop`. Verified end to end on an
isolated beta: the session payload carried `watching: "1 background terminal"`
and the badge rendered with it, and both cleared when the terminal stopped. The
fixtures in the tests are that capture verbatim.

Codex has no hook signals, so no idle prompt and no false NEEDS YOU row: for a
Codex session this is the badge alone, which is the case the maintainer said a
registry field could cover and a hook never could. Cross-CLI tests pin that
neither pattern fires on the other's screen, and that a CLI declaring nothing
still reports nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-22 08:58:52 +02:00
co-authored by Claude Opus 5
parent 74884a20eb
commit 64c288a683
10 changed files with 185 additions and 45 deletions
+15
View File
@@ -133,6 +133,21 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
}, 'this one runs over a pane capture every time a session settles, so it can freeze the event loop the same way');
});
it('bounds how far up the screen a config file may search', () => {
// The window is the injection guard: every row it adds is another row the agent
// itself may be able to write, and the label is what silences an idle alert.
for (const lines of [0, 9, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLine: 'chip (\\d+)',
watchingLines: lines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
});
it('accepts every shipped watchingLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.watchingLine;
+91 -14
View File
@@ -22,8 +22,40 @@ import {
IDLE_SILENCE_MS,
} from '../src/session-activity.js';
/** The registry's own pattern for Claude, which is what every consumer runs. */
/** The registry's own patterns, which are what every consumer runs. */
const CLAUDE_WATCHING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)!;
const CODEX_WATCHING = compileVersionRegex(getCli('codex')!.capabilities.workDetect!.watchingLine!)!;
const CODEX_TAIL = getCli('codex')!.capabilities.workDetect!.watchingLines!;
/**
* The foot of a Codex pane, verbatim (codex-cli 0.154.0, 2026-09-22). Codex does not
* write on its last row: the status line is there, the composer above it, and the
* background-terminal row above that, which is why codex declares its own window.
*/
const CODEX_STATUS =
' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left';
const CODEX_WITH_TERMINAL = [
'• OK',
'',
' 1 background terminal running · /ps to view · /stop to close',
'',
'',
'› Ask Codex to do anything',
'',
CODEX_STATUS,
'',
].join('\n');
const CODEX_STOPPED = [
'• OK',
'',
'• Stopping all background terminals.',
'',
'',
'› Ask Codex to do anything',
'',
CODEX_STATUS,
'',
].join('\n');
/** The bottom of a Claude pane: composer, the user's status line, the footer row. */
function pane(footer: string, body = ''): string {
@@ -72,10 +104,13 @@ function withFakePane(screen: string | (() => string), mode: 'claude' | 'codex'
} as ConstructorParameters<typeof Session>[0]);
}
/** Codex's own composer repaint, the frame that arms its idle confirmation. */
const CODEX_COMPOSER_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
/** Run one turn and let it end, which is when the probe reads the screen. */
function runAndSettle(session: Session): void {
function runAndSettle(session: Session, repaint: string = COMPOSER_REPAINT): void {
for (let i = 0; i < 3; i++) {
feed(session, COMPOSER_REPAINT);
feed(session, repaint);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
@@ -205,22 +240,35 @@ describe('Session.watching', () => {
expect(session.watching).toBe('1 monitor');
});
it('reports nothing for a CLI whose footer nobody has characterised', () => {
it('reads Codex own row, three up from the bottom of its screen', () => {
vi.useFakeTimers();
expect(getCli('codex')?.capabilities.workDetect?.watchingLine).toBeUndefined();
// Codex draws its own composer glyph, so this session settles the same way; what it
// must not do is read Claude's footer on a screen that is not Claude's.
const session = withFakePane(WITH_MONITOR, 'codex');
for (let i = 0; i < 3; i++) {
feed(session, '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m');
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
const session = withFakePane(CODEX_WITH_TERMINAL, 'codex');
runAndSettle(session, CODEX_COMPOSER_REPAINT);
expect(session.status).toBe('idle');
expect(session.watching).toBe('1 background terminal');
});
it('reports nothing for a CLI whose screen nobody has characterised', () => {
vi.useFakeTimers();
expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined();
const session = withFakePane(WITH_MONITOR, 'gemini');
runAndSettle(session);
expect(session.watching).toBeNull();
});
it('never reads another CLI screen', () => {
vi.useFakeTimers();
// Each pattern is anchored on chrome its own CLI draws, so neither can fire on the
// other's pane. A shared fallback would have both reading a screen nobody measured.
const codexOnClaudeScreen = withFakePane(WITH_MONITOR, 'codex');
runAndSettle(codexOnClaudeScreen, CODEX_COMPOSER_REPAINT);
expect(codexOnClaudeScreen.watching).toBeNull();
const claudeOnCodexScreen = withFakePane(CODEX_WITH_TERMINAL, 'claude');
runAndSettle(claudeOnCodexScreen);
expect(claudeOnCodexScreen.watching).toBeNull();
});
it('rides along on the payload every session surface reads', () => {
vi.useFakeTimers();
const session = withFakePane(WITH_SHELL);
@@ -230,6 +278,35 @@ describe('Session.watching', () => {
});
});
describe('the row Codex draws', () => {
it('reads the label, and only while a terminal is running', () => {
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, CODEX_TAIL)).toBe('1 background terminal');
expect(watchingLabel(CODEX_STOPPED, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
});
it('counts terminals', () => {
const three = CODEX_WITH_TERMINAL.replace('1 background terminal running', '3 background terminals running');
expect(watchingLabel(three, CODEX_WATCHING, CODEX_TAIL)).toBe('3 background terminals');
});
it('needs the window Codex declares: its row is not the last one', () => {
// Pins WHY `watchingLines` exists. Claude's default of two rows reaches the status
// line and the composer, and Codex's row sits one further up.
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, 2)).toBeNull();
expect(CODEX_TAIL).toBeGreaterThanOrEqual(3);
});
it('refuses the same words in the transcript, which is the injection guard', () => {
// ` · /ps to view` is chrome: only the CLI offers that slash command. Without the
// anchor an agent could print the sentence and silence itself.
const claim = CODEX_STOPPED.replace(
'• Stopping all background terminals.',
'• I left 1 background terminal running for you.'
);
expect(watchingLabel(claim, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
});
});
describe('the registry pattern Claude declares', () => {
it('is one the config-regex guard accepts', () => {
// Same guard as `workingLine`: ~/.codeman/clis.json can set this field, and the