DEV Community

Cover image for osFoundry की तरह Python से Codex CLI को wrap कैसे करें
BBT for BBT हिन्दी

Posted on

osFoundry की तरह Python से Codex CLI को wrap कैसे करें

यह वह command है जो osFoundry desktop app तब चलाता है जब आप Codex के लिए Verify sign-in पर click करते हैं। इसे src-tauri/src/providers/agent_cli.rs में build_codex_command बनाता है, और हर असली cli:codex:agent run इसी function से होकर गुज़रता है:

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

इसे एक-एक हिस्सा करके देखते हैं:

  • exec के साथ prompt एक positional argument के रूप में। Codex में system prompt का कोई flag नहीं है, इसलिए osFoundry वह argument system + "\n\n" + transcript के रूप में बनाता है।
  • --json से stdout में हर line पर एक JSON object आता है। osFoundry सिर्फ़ यही stream पढ़ता है।
  • --skip-git-repo-check से run ऐसे folder में भी शुरू हो जाता है जो repository नहीं है।
  • --ignore-user-config ~/.codex/config.toml को skip करता है, ताकि user के अपने MCP servers run में न आएँ। Auth फिर भी CODEX_HOME से load होता है।
  • --ignore-rules तभी जोड़ा जाता है जब codex exec --help में वह दिखे। osFoundry binary से पूछता है क्योंकि पुराने builds यह flag reject करते हैं, और एक unknown flag पूरे run को fail कर देता है।
  • -s और approval_policy="never" sandbox set करते हैं और Codex को बताते हैं कि कुछ भी approve करने के लिए कोई मौजूद नहीं होगा। Autonomous run को -s workspace-write मिलता है। जो run osFoundry का MCP bridge जोड़ता है, उसे इसकी जगह --dangerously-bypass-approvals-and-sandbox मिलता है, और वह भी सिर्फ़ git worktree के अंदर।

Spawn करने से पहले osFoundry child के environment से CODEX_API_KEY, OPENAI_API_KEY और OPENAI_BASE_URL भी हटा देता है। अगर इनमें से कोई भी set हो, तो run ChatGPT sign-in की जगह metered API account पर चला जाएगा।

इस post का बाकी हिस्सा उस pipe के दूसरे सिरे के बारे में है। आपके पास एक agent CLI है जिसके stream format की कोई गारंटी नहीं है, और आपको उससे उसका answer, उसकी progress और एक साफ़ failure निकालना है।

Final-answer file की जगह events क्यों पढ़ें?

ऐसे CLI से answer निकालने के दो तरीके हैं:

  1. Final answer किसी file में। एक output path दें (codex exec -o FILE; osFoundry के अपने module notes इसे documented file output कहते हैं), process के exit होने का इंतज़ार करें, फिर file पढ़ें। Contract बस इतना है: एक file, उसमें एक string।
  2. stdout पर event stream। --json दें, line by line पढ़ें, और जो items आप समझते हैं उन्हें रखें।

osFoundry का मौजूदा builder दूसरा तरीका अपनाता है। Parser runtime/session_event.rs (parse_codex_line) में है और बहुत कम चीज़ों पर निर्भर करता है:

Event क्या बनता है
item.completed + agent_message answer text
item.completed + reasoning Thinking: … line
item.completed + command_execution Using shell — <command>
item.completed + mcp_tool_call Using <tool>… (Codex server और tool अलग-अलग देता है)
item.completed + file_change / todo_list Edited <path> / Plan: 1. … 2. …
turn.completed usage (input_tokens, cached_input_tokens, output_tokens)
turn.failed, error failure, अगर message auth जैसा लगे तो auth के रूप में flag

बाकी सब ignore होता है, और जो line JSON नहीं है वह भी। यही tolerance stable schema के बिना इसे काम करने लायक बनाती है। Unknown event type से कोई नुकसान नहीं होता। Parser सिर्फ़ तब टूट सकता है जब इन सात rows में से किसी field का नाम बदल जाए।

इसकी कीमत granularity है। Codex completed items emit करता है, token deltas नहीं। Message पूरा होने पर answer एक साथ आता है, इसीलिए app Codex पर No live stream का label लगाता है और इसे run-then-emit बताता है। Progress lines अलग हैं: वे हर item पूरा होते ही आती हैं।

Mac पर दोनों को दोबारा बनाना

जिस machine पर मैंने काम किया, उस पर कोई Codex binary installed नहीं है। इसलिए मैंने fake_codex.py लिखा, एक stand-in जो वही flags लेता है, ऊपर वाले event types fixed delays के साथ print करता है, exit पर final message -o file में लिखता है, और जिसे hang, crash या auth fail करने के लिए कहा जा सकता है। यह Codex नहीं है। इससे आप wrapper का behaviour माप सकते हैं, Codex की speed नहीं।

Stand-in की timeline: 0.4 s पर एक reasoning item, फिर एक shell child (sleep 0.8), फिर लगभग 1.3 s बाद agent_message, फिर उसके 0.2 s बाद turn.completed। Answer file exit पर लिखी जाती है।

Wrapper, wrap.py, Python 3.9.6 पर सिर्फ़ standard library इस्तेमाल करता है। Events strategy का core, छोटा करके:

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

File strategy वही spawn है, बस --json की जगह -o <tmp>, फिर p.wait(timeout=…), फिर file मौजूद हो तो उसे पढ़ना।

First byte तक का समय बनाम answer तक का समय

Test setup: Apple M1, macOS 15.8.1, हर strategy के 20 runs, medians, Popen से ठीक पहले से time.monotonic() से मापा गया (bench.py)।

events (--json) file (-o)
पहला stdout byte 0.034 s कुछ नहीं
पहला completed item 0.443 s कुछ नहीं
answer उपलब्ध 2.562 s 2.824 s
process ख़त्म 2.775 s 2.824 s
सफल runs 20 / 20 20 / 20

Absolute numbers stand-in के sleeps से आते हैं। Table की दो बातें उनसे नहीं आतीं:

  • File के साथ exit तक कुछ भी बाहर नहीं आता। 2.8 s तक caller के पास "सोच रहा है" और "अटक गया है" में फ़र्क करने का कोई तरीका नहीं होता। Events के साथ पहली progress line 0.44 s पर आती है, और bytes 0.034 s पर शुरू हो जाते हैं, जो लगभग Python की अपनी spawn cost है (यहाँ एक खाली python3 -c pass का average 19 ms रहा)।
  • File CLI के आख़िरी step के बाद ही बनती है। इस run में यह message के stdout पर आ जाने के 0.26 s बाद हुआ। असली agent के साथ यह gap उतना होगा जितना CLI message ख़त्म करने और exit करने के बीच करता है।

हर तरफ़ से failure कैसा दिखता है?

वही wrapper, हर case का एक run:

Case exit events strategy file strategy
answer से पहले crash 101 error, hint के रूप में stderr tail के साथ, 0.44 s error: कोई answer file नहीं, 0.51 s
auth failure (turn.failed 401) 1 401 Unauthorized: please log in, auth के रूप में flag कोई answer file नहीं + stderr text
message के बाद kill (कोई turn.completed नहीं) 137 answer बचा रहा, कोई error नहीं कोई answer file नहीं
3 s deadline के बाद भी hang -9 3.004 s पर timed out after 3s 3.027 s पर timed out after 3s

यहाँ दो rows अहम हैं।

Auth. Event stream एक structured field में बताता है कि यह login की problem थी। File strategy के पास अंदाज़ा लगाने के लिए सिर्फ़ stderr का text है। osFoundry message को एक छोटी list (401, unauthorized, please log in, sign in, …) से match करता है और अपना sign-in prompt दिखाता है।

Message के बाद kill. यहाँ दोनों strategies अलग नतीजा देती हैं। Events पढ़ने पर आपके पास पूरा agent_message है और कोई turn.completed नहीं। File पढ़ने पर आपके पास कुछ नहीं है। osFoundry फ़ैसला इस पर करता है कि stream ने क्या कहा: run तब fail होता है जब turn.failed/error event आया हो या कोई text आया ही न हो, और exit status इस फ़ैसले का हिस्सा नहीं है। अगर आप missing turn.completed को partial मानना चाहते हैं, तो उसे जोड़ने की जगह यही है।

Timeout असल में किसे kill करता है?

osFoundry पूरे run को tokio::time::timeout में wrap करता है: autonomous run के लिए 1,800 s, sign-in probe और बाकी non-streaming completions के लिए 300 s। Child kill_on_drop(true) के साथ spawn होता है, इसलिए जब timeout future को drop करता है, तो process kill हो जाता है।

लेकिन agent CLI के अपने children भी होते हैं। Stand-in अपने "shell tool" के रूप में sleep 30 शुरू करता है और उसी पर hang हो जाता है। Wrapper के return होने के 0.3 s बाद मैंने वह PID check किया:

Kill wrapper return हुआ बाद में tool child
p.kill() (सिर्फ़ direct child) 3.010 s अब भी चल रहा
start_new_session=True + os.killpg 3.011 s ख़त्म

Timing वही, cleanup अलग। अगर tool child आपका stdout pipe खुला रखे, तो EOF पर चलने वाला reader भी कभी EOF नहीं देखेगा। इसीलिए ऊपर वाला loop readline के return होने का इंतज़ार करने की जगह deadline check करता है।

stderr को अलग thread पर drain क्यों करें?

ये CLIs auth failures और panics stderr पर लिखते हैं, इसलिए osFoundry उसे pipe करता है और error message के लिए एक tail रखता है (agent path पर लगभग 4,000 bytes)। लेकिन pipe का buffer fixed होता है। इस Mac पर एक child, बिना पढ़े stderr पर ठीक 65,536 bytes लिखने के बाद block हो गया (pipecap.py)। फिर मैंने stand-in से answer देने से पहले लगभग 200 KB trace output लिखवाया:

stderr handling नतीजा
stdout पढ़ते समय thread पर drain 2.56 s पर answer
stdout बंद होने के बाद ही पढ़ा कोई answer नहीं, timed out after 8s

CLI एक ऐसे write पर block हो जाता है जिसे कोई नहीं पढ़ रहा, stdout खुला रहता है, और run deadline में चला जाता है। stderr को साथ-साथ पढ़ने की कीमत बस एक thread है।

अगर आप इसे ख़ुद जोड़ रहे हैं

  • अगर कोई run को देख रहा है तो answer file की जगह event stream चुनें। सिर्फ़ उन्हीं कुछ event types पर निर्भर रहें जिनकी ज़रूरत है, और non-JSON lines व unknown types पर fail होने की जगह उन्हें skip करें।
  • अगर CLI में system prompt का flag नहीं है तो उसे prompt text में ही रखें, एक सादे separator के साथ, और पिछले turns को labelled transcript के रूप में रखें।
  • पहले से तय करें कि success exit code तय करेगा या stream, और turn.completed के बिना message को क्या माना जाएगा।
  • Child को उसका अपना process group दें और timeout पर पूरा group kill करें, ताकि tool processes भी साथ में ख़त्म हों।
  • stderr को साथ-साथ पढ़ें और एक bounded tail रखें। जो auth errors आप दिखाना चाहेंगे, वे इसी में आते हैं।
  • वे environment variables हटा दें जो subscription login को metered key पर मोड़ देंगे, और version table रखने की जगह optional flags के लिए --help probe करें।

ऊपर बताई गई Codex row app के BYOK keys dialog के External AI tab में है।

Top comments (0)