DEV Community

Dawon Lee
Dawon Lee

Posted on Fully Autonomous

Codex will answer its own question if you're not looking. Here's how to catch it

If you use Codex, you've probably seen this: it stops to ask you something ("Which folder should I refactor?"), and if you don't answer quickly it goes ahead with its own recommended option. People have asked for a way to turn that off, and the issue has 200+ 👍. Since 0.147, Plan mode waits for you, but per the thread, questions in default mode (the desktop app's "ask while working" questions) can still resolve themselves.

Until there's a setting, the practical fix is to find out the moment Codex asks, even when the terminal is behind three other windows or you're on the couch.

There's no hook for it

Codex hooks cover SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop and a few others (docs). PermissionRequest fires for approvals (shell escalation, network), not for questions. So a notify/hook setup that pings you on approvals stays silent when Codex asks a question.

But the session log has it

Every Codex session writes a JSONL rollout under ~/.codex/sessions/YYYY/MM/DD/. A question is a tool call named request_user_input:

{"type":"response_item","payload":{"type":"function_call","name":"request_user_input",
 "arguments":"{\"questions\":[{\"header\":\"Scope\",\"question\":\"Which folder should I refactor?\"}],\"isBlocking\":true}",
 "call_id":"call_…"}}
Enter fullscreen mode Exit fullscreen mode

When you answer, a function_call_output with the same call_id follows. So a request_user_input call with no output yet = Codex is waiting on you.

Two caveats from checking my own logs:

  • The desktop app's default-mode questions show up as request_user_input_async. Those get {"accepted":true} back within a fraction of a second while Codex keeps working, so "no output yet" doesn't work for them. The script below treats any async question from the last 3 minutes as new instead. These are the ones that can still resolve without you.
  • The field layout above is from Codex's source (protocol/src/request_user_input.rs) and matches the envelope in my logs, but log formats aren't a stable API, so expect to adjust it someday.

A small watcher

#!/usr/bin/env python3
"""Notify (macOS) when Codex is blocked on a question you haven't answered yet."""
import json, os, subprocess, time, glob

ROOT = os.path.expanduser("~/.codex/sessions")
seen = set()

def pending(path):
    calls, done = {}, set()
    with open(path, errors="ignore") as f:
        f.seek(max(0, os.path.getsize(path) - 256_000))   # the tail is enough
        for line in f:
            if '"response_item"' not in line:
                continue
            try:
                p = json.loads(line)["payload"]
            except (ValueError, KeyError):
                continue
            if p.get("type") == "function_call_output":
                done.add(p.get("call_id"))
            elif p.get("type") == "function_call" and p.get("name") in ("request_user_input", "request_user_input_async"):
                if p["name"].endswith("_async") and time.time() - os.path.getmtime(path) > 180:
                    continue   # async: only fresh ones (they're 'accepted' instantly)
                try:
                    q = json.loads(p.get("arguments") or "{}")["questions"][0]
                    text = q.get("question") or q.get("title") or q.get("header") or ""
                except (ValueError, KeyError, IndexError):
                    text = ""
                calls[p.get("call_id")] = (text, p["name"].endswith("_async"))
    return {c: t for c, (t, is_async) in calls.items() if is_async or c not in done}

def notify(text):
    msg = (text or "Codex is waiting for your answer")[:180].replace('"', "'")
    subprocess.run(["osascript", "-e",
        f'display notification "{msg}" with title "Codex has a question" sound name "Glass"'])

while True:
    for path in glob.glob(f"{ROOT}/*/*/*/*.jsonl"):
        if time.time() - os.path.getmtime(path) > 1800:   # only live sessions
            continue
        for cid, text in pending(path).items():
            if cid not in seen:
                seen.add(cid)
                notify(text)
    time.sleep(3)
Enter fullscreen mode Exit fullscreen mode

Run it in a spare terminal (python3 codex-question-alert.py). It only reads files that changed in the last 30 minutes and only the last 256 KB of each, so it's cheap. I tested it against a synthetic log (one unanswered question, one answered, one fresh async) and against a week of my real logs (no false alarms).

To get it on your phone, swap notify() for a curl to an ntfy topic.

What I did in Lidnight

I build Lidnight, a menu bar app that keeps a MacBook awake (lid closed, even on battery) while Claude Code and Codex work and lets it sleep when they're done. Version 1.7.69 does the check above on its own: when Codex asks you a question (blocking or the desktop app's async kind), you get a Mac notification and a phone push (if you've linked one) right away, with the question text. It can't answer for you. It just makes sure you see the question in time.

Lidnight is free with the lid open (AI Auto mode); a one-time Pro keeps it going with the lid closed, even on battery. Launch price is 50% off through Oct 31. brew install whereisdw/tap/lidnight

Top comments (1)

Collapse
 
autenai profile image
Auten •

Nice find on request_user_input_async. Two small things I'd tweak in the watcher:

  1. The async freshness check uses the file's mtime, and a live session file is touched on every step. So after you restart the watcher, every async question still in the last 256 KB of an active session counts as "fresh" and fires again (seen is empty on start). Comparing against the rollout line's own timestamp, if your envelope has one, instead of os.path.getmtime(path) keeps it to genuinely new questions.

  2. glob("*/*/*/*.jsonl") every 3 s stats every session you have ever run. After a few months that is thousands of files per tick. Globbing only today's and yesterday's YYYY/MM/DD folders gives the same result, since you skip anything older than 30 minutes anyway.

Also agree on treating the log format as unstable. A jq one-liner test against one real rollout after each Codex update is a cheap way to notice when it changes.