Questo è il comando che l'app desktop di osFoundry esegue quando clicchi Verify sign-in per Codex. Lo costruisce build_codex_command in src-tauri/src/providers/agent_cli.rs, la stessa funzione da cui passa ogni vera esecuzione di cli:codex:agent:
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"' Vediamolo pezzo per pezzo:
-
execcon il prompt come unico argomento posizionale. Codex non ha un flag per il system prompt, quindi osFoundry costruisce quell'argomento comesystem + "\n\n" + transcript. -
--jsonfa sì che stdout sia un oggetto JSON per riga. Quello stream è l'unico output che osFoundry legge. -
--skip-git-repo-checkpermette di avviare l'esecuzione in una cartella che non è un repository. -
--ignore-user-configsalta~/.codex/config.toml, così i server MCP dell'utente restano fuori dall'esecuzione. L'autenticazione viene comunque caricata daCODEX_HOME. -
--ignore-rulesviene aggiunto solo secodex exec --helplo elenca. osFoundry lo chiede al binario perché le build più vecchie rifiutano il flag, e un flag sconosciuto fa fallire l'intera esecuzione. -
-sinsieme aapproval_policy="never"imposta la sandbox e dice a Codex che non ci sarà nessuno ad approvare nulla. Un'esecuzione autonoma riceve-s workspace-write. Una che collega il bridge MCP di osFoundry riceve invece--dangerously-bypass-approvals-and-sandbox, e solo dentro un git worktree.
Prima di lanciare il processo, osFoundry rimuove anche CODEX_API_KEY, OPENAI_API_KEY e OPENAI_BASE_URL dall'ambiente del processo figlio. Se una di queste fosse impostata, l'esecuzione finirebbe su un account API a consumo invece che sull'accesso ChatGPT.
Il resto del post parla dell'altro capo di quella pipe. Hai una CLI di agente il cui formato di stream non è garantito, e devi tirarne fuori la risposta, l'avanzamento e un errore pulito.
Perché leggere gli eventi invece di un file con la risposta finale?
Ci sono due modi per ottenere una risposta da una CLI del genere:
- Risposta finale su file. Passi un percorso di output (
codex exec -o FILE; le note del modulo di osFoundry lo chiamano l'output su file documentato), aspetti che il processo termini e poi leggi il file. Il contratto è un file con dentro una stringa. - Stream di eventi su stdout. Passi
--json, leggi riga per riga e tieni gli item che capisci.
Il builder attuale di osFoundry fa la seconda cosa. Il parser si trova in runtime/session_event.rs (parse_codex_line) e si basa su pochissimo:
| Evento | Diventa |
|---|---|
item.completed + agent_message | testo della risposta |
item.completed + reasoning | riga Thinking: … |
item.completed + command_execution | Using shell — <command> |
item.completed + mcp_tool_call | Using <tool>… (Codex fornisce server e tool separatamente) |
item.completed + file_change / todo_list | Edited <path> / Plan: 1. … 2. … |
turn.completed | utilizzo (input_tokens, cached_input_tokens, output_tokens) |
turn.failed, error | errore, marcato come di autenticazione se il messaggio lo sembra |
Tutto il resto viene ignorato, così come qualsiasi riga che non sia JSON. È questa tolleranza che rende la cosa praticabile senza uno schema stabile. Un tipo di evento sconosciuto non costa nulla. Solo un campo rinominato in una di quelle sette righe può rompere il parser.
Il prezzo è la granularità. Codex emette item completati, non delta di token. La risposta compare tutta in una volta quando il messaggio è finito, ed è per questo che l'app etichetta Codex come No live stream e lo descrive come run-then-emit. Le righe di avanzamento sono diverse: arrivano man mano che ogni item si completa.
Ricostruire entrambe su un Mac
Sulla macchina che ho usato non c'è nessun binario di Codex installato. Quindi ho scritto fake_codex.py, un sostituto che accetta gli stessi flag, stampa i tipi di evento visti sopra con ritardi fissi, scrive il messaggio finale nel file -o all'uscita e si può configurare per bloccarsi, andare in crash o fallire l'autenticazione. Non è Codex. Quello che permette di misurare è il comportamento del wrapper, non la velocità di Codex.
La timeline del sostituto: un item reasoning a 0.4 s, poi un processo figlio di shell (sleep 0.8), poi l'agent_message circa 1.3 s dopo, poi turn.completed 0.2 s più tardi. Il file della risposta viene scritto all'uscita.
Il wrapper, wrap.py, usa solo la libreria standard su Python 3.9.6. Il cuore della strategia a eventi, accorciato:
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:] La strategia su file è lo stesso spawn con -o <tmp> al posto di --json, poi p.wait(timeout=…), poi la lettura del file se esiste.
Tempo al primo byte contro tempo alla risposta
Setup del test: Apple M1, macOS 15.8.1, 20 esecuzioni per strategia, mediane, cronometrate con time.monotonic() da subito prima di Popen (bench.py).
eventi (--json) | file (-o) | |
|---|---|---|
| primo byte su stdout | 0.034 s | nessuno |
| primo item completato | 0.443 s | nessuno |
| risposta disponibile | 2.562 s | 2.824 s |
| processo terminato | 2.775 s | 2.824 s |
| esecuzioni riuscite | 20 / 20 | 20 / 20 |
I numeri assoluti vengono dagli sleep del sostituto. Due cose nella tabella no:
- Con un file, non esce nulla fino all'uscita del processo. Per 2.8 s il chiamante non ha modo di distinguere "sta pensando" da "è bloccato". Con gli eventi, la prima riga di avanzamento arriva a 0.44 s, e i byte iniziano a 0.034 s, più o meno il costo di avvio del processo da parte di Python (un semplice
python3 -c passqui ha fatto in media 19 ms). - Il file esiste solo dopo l'ultimo passo della CLI. In questa esecuzione è stato 0.26 s dopo che il messaggio era già comparso su stdout. Con un agente reale, il distacco è quello che la CLI fa tra la fine del messaggio e l'uscita.
Che aspetto ha un errore da ciascun lato?
Stesso wrapper, un'esecuzione per caso:
| Caso | exit | strategia a eventi | strategia su file |
|---|---|---|---|
| crash prima di rispondere | 101 | errore, con la coda di stderr come indizio, 0.44 s | errore: nessun file di risposta, 0.51 s |
errore di autenticazione (turn.failed 401) | 1 | 401 Unauthorized: please log in, marcato come di autenticazione | nessun file di risposta + testo di stderr |
ucciso dopo il messaggio (nessun turn.completed) | 137 | risposta conservata, nessun errore | nessun file di risposta |
| blocco oltre una deadline di 3 s | -9 | timed out after 3s a 3.004 s | timed out after 3s a 3.027 s |
Qui contano due righe.
Autenticazione. Lo stream di eventi ti dice in un campo strutturato che si trattava di un problema di login. La strategia su file ha solo il testo di stderr per tirare a indovinare. osFoundry confronta il messaggio con una breve lista (401, unauthorized, please log in, sign in, …) e mostra il proprio prompt di accesso.
Ucciso dopo il messaggio. Le due strategie non sono d'accordo. Leggendo gli eventi, hai un agent_message completo e nessun turn.completed. Leggendo un file, non hai niente. osFoundry decide in base a quello che ha detto lo stream: l'esecuzione fallisce se è arrivato un evento turn.failed/error o se non è arrivato alcun testo, e lo stato di uscita non entra in questa decisione. Se preferisci trattare l'assenza di turn.completed come un risultato parziale, è lì che va aggiunto.
Cosa uccide davvero un timeout?
osFoundry avvolge l'intera esecuzione in tokio::time::timeout: 1,800 s per un'esecuzione autonoma, 300 s per la verifica dell'accesso e per le altre completion senza streaming. Il figlio viene lanciato con kill_on_drop(true), quindi quando il timeout fa il drop del future, il processo viene ucciso.
Una CLI di agente però ha dei figli suoi. Il sostituto avvia sleep 30 come suo "tool di shell" e ci resta bloccato sopra. Ho controllato quel PID 0.3 s dopo il ritorno del wrapper:
| Kill | ritorno del wrapper | figlio del tool dopo |
|---|---|---|
p.kill() (solo il figlio diretto) | 3.010 s | ancora in esecuzione |
start_new_session=True + os.killpg | 3.011 s | sparito |
Stessi tempi, pulizia diversa. Se il figlio del tool tiene aperta la tua pipe di stdout, anche un lettore guidato dall'EOF non vede mai l'EOF, ed è per questo che il loop sopra controlla una deadline invece di aspettare che readline ritorni.
Perché svuotare stderr su un thread a parte?
Queste CLI scrivono gli errori di autenticazione e i panic su stderr, quindi osFoundry lo mette in una pipe e ne tiene la coda (circa 4,000 byte sul percorso dell'agente) per il messaggio di errore. Una pipe però ha un buffer fisso. Su questo Mac un figlio si è bloccato dopo esattamente 65,536 byte scritti su uno stderr non letto (pipecap.py). Poi ho fatto scrivere al sostituto circa 200 KB di trace prima di rispondere:
| Gestione di stderr | risultato |
|---|---|
| svuotato su un thread mentre si legge stdout | risposta a 2.56 s |
| letto solo dopo la chiusura di stdout | nessuna risposta, timed out after 8s |
La CLI si blocca su una scrittura che nessuno legge, stdout resta aperto e l'esecuzione si perde alla deadline. Leggere stderr in parallelo costa un thread.
Se lo implementi tu
- Scegli lo stream di eventi invece del file di risposta se qualcuno segue l'esecuzione. Basati sui pochi tipi di evento che ti servono, e salta le righe non JSON e i tipi sconosciuti invece di fallire.
- Metti il system prompt nel testo del prompt quando la CLI non ha un flag apposito, con un separatore semplice, e tieni i turni precedenti come un transcript etichettato.
- Decidi fin da subito se il successo lo stabilisce il codice di uscita o lo stream, e cosa conta un messaggio senza
turn.completed. - Dai al figlio un suo process group e uccidi il gruppo al timeout, così i processi dei tool se ne vanno con lui.
- Leggi stderr in parallelo e tieni una coda di dimensione limitata. Contiene gli errori di autenticazione che vorrai mostrare.
- Elimina le variabili d'ambiente che dirotterebbero un login con abbonamento su una chiave a consumo, e interroga
--helpper i flag opzionali invece di mantenere una tabella delle versioni.
La riga di Codex descritta sopra si trova nella scheda External AI della finestra BYOK keys dell'app.