DEV Community

Cover image for Wie du Codex CLI aus Python so ansteuerst wie osFoundry
BBT for BBT Deutsch

Posted on

Wie du Codex CLI aus Python so ansteuerst wie osFoundry

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"'
Enter fullscreen mode Exit fullscreen mode

Gehen wir das Stück für Stück durch:

  • exec mit dem Prompt als einzigem Positionsargument. Codex hat kein Flag für einen System-Prompt, deshalb baut osFoundry dieses Argument als system + "\n\n" + transcript zusammen.
  • --json sorgt dafür, dass stdout ein JSON-Objekt pro Zeile liefert. Dieser Stream ist die einzige Ausgabe, die osFoundry liest.
  • --skip-git-repo-check erlaubt 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 aus CODEX_HOME geladen.
  • --ignore-rules kommt nur dazu, wenn codex exec --help es auflistet. osFoundry fragt das Binary, weil ältere Builds das Flag ablehnen und ein unbekanntes Flag den ganzen Lauf scheitern lässt.
  • -s zusammen mit approval_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:

  1. 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.
  2. 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:]
Enter fullscreen mode Exit fullscreen mode

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 pass lag 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.completed zä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)