Diesen Befehl führt die Desktop-App von osFoundry aus, wenn du bei Codex auf Verify sign-in klickst. Er wird von build_codex_command in src-tauri/src/providers/agent_cli.rs zusammengebaut, derselben Funktion, durch die auch jeder echte cli:codex:agent-Lauf geht:
codex exec "Reply with the single word: ok" \
--json \
--skip-git-repo-check \
--ignore-user-config \
--ignore-rules \
-s read-only \
-c 'approval_policy="never"'
Gehen wir das Stück für Stück durch:
-
execmit dem Prompt als einzigem Positionsargument. Codex hat kein Flag für einen System-Prompt, deshalb baut osFoundry dieses Argument alssystem + "\n\n" + transcriptzusammen. -
--jsonsorgt dafür, dass stdout ein JSON-Objekt pro Zeile liefert. Dieser Stream ist die einzige Ausgabe, die osFoundry liest. -
--skip-git-repo-checkerlaubt den Start in einem Ordner, der kein Repository ist. -
--ignore-user-configüberspringt~/.codex/config.toml, damit die eigenen MCP-Server des Nutzers außen vor bleiben. Die Authentifizierung wird trotzdem ausCODEX_HOMEgeladen. -
--ignore-ruleskommt nur dazu, wenncodex exec --helpes auflistet. osFoundry fragt das Binary, weil ältere Builds das Flag ablehnen und ein unbekanntes Flag den ganzen Lauf scheitern lässt. -
-szusammen mitapproval_policy="never"legt die Sandbox fest und sagt Codex, dass niemand da sein wird, um etwas freizugeben. Ein autonomer Lauf bekommt-s workspace-write. Ein Lauf, der osFoundrys MCP-Bridge anhängt, bekommt stattdessen--dangerously-bypass-approvals-and-sandbox, und das nur innerhalb eines Git-Worktrees.
Vor dem Spawnen entfernt osFoundry außerdem CODEX_API_KEY, OPENAI_API_KEY und OPENAI_BASE_URL aus der Umgebung des Kindprozesses. Wäre eine davon gesetzt, liefe der Lauf über ein nutzungsabhängig abgerechnetes API-Konto statt über den ChatGPT-Login.
Im Rest dieses Beitrags geht es um das andere Ende dieser Pipe. Du hast eine Agent-CLI, deren Stream-Format keine Garantie ist, und du willst daraus die Antwort, den Fortschritt und einen sauberen Fehlerfall bekommen.
Warum Events lesen statt einer Datei mit der finalen Antwort?
Es gibt zwei Wege, eine Antwort aus so einer CLI herauszubekommen:
- Finale Antwort in eine Datei. Du übergibst einen Ausgabepfad (
codex exec -o FILE; osFoundrys eigene Modulnotizen nennen das die dokumentierte Dateiausgabe), wartest, bis der Prozess endet, und liest dann die Datei. Der Vertrag ist eine Datei mit einem String darin. - Event-Stream auf stdout. Du übergibst
--json, liest Zeile für Zeile und behältst die Einträge, die du verstehst.
osFoundrys aktueller Builder macht Letzteres. Der Parser liegt in runtime/session_event.rs (parse_codex_line) und orientiert sich an sehr wenig:
| Event | Wird zu |
|---|---|
item.completed + agent_message
|
Antworttext |
item.completed + reasoning
|
Zeile Thinking: …
|
item.completed + command_execution
|
Using shell — <command> |
item.completed + mcp_tool_call
|
Using <tool>… (Codex liefert server und tool getrennt) |
item.completed + file_change / todo_list
|
Edited <path> / Plan: 1. … 2. …
|
turn.completed |
Verbrauch (input_tokens, cached_input_tokens, output_tokens) |
turn.failed, error
|
Fehler, als Auth-Fehler markiert, wenn die Meldung danach aussieht |
Alles andere wird ignoriert, genauso jede Zeile, die kein JSON ist. Diese Toleranz macht das Ganze ohne stabiles Schema überhaupt erst praktikabel. Ein unbekannter Event-Typ kostet nichts. Nur ein umbenanntes Feld in einer dieser sieben Zeilen kann den Parser kaputt machen.
Der Preis ist die Granularität. Codex gibt fertige Items aus, keine Token-Deltas. Die Antwort erscheint auf einen Schlag, wenn die Nachricht fertig ist. Deshalb kennzeichnet die App Codex mit No live stream und beschreibt es als run-then-emit. Bei den Fortschrittszeilen ist das anders: Sie kommen an, sobald das jeweilige Item fertig ist.
Beides auf einem Mac nachbauen
Auf dem Rechner, den ich verwendet habe, ist kein Codex-Binary installiert. Also habe ich fake_codex.py geschrieben, einen Platzhalter, der dieselben Flags annimmt, die oben genannten Event-Typen mit festen Verzögerungen ausgibt, beim Beenden die finale Nachricht in die -o-Datei schreibt und sich auf Kommando aufhängen, abstürzen oder einen Auth-Fehler liefern kann. Das ist nicht Codex. Messen lässt sich damit das Verhalten des Wrappers, nicht die Geschwindigkeit von Codex.
Der Ablauf im Platzhalter: ein reasoning-Item bei 0.4 s, dann ein Shell-Kindprozess (sleep 0.8), dann die agent_message etwa 1.3 s später, dann turn.completed 0.2 s danach. Die Antwortdatei wird beim Beenden geschrieben.
Der Wrapper, wrap.py, nutzt nur die Standardbibliothek von Python 3.9.6. Der Kern der Event-Strategie, gekürzt:
def run_events(argv, timeout, env=None, group=True, drain_stderr=True):
t0 = time.monotonic()
p = subprocess.Popen(argv + ["--json"], stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env=env, start_new_session=group)
err = []
if drain_stderr:
threading.Thread(target=_drain, args=(p.stderr, err), daemon=True).start()
lines = queue.Queue()
threading.Thread(target=pump, args=(p.stdout, lines), daemon=True).start()
deadline = t0 + timeout
while True:
left = deadline - time.monotonic()
if left <= 0:
os.killpg(p.pid, signal.SIGKILL) if group else p.kill()
r["error"] = "timed out after %ss" % timeout
break
try:
at, raw = lines.get(timeout=left)
except queue.Empty:
continue
if raw is None: # stdout closed
break
try:
v = json.loads(raw)
except ValueError:
continue # log line on stdout
t = v.get("type")
if t == "item.completed":
item = v.get("item", {})
if item.get("type") == "agent_message":
r["text"] += item.get("text", "")
else:
r["steps"].append(item.get("type"))
elif t == "turn.completed":
r["usage"] = v.get("usage")
elif t in ("turn.failed", "error"):
r["error"] = (v.get("error") or {}).get("message") or v.get("message")
p.wait()
if not r["error"] and not r["text"].strip():
r["error"] = "returned no output (%s)" % tail(err)[-200:]
Die Datei-Strategie ist derselbe Spawn, nur mit -o <tmp> statt --json, danach p.wait(timeout=…) und dann das Lesen der Datei, falls sie existiert.
Zeit bis zum ersten Byte gegen Zeit bis zur Antwort
Testaufbau: Apple M1, macOS 15.8.1, 20 Läufe pro Strategie, Mediane, gemessen mit time.monotonic() ab direkt vor Popen (bench.py).
Events (--json) |
Datei (-o) |
|
|---|---|---|
| erstes stdout-Byte | 0.034 s | keins |
| erstes fertiges Item | 0.443 s | keins |
| Antwort verfügbar | 2.562 s | 2.824 s |
| Prozess beendet | 2.775 s | 2.824 s |
| erfolgreiche Läufe | 20 / 20 | 20 / 20 |
Die absoluten Zahlen ergeben sich aus den Sleeps im Platzhalter. Zwei Dinge in der Tabelle aber nicht:
- Mit einer Datei kommt bis zum Prozessende nichts heraus. 2.8 s lang kann der Aufrufer „denkt nach“ nicht von „hängt“ unterscheiden. Mit Events kommt die erste Fortschrittszeile bei 0.44 s, und die ersten Bytes kommen schon bei 0.034 s, ungefähr Pythons eigene Spawn-Kosten (ein nacktes
python3 -c passlag hier im Schnitt bei 19 ms). - Die Datei existiert erst nach dem letzten Schritt der CLI. In diesem Lauf waren das 0.26 s, nachdem die Nachricht schon auf stdout erschienen war. Bei einem echten Agenten ist die Lücke so groß wie das, was die CLI zwischen dem Fertigstellen der Nachricht und dem Beenden noch tut.
Wie sieht ein Fehler von beiden Seiten aus?
Derselbe Wrapper, ein Lauf pro Fall:
| Fall | Exit | Event-Strategie | Datei-Strategie |
|---|---|---|---|
| Absturz vor der Antwort | 101 | Fehler, mit dem stderr-Ende als Hinweis, 0.44 s | Fehler: keine Antwortdatei, 0.51 s |
Auth-Fehler (turn.failed 401) |
1 |
401 Unauthorized: please log in, als Auth-Fehler markiert |
keine Antwortdatei + stderr-Text |
nach der Nachricht gekillt (kein turn.completed) |
137 | Antwort bleibt erhalten, kein Fehler | keine Antwortdatei |
| hängt über eine Deadline von 3 s hinaus | -9 |
timed out after 3s bei 3.004 s |
timed out after 3s bei 3.027 s |
Zwei Zeilen sind hier wichtig.
Auth. Der Event-Stream sagt dir in einem strukturierten Feld, dass es ein Login-Problem war. Die Datei-Strategie hat nur stderr-Fließtext zum Raten. osFoundry gleicht die Meldung mit einer kurzen Liste ab (401, unauthorized, please log in, sign in, …) und zeigt dann eine eigene Login-Aufforderung an.
Nach der Nachricht gekillt. Hier sind sich die beiden Strategien uneinig. Liest du Events, hast du eine vollständige agent_message und kein turn.completed. Liest du eine Datei, hast du nichts. osFoundry entscheidet nach dem, was im Stream stand: Der Lauf schlägt fehl, wenn ein turn.failed/error-Event kam oder gar kein Text, und der Exit-Status spielt bei dieser Entscheidung keine Rolle. Wenn du ein fehlendes turn.completed lieber als unvollständig behandeln willst, ist das die Stelle dafür.
Was killt ein Timeout eigentlich?
osFoundry packt den ganzen Lauf in tokio::time::timeout: 1,800 s für einen autonomen Lauf, 300 s für den Login-Check und andere Completions ohne Streaming. Der Kindprozess wird mit kill_on_drop(true) gestartet. Wenn der Timeout die Future droppt, wird der Prozess also gekillt.
Eine Agent-CLI hat aber eigene Kindprozesse. Der Platzhalter startet sleep 30 als sein „Shell-Tool“ und hängt daran fest. Diese PID habe ich 0.3 s nach der Rückkehr des Wrappers geprüft:
| Kill | Wrapper zurück nach | Tool-Kindprozess danach |
|---|---|---|
p.kill() (nur direktes Kind) |
3.010 s | läuft noch |
start_new_session=True + os.killpg
|
3.011 s | weg |
Gleiches Timing, anderes Aufräumen. Wenn der Tool-Kindprozess deine stdout-Pipe offen hält, sieht ein Reader, der auf EOF wartet, auch nie ein EOF. Deshalb prüft die Schleife oben eine Deadline, statt darauf zu warten, dass readline zurückkommt.
Warum stderr in einem eigenen Thread leeren?
Diese CLIs schreiben Auth-Fehler und Panics nach stderr. Deshalb leitet osFoundry stderr in eine Pipe und behält ein Ende davon (etwa 4,000 Bytes auf dem Agent-Pfad) für die Fehlermeldung. Eine Pipe hat aber einen festen Puffer. Auf diesem Mac blockierte ein Kindprozess nach genau 65,536 Bytes in ein ungelesenes stderr (pipecap.py). Dann habe ich den Platzhalter vor der Antwort etwa 200 KB Trace-Ausgabe schreiben lassen:
| stderr-Behandlung | Ergebnis |
|---|---|
| in einem Thread geleert, während stdout gelesen wird | Antwort bei 2.56 s |
| erst gelesen, nachdem stdout geschlossen ist | keine Antwort, timed out after 8s
|
Die CLI blockiert bei einem Schreibvorgang, den niemand liest, stdout bleibt offen, und der Lauf geht an die Deadline verloren. stderr parallel zu lesen kostet einen Thread.
Wenn du das selbst verdrahtest
- Nimm den Event-Stream statt der Antwortdatei, wenn irgendwer dem Lauf zuschaut. Orientiere dich an den wenigen Event-Typen, die du brauchst, und überspring Nicht-JSON-Zeilen und unbekannte Typen, statt daran zu scheitern.
- Pack den System-Prompt in den Prompt-Text, wenn die CLI kein Flag dafür hat, mit einem schlichten Trenner, und halte frühere Turns als beschriftetes Transkript.
- Leg vorher fest, ob der Exit-Code oder der Stream über Erfolg entscheidet und was eine Nachricht ohne
turn.completedzählt. - Gib dem Kindprozess eine eigene Prozessgruppe und kill beim Timeout die ganze Gruppe, damit die Tool-Prozesse mitgehen.
- Lies stderr parallel und behalte ein begrenztes Ende. Dort stehen die Auth-Fehler, die du anzeigen willst.
- Entfern die Umgebungsvariablen, die einen Abo-Login auf einen nutzungsabhängig abgerechneten Key umleiten würden, und prüf optionale Flags per
--help, statt eine Versionstabelle zu pflegen.
Die oben beschriebene Codex-Zeile findest du im Tab External AI des BYOK-Keys-Dialogs der App.
Top comments (0)