Ini adalah perintah yang dijalankan aplikasi desktop osFoundry saat Anda mengklik Verify sign-in untuk Codex. Perintah ini dibangun oleh build_codex_command di src-tauri/src/providers/agent_cli.rs, fungsi yang sama yang dilewati setiap run cli:codex:agent sungguhan:

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"' 

Kita bedah satu per satu:

  • exec dengan prompt sebagai satu argumen posisional. Codex tidak punya flag untuk system prompt, jadi osFoundry menyusun argumen itu sebagai system + "\n\n" + transcript.
  • --json membuat stdout berisi satu objek JSON per baris. Stream itu satu-satunya output yang dibaca osFoundry.
  • --skip-git-repo-check membolehkan run dimulai di folder yang bukan repository.
  • --ignore-user-config melewati ~/.codex/config.toml, sehingga server MCP milik pengguna tidak ikut masuk ke run. Auth tetap dimuat dari CODEX_HOME.
  • --ignore-rules hanya ditambahkan kalau codex exec --help mencantumkannya. osFoundry bertanya ke binary-nya karena build lama menolak flag ini, dan satu flag yang tidak dikenal menggagalkan seluruh run.
  • -s ditambah approval_policy="never" mengatur sandbox dan memberi tahu Codex bahwa tidak akan ada orang yang menyetujui apa pun. Run otonom mendapat -s workspace-write. Run yang memasang MCP bridge osFoundry mendapat --dangerously-bypass-approvals-and-sandbox sebagai gantinya, dan hanya di dalam git worktree.

Sebelum spawn, osFoundry juga menghapus CODEX_API_KEY, OPENAI_API_KEY, dan OPENAI_BASE_URL dari environment child process. Kalau salah satunya terisi, run akan masuk ke akun API berbayar per pemakaian, bukan ke login ChatGPT.

Sisa tulisan ini membahas ujung lain dari pipe tersebut. Anda punya agent CLI yang format stream-nya tidak dijamin stabil, dan Anda perlu mengambil jawabannya, progresnya, dan kegagalan yang rapi darinya.

Kenapa membaca event, bukan file jawaban akhir?

Ada dua cara mengambil jawaban dari CLI seperti ini:

  1. Jawaban akhir ke file. Berikan path output (codex exec -o FILE; catatan modul osFoundry sendiri menyebutnya output file yang terdokumentasi), tunggu proses selesai, lalu baca file-nya. Kontraknya satu file berisi satu string.
  2. Event stream di stdout. Berikan --json, baca baris per baris, dan simpan item yang Anda pahami.

Builder osFoundry saat ini memakai cara kedua. Parser-nya ada di runtime/session_event.rs (parse_codex_line) dan hanya bergantung pada sedikit hal:

Event Menjadi
item.completed + agent_message teks jawaban
item.completed + reasoning baris Thinking: …
item.completed + command_execution Using shell — <command>
item.completed + mcp_tool_call Using <tool>… (Codex memberikan server dan tool secara terpisah)
item.completed + file_change / todo_list Edited <path> / Plan: 1. … 2. …
turn.completed usage (input_tokens, cached_input_tokens, output_tokens)
turn.failed, error kegagalan, ditandai sebagai auth kalau pesannya terlihat seperti masalah login

Selain itu diabaikan, begitu juga baris apa pun yang bukan JSON. Toleransi inilah yang membuat pendekatan ini jalan tanpa skema yang stabil. Tipe event yang tidak dikenal tidak berdampak apa-apa. Hanya field yang berganti nama di salah satu dari tujuh baris itu yang bisa merusak parser.

Harganya adalah granularitas. Codex mengeluarkan item yang sudah selesai, bukan token delta. Jawaban muncul sekaligus saat pesannya selesai, itulah sebabnya aplikasi memberi Codex label No live stream dan menyebutnya run-then-emit. Baris progres berbeda: baris itu datang setiap kali satu item selesai.

Membangun ulang keduanya di Mac

Di mesin yang saya pakai tidak ada binary Codex yang terpasang. Jadi saya menulis fake_codex.py, pengganti yang menerima flag yang sama, mencetak tipe event di atas dengan jeda tetap, menulis pesan akhir ke file -o saat exit, dan bisa disuruh hang, crash, atau gagal auth. Ini bukan Codex. Yang bisa diukur dengannya adalah perilaku wrapper, bukan kecepatan Codex.

Timeline pengganti ini: item reasoning di 0.4 s, lalu child shell (sleep 0.8), lalu agent_message sekitar 1.3 s kemudian, lalu turn.completed 0.2 s setelahnya. File jawaban ditulis saat exit.

Wrapper-nya, wrap.py, hanya memakai standard library di Python 3.9.6. Inti dari strategi event, sudah dipangkas:

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:] 

Strategi file adalah spawn yang sama dengan -o <tmp> sebagai ganti --json, lalu p.wait(timeout=…), lalu membaca file-nya kalau ada.

Waktu sampai byte pertama vs waktu sampai jawaban

Setup pengujian: Apple M1, macOS 15.8.1, 20 run per strategi, nilai median, diukur dengan time.monotonic() dari tepat sebelum Popen (bench.py).

event (--json) file (-o)
byte stdout pertama 0.034 s tidak ada
item selesai pertama 0.443 s tidak ada
jawaban tersedia 2.562 s 2.824 s
proses selesai 2.775 s 2.824 s
run berhasil 20 / 20 20 / 20

Angka absolutnya berasal dari sleep di pengganti tadi. Dua hal di tabel ini tidak:

  • Dengan file, tidak ada yang keluar sampai exit. Selama 2.8 s pemanggil tidak bisa membedakan "sedang berpikir" dari "macet". Dengan event, baris progres pertama datang di 0.44 s, dan byte mulai mengalir di 0.034 s, kurang lebih sama dengan biaya spawn Python itu sendiri (python3 -c pass kosong rata-rata 19 ms di sini).
  • File baru ada setelah langkah terakhir CLI. Di run ini jaraknya 0.26 s setelah pesan sudah muncul di stdout. Dengan agent sungguhan, jaraknya tergantung apa saja yang dikerjakan CLI antara menyelesaikan pesan dan exit.

Seperti apa kegagalan dilihat dari masing-masing sisi?

Wrapper yang sama, satu run per kasus:

Kasus exit strategi event strategi file
crash sebelum menjawab 101 error, dengan ekor stderr sebagai petunjuk, 0.44 s error: tidak ada file jawaban, 0.51 s
gagal auth (turn.failed 401) 1 401 Unauthorized: please log in, ditandai sebagai auth tidak ada file jawaban + teks stderr
di-kill setelah pesan (tanpa turn.completed) 137 jawaban tetap ada, tanpa error tidak ada file jawaban
hang melewati deadline 3 s -9 timed out after 3s di 3.004 s timed out after 3s di 3.027 s

Ada dua baris yang penting di sini.

Auth. Event stream memberi tahu bahwa ini masalah login lewat field terstruktur. Strategi file cuma punya teks stderr untuk ditebak. osFoundry mencocokkan pesannya dengan daftar pendek (401, unauthorized, please log in, sign in, …) lalu memunculkan prompt sign-in miliknya sendiri.

Di-kill setelah pesan. Di sini kedua strategi berbeda hasil. Dengan membaca event, Anda punya agent_message yang lengkap dan tidak ada turn.completed. Dengan membaca file, Anda tidak punya apa-apa. osFoundry memutuskan berdasarkan isi stream: run dianggap gagal kalau event turn.failed/error datang atau kalau tidak ada teks sama sekali, dan exit status tidak ikut menentukan. Kalau Anda lebih suka menganggap turn.completed yang hilang sebagai hasil parsial, di situlah tempat menambahkannya.

Apa yang sebenarnya di-kill oleh timeout?

osFoundry membungkus seluruh run dengan tokio::time::timeout: 1,800 s untuk run otonom, 300 s untuk probe sign-in dan completion lain yang tidak streaming. Child di-spawn dengan kill_on_drop(true), jadi saat timeout men-drop future-nya, prosesnya ikut di-kill.

Tapi agent CLI punya child process sendiri. Pengganti tadi menjalankan sleep 30 sebagai "shell tool"-nya dan hang di situ. Saya mengecek PID itu 0.3 s setelah wrapper kembali:

Kill wrapper kembali tool child sesudahnya
p.kill() (hanya child langsung) 3.010 s masih berjalan
start_new_session=True + os.killpg 3.011 s sudah hilang

Waktunya sama, pembersihannya beda. Kalau tool child memegang pipe stdout Anda tetap terbuka, reader yang bergantung pada EOF juga tidak akan pernah melihat EOF. Itulah sebabnya loop di atas mengecek deadline, alih-alih menunggu readline kembali.

Kenapa stderr dikuras di thread tersendiri?

CLI seperti ini menulis kegagalan auth dan panic ke stderr, jadi osFoundry mem-pipe-nya dan menyimpan ekornya (sekitar 4,000 byte di jalur agent) untuk pesan error. Tapi pipe punya buffer berukuran tetap. Di Mac ini, sebuah child terblokir setelah tepat 65,536 byte ke stderr yang tidak dibaca (pipecap.py). Lalu saya membuat pengganti tadi menulis sekitar 200 KB output trace sebelum menjawab:

Penanganan stderr hasil
dikuras di thread sambil stdout dibaca jawaban di 2.56 s
baru dibaca setelah stdout tertutup tidak ada jawaban, timed out after 8s

CLI terblokir pada write yang tidak dibaca siapa pun, stdout tetap terbuka, dan run hilang karena deadline. Membaca stderr secara bersamaan hanya butuh satu thread.

Kalau Anda merangkainya sendiri

  • Pilih event stream ketimbang file jawaban kalau ada yang memantau run-nya. Bergantunglah pada sedikit tipe event yang Anda butuhkan, dan lewati baris non-JSON serta tipe yang tidak dikenal alih-alih gagal karenanya.
  • Taruh system prompt di dalam teks prompt kalau CLI-nya tidak punya flag untuk itu, dengan pemisah sederhana, dan simpan giliran sebelumnya sebagai transcript berlabel.
  • Tentukan dari awal apakah exit code atau stream yang menentukan sukses, dan dianggap apa pesan tanpa turn.completed.
  • Beri child process group sendiri dan kill group-nya saat timeout, supaya proses tool ikut berhenti.
  • Baca stderr secara bersamaan dan simpan ekor yang dibatasi ukurannya. Di situlah error auth yang ingin Anda tampilkan.
  • Hapus environment variable yang akan mengalihkan login langganan ke key berbayar per pemakaian, dan probe --help untuk flag opsional alih-alih menyimpan tabel versi.

Baris Codex yang dijelaskan di atas ada di tab External AI pada dialog BYOK keys di aplikasi.