Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 74 additions & 10 deletions bin/omarchy-agent-usage-codex
Original file line number Diff line number Diff line change
Expand Up @@ -474,25 +474,40 @@ def _cached_local_stats(max_age):
return stats


def rpc_send(proc, payload, operation):
"""Write one JSON-RPC frame. Raise immediately if the app-server is gone."""
try:
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
except OSError as exc:
try:
proc.stdin.close()
except OSError:
pass
raise RuntimeError(f"Codex app-server exited before {operation}") from exc


def rpc_request(proc, request_id, method, params=None, timeout=8):
payload = {"id": request_id, "method": method, "params": params or {}}
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
rpc_send(proc, payload, method)
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
if not ready:
if proc.poll() is not None:
raise RuntimeError(f"Codex app-server exited before {method}")
continue
line = proc.stdout.readline()
if not line:
break
raise RuntimeError(f"Codex app-server exited before {method}")
try:
message = json.loads(line)
except Exception:
continue
if message.get("id") == request_id:
return message
raise TimeoutError(method)
# Process still up but silent: keep the method name searchable, but name the stall.
raise TimeoutError(f"Codex app-server did not answer {method}")


def limit_window(window):
Expand All @@ -518,6 +533,42 @@ def limit_window(window):
}


def codex_rpc_help(proc, stderr_file, exc):
"""Explain an app-server RPC failure without leaking bare method names.

rpc_request used to raise TimeoutError(method), so a dead CLI left the panel
showing just "initialize". Prefer, in order:

1. Exception text when the process is still running (stall / parse error).
2. The CLI's own stderr once it has exited (names a dropped/renamed flag).
3. The login hint when the process exited with nothing useful to say.
"""
try:
proc.wait(timeout=1)
except Exception:
pass

text = str(exc).strip()
if proc.poll() is None:
# Still up: stall or unparseable payload. Not an auth problem, and any
# stderr so far is its logging, not why it stopped.
return text[:300] or AUTH_HELP

detail = ""
if stderr_file is not None:
try:
stderr_file.seek(0)
detail = stderr_file.read()
except Exception:
detail = ""
lines = [line.strip() for line in detail.splitlines() if line.strip()]
if lines:
# The fatal error comes last, after whatever the CLI logged on the way up.
return f"codex app-server exited: {' '.join(lines)[-275:]}"
# Exited cleanly with empty stderr — typical "not logged in" shape.
return AUTH_HELP


def reset_credits(result):
granted = (result.get("rateLimitResetCredits") or {}).get("credits") or []
available = [c for c in granted if isinstance(c, dict) and c.get("status") == "available"]
Expand All @@ -539,24 +590,35 @@ def fetch_codex_rpc(home=None):
result["authHelpText"] = "codex not found in PATH"
return result

# Regular file, not a PIPE: an unread stderr pipe can fill and deadlock a
# chatty app-server. Only read on failure after the process has exited.
try:
stderr_file = tempfile.TemporaryFile("w+", errors="replace")
except OSError:
# No temp space is no reason to skip the probe; it only costs the CLI's own error text.
stderr_file = None
try:
# -a never: non-interactive probe; never prompts. on-request is also valid
# on codex-cli >= 0.149, but never matches the old untrusted "do not ask"
# intent and is what the collector needs (it never starts a turn).
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "on-request", "app-server"],
[codex, "-s", "read-only", "-a", "never", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
stderr=stderr_file or subprocess.DEVNULL,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Startup errors become login hints If the temporary file cannot be created, this fallback discards Codex’s error output. If Codex then exits with a startup error, the collector tells the user to run codex login instead of showing the cause, making unavailable limits harder to diagnose.

text=True,
env=env,
)
except Exception as exc:
if stderr_file:
stderr_file.close()
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = str(exc)
return result

try:
rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-agent-usage", "version": "1"}}, timeout=8)
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
proc.stdin.flush()
rpc_send(proc, {"method": "initialized", "params": {}}, "initialized")
limits_msg = rpc_request(proc, 2, "account/rateLimits/read", timeout=8)

# A home nobody is signed in to answers with an error rather than limits.
Expand All @@ -583,7 +645,7 @@ def fetch_codex_rpc(home=None):
account_msg = rpc_request(proc, 3, "account/read", timeout=2)
account = (account_msg.get("result") or {}).get("account") or {}
plan = account.get("planType") or account.get("type") or ""
except TimeoutError:
except (TimeoutError, RuntimeError):
pass
result["tierLabel"] = str(plan) if plan else ""

Expand All @@ -599,7 +661,7 @@ def fetch_codex_rpc(home=None):
result["resetCredits"] = credits
except Exception as exc:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = str(exc)
result["authHelpText"] = codex_rpc_help(proc, stderr_file, exc)
finally:
try:
proc.terminate()
Expand All @@ -609,6 +671,8 @@ def fetch_codex_rpc(home=None):
proc.kill()
except Exception:
pass
if stderr_file:
stderr_file.close()
return result


Expand Down
150 changes: 148 additions & 2 deletions test/shell.d/agent-usage-codex-scanner-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ EOF
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" CODEX_ARGS_FILE="$TEST_HOME/codex-args" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
"$ROOT/bin/omarchy-agent-usage-codex")

# NUL-separated, so the assertion sees argument boundaries: a single "-a on-request"
# NUL-separated, so the assertion sees argument boundaries: a single "-a never"
# would flatten to the same text as two arguments but is not a policy codex accepts.
expected_args=(-s read-only -a on-request app-server)
expected_args=(-s read-only -a never app-server)
mapfile -d '' -t codex_args <"$TEST_HOME/codex-args"

[[ ${codex_args[*]@Q} == "${expected_args[*]@Q}" ]] ||
Expand Down Expand Up @@ -640,3 +640,149 @@ result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_H
[[ $(jq -r '.usageStatusText' <<<"$result") == "Waiting for auth" ]] ||
fail "Codex collector reports a missing sign-in as one" "$result"
pass "Codex collector reports a missing sign-in as one"

# A codex that exits before speaking the protocol (rejected flag, crash, etc.)
# must not leave the panel showing the bare RPC method name "initialize".
EXIT_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$EXIT_HOME"' EXIT
mkdir -p "$EXIT_HOME/bin"
cat >"$EXIT_HOME/bin/codex" <<'EOF'
#!/bin/bash
echo "error: invalid value 'untrusted' for '--ask-for-approval <APPROVAL_POLICY>'" >&2
echo " [possible values: on-request, never]" >&2
exit 2
EOF
chmod +x "$EXIT_HOME/bin/codex"

result=$(HOME="$EXIT_HOME" CODEX_HOME="$EXIT_HOME/.codex" XDG_CACHE_HOME="$EXIT_HOME/.cache" XDG_DATA_HOME="$EXIT_HOME/.local/share" \
PATH="$EXIT_HOME/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only)

[[ $(jq -r '.usageStatusText' <<<"$result") == "Codex limits unavailable" ]] ||
fail "Codex collector reports limits unavailable when app-server rejects argv" "$result"
help=$(jq -r '.authHelpText' <<<"$result")
[[ $help == *"invalid value 'untrusted'"* ]] ||
fail "Codex collector surfaces the CLI's own error" "$result"
[[ $help != "initialize" ]] ||
fail "Codex collector must not leak the raw RPC method name" "$result"
pass "Codex collector surfaces a rejected app-server call instead of the RPC method name"

# EOF on stdout during initialize (process died) is reported as an exit, not a bare method.
DEAD_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$EXIT_HOME" "$DEAD_HOME"' EXIT
mkdir -p "$DEAD_HOME/bin"
cat >"$DEAD_HOME/bin/codex" <<'EOF'
#!/bin/bash
exec 1>&-
exec sleep 30
EOF
chmod +x "$DEAD_HOME/bin/codex"

result=$(HOME="$DEAD_HOME" CODEX_HOME="$DEAD_HOME/.codex" XDG_CACHE_HOME="$DEAD_HOME/.cache" XDG_DATA_HOME="$DEAD_HOME/.local/share" \
PATH="$DEAD_HOME/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only)

help=$(jq -r '.authHelpText' <<<"$result")
[[ $help == "Codex app-server exited before initialize" ]] ||
fail "Codex collector identifies an app-server that exits during startup" "$result"
pass "Codex collector identifies an app-server that exits during startup"

# Failure while sending the initialized notification after initialize answered.
HALF_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$EXIT_HOME" "$DEAD_HOME" "$HALF_HOME"' EXIT
mkdir -p "$HALF_HOME/bin"
cat >"$HALF_HOME/bin/codex" <<'EOF'
#!/bin/bash
read -r request
exec 0<&-
jq -cn --argjson id "$(jq -r '.id' <<<"$request")" '{id: $id, result: {}}'
exec sleep 30
EOF
chmod +x "$HALF_HOME/bin/codex"

result=$(HOME="$HALF_HOME" CODEX_HOME="$HALF_HOME/.codex" XDG_CACHE_HOME="$HALF_HOME/.cache" XDG_DATA_HOME="$HALF_HOME/.local/share" \
PATH="$HALF_HOME/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only)

help=$(jq -r '.authHelpText' <<<"$result")
[[ $help == "Codex app-server exited before initialized" ]] ||
fail "Codex collector translates failure to send the initialized notification" "$result"
pass "Codex collector translates failure to send the initialized notification"

# Silent clean exit with no stderr: fall back to the login hint.
SILENT_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$EXIT_HOME" "$DEAD_HOME" "$HALF_HOME" "$SILENT_HOME"' EXIT
mkdir -p "$SILENT_HOME/bin"
cat >"$SILENT_HOME/bin/codex" <<'EOF'
#!/bin/bash
exit 0
EOF
chmod +x "$SILENT_HOME/bin/codex"

result=$(HOME="$SILENT_HOME" CODEX_HOME="$SILENT_HOME/.codex" XDG_CACHE_HOME="$SILENT_HOME/.cache" XDG_DATA_HOME="$SILENT_HOME/.local/share" \
PATH="$SILENT_HOME/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only)

help=$(jq -r '.authHelpText' <<<"$result")
[[ $help == "Run \`codex login\` to authenticate." ]] ||
fail "Codex collector falls back to the login hint when the CLI is silent" "$result"
pass "Codex collector falls back to the login hint when the app-server says nothing"

# Live app-server that stalls on account/rateLimits/read: keep a clear stall message, not login.
STALL_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$EXIT_HOME" "$DEAD_HOME" "$HALF_HOME" "$SILENT_HOME" "$STALL_HOME"' EXIT
mkdir -p "$STALL_HOME/bin"
cat >"$STALL_HOME/bin/codex" <<'EOF'
#!/bin/bash
while read -r request; do
id=$(jq -r '.id // empty' <<<"$request")
method=$(jq -r '.method // empty' <<<"$request")
case "$method" in
initialize) jq -cn --argjson id "$id" '{id: $id, result: {}}' ;;
account/rateLimits/read) : ;;
esac
done
EOF
chmod +x "$STALL_HOME/bin/codex"

result=$(HOME="$STALL_HOME" CODEX_HOME="$STALL_HOME/.codex" XDG_CACHE_HOME="$STALL_HOME/.cache" XDG_DATA_HOME="$STALL_HOME/.local/share" \
PATH="$STALL_HOME/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only)

help=$(jq -r '.authHelpText' <<<"$result")
[[ $help != "Run \`codex login\` to authenticate." ]] ||
fail "Codex collector must not blame auth when the app-server is merely stalled" "$result"
[[ $help == "Codex app-server did not answer account/rateLimits/read" ]] ||
fail "Codex collector names the stalled RPC method clearly" "$result"
[[ $help != "account/rateLimits/read" && $help != "initialize" ]] ||
fail "Codex collector must not leak a bare method name" "$result"
pass "Codex collector names a stalled RPC instead of leaking the method name"

# A stalled app-server that has logged to stderr is still running: its logging
# is not why it stopped, and it has not exited.
NOISY_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$EXIT_HOME" "$DEAD_HOME" "$HALF_HOME" "$SILENT_HOME" "$STALL_HOME" "$NOISY_HOME"' EXIT
mkdir -p "$NOISY_HOME/bin"
sed 's/^while read/echo "WARN codex_core: startup notice" >\&2\nwhile read/' "$STALL_HOME/bin/codex" >"$NOISY_HOME/bin/codex"
chmod +x "$NOISY_HOME/bin/codex"

result=$(HOME="$NOISY_HOME" CODEX_HOME="$NOISY_HOME/.codex" XDG_CACHE_HOME="$NOISY_HOME/.cache" XDG_DATA_HOME="$NOISY_HOME/.local/share" \
PATH="$NOISY_HOME/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only)

[[ $(jq -r '.authHelpText' <<<"$result") == "Codex app-server did not answer account/rateLimits/read" ]] ||
fail "Codex collector reports a stall, not an exit, when a live app-server has logged" "$result"
pass "Codex collector reports a stall, not an exit, when a live app-server has logged"

# A CLI that logs plenty before failing must still show the failure, not the logging.
CHATTY_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$EXIT_HOME" "$DEAD_HOME" "$HALF_HOME" "$SILENT_HOME" "$STALL_HOME" "$NOISY_HOME" "$CHATTY_HOME"' EXIT
mkdir -p "$CHATTY_HOME/bin"
cat >"$CHATTY_HOME/bin/codex" <<'EOF'
#!/bin/bash
for i in {1..20}; do echo "WARN codex_core::config: ignoring unknown key number $i" >&2; done
echo "error: failed to start app-server" >&2
exit 1
EOF
chmod +x "$CHATTY_HOME/bin/codex"

result=$(HOME="$CHATTY_HOME" CODEX_HOME="$CHATTY_HOME/.codex" XDG_CACHE_HOME="$CHATTY_HOME/.cache" XDG_DATA_HOME="$CHATTY_HOME/.local/share" \
PATH="$CHATTY_HOME/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only)

[[ $(jq -r '.authHelpText' <<<"$result") == "codex app-server exited: "*"error: failed to start app-server" ]] ||
fail "Codex collector keeps the CLI's final error past its startup logging" "$result"
pass "Codex collector keeps the CLI's final error past its startup logging"