Skip to the content.

Troubleshooting

Start in the environment where Codex actually runs. The Codex app on Windows, a local VS Code window, a WSL window, and a Remote SSH window can use different CODEX_HOME values, hook files, rollout histories, databases, and notifier state.

Do not post private config, pending/outbox records, rollout files, Codex databases, dead letters, backups, or raw logs. See Security and privacy.

Quick health check

Windows PowerShell:

& "$HOME\.codex\notify-ntfy.ps1" -Doctor
Get-ScheduledTask -TaskName CodexNtfyWatcher | Select-Object TaskName, State
Get-Content "$HOME\.codex\ntfy-state\notify.log" -Tail 40

Linux or WSL:

python3 "$HOME/.codex/notify-ntfy.py" --doctor
systemctl --user status codex-ntfy.service --no-pager
tail -n 40 "$HOME/.codex/ntfy-state/notify.log"

The doctor output does not print the topic or credentials. Check:

The explicit test bypasses idle detection and sends a real notification:

& "$HOME\.codex\notify-ntfy.ps1" -Test
python3 "$HOME/.codex/notify-ntfy.py" --test

Use -Test/--test only when publishing to the configured topic is acceptable. A successful test proves delivery, not hook or idle detection.

Understand pending versus queued

Version 2.4 and later have two intentionally separate stages:

Doctor field Directory Meaning
pending_idle ntfy-state/pending/ A root completion candidate exists, but the logical-idle gate has not yet accepted it.
queued ntfy-state/outbox/ Idle was confirmed and the event is waiting for ntfy delivery/retry.

A short-lived pending_idle > 0 is normal while the quiet window settles, a goal changes status, or a descendant finishes. A persistent value points to idle evidence; a persistent queued > 0 points to delivery.

Do not move a pending record into outbox/ manually. That bypasses the intended diagnosis and can create the premature alert this release is designed to prevent.

Modern Stop hook does not run

The installer writes ~/.codex/hooks.json, but Codex requires explicit review before a new hook can execute. The project does not modify the Codex trust store.

Verify that a Stop group is present without printing its command:

$doc = Get-Content "$HOME\.codex\hooks.json" -Raw | ConvertFrom-Json
@($doc.hooks.Stop).Count
python3 -c 'import json, pathlib; p=pathlib.Path.home()/".codex"/"hooks.json"; print(len(json.loads(p.read_text()).get("hooks", {}).get("Stop", [])))'

Then open Codex in that same environment, run /hooks, inspect the managed command containing notify-ntfy, and approve it. Repeat for each WSL distribution or Remote SSH host on which the notifier was installed.

Reload app/CLI processes and VS Code windows that were already running during installation. If Stop remains untrusted, the legacy notification and rollout watcher can still detect completions, but the modern candidate is absent.

Legacy notification does not run

Confirm the relevant config.toml has one root-level notify entry:

Select-String -Path "$HOME\.codex\config.toml" -Pattern '^\s*notify\s*='
grep -nE '^[[:space:]]*notify[[:space:]]*=' "$HOME/.codex/config.toml"

The key must appear before TOML table headers such as [features]. The legacy source is documented by OpenAI as agent-turn-complete in advanced notification configuration.

Legacy notify is a fallback, not the finality decision. Its event should enter pending/ and pass the same idle gate as Stop.

No candidate appears at all

If pending_idle, queued, receipt counts, watched_rollouts, and the log never change:

  1. confirm CODEX_HOME and, when used, CODEX_SQLITE_HOME in the Codex process environment;
  2. reload the affected Codex app/VS Code/CLI process;
  3. review the Stop hook with /hooks;
  4. verify the root-level legacy notify entry;
  5. confirm the continuous worker is running so rollout recovery can happen independently of a hook process;
  6. confirm Codex writes a local rollout under the expected sessions/ tree.

Pure cloud tasks that do not mirror lifecycle state into the local environment cannot be recovered by this notifier. It does not attach to a private app or VS Code status stream.

On Windows, an exceptionally large legacy payload can fail before the notifier process launches. The continuous rollout watcher can recover it only when a local task_complete or turn_aborted was persisted.

A candidate remains pending

Inspect the sanitized log first. Common idle reasons are:

Reason Meaning Action
settling The rollout has not been quiet for idle_grace_seconds. Wait briefly.
turn-active A later task_started has no matching completion yet. Let Codex finish or abort that turn.
goal-active The root goal is still active. Let the goal reach a non-running status.
subagents-active At least one descendant rollout still looks active. Let the descendant finish; check stale-child policy if it crashed.
probe-incomplete Matching local rollout evidence is missing or unreadable. Verify the real Codex/session paths and upstream state format.

Strict mode waits indefinitely

This is intentional for unresolved root classification or missing matching rollout completion. strict has no timed fail-open. It prefers no notification over a false final notification.

Check:

If that environment cannot retain usable rollout evidence, idle_detection_mode: "balanced" permits a fallback after idle_probe_grace_seconds. This explicitly increases the risk of an intermediate notification. off disables idle gating and should not be used when final-only alerts are required.

Goal stays active

With goal_aware: true, active blocks notification. Other observed statuses, including complete, paused, blocked, usage_limited, and budget_limited, are non-running and do not block.

These terminal states still control whether a task is running, but version 2.4.2 deliberately omits lifecycle status from the notification title to save space. The title identifies only the task or project.

The notifier reads only the goal status. If a stale upstream goal is permanently active, correct/finish the task state. Setting goal_aware: false is possible but weakens the final-only guarantee.

A child appears stuck

Descendants are discovered recursively from local Codex spawn edges and checked through their rollouts. A recent task_started child blocks its root. After subagent_orphan_seconds (1800 seconds by default), an unchanged child is considered orphaned so one abandoned rollout cannot block forever.

Lowering that timeout can notify while a genuinely long-running child is still active. Increasing it delays recovery from crashed children.

Intermediate notifications still arrive

Confirm all of the following:

Per-thread coalescing writes older candidates to suppressed/ with reason superseded. Known descendants use reason subagent; non-user-facing legacy/watcher turns use technical-turn. Those receipts are expected and are not sent.

If balanced is enabled, a fallback after idle_probe_grace_seconds can be premature. Switch back to strict.

The task finished but no notification arrived

Separate detection from delivery:

  1. If pending_idle > 0, use the pending guidance above.
  2. If queued > 0, inspect worker/network/authentication.
  3. If sent_receipts increased, ntfy accepted the event; inspect topic, client subscription, client privacy, and server retention.
  4. If only suppressed increased, the candidate was classified as subagent, technical, or superseded.
  5. If no state changed, verify hooks, continuous watcher, and local rollout availability.

A terminal goal status makes an otherwise matching candidate eligible; it does not synthesize a missing thread/turn identity on its own. The rollout watcher is the recovery source for a persisted completion whose hook signal was lost.

Events queue but do not send

A nonzero queued count means idle confirmation and persistence succeeded. Do not delete outbox/ during a normal outage.

Worker is stopped

Windows:

Start-ScheduledTask -TaskName CodexNtfyWatcher
Get-ScheduledTask -TaskName CodexNtfyWatcher | Select-Object State

Linux:

systemctl --user daemon-reload
systemctl --user restart codex-ntfy.service
systemctl --user is-active codex-ntfy.service

An on-demand worker drains known queues but cannot continuously discover a hook that never ran. For missed-hook recovery, keep the scheduled task or systemd user service active.

Only one worker per state directory acquires worker.lock. A second worker exiting immediately is expected.

Authentication or authorization fails

HTTP 401 and 403 are retryable because credentials or server policy can be repaired. With max_attempts: 0, the record stays in the outbox indefinitely. Correct the token/user/password and restart or wait for the worker; do not create replacement Codex events.

Check JSON syntax without printing values:

Get-Content "$HOME\.codex\ntfy-config.json" -Raw | ConvertFrom-Json | Out-Null
python3 -m json.tool "$HOME/.codex/ntfy-config.json" >/dev/null

Environment overrides must exist in the worker’s environment. A systemd service does not automatically inherit variables exported later in an interactive shell.

Insecure-auth refusal

refusing to send ntfy credentials over an insecure connection means token/basic credentials target non-HTTPS, non-loopback HTTP. Configure TLS or a trusted local proxy. Enable allow_insecure_auth only for a separately protected transport whose risk is understood.

Redirect rejected

Redirects are never followed. Configure server as the final publishing endpoint. A 3xx response is a permanent failure and moves the event to dead/.

Network, TLS, rate limit, or server failure

DNS, connection, timeout, TLS, and 5xx failures retry with exponential backoff. HTTP 429 and numeric Retry-After are retried. Test from the same host and worker account; local browser success does not prove remote reachability.

Dead letters

dead/ contains malformed pending/outbox files, redirects, most non-retryable 4xx failures, and events that reached a positive max_attempts. The default retention is 30 days.

Fix the cause first. Do not blindly move malformed state into outbox/. A complete reviewed outbox record may be replayed privately, but it can contain sensitive content and may already have reached ntfy after an ambiguous failure.

Deleting a dead letter does not remove any server-side notification.

Duplicate notifications

Local deduplication requires both Codex thread and turn IDs. A weak identity cannot be deterministically deduplicated.

If ntfy accepts a request but the local receipt is not written, the retry reuses the same sequence_id. Exactly-once display is still not guaranteed.

Queues and receipts are per state directory. Two hosts or two separate CODEX_NTFY_STATE_DIR values do not coordinate. Coalescing prevents several candidates for one root thread from becoming several final notifications on the same state store; it does not merge independent hosts.

WSL-specific checks

Normal routing:

WSL hooks/rollout -> WSL classifier -> powershell.exe bridge -> Windows pending/outbox worker

When interop fails, the WSL Python notifier owns native pending/outbox state.

Inside the affected distribution:

test -x "$HOME/.codex/notify-ntfy-wsl.sh"
test -x "$HOME/.codex/notify-ntfy.py"
test -f "$HOME/.codex/hooks.json"
command -v python3
command -v powershell.exe || true
printf '%s\n' "$WSL_DISTRO_NAME"

Run Windows and WSL doctor commands separately. Install into the exact distribution name returned by wsl.exe -l -q; configuring one distribution does not configure another. Review /hooks inside the affected WSL Codex environment.

The Windows scheduled watcher scans its Windows CODEX_HOME plus WSL roots registered by install.ps1 -WslDistro. Check the private config’s watch_roots entries when both WSL hooks were missed. Each entry must point to the correct distribution Codex root and, when different, sqlite_path; reinstall that distribution to refresh them. Unregistered distributions are intentionally not crawled.

Remote SSH checks

Open a terminal in the same VS Code Remote SSH context and run the remote platform’s doctor. Confirm:

A Linux user service may stop after logout unless the host keeps the user manager alive:

loginctl show-user "$USER" -p Linger

Without a persistent service, hook-driven on-demand delivery still works, but autonomous rollout scanning between hooks is not guaranteed.

Notification content looks wrong

Redaction is best-effort. If sensitive data was published, rotate credentials/topic access and follow the ntfy server/client deletion procedure; configuration changes cannot recall it.

Prevent queued message content from being sent

Set include_message to false in the private config. The worker checks this setting again when it builds every network request, so final-message text captured while the option was previously enabled is omitted from pending and outbox deliveries, including retries.

For an urgent change, stop the continuous worker before editing and avoid launching another hook-driven worker until the config is saved. This closes the avoidable race with a new request, but cannot cancel a request already in flight. Existing queue/dead-letter files and backups may still contain the captured text; the opt-out prevents transmission, not local erasure. It also cannot recall a notification already accepted by ntfy.

Safe issue checklist

For a public issue, provide only:

For a possible vulnerability or credential leak, use the private process in SECURITY.md.