Skip to content

Commit 9614233

Browse files
committed
feat(protocol): add JSON event streaming client and Event model; extend run_exec with --json option; docs
1 parent f7ca84c commit 9614233

4 files changed

Lines changed: 89 additions & 3 deletions

File tree

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ fmt:
1717
uv run --group dev ruff format .
1818

1919
lint:
20-
uv run --group dev ruff format --check .
21-
uv run --group dev ruff check .
20+
uv run --group dev ruff format .
21+
uv run --group dev ruff check --fix --unsafe-fixes .
2222
uv run --group dev mypy codex
2323

2424
test:

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ Options:
3030
- Full auto: `run_exec("scaffold a cli", full_auto=True)`
3131
- Run in another dir: `run_exec("...", cd="/path/to/project")`
3232

33+
Streaming JSON events (no PyO3 required):
34+
35+
```
36+
from codex.protocol.runtime import stream_exec_events
37+
38+
for event in stream_exec_events("explain this repo", full_auto=True):
39+
# event is a dict with shape {"id": str, "msg": {...}}
40+
print(event)
41+
```
42+
43+
The event payload matches the Pydantic models in `codex.protocol.types` (e.g., `EventMsg`).
44+
3345
Using a client with defaults:
3446

3547
```

codex/api.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def run_exec(
5757
env: Mapping[str, str] | None = None,
5858
executable: str = "codex",
5959
extra_args: Iterable[str] | None = None,
60+
json: bool = False,
6061
) -> str:
6162
"""
6263
Run `codex exec` with the given prompt and return stdout as text.
@@ -77,7 +78,10 @@ def run_exec(
7778
if extra_args:
7879
cmd.extend(list(extra_args))
7980

80-
cmd.extend(["exec", prompt])
81+
cmd.append("exec")
82+
if json:
83+
cmd.append("--json")
84+
cmd.append(prompt)
8185

8286
completed = subprocess.run(
8387
cmd,

codex/protocol/runtime.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import subprocess
6+
from collections.abc import Iterator
7+
8+
from pydantic import BaseModel
9+
10+
from .types import EventMsg
11+
12+
13+
class Event(BaseModel):
14+
"""Protocol event envelope emitted by `codex exec --json`."""
15+
16+
id: str
17+
msg: EventMsg
18+
19+
20+
def stream_exec_events(
21+
prompt: str,
22+
*,
23+
executable: str = "codex",
24+
model: str | None = None,
25+
full_auto: bool = False,
26+
cd: str | None = None,
27+
env: dict[str, str] | None = None,
28+
) -> Iterator[Event]:
29+
"""Spawn `codex exec --json` and yield Event objects from NDJSON stdout.
30+
31+
Non-event lines (config summary, prompt echo) are ignored.
32+
"""
33+
cmd: list[str] = [executable]
34+
if cd:
35+
cmd += ["--cd", cd]
36+
if model:
37+
cmd += ["-m", model]
38+
if full_auto:
39+
cmd.append("--full-auto")
40+
cmd += ["exec", "--json", prompt]
41+
42+
with subprocess.Popen(
43+
cmd,
44+
stdout=subprocess.PIPE,
45+
stderr=subprocess.PIPE,
46+
text=True,
47+
env={**os.environ, **(env or {})},
48+
) as proc:
49+
assert proc.stdout is not None
50+
for line in proc.stdout:
51+
line = line.strip()
52+
if not line:
53+
continue
54+
try:
55+
obj = json.loads(line)
56+
except json.JSONDecodeError:
57+
continue
58+
59+
# Filter out non-event helper lines
60+
if not isinstance(obj, dict):
61+
continue
62+
if "id" in obj and "msg" in obj:
63+
# Attempt to validate into our Pydantic Event model
64+
yield Event.model_validate(obj)
65+
66+
# Drain stderr for diagnostics if the process failed
67+
ret = proc.wait()
68+
if ret != 0 and proc.stderr is not None:
69+
err = proc.stderr.read()
70+
raise RuntimeError(f"codex exec failed with {ret}: {err}")

0 commit comments

Comments
 (0)