Skip to content

Commit 4038759

Browse files
Codex, phases 4 and 5: Codex sessions read, the hooks answer Codex (Codex closed on every system) (#54)
* codex phase 4: read a Codex session (_codex_rollout.py), tested on step 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. * codex phase 4: the reflection reads the day's Codex sessions when agents.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. * codex phase 4: session-recap.py as Codex's Stop hook, under the whole 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. * CI codex-mock: read a session the real Codex keeps 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. * docs: the Codex plan after step 4 (Codex sessions read, whole ids) 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. * codex phase 4: leave out what Codex adds by its own marks too; a global 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. * docs: the Codex plan, step 4's first CI run (a global AGENTS.md has no folder) * docs: the Codex plan, step 4's CI run * codex phase 5: _agents.codex_hook(), the guard of the hooks Codex runs, 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. * codex phase 5: the hooks answer Codex (--agent codex), Claude Code's 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). * CI codex-mock: the real Codex runs the kit's hooks from a hooks.json 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. * CI codex-mock: a user's codex exec, as it is, is left alone from its 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. * docs: the Codex plan after step 5 (the hooks answer Codex, their guard and sign of life) * codex phase 4 review: a time out of range in a rollout is a line without 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. * codex phase 4 review: the reflection says a Codex session's source, a 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. * codex phase 4 review: in a Codex block only an entry starts a line, no 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. * tests: the reflection's sources sentence with Hermes is master's for 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. * docs: the Codex plan after the review of steps 4 and 5 * docs: the Codex plan, the review's CI run --------- Co-authored-by: Chad El Kurdi <162061136+El-Kurdi-Chad@users.noreply.github.com>
1 parent 0ea0512 commit 4038759

23 files changed

Lines changed: 2502 additions & 46 deletions

‎.github/workflows/test.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,14 +273,16 @@ jobs:
273273
echo "$files"
274274
if grep -qE '^(app/|install\.ps1$|\.github/workflows/test\.yml$|\.github/ci/)' <<< "$files"; then app=true; else app=false; fi
275275
echo "app=$app" | tee -a "$GITHUB_OUTPUT"
276-
if grep -qE '^(engine/hooks/(_headless(_codex|_claude)?|_agents|_controle_judge|daily-reflection)\.py|engine/codex-features\.json|engine/tests/(codex_mock|mock_openai)\.py|\.github/workflows/test\.yml)$' <<< "$files"; then codex=true; else codex=false; fi
276+
if grep -qE '^(engine/hooks/(_headless(_codex|_claude)?|_agents|_codex_rollout|_controle_judge|daily-reflection|session-recap|session-logger|correction-detector|controle-sessionstart)\.py|engine/codex-features\.json|engine/tests/(codex_mock|mock_openai|codex_captures)\.py|\.github/workflows/test\.yml)$' <<< "$files"; then codex=true; else codex=false; fi
277277
echo "codex=$codex" | tee -a "$GITHUB_OUTPUT"
278278
279279
# The launcher without tools for Codex (engine/hooks/_headless_codex.py) with the real Codex CLI,
280280
# pinned, in ChatGPT mode against a local fake server (engine/tests/mock_openai.py): fake tokens,
281281
# no account, no secret (docs/proposals/codex.md, §5.2). Forced tool calls refused, tools
282282
# announced, URLs, the caller's keys, a system config and requirements.toml, the user's MCP server;
283-
# then the reflection's and the review's questions through the choice of engine (_headless.py).
283+
# then the reflection's and the review's questions through the choice of engine (_headless.py); then
284+
# a session the real Codex keeps, read as the reflection and Codex's Stop hook read it (_codex_rollout.py);
285+
# then the kit's hooks run by the real Codex from a hooks.json (--agent codex): their answers taken.
284286
codex-mock:
285287
name: codex-mock (${{ matrix.os }})
286288
needs: changes

‎docs/proposals/codex.md‎

Lines changed: 83 additions & 1 deletion
Large diffs are not rendered by default.

‎engine/hooks/_agents.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@
1616
§1.1): every merged phase reaches users at once through master.zip, so asking for it is
1717
refused there, and a file that lists it there is not trusted.
1818
19+
The hooks Codex runs (hooks.json, --agent codex) ask codex_hook() whether to act; Claude Code's
20+
never load this module.
21+
1922
Not a hook: installed next to them, never registered in settings.json.
2023
"""
2124
import argparse
2225
import json
2326
import os
27+
import re
2428
import shutil
2529
import stat
2630
import sys
@@ -33,6 +37,14 @@
3337
CODEX_OUVERT = frozenset()
3438
FICHIER_MAX = 64 * 1024
3539
POSE_PAR = ("install.sh", "install.ps1")
40+
# The sign that Codex runs the kit's hooks: per hook, when it last ran (codex_hook()).
41+
VUS = "codex-hooks-vus.json"
42+
# The sessions the user types in: Codex's terminal (cli) and its editor's (vscode). "exec" is a script's
43+
# run or the kit's own, "mcp" another program's; sub-agents and Codex's internal sessions say an
44+
# object, not a word (codex-rs/protocol/src/protocol.rs, SessionSource, 0.162.1).
45+
SOURCES_TAPEES = ("cli", "vscode")
46+
LIGNE_MAX = 1024 * 1024 # a session file's first line: Codex's base instructions, about 23 kB in 0.162.1
47+
ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
3648

3749

3850
class Choix(namedtuple("Choix", "agents prefere codex_home")):
@@ -246,6 +258,93 @@ def ecrire(choix, pose_par, existant, home=None):
246258
return True
247259

248260

261+
PAS_ECRIT = object() # _source(): the session file is not there yet
262+
263+
264+
def _source(chemin):
265+
"""What the first line of a session file says of the session: its source ("cli", "exec"...),
266+
PAS_ECRIT when the file is not there, None when it cannot tell (a link, not a regular file, not a
267+
session_meta line, a source that is not a word). Never waits on a pipe."""
268+
try:
269+
fd, _ = _ouvrir(chemin)
270+
except FileNotFoundError:
271+
return PAS_ECRIT
272+
except (OSError, ValueError): # ValueError: a NUL in the path
273+
return None
274+
with os.fdopen(fd, "rb") as f:
275+
ligne = f.readline(LIGNE_MAX + 1)
276+
if len(ligne) > LIGNE_MAX:
277+
return None
278+
try:
279+
d = json.loads(ligne.decode("utf-8"))
280+
except (ValueError, RecursionError):
281+
return None
282+
p = d.get("payload") if isinstance(d, dict) and d.get("type") == "session_meta" else None
283+
source = p.get("source") if isinstance(p, dict) else None
284+
return source if isinstance(source, str) else None
285+
286+
287+
def codex_hook(nom, payload, env=None, home=None, codex_ouvert=None):
288+
"""Whether the hook `nom`, run by Codex (hooks.json, --agent codex), acts on what Codex gave it
289+
(docs/proposals/codex.md, §3.4). Never in a run without tools (BRAIN_HEADLESS=1, the kit's own;
290+
claude -p), nor when agents.json does not list Codex or cannot be trusted: nothing read, nothing
291+
written then. Otherwise Codex does run the kit's hooks, and vu() notes it (§3.3). Then only for a
292+
session the user types in: one Codex keeps a file of (none: --ephemeral), under a plain id, a
293+
prompt the user typed (agent_id: another agent's), a file whose first line says "cli" or "vscode",
294+
or a file not written yet (Codex may run a session's first hooks before it writes it)."""
295+
env = os.environ if env is None else env
296+
if env.get("BRAIN_HEADLESS") == "1" or env.get("CLAUDE_CODE_ENTRYPOINT") == "sdk-cli":
297+
return False
298+
if "codex" not in lire(home, codex_ouvert=codex_ouvert)[0].agents:
299+
return False
300+
try:
301+
vu(nom, home)
302+
except (OSError, ValueError):
303+
pass # a sign for later checks: the hook still does its work
304+
if not isinstance(payload, dict) or payload.get("agent_id"):
305+
return False
306+
sid, chemin = payload.get("session_id"), payload.get("transcript_path")
307+
if not (isinstance(sid, str) and ID.fullmatch(sid) and isinstance(chemin, str) and chemin):
308+
return False
309+
source = _source(chemin)
310+
return source is PAS_ECRIT or source in SOURCES_TAPEES
311+
312+
313+
def vus(home=None):
314+
"""{hook: when Codex last ran it, UTC} from codex-hooks-vus.json; {} when there is none to trust."""
315+
try:
316+
fd, _ = _ouvrir(os.path.join(os.path.dirname(fichier(home)), VUS))
317+
with os.fdopen(fd, "rb") as f:
318+
d = json.loads(f.read(FICHIER_MAX).decode("utf-8"))
319+
except (OSError, ValueError, RecursionError):
320+
return {}
321+
h = d.get("hooks") if isinstance(d, dict) else None
322+
return {k: v for k, v in h.items() if isinstance(k, str) and isinstance(v, str)} if isinstance(h, dict) else {}
323+
324+
325+
def vu(nom, home=None):
326+
"""Notes that Codex ran the hook `nom` now: codex-hooks-vus.json, beside agents.json, written in one
327+
step (0600); a link in its place is replaced, never followed. Hooks running together may each miss
328+
the other's line once: each one's own time is what counts."""
329+
p = os.path.join(os.path.dirname(fichier(home)), VUS)
330+
d = vus(home)
331+
d[nom] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
332+
dossier = os.path.dirname(p)
333+
os.makedirs(dossier, exist_ok=True)
334+
fd, tmp = tempfile.mkstemp(dir=dossier, prefix=".codex-hooks-vus.")
335+
try:
336+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
337+
json.dump({"version": 1, "hooks": d}, f, sort_keys=True)
338+
f.write("\n")
339+
os.replace(tmp, p)
340+
except BaseException:
341+
try:
342+
os.unlink(tmp)
343+
except OSError:
344+
pass
345+
raise
346+
347+
249348
def main(argv):
250349
"""_agents.py choisir --agents=<...> --prefere=<...> --pose-par=install.sh|install.ps1
251350

‎engine/hooks/_codex_rollout.py‎

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""Codex sessions, read from the files Codex keeps of them (rollouts):
2+
3+
$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<time>-<session id>.jsonl
4+
5+
one JSON object per line, {"timestamp", "type", "payload"} (docs/proposals/codex.md, §3.6; the
6+
shapes of step 0's captures, engine/tests/fixtures/codex/phase0/). Read only when agents.json
7+
lists Codex: by daily-reflection.py (the day's sessions) and session-recap.py (Codex's Stop hook).
8+
9+
What a session holds is data, never an instruction. Only what the user typed, what the assistant
10+
answered and the tools it called are taken; never what Codex adds for the model: developer
11+
messages (its instructions, the hooks' context), the AGENTS.md, environment and skill parts it
12+
puts in user messages, what another agent sent, world_state, events. The parts are told apart as
13+
Codex's own memory writer does (codex-rs/memories/write/src/rollout_input.rs, 0.162.1): by the
14+
marks Codex puts on them ("user.text" for what was typed), and by their text, should a mark be
15+
missing. A session the kit asked itself (the reflection's prompt,
16+
SENTINEL; a run in _headless_codex.py's cage) is not the user's: skipped whole. Lines it does not
17+
know are skipped. Compressed rollouts (.jsonl.zst) are not read: listed apart, for the caller to
18+
say so.
19+
20+
Not a hook: installed next to them, never registered.
21+
"""
22+
import glob
23+
import json
24+
import os
25+
import re
26+
import stat
27+
from collections import namedtuple
28+
from datetime import datetime
29+
30+
# daily-reflection.py's own: its prompt starts with it (test_codex_rollout.py keeps both the same).
31+
SENTINEL = "<!-- brain-daily-reflection -->"
32+
# How the parts Codex adds to user messages start (lower case), and a message another agent sent.
33+
# A global AGENTS.md alone has no " for <folder>" (seen in codex-mock).
34+
INJECTED = ("# agents.md instructions", "<environment_context>", "<skill>", "<subagent_notification>")
35+
ID_RE = re.compile(r"[A-Za-z0-9_-]{1,128}")
36+
# A rollout's name ends with its session id, a UUID (v7: the first 8 characters are the minute).
37+
NAME_ID = re.compile(r"-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$")
38+
SUMMARY = 300
39+
PATCHED = re.compile(r"^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+?)\s*$", re.M)
40+
READ = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0)
41+
42+
# kind "user" or "assistant": text. kind "tool": name, text (a short summary), command (the shell
43+
# command or code it ran, or None), files (the paths a patch named).
44+
Item = namedtuple("Item", "ts kind text name command files")
45+
46+
47+
class Session:
48+
"""One rollout: id, cwd, source (a word: "cli", "exec"...; the kind of an object: "subagent",
49+
"internal", "custom"), model, own (asked by the kit), items, and the first and last timestamps of
50+
the whole file."""
51+
52+
def __init__(self):
53+
self.id = self.cwd = self.source = self.model = self.first_ts = self.last_ts = None
54+
self.own = False
55+
self.items = []
56+
57+
58+
def local(ts):
59+
"""Local datetime of an ISO timestamp, or None."""
60+
if not isinstance(ts, str) or not ts:
61+
return None
62+
try:
63+
return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone()
64+
except (ValueError, OverflowError, OSError): # a year 0 or 10000 once local; a time localtime() refuses
65+
return None
66+
67+
68+
def recent(codex_home, since):
69+
"""(rollouts, compressed): the rollout files changed since `since` (epoch seconds), oldest
70+
first, and the compressed ones (.jsonl.zst) changed since then, which are not read. Regular
71+
files only: a pipe left under such a name would hang the reader."""
72+
found, packed = [], []
73+
for pattern, out in (("rollout-*.jsonl", found), ("rollout-*.jsonl.zst", packed)):
74+
for p in glob.glob(os.path.join(glob.escape(codex_home), "sessions", "*", "*", "*", pattern)):
75+
try:
76+
st = os.stat(p)
77+
except OSError:
78+
continue
79+
if stat.S_ISREG(st.st_mode) and st.st_mtime >= since:
80+
out.append((st.st_mtime, p))
81+
return [p for _, p in sorted(found)], [p for _, p in sorted(packed)]
82+
83+
84+
def _text(content, role):
85+
"""A message's text. Of a user message, without the parts Codex adds, which it may put in the same
86+
message (step 0: AGENTS.md and the environment together)."""
87+
if not isinstance(content, list):
88+
return ""
89+
return "\n".join(c["text"] for c in content
90+
if isinstance(c, dict) and c.get("type") in ("input_text", "output_text")
91+
and isinstance(c.get("text"), str)
92+
and not (role == "user" and c["text"].lstrip()[:40].lower().startswith(INJECTED)))
93+
94+
95+
def _marked_as_codexs(p, role):
96+
"""True when Codex's own marks say the message is not the user's nor the assistant's: another
97+
agent's, or a user message with no part typed by the user."""
98+
meta = p.get("internal_chat_message_metadata_passthrough")
99+
kinds = meta.get("content_item_kinds") if isinstance(meta, dict) else None
100+
if not isinstance(kinds, list) or not kinds:
101+
return False
102+
kinds = [k if isinstance(k, str) else "" for k in kinds]
103+
return (any(k.startswith("multi_agent.") for k in kinds)
104+
or (role == "user" and not any(k.startswith("user.") for k in kinds)))
105+
106+
107+
def _kit_cage(cwd):
108+
"""_headless_codex.py's working folder: <tmp>/brain-codex-*/travail."""
109+
parent, name = os.path.split(os.path.normpath(cwd))
110+
return name == "travail" and os.path.basename(parent).startswith("brain-codex-")
111+
112+
113+
def tool(name, raw):
114+
"""(summary, command, files) of a function_call (raw: its arguments, a JSON text) or a
115+
custom_tool_call (raw: its input, a text: JavaScript for exec, a patch for apply_patch)."""
116+
args = raw
117+
if isinstance(raw, str) and raw.lstrip().startswith("{"):
118+
try:
119+
args = json.loads(raw)
120+
except (ValueError, RecursionError): # what the model wrote: any text at all
121+
pass
122+
command, patch = None, None
123+
if isinstance(args, dict):
124+
c = args.get("cmd") or args.get("command")
125+
if isinstance(c, list) and all(isinstance(x, str) for x in c):
126+
c = " ".join(c)
127+
command = c if isinstance(c, str) else None
128+
patch = args.get("input") if isinstance(args.get("input"), str) else None
129+
elif isinstance(args, str):
130+
if "*** Begin Patch" in args:
131+
patch = args
132+
elif name == "exec":
133+
command = args
134+
files = sorted(set(PATCHED.findall(patch or "")))
135+
if isinstance(args, dict):
136+
named = {args[k] for k in ("file_path", "path") if isinstance(args.get(k), str) and args[k]}
137+
files = sorted(set(files) | named)
138+
if command:
139+
summary = command
140+
elif files:
141+
summary = ", ".join(files)
142+
elif isinstance(args, dict):
143+
key = next((k for k in ("pattern", "url", "query") if args.get(k)), None)
144+
summary = str(args[key]) if key else json.dumps(args, ensure_ascii=False)[:200]
145+
else:
146+
summary = args if isinstance(args, str) else ""
147+
return summary[:SUMMARY], command, files
148+
149+
150+
def _open(path):
151+
"""The rollout as text, a regular file only (no wait on a pipe); OSError otherwise."""
152+
fd = os.open(path, READ)
153+
try:
154+
if not stat.S_ISREG(os.fstat(fd).st_mode):
155+
raise OSError("%s is not a regular file" % path)
156+
except BaseException:
157+
os.close(fd)
158+
raise
159+
return os.fdopen(fd, "r", encoding="utf-8", errors="replace")
160+
161+
162+
def read(path, day=None):
163+
"""The Session of one rollout, streamed, or None when it cannot be read. With `day`
164+
(YYYY-MM-DD, local), only the items of that day are kept; its id, folder and model come from
165+
the whole file. A session the kit asked comes back own=True, without items."""
166+
s = Session()
167+
try:
168+
with _open(path) as f:
169+
for raw in f:
170+
try:
171+
d = json.loads(raw)
172+
except (ValueError, RecursionError):
173+
continue
174+
if not isinstance(d, dict):
175+
continue
176+
when = local(d.get("timestamp"))
177+
if when:
178+
s.first_ts = s.first_ts or d["timestamp"]
179+
s.last_ts = d["timestamp"]
180+
kind, p = d.get("type"), d.get("payload")
181+
if not isinstance(p, dict):
182+
continue
183+
if kind == "session_meta" and s.id is None:
184+
sid, cwd, source = p.get("id"), p.get("cwd"), p.get("source")
185+
s.id = sid if isinstance(sid, str) and ID_RE.fullmatch(sid) else None
186+
s.cwd = cwd if isinstance(cwd, str) and cwd else s.cwd
187+
if isinstance(source, dict) and len(source) == 1:
188+
source = next(iter(source)) # {"subagent": …}, {"internal": …}, {"custom": …}
189+
s.source = source if isinstance(source, str) else None
190+
if s.cwd and _kit_cage(s.cwd):
191+
return _own(s)
192+
elif kind == "turn_context":
193+
model, cwd = p.get("model"), p.get("cwd")
194+
s.model = s.model or (model if isinstance(model, str) and model else None)
195+
s.cwd = s.cwd or (cwd if isinstance(cwd, str) and cwd else None)
196+
elif kind == "response_item":
197+
item = _item(p, d.get("timestamp"))
198+
if item is None:
199+
continue
200+
if item.kind == "user" and item.text.lstrip().startswith(SENTINEL):
201+
return _own(s)
202+
if day is None or (when and when.strftime("%Y-%m-%d") == day):
203+
s.items.append(item)
204+
except OSError:
205+
return None
206+
if s.id is None:
207+
m = NAME_ID.search(os.path.basename(path))
208+
s.id = m.group(1) if m else None
209+
return s
210+
211+
212+
def _own(s):
213+
s.own, s.items = True, []
214+
return s
215+
216+
217+
def _item(p, ts):
218+
"""An Item of a response_item payload, or None: not one we read, or text Codex added."""
219+
kind = p.get("type")
220+
if kind == "message":
221+
role = p.get("role")
222+
if role not in ("user", "assistant") or _marked_as_codexs(p, role):
223+
return None # developer, system: Codex's instructions and the hooks' context
224+
text = _text(p.get("content"), role)
225+
if not text.strip():
226+
return None
227+
return Item(ts, role, text, None, None, ())
228+
if kind in ("function_call", "custom_tool_call"):
229+
name = p.get("name") if isinstance(p.get("name"), str) and p.get("name") else "?"
230+
summary, command, files = tool(name, p.get("arguments" if kind == "function_call" else "input"))
231+
return Item(ts, "tool", summary, name, command, tuple(files))
232+
return None

0 commit comments

Comments
 (0)