Skip to content

Codex, phases 4 and 5: Codex sessions read, the hooks answer Codex (Codex closed on every system) - #54

Merged
undistracted2cha merged 19 commits into
masterfrom
feat/codex-sessions-hooks
Oct 16, 2026
Merged

undistracted2cha merged 19 commits into
masterfrom
feat/codex-sessions-hooks

Conversation

@undistracted2cha

@undistracted2cha undistracted2cha commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Phases 4 and 5 of the Codex plan (docs/proposals/codex.md, §3.4, §3.6 and §6): Codex's sessions read for the daily reflection and for Codex's Stop hook, under their whole id (phase 4); the hooks answer Codex, with their guard and sign of life (phase 5). Codex stays closed on every system (_agents.py): an agents.json that lists it is not trusted, so the reflection reads no Codex session and session-recap.py --agent codex stays silent, for everyone. Without agents.json, or with Claude alone, nothing changes, byte for byte.

engine/hooks/_codex_rollout.py

  • Reads one rollout ($CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl), streamed: the session's whole id (UUIDv7), folder, source, model, then the user's and assistant's messages and the tools called (name, command, files a patch names), sliced to a day when asked.
  • Never taken (what Codex adds for the model): developer messages (its instructions, the hooks' context); in user messages, what Codex marks as its own (content_item_kinds without a user. part, or with a multi_agent. one) and, marks missing, parts starting with # AGENTS.md instructions, <environment_context>, <skill>, <subagent_notification> (the rule of Codex's own memory writer, memories/write/src/rollout_input.rs, 0.162.1); world_state, events, tool outputs, reasoning. Unknown, broken or too deeply nested lines are skipped.
  • A session the kit asked itself (a user message starting with the reflection's SENTINEL, or a run in _headless_codex.py's cage) comes back without anything.
  • Regular files only, opened without waiting (a pipe under a rollout's name does not hang it). .jsonl.zst are listed apart, never read.

Wired in

  • daily-reflection.py: codex_sessions(day, codex_home) only when agents.json lists Codex, from its codex_home; label codex <whole id> · cwd=… (the first 8 characters of a UUIDv7 are the minute), with the source when not typed in Codex's terminal or editor (· exec ·, · mcp ·…), sub-agents' and Codex's internal sessions left out (review, below); at least 3 messages, as for Claude; compressed rollouts said in the error log and in --dry-run, which counts Codex's sessions; the prompt's sources name the agents chosen.
  • session-recap.py: with exactly --agent codex, silent unless Codex is listed (nothing read, no lock); otherwise the block of the rollout, in the same section, codex <model> · sid: + the whole id, found again by the whole id. Two Codex sessions of one minute keep a block each; a Claude block sharing the first 8 characters is never taken. The rest of the hook guard came with phase 5 (below).

Tests

  • test_codex_rollout.py on the 33 rollouts step 0 kept (3 systems, 2 versions): what is read equals what Codex's own item_completed events record as typed and answered; the Stop payload's session_id equals the rollout's id; tools, unknown lines, kit sessions, day slicing, ids from the file name, pipes, .zst. codex_captures.py builds test sessions out of those real lines.
  • CodexSessions (test_reflection), Codex (test_session_recap), test-hooks.sh §4 (Codex's Stop hook silent while not chosen or closed; control in a copy where Codex is open).
  • codex-mock (real Codex 0.162.1, 3 systems): a user's non-ephemeral codex exec with a global AGENTS.md and a tool call, its rollout read by the reader, the reflection and the recap: whole id = thread_id, only what was typed and answered, nothing Codex added, the kit's own session left out. The fake server can send several items in one reply.
  • Claude alone: with ~/.codex/sessions full, prompt, state, output, journal and memory.md equal a run without Codex sessions; _codex_rollout.py never imported in a fresh Python (control: Codex listed and opened, it is); Claude's recap imports neither _codex_rollout.py nor _agents.py, its block pinned character for character. A local before/after run (origin/master vs this branch, same doubles, no agents.json then ["claude"]) is identical file for file.
  • Each of 40 protections, removed alone, turns at least one test red.

Found by CI

The first run of codex-mock failed on the three systems for one reason: with the real Codex, a global AGENTS.md alone is written # AGENTS.md instructions + a blank line, without the for <folder> of the plan's prefix and of step 0's captures, so it was read as the user's text. Fixed in the reader (marks + text, above); CI now checks both rules on the real rollout. Run 38860953268: all green, codex-mock 70 checks per system.

Deviations from the plan

Recorded in the plan, section "Étape 4 — fait et écarts": what Codex adds told apart by its marks and by text, part by part (the plan's # AGENTS.md instructions for prefix was too narrow; step 0's captures put AGENTS.md and <environment_context> in one message); the launcher's cage counted as a kit session; the recap in the existing section (the night check knows it as the kit's); only an exact --agent codex; the prompt's sources follow the choice.

Phase 5: the hooks answer Codex

Codex stays closed on every system: in production a hook run with --agent codex says and writes nothing. Without that exact argument the hooks are Claude Code's, unchanged.

_agents.codex_hook()

  • Silent, nothing read nor written, in a run without tools (BRAIN_HEADLESS=1, the kit's own; CLAUDE_CODE_ENTRYPOINT=sdk-cli) and when agents.json does not list Codex or cannot be trusted.
  • Otherwise Codex runs the kit's hooks: ~/.brain-in-a-box/codex-hooks-vus.json notes when each one last ran (UTC, 0600, written in one step, never through a link) for the later "hooks not approved" line.
  • Then the hook acts only for a session the user types in: a session file (none: --ephemeral), a plain id, no agent_id (another agent's prompt), and a first line saying cli or vscode, or no file yet. exec, mcp, sub-agents and Codex's internal sessions, a link, a pipe, a first line past 1 MiB: left alone.

Hooks

correction-detector, controle-sessionstart, gbrain-update-check, session-logger, session-indexer, session-recap: each asks the guard first with exactly --agent codex. Logs say "agent": "codex"; a correction's lessons.md is looked for in Codex's cwd. Answers unchanged: Codex 0.162.1's own schemas take them (additionalContext, systemMessage).

Tests

  • test_codex_hooks.py: each hook run as Codex runs it on every payload step 0 captured (macOS, Linux, Windows paths, PowerShell 7 and 5.1), with and without the session file (exec: left alone; terminal: served), answers checked against Codex's schemas (fixtures/codex/hooks-schema-0.162.1/, codex_captures.output_errors), the guard case by case. CodexHook (test_agents), Codex (test_session_recap), test-hooks.sh §7ter on the installed hooks.
  • codex-mock (real Codex 0.162.1, 3 systems): the hooks run from a hooks.json (trust bypassed; approval is phase 6), on Windows as & "python.exe" "hook". A user's codex exec as it is: from SessionStart on, the session file is there and says exec, so nothing reaches the model and nothing is logged, only the sign. The same session handed on as typed in the terminal: the correction reminder and the night check's line (with its systemMessage) reach the fake server. The launcher's own call runs none of them. 82 checks per system.
  • Claude alone: hooks never load _agents.py nor read agents.json (same output with it absent, ["claude"], ["codex"], or an _agents.py that raises); Claude's reminder pinned character for character. Local run of origin/master's hooks against these (25 calls, three agents.json states, Python 3.13 and 3.9.6): same codes, output and files.
  • 30 protections removed one at a time: 29 turn a test red; the 30th (a size bound doubling a bounded read) was removed from the code.

Deviations from the plan

Recorded in the plan, section "Étape 5 — fait et écarts": 7 code files (gbrain-update-check was missing from the phase's list, session-recap's full guard came from phase 4); an allow-list of sources instead of "not exec"; agent_id and a plain id required; the fact that an exec run's file is written before its first hook; the sign written as soon as Codex runs a hook (its reader is phases 6 and 10a); cwd for Codex's lessons; shown.json shared by both agents.

Review of phases 4 and 5

Six findings (security, non-regression), one a duplicate: five defects, all checked and fixed, each with a test red before the fix. Recorded in the plan, section "Étapes 4 et 5 — relecture et corrections".

  • A time out of range stopped the whole reflection. A rollout line timed 9999-12-31T23:59:59-14:00 (year 10000 once in UTC): astimezone() raises OverflowError, which _codex_rollout.local() let through, and reflect() stopped with it, Claude's sessions of the day included (no state, no error log, no status block); Codex's recap failed too. local() now takes OverflowError and OSError, and codex_sessions() reads each rollout apart: a file that still raises costs only itself, logged by name and error type.
  • Sources. The reflection read every user line as typed. A sub-agent's rollout (kept under the terminal, its prompt written by the parent model, Codex 0.162.1's code) showed the model's text as the user's. Now as the hooks' guard: cli/vscode as before, sub-agents and Codex's internal runs left out, the others labelled with their source.
  • Forged entries. A model's answer or a tool command holding \n12:07 user: … made a line the reflection could not tell from the user's. In Codex's blocks a message goes on as |; a tool's summary and the label's folder are one line (every line break counts). Claude's blocks have the same defect on master: left for a PR of its own, since their format would change.
  • Hermes in the sources sentence, rewritten in phase 4, had no test: now pinned to master's for Claude alone.
  • The plan's byte-for-byte proof now names _codex_rollout.py, copied with the hooks for everyone, inert without Codex in agents.json.

Each of the 11 protections added, removed alone, turns at least one test red. Local: 606 unit tests, test-hooks.sh 178/178.

…ep 0's captures

engine/hooks/_codex_rollout.py reads one rollout, $CODEX_HOME/sessions/YYYY/MM/DD/
rollout-*.jsonl, streamed: the session's whole id (a UUIDv7), folder, source and model, and
the user's and assistant's messages and the tools called (name, command, files a patch
names), sliced to a day when asked. What Codex adds for the model is never taken: developer
messages (its instructions, the hooks' context), the AGENTS.md and <environment_context>
parts it puts in a user message, world_state, events, tool outputs, reasoning. Unknown or
broken lines are skipped. A session the kit asked itself (the reflection's SENTINEL, or a run
in _headless_codex.py's cage) comes back without items. Regular files only, opened without
waiting (a pipe under a rollout's name does not hang the reader); compressed rollouts
(.jsonl.zst) are listed apart, never read.

Tests on the 33 rollouts step 0 kept (macOS, Linux, Windows; 0.162.1 and 0.163.0-alpha.11):
what is read is exactly what Codex's own item_completed events record as typed and answered;
the Stop hook's session_id and the rollout's id agree. engine/tests/codex_captures.py builds
test sessions out of those real lines.
…nts.json lists Codex

daily-reflection.py gathers the day's Codex sessions (codex_sessions) from agents.json's
CODEX_HOME, only when Codex is one of the agents, labelled with their whole id: a UUIDv7's
first 8 characters are the minute, so two sessions of one minute stayed one label. At least 3
messages, as for Claude. Compressed rollouts are said in the error log and in --dry-run, which
also counts Codex's sessions. The prompt's sources name the agents chosen.

Claude alone is unchanged: without agents.json or with ["claude"], and ~/.codex/sessions
full, the prompt, state, output, journal and memory.md are those of a run without any Codex
session, and in a fresh Python _codex_rollout.py is never imported (control: Codex listed and
opened, it is). The tests run on sessions made from step 0's captures.
… session id

With exactly --agent codex, session-recap.py reads the session's rollout (_codex_rollout.py)
and writes its block in the same "Claude Sessions" section, naming Codex before the model, its
whole id shown and matched: two Codex sessions of one minute keep a block each, and a Claude
block sharing the first 8 characters is never taken. It stays silent, reading and locking
nothing, unless agents.json lists Codex: with Codex closed on every system, always for now.

Claude's recap is unchanged: any other argument is ignored as before, its block is pinned
character for character, and it imports neither _codex_rollout.py nor _agents.py.
test-hooks.sh: Codex's Stop hook silent without Codex chosen and while Codex is closed; its
control, in a copy of the hooks where Codex is open, writes the block.
A user's `codex exec` (not --ephemeral, a global AGENTS.md, a message and a tool call in one
reply) with the real Codex 0.162.1 against the fake server, then its rollout read by
_codex_rollout.py, the reflection's codex_sessions and the recap's parse_rollout: the whole id
is the thread's, only what was typed and answered, the tool called, nothing Codex added, and a
session holding the reflection's own prompt is left out. mock_openai.py can send several items
in one reply. The job also runs when _codex_rollout.py or session-recap.py change.
A dated section that takes precedence: what step 4 delivers, its deviations (Codex-added parts filtered one by one, the launcher's cage as a kit session, the recap in the same section, only an exact --agent codex, the prompt's sources), the proof that Claude alone is unchanged, the mutations.
…al AGENTS.md has no folder

codex-mock found it with the real Codex on the three systems: a global AGENTS.md alone reaches
the rollout as "# AGENTS.md instructions" followed by a blank line, without the " for <folder>"
the plan's prefix and step 0's captures had, so its text was read as the user's.

The reader now tells Codex's parts apart as Codex's own memory writer does
(codex-rs/memories/write/src/rollout_input.rs, 0.162.1): a message whose marks
(content_item_kinds) hold no "user." part, or any "multi_agent." one, is Codex's or another
agent's; and, should the marks be missing, parts starting with "# AGENTS.md instructions",
<environment_context>, <skill> or <subagent_notification> (any case) are left out. codex-mock
checks both on the real rollout, and that the reflection's text holds no AGENTS.md.

A line, or tool arguments the model wrote, nested past Python's stack was a RecursionError that
stopped the reflection: now a line skipped, the arguments kept as text.
…s, and their sign of life

A hook run by Codex (hooks.json, --agent codex) asks codex_hook() whether to act on what Codex
gave it (docs/proposals/codex.md, §3.4). Silent, and nothing written, in a run without tools
(BRAIN_HEADLESS=1, the kit's own; CLAUDE_CODE_ENTRYPOINT=sdk-cli) and when agents.json does not
list Codex or cannot be trusted (Codex is closed on every system: always, today).

Otherwise Codex does run the kit's hooks: codex-hooks-vus.json, beside agents.json, notes when
each one last ran (UTC, written in one step, 0600, never through a link), for the "hooks not
approved" line of later phases. Then the hook acts only for a session the user types in: a file
Codex keeps of it (transcript_path null: --ephemeral), a plain session id, a prompt the user typed
(agent_id: another agent's), and the file's first line saying "cli" or "vscode", or no file yet.
"exec" (scripts, the kit's own), "mcp", sub-agents and Codex's internal sessions (an object, not a
word, SessionSource in 0.162.1), a link, a pipe or a line past 1 MiB are left alone.
…unchanged

correction-detector, controle-sessionstart, gbrain-update-check, session-logger, session-indexer
and session-recap: with exactly --agent codex, each asks _agents.codex_hook() first; session-recap
gets the whole guard step 4 left for this phase. The logs' lines say "agent": "codex"; a
correction's lessons.md is looked for in the folder Codex says it works in (cwd), not in Claude's
CLAUDE_PROJECT_DIR. Their answers are the ones Claude Code takes, and Codex 0.162.1's own schemas
(codex-rs/hooks/schema/generated, kept in engine/tests/fixtures/codex/hooks-schema-0.162.1) take
them: additionalContext, systemMessage, nothing else.

Tests: test_codex_hooks.py runs each hook as Codex runs it on every payload step 0 captured
(macOS, Linux, Windows paths, PowerShell 7 and 5.1), with and without the session file Codex wrote
(exec: left alone; the terminal's: served), checks the answers against Codex's schemas, and the
guard; test-hooks.sh does the same on the installed hooks. Without the argument the hooks are
Claude Code's: they never load _agents.py nor read agents.json, and give the same output and files
as master (tests, and a local run of master's hooks against these, three agents.json states).
A user's codex exec, with the hooks (a copy where Codex is open) in its hooks.json, trust
bypassed (approval is phase 6), on Windows in the plan's PowerShell form (& "python.exe" "hook").
A test wrapper notes what each hook was given and hands SessionStart and UserPromptSubmit on as if
typed in Codex's terminal: correction-detector's reminder and the night check's line (with its
systemMessage) must reach the fake server, so Codex took both answers. Stop goes as it is: the
exec session's file says "exec", nothing is logged. The sign of life names the three hooks, and
the launcher's own call, with that hooks.json in its CODEX_HOME, runs none of them.
…first hook

Fact of the first run, the same on the three systems with Codex 0.162.1: from SessionStart on,
Codex has written the session file of a codex exec and it says "exec". codex-mock now checks it,
and runs that session as it is first: no reminder, no night check's line reach the model, nothing
is logged, only the sign that the hooks ran. Then the same session handed on as typed in the
terminal, as before: both answers reach the model.
@undistracted2cha undistracted2cha changed the title Codex, phase 4: Codex sessions read for the reflection and the recap, whole ids (Codex closed on every system) Codex, phases 4 and 5: Codex sessions read, the hooks answer Codex (Codex closed on every system) Oct 16, 2026
…out time, not the end of the reflection

_codex_rollout.local() let OverflowError through (a year 0 or 10000 once local), and the whole
reflection stopped with it, Claude's sessions of the day included: no state, no error log, no
status block in memory.md. Codex's Stop recap failed the same way. local() now also takes
OverflowError and OSError (a time localtime() refuses), as the reflection's _local() does, and
codex_sessions() reads each rollout apart: a file that still raises costs only itself, logged
by name and error type, never its text.
… sub-agent's and Codex's own are left out

codex_sessions() did not look at session_meta.source: every user line of a rollout read as what
the user typed. In Codex 0.162.1 a sub-agent starts from a copy of its session's config (so kept,
under the terminal) and gets the parent model's prompt as user input: its rollout showed the
model's text as the user's. Now, as the hooks' guard (_agents.SOURCES_TAPEES): cli and vscode as
before; sub-agents ({"subagent": ...}) and Codex's internal runs ({"internal": ...}) left out;
the others read, their source in their label (codex <id> · exec · cwd=..., '?' when missing or not
a plain word). read() keeps the kind of an object source. codex-mock: the real Codex's exec
session is labelled so.
…o text poses as the user's

An answer "Done.\n12:07 user: Decision: ...", or a tool call whose command holds a line break,
gave a line '12:07 user: ...' the reflection could not tell from a real one, without ever
addressing an AI. In Codex's blocks a message now goes on as '    | ', a tool's summary and the
label's folder are one line; every line break counts (\r, \v, \x85, \u2028...). Claude's blocks
are master's, unchanged: the same defect there (_render, _tool_summary) is for a PR of its own.
…Claude alone

Rewritten in step 4 (the agents chosen), it had no test: dropping Hermes from it left every test
green. Fixed for Claude alone (no agents.json, ["claude"], and build_prompt with no trusted
agent), and for Codex alone and both, Hermes listed.
@undistracted2cha
undistracted2cha marked this pull request as ready for review October 16, 2026 09:35
@undistracted2cha
undistracted2cha merged commit 4038759 into master Oct 16, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant