#!/usr/bin/env bash # codex-crew: deterministic wrapper around `codex exec` with stall watchdog. # # Usage: # codex-run.sh exec [codex-flags...] [prompt] # codex-run.sh resume [codex-flags...] [prompt] # codex-run.sh review [--base X|--commit Y|--uncommitted] [codex-flags...] [prompt] # # If /prompt.md is non-empty it is always fed via stdin ('-'); codex-flags # stay positional, so options and a file prompt combine freely. # Review runs against the git repo at the CURRENT WORKING DIRECTORY (no -C support). # Review mode: codex CLI forbids combining scope flags (--base/--commit/--uncommitted) # with a positional [PROMPT] — and stdin '-' counts as PROMPT. So with a non-empty # prompt.md the scope flag is translated into prompt text and review runs with custom # instructions only; with an empty prompt.md the native `review ` is used. # # Files written to : events.jsonl last.md err.log thread_id status # status: ok | stalled | error: . Exit code 0 only on ok. # # Env knobs: # CODEX_STALL_TIMEOUT seconds of event silence before kill (default 600) # CODEX_WATCH_INTERVAL watchdog poll interval seconds (default 15) # CODEX_BIN codex binary (default codex; tests use a stub) set -u JOB_DIR=$1; MODE=$2; shift 2 mkdir -p "$JOB_DIR" JOB_DIR=$(cd "$JOB_DIR" && pwd) EV=$JOB_DIR/events.jsonl LAST=$JOB_DIR/last.md ERR=$JOB_DIR/err.log PF=$JOB_DIR/prompt.md STALL=${CODEX_STALL_TIMEOUT:-600} TICK=${CODEX_WATCH_INTERVAL:-15} BIN=${CODEX_BIN:-codex} case $MODE in exec) CMD=("$BIN" exec --json --skip-git-repo-check -o "$LAST") ;; resume) TID=$1; shift; printf '%s\n' "$TID" > "$JOB_DIR/thread_id" CMD=("$BIN" exec resume "$TID" --json --skip-git-repo-check -o "$LAST") ;; review) if [[ -s $JOB_DIR/prompt.md ]]; then # scope flags conflict with a prompt at the CLI level — move scope into text SCOPE_DESC=""; REST=() while [[ $# -gt 0 ]]; do case $1 in --base) SCOPE_DESC="Review the changes on the current branch against base ref '$2' — run \`git diff $2...HEAD\` (merge-base) to see the full change set."; shift 2 ;; --commit) SCOPE_DESC="Review the changes introduced by commit '$2' — run \`git show $2\` to see them."; shift 2 ;; --uncommitted) SCOPE_DESC="Review all uncommitted changes (staged, unstaged, untracked) — run \`git status\` and \`git diff HEAD\` to see them."; shift ;; *) REST+=("$1"); shift ;; esac done if [[ -n $SCOPE_DESC ]]; then PF=$JOB_DIR/prompt.final.md { printf 'This is a code review task. %s\n\n' "$SCOPE_DESC"; cat "$JOB_DIR/prompt.md"; } > "$PF" fi set -- ${REST[@]+"${REST[@]}"} fi CMD=("$BIN" exec review --json --skip-git-repo-check -o "$LAST") ;; *) echo "usage: codex-run.sh exec|resume|review ..." >&2; exit 2 ;; esac rm -f "$JOB_DIR/status" "$LAST"; : > "$EV"; : > "$ERR" if [[ -s $PF ]]; then "${CMD[@]}" "$@" - < "$PF" > "$EV" 2> "$ERR" & else "${CMD[@]}" "$@" < /dev/null > "$EV" 2> "$ERR" & fi PID=$! ( while kill -0 "$PID" 2>/dev/null; do sleep "$TICK" now=$(date +%s); mt=$(stat -c %Y "$EV" 2>/dev/null || echo "$now") if (( now - mt > STALL )); then echo stalled > "$JOB_DIR/status" kill "$PID" 2>/dev/null; sleep 3; kill -9 "$PID" 2>/dev/null break fi done ) & WATCH=$! wait "$PID"; RC=$? kill "$WATCH" 2>/dev/null; wait "$WATCH" 2>/dev/null TID_FOUND=$(grep -m1 -o '"thread_id":"[^"]*"' "$EV" | cut -d'"' -f4) [[ -n $TID_FOUND ]] && printf '%s\n' "$TID_FOUND" > "$JOB_DIR/thread_id" if [[ -s $JOB_DIR/status ]]; then : # stalled, written by watchdog elif [[ $RC -eq 0 && -s $LAST ]]; then echo ok > "$JOB_DIR/status" else echo "error: exit=$RC $(tail -c 300 "$ERR" | tr '\n' ' ')" > "$JOB_DIR/status" fi ST=$(head -n1 "$JOB_DIR/status") echo "status: $ST" echo "thread_id: $(cat "$JOB_DIR/thread_id" 2>/dev/null || echo none)" [[ $ST == ok ]]