From 31c2dd9181576ceb02d9dcac248109da159f0f96 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Thu, 2 Jul 2026 10:42:14 +0900 Subject: [PATCH 01/12] feat(codex): Codex.app wake path for monitor delivery Codex has no Monitor tool, and the PATH-shim/app-server bridge only covers CLI-launched sessions. This adds a Desktop-friendly path: - actas-monitor.sh: rebind monitor delivery to an explicit actas identity; app-server sessions keep the bridge, ordinary Codex.app threads fall back to codex-app-monitor.sh - codex-app-monitor.sh: watch inbox rows for one identity and wake the originating Codex.app thread via 'codex exec resume ' - restore-actas-monitor.sh: global Codex SessionStart hook that restores the last actas monitor for the project - codex-bridge.js: honor the inbox ids-file so inline-delivered messages are acknowledged read-integrity-safely; add --owner/--claim passthrough; require visible UI acknowledgement in the wake prompt - watch-once.sh: --owner/--claim options for exclusivity claims - dispatch.sh: run the type's actas-monitor hook on 'agmsg actas' - template.md: document the Codex.app wake flow for delivery mode 3 Co-Authored-By: Claude Fable 5 --- scripts/drivers/types/codex/actas-monitor.sh | 416 ++++++++++++++++++ .../drivers/types/codex/codex-app-monitor.sh | 234 ++++++++++ scripts/drivers/types/codex/codex-bridge.js | 57 ++- .../types/codex/restore-actas-monitor.sh | 72 +++ scripts/drivers/types/codex/template.md | 21 +- scripts/drivers/types/codex/watch-once.sh | 8 +- 6 files changed, 796 insertions(+), 12 deletions(-) create mode 100755 scripts/drivers/types/codex/actas-monitor.sh create mode 100755 scripts/drivers/types/codex/codex-app-monitor.sh create mode 100755 scripts/drivers/types/codex/restore-actas-monitor.sh diff --git a/scripts/drivers/types/codex/actas-monitor.sh b/scripts/drivers/types/codex/actas-monitor.sh new file mode 100755 index 00000000..3c191aac --- /dev/null +++ b/scripts/drivers/types/codex/actas-monitor.sh @@ -0,0 +1,416 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Rebind Codex monitor delivery to an explicit actas identity. +# +# Usage: +# actas-monitor.sh [session_id] +# +# Codex has no Monitor tool. Monitor mode is implemented by a bridge process +# that watches one agmsg identity and wakes a Codex thread. Sessions launched +# through codex-monitor.sh use app-server; ordinary Codex.app sessions fall back +# to codex-app-monitor.sh, which wakes the Desktop thread via `codex exec resume`. +# actas must therefore start or rebind that receiver, otherwise sends use the new +# identity while receives keep watching the old one. + +PROJECT="${1:?Usage: actas-monitor.sh [session_id]}" +TYPE="${2:?Missing type}" +NAME="${3:?Missing name}" +SESSION_ID="${4:-${CODEX_THREAD_ID:-}}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +RUN_DIR="$SKILL_DIR/run" + +# shellcheck source=../../../lib/hash.sh +source "$SCRIPT_DIR/../../../lib/hash.sh" +# shellcheck source=../../../lib/node.sh +source "$SCRIPT_DIR/../../../lib/node.sh" +# shellcheck source=../../../lib/resolve-project.sh +source "$SCRIPT_DIR/../../../lib/resolve-project.sh" +# shellcheck source=../../../lib/actas-lock.sh +source "$SCRIPT_DIR/../../../lib/actas-lock.sh" + +PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")" +mkdir -p "$RUN_DIR" + +NODE_BIN="$(agmsg_resolve_node)" +TAB="$(printf '\t')" + +find_identity() { + "$SCRIPT_DIR/../../../identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ + | awk -v want="$NAME" -v tab="$TAB" 'NF >= 2 && $2 == want { print $1 tab $2 }' \ + | sort -u +} + +IDS="$(find_identity || true)" +COUNT="$(printf '%s\n' "$IDS" | grep -c . || true)" +case "$COUNT" in + 0) + echo "status=not_registered name=$NAME" + exit 2 + ;; + 1) + IFS="$TAB" read -r TEAM _agent </dev/null || true +fi + +record_last_actas() { + local project_hash state tmp + project_hash="$(agmsg_sha1 <<<"$PROJECT")" + state="$RUN_DIR/codex-last-actas.$project_hash.tsv" + tmp="$state.$$" + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp" + mv "$tmp" "$state" +} + +record_last_actas + +# actas means "make this role live now", so enable monitor mode if needed. +MODE="$("$SCRIPT_DIR/../../../delivery.sh" status "$TYPE" "$PROJECT" 2>/dev/null \ + | sed -n 's/^mode: //p')" +if [ "$MODE" != "monitor" ]; then + "$SCRIPT_DIR/../../../delivery.sh" set monitor "$TYPE" "$PROJECT" >/dev/null +fi + +resolve_thread_id() { + if [ -n "${CODEX_THREAD_ID:-}" ]; then + printf '%s\n' "$CODEX_THREAD_ID" + return 0 + fi + "$NODE_BIN" - "$PROJECT" <<'NODE' +const fs = require("fs"); +const path = require("path"); +const project = fs.realpathSync(process.argv[2]); +const root = path.join(process.env.HOME || "", ".codex", "sessions"); +const files = []; +function walk(dir) { + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) { + try { + files.push({ full, mtime: fs.statSync(full).mtimeMs }); + } catch {} + } + } +} +walk(root); +files.sort((a, b) => b.mtime - a.mtime); +for (const { full } of files.slice(0, 80)) { + let first = ""; + try { + first = fs.readFileSync(full, "utf8").split(/\r?\n/, 1)[0] || ""; + } catch { + continue; + } + try { + const row = JSON.parse(first); + const payload = row && row.payload; + if (!payload || !payload.cwd || !payload.id) continue; + let cwd = ""; + try { + cwd = fs.realpathSync(payload.cwd); + } catch { + cwd = path.resolve(payload.cwd); + } + if (cwd === project) { + console.log(payload.id); + process.exit(0); + } + } catch {} +} +NODE +} + +kill_other_project_receivers() { + local meta pidfile pid meta_project meta_type meta_team meta_name + for meta in "$RUN_DIR"/codex-bridge.*.meta "$RUN_DIR"/codex-app-monitor.*.meta; do + [ -f "$meta" ] || continue + meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" + meta_type="$(sed -n 's/^type=//p' "$meta" | head -1)" + meta_team="$(sed -n 's/^team=//p' "$meta" | head -1)" + meta_name="$(sed -n 's/^name=//p' "$meta" | head -1)" + [ "$meta_project" = "$PROJECT" ] || continue + [ "$meta_type" = "$TYPE" ] || continue + [ "$meta_team" = "$TEAM" ] && [ "$meta_name" = "$NAME" ] && continue + pidfile="${meta%.meta}.pid" + [ -f "$pidfile" ] || continue + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + rm -f "$pidfile" "$meta" + done +} + +kill_receiver_files() { + local pidfile="$1" meta="$2" pid + local label + if [ -f "$meta" ] && command -v launchctl >/dev/null 2>&1; then + label="$(sed -n 's/^launch_label=//p' "$meta" | head -1)" + if [ -n "$label" ]; then + launchctl bootout "gui/$(id -u)/$label" >/dev/null 2>&1 || true + fi + fi + [ -f "$pidfile" ] || return 0 + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + rm -f "$pidfile" "$meta" +} + +xml_escape() { + sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' \ + -e "s/'/\'/g" +} + +plist_string() { + printf '%s' "$1" | xml_escape +} + +codex_app_monitor_label() { + local safe_team safe_name + safe_team="$(printf '%s' "$TEAM" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + safe_name="$(printf '%s' "$NAME" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + printf 'com.agmsg.codex-app-monitor.%s.%s.%s' "$PROJECT_HASH" "$safe_team" "$safe_name" +} + +write_codex_app_monitor_plist() { + local plist="$1" label="$2" thread_id="$3" log="$4" tmp + local codex_bin path_value + if [ -x "/Applications/Codex.app/Contents/Resources/codex" ]; then + codex_bin="/Applications/Codex.app/Contents/Resources/codex" + else + codex_bin="$(command -v codex 2>/dev/null || true)" + fi + path_value="${PATH:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}" + tmp="$plist.$$" + cat > "$tmp" < + + + + Label + $(plist_string "$label") + ProgramArguments + + $(plist_string "$SCRIPT_DIR/codex-app-monitor.sh") + $(plist_string "$PROJECT") + $(plist_string "$TYPE") + $(plist_string "$TEAM") + $(plist_string "$NAME") + $(plist_string "$thread_id") + + EnvironmentVariables + + HOME + $(plist_string "$HOME") + PATH + $(plist_string "$path_value") + AGMSG_CODEX_APP_MONITOR_LABEL + $(plist_string "$label") + AGMSG_CODEX_APP_MONITOR_CODEX + $(plist_string "${codex_bin:-codex}") + + WorkingDirectory + $(plist_string "$PROJECT") + StandardOutPath + $(plist_string "$log") + StandardErrorPath + $(plist_string "$log") + RunAtLoad + + KeepAlive + + + +EOF + mv "$tmp" "$plist" +} + +start_codex_app_monitor() { + local thread_id="$1" + local pidfile log existing_pid existing_thread app_monitor_pid label plist domain + if [ -z "$thread_id" ]; then + actas_lock_gc_stale >/dev/null 2>&1 || true + echo "status=no_thread team=$TEAM name=$NAME reason=codex_app_thread_id_unavailable" + exit 8 + fi + + kill_other_project_receivers + kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + + pidfile="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" + log="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.log" + label="$(codex_app_monitor_label)" + plist="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.plist" + domain="gui/$(id -u)" + if [ -f "$pidfile" ]; then + existing_pid="$(cat "$pidfile" 2>/dev/null || true)" + if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then + existing_thread="$(sed -n 's/^thread=//p' "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" 2>/dev/null | head -1)" + if [ "$existing_thread" = "$thread_id" ]; then + echo "status=already_running team=$TEAM name=$NAME app_monitor_pid=$existing_pid thread=$thread_id transport=codex-app-exec-resume" + exit 0 + fi + kill_receiver_files "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + else + rm -f "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + fi + fi + + if command -v launchctl >/dev/null 2>&1 && launchctl print "$domain" >/dev/null 2>&1; then + write_codex_app_monitor_plist "$plist" "$label" "$thread_id" "$log" + launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + if ! launchctl bootstrap "$domain" "$plist" >/dev/null 2>&1; then + echo "status=app_monitor_failed team=$TEAM name=$NAME reason=launchctl_bootstrap_failed log=$log plist=$plist" + exit 9 + fi + else + nohup "$SCRIPT_DIR/codex-app-monitor.sh" "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$thread_id" >>"$log" 2>&1 & + fi + + sleep 1.2 + app_monitor_pid="$(cat "$pidfile" 2>/dev/null || true)" + if ! kill -0 "$app_monitor_pid" 2>/dev/null; then + rm -f "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + actas_lock_gc_stale >/dev/null 2>&1 || true + echo "status=app_monitor_failed team=$TEAM name=$NAME log=$log" + exit 9 + fi + echo "status=ok team=$TEAM name=$NAME app_monitor_pid=$app_monitor_pid thread=$thread_id transport=codex-app-exec-resume" + exit 0 +} + +THREAD_ID="${AGMSG_CODEX_ACTAS_THREAD:-}" +if [ -z "$THREAD_ID" ]; then + THREAD_ID="$(resolve_thread_id || true)" +fi + +port_alive() { + local port="$1" + (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null +} + +PROJECT_HASH="$(agmsg_sha1 <<<"$PROJECT")" +SERVER_LOG="$RUN_DIR/codex-app-server.$PROJECT_HASH.log" +SERVER_PID="$RUN_DIR/codex-app-server.$PROJECT_HASH.pid" +PORT_FILE="$RUN_DIR/codex-app-server.$PROJECT_HASH.port" + +APP_SERVER="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" +if [ -z "$APP_SERVER" ] && [ -f "$PORT_FILE" ] && [ -f "$SERVER_PID" ]; then + existing_port="$(cat "$PORT_FILE" 2>/dev/null || true)" + existing_pid="$(cat "$SERVER_PID" 2>/dev/null || true)" + if [ -n "$existing_port" ] && [ -n "$existing_pid" ] \ + && kill -0 "$existing_pid" 2>/dev/null && port_alive "$existing_port"; then + APP_SERVER="ws://127.0.0.1:$existing_port" + fi +fi + +if [ -z "$APP_SERVER" ]; then + start_codex_app_monitor "$THREAD_ID" +fi + +if [ -z "$THREAD_ID" ]; then + THREAD_ID="loaded" +fi + +kill_other_project_receivers +kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + +BRIDGE_PIDFILE="$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" +BRIDGE_LOG="$RUN_DIR/codex-bridge.$TEAM.$NAME.log" +if [ -f "$BRIDGE_PIDFILE" ]; then + existing_bridge="$(cat "$BRIDGE_PIDFILE" 2>/dev/null || true)" + if [ -n "$existing_bridge" ] && kill -0 "$existing_bridge" 2>/dev/null; then + echo "status=already_running team=$TEAM name=$NAME bridge_pid=$existing_bridge app_server=$APP_SERVER thread=$THREAD_ID" + exit 0 + fi + rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" +fi + +start_bridge() { + local thread_id="$1" + local args=( + "$SCRIPT_DIR/codex-bridge.js" + --project "$PROJECT" + --type "$TYPE" + --team "$TEAM" + --name "$NAME" + --app-server "$APP_SERVER" + --inline-inbox + ) + if [ -n "$thread_id" ]; then + args+=(--thread "$thread_id") + fi + nohup "$NODE_BIN" "${args[@]}" >>"$BRIDGE_LOG" 2>&1 & + echo "$!" +} + +BRIDGE_PID="$(start_bridge "$THREAD_ID")" + +sleep 0.8 +if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + # Codex Desktop rollouts can be unreadable to a standalone app-server while + # the Desktop owns them. Fall back to a new bridge-owned thread instead of + # leaving actas with no receive side. + rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + printf 'actas-monitor: retrying without thread attach after failed thread=%s\n' "$THREAD_ID" >>"$BRIDGE_LOG" + THREAD_ID="new" + BRIDGE_PID="$(start_bridge "")" + sleep 0.8 +fi + +if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + echo "status=bridge_failed team=$TEAM name=$NAME log=$BRIDGE_LOG" + exit 5 +fi + +READY_SECONDS="${AGMSG_CODEX_ACTAS_READY_SECONDS:-8}" +sleep "$READY_SECONDS" +if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + actas_lock_gc_stale >/dev/null 2>&1 || true + echo "status=bridge_exited team=$TEAM name=$NAME log=$BRIDGE_LOG" + exit 5 +fi +case "$APP_SERVER" in + ws://127.0.0.1:*) + check_port="${APP_SERVER#ws://127.0.0.1:}" + check_port="${check_port%%/*}" + if ! port_alive "$check_port"; then + rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + actas_lock_gc_stale >/dev/null 2>&1 || true + echo "status=app_server_exited team=$TEAM name=$NAME app_server=$APP_SERVER log=$SERVER_LOG" + exit 6 + fi + ;; +esac + +echo "status=ok team=$TEAM name=$NAME bridge_pid=$BRIDGE_PID app_server=$APP_SERVER thread=$THREAD_ID" diff --git a/scripts/drivers/types/codex/codex-app-monitor.sh b/scripts/drivers/types/codex/codex-app-monitor.sh new file mode 100755 index 00000000..8edca3d6 --- /dev/null +++ b/scripts/drivers/types/codex/codex-app-monitor.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Watch agmsg inbox rows for a Codex identity and wake an ordinary Codex.app +# thread via `codex exec resume`. This is the Desktop fallback for sessions that +# were opened directly in Codex.app rather than through codex-monitor.sh. + +usage() { + cat < + +Environment: + AGMSG_CODEX_APP_MONITOR_TIMEOUT watch-once timeout seconds (default: 300) + AGMSG_CODEX_APP_MONITOR_INTERVAL watch-once poll interval seconds (default: 2) + AGMSG_CODEX_APP_MONITOR_MAX_WAKES stop after N wakes, useful for tests + AGMSG_CODEX_APP_MONITOR_FLAGS extra flags passed to "codex exec" + AGMSG_CODEX_APP_MONITOR_CODEX codex binary override +EOF +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +PROJECT="${1:?Usage: codex-app-monitor.sh }" +TYPE="${2:?Missing type}" +TEAM="${3:?Missing team}" +NAME="${4:?Missing name}" +THREAD_ID="${5:-}" + +if [ -z "$THREAD_ID" ] || [ "$THREAD_ID" = "loaded" ] || [ "$THREAD_ID" = "new" ]; then + echo "codex-app-monitor: a concrete Codex thread id is required" >&2 + exit 2 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +RUN_DIR="$SKILL_DIR/run" + +# shellcheck source=../../../lib/resolve-project.sh +source "$SCRIPT_DIR/../../../lib/resolve-project.sh" +# shellcheck source=../../../lib/storage.sh +source "$SCRIPT_DIR/../../../lib/storage.sh" +# shellcheck source=../../../lib/actas-lock.sh +source "$SCRIPT_DIR/../../../lib/actas-lock.sh" + +PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")" +mkdir -p "$RUN_DIR" + +TIMEOUT="${AGMSG_CODEX_APP_MONITOR_TIMEOUT:-${AGMSG_WATCH_ONCE_TIMEOUT:-300}}" +INTERVAL="${AGMSG_CODEX_APP_MONITOR_INTERVAL:-${AGMSG_WATCH_ONCE_INTERVAL:-2}}" +MAX_WAKES="${AGMSG_CODEX_APP_MONITOR_MAX_WAKES:-0}" +resolve_codex_bin() { + if [ -n "${AGMSG_CODEX_APP_MONITOR_CODEX:-}" ]; then + printf '%s\n' "$AGMSG_CODEX_APP_MONITOR_CODEX" + return 0 + fi + if [ -x "/Applications/Codex.app/Contents/Resources/codex" ]; then + printf '%s\n' "/Applications/Codex.app/Contents/Resources/codex" + return 0 + fi + command -v codex 2>/dev/null || printf '%s\n' "codex" +} + +CODEX_BIN="$(resolve_codex_bin)" +OWNER_ID="agmsg-codex-app-monitor-$$.$$" + +case "$TIMEOUT" in ''|*[!0-9]*) echo "codex-app-monitor: timeout must be a whole number" >&2; exit 2 ;; esac +case "$INTERVAL" in ''|*[!0-9]*) echo "codex-app-monitor: interval must be a whole number" >&2; exit 2 ;; esac +case "$MAX_WAKES" in ''|*[!0-9]*) echo "codex-app-monitor: max wakes must be a whole number" >&2; exit 2 ;; esac +[ "$INTERVAL" -gt 0 ] || INTERVAL=1 + +PIDFILE="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" +META="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" +LOG="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.log" +LAST_PROMPT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-prompt.txt" +LAST_OUTPUT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-message.txt" +LAST_STATUS="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-status" +LAST_IDS="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-ids" + +if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then + echo "codex-app-monitor: codex binary not found: $CODEX_BIN" >&2 + exit 3 +fi + +cleanup() { + actas_lock_release "$TEAM" "$NAME" "$OWNER_ID" 2>/dev/null || true + rm -f "$PIDFILE" "$META" +} +terminate() { + cleanup + exit 0 +} +trap cleanup EXIT +trap terminate INT TERM + +printf '%s\n' "$$" > "$PIDFILE" +{ + printf 'project=%s\n' "$PROJECT" + printf 'type=%s\n' "$TYPE" + printf 'team=%s\n' "$TEAM" + printf 'name=%s\n' "$NAME" + printf 'thread=%s\n' "$THREAD_ID" + printf 'transport=codex-app-exec-resume\n' + printf 'owner=%s\n' "$OWNER_ID" + if [ -n "${AGMSG_CODEX_APP_MONITOR_LABEL:-}" ]; then + printf 'launch_label=%s\n' "$AGMSG_CODEX_APP_MONITOR_LABEL" + fi +} > "$META" + +build_prompt() { + local inbox_text="$1" + local send_script="$SKILL_DIR/scripts/send.sh" + cat < + +Visible UI requirement: in this assistant turn, briefly show the agmsg sender, +the message summary, what you did, and any reply target plus reply summary. +If you do not reply, state why. Do not treat DB writes or monitor delivery as +complete unless the handling is visible in the Codex thread UI. +EOF + } + +sql_escape() { + printf '%s' "$1" | sed "s/'/''/g" +} + +read_unread_for_prompt() { + : > "$LAST_IDS" + "$SKILL_DIR/scripts/inbox-peek.sh" "$TEAM" "$NAME" --quiet --ids-file "$LAST_IDS" + [ -s "$LAST_IDS" ] || return 1 +} + +mark_delivered_ids_read() { + [ -f "$LAST_IDS" ] || return 0 + "$SKILL_DIR/scripts/mark-read.sh" "$TEAM" "$NAME" --ids-file "$LAST_IDS" +} + +run_resume() { + local prompt_file="$1" + local -a cmd + local child wait_status + cmd=("$CODEX_BIN" exec) + + # Non-interactive wakeups cannot answer approval prompts. The default mirrors + # Codex.app's automation context; callers can override or append with + # AGMSG_CODEX_APP_MONITOR_FLAGS when they need a stricter profile. + if [ -z "${AGMSG_CODEX_APP_MONITOR_NO_BYPASS:-}" ]; then + cmd+=(--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust) + fi + if [ -n "${AGMSG_CODEX_APP_MONITOR_FLAGS:-}" ]; then + # shellcheck disable=SC2206 + extra_flags=(${AGMSG_CODEX_APP_MONITOR_FLAGS}) + cmd+=("${extra_flags[@]}") + fi + cmd+=(-C "$PROJECT" -o "$LAST_OUTPUT" resume "$THREAD_ID" -) + + : > "$LAST_STATUS" + printf 'codex-app-monitor: running %q' "${cmd[0]}" + printf ' %q' "${cmd[@]:1}" + printf '\n' + + ( + set +e + "${cmd[@]}" < "$prompt_file" + printf '%s\n' "$?" > "$LAST_STATUS" + ) >>"$LOG" 2>&1 & + child="$!" + wait "$child" + wait_status=$? + if [ -s "$LAST_STATUS" ]; then + wait_status="$(cat "$LAST_STATUS" 2>/dev/null || printf '%s' "$wait_status")" + fi + return "$wait_status" +} + +wake_count=0 +printf 'codex-app-monitor: started team=%s name=%s thread=%s pid=%s\n' "$TEAM" "$NAME" "$THREAD_ID" "$$" + +while :; do + set +e + watch_output="$("$SCRIPT_DIR/watch-once.sh" "$PROJECT" "$TYPE" \ + --team "$TEAM" \ + --name "$NAME" \ + --owner "$OWNER_ID" \ + --claim \ + --timeout "$TIMEOUT" \ + --interval "$INTERVAL" 2>&1)" + watch_status=$? + set -e + + case "$watch_status" in + 0) + ;; + 2) + continue + ;; + *) + printf 'codex-app-monitor: watch-once failed status=%s output=%s\n' "$watch_status" "$watch_output" >&2 + sleep 5 + continue + ;; + esac + + if ! inbox_text="$(read_unread_for_prompt)"; then + continue + fi + + build_prompt "$inbox_text" > "$LAST_PROMPT" + wake_count=$((wake_count + 1)) + printf 'codex-app-monitor: wakeup %s for %s/%s thread=%s\n' "$wake_count" "$TEAM" "$NAME" "$THREAD_ID" + + set +e + run_resume "$LAST_PROMPT" + resume_status=$? + set -e + if [ "$resume_status" -ne 0 ]; then + printf 'codex-app-monitor: codex exec resume failed status=%s prompt=%s output=%s\n' "$resume_status" "$LAST_PROMPT" "$LAST_OUTPUT" >&2 + sleep 30 + else + mark_delivered_ids_read + fi + + if [ "$MAX_WAKES" -gt 0 ] && [ "$wake_count" -ge "$MAX_WAKES" ]; then + exit 0 + fi +done diff --git a/scripts/drivers/types/codex/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js index 6fccb208..64ac8f79 100755 --- a/scripts/drivers/types/codex/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -674,6 +674,8 @@ class CodexBridge { this.watchFailureCount = 0; this.watchRearmTimer = null; this.inlineInboxText = ""; + this.inlineInboxIdsFile = path.join(RUN_DIR, `codex-bridge.${identity.team}.${identity.name}.last-ids`); + this.deliveredInboxPending = false; this.stopping = false; this.pidfile = path.join(RUN_DIR, `codex-bridge.${identity.team}.${identity.name}.pid`); this.metafile = path.join(RUN_DIR, `codex-bridge.${identity.team}.${identity.name}.meta`); @@ -818,6 +820,7 @@ class CodexBridge { if (this.stopping || this.watchHandle) return; const handle = `agmsg-watch-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.watchHandle = handle; + const ownerId = `agmsg-codex-bridge-${process.pid}.${process.pid}`; const command = [ BASH_BIN, path.join(SCRIPT_DIR, "watch-once.sh"), @@ -830,6 +833,9 @@ class CodexBridge { this.identity.team, "--name", this.identity.name, + "--owner", + ownerId, + "--claim", "--timeout", String(this.opts.timeout), "--interval", @@ -937,6 +943,7 @@ class CodexBridge { this.clearTurnWatchdog(); this.turnActive = false; this.threadIdle = true; + this.markDeliveredInboxRead(); if (this.opts.maxWakes && this.wakeCount >= this.opts.maxWakes) { await this.shutdown(); process.exit(0); @@ -980,6 +987,10 @@ class CodexBridge { }); console.error(`codex-bridge: started turn on thread ${this.threadId}`); this.pendingWake = false; + if (this.opts.inlineInbox && fs.existsSync(this.inlineInboxIdsFile)) { + const ids = fs.readFileSync(this.inlineInboxIdsFile, "utf8").trim(); + this.deliveredInboxPending = Boolean(ids); + } // Bound how long we treat the turn as active. The real app-server may // never send turn/completed; the watchdog (and thread/status idle) drive // onTurnEnded so detection re-arms instead of sleeping forever. See #41. @@ -1035,6 +1046,11 @@ class CodexBridge { "", "Continue the conversation in this Codex thread. If a reply to an agmsg sender is needed, send it with:", `${send} ${this.identity.team} ${this.identity.name} `, + "", + "Visible UI requirement: in this assistant turn, briefly show the agmsg sender,", + "the message summary, what you did, and any reply target plus reply summary.", + "If you do not reply, state why. Do not treat DB writes or monitor delivery as", + "complete unless the handling is visible in the Codex thread UI.", ].join("\n"); } return [ @@ -1042,11 +1058,28 @@ class CodexBridge { `Run: ${inbox} ${this.identity.team} ${this.identity.name}`, "Read the messages and continue the conversation. If a reply is needed, send it with:", `${send} ${this.identity.team} ${this.identity.name} `, + "", + "Visible UI requirement: in this assistant turn, briefly show the agmsg sender,", + "the message summary, what you did, and any reply target plus reply summary.", + "If you do not reply, state why. Do not treat DB writes or monitor delivery as", + "complete unless the handling is visible in the Codex thread UI.", ].join("\n"); } readInboxForPrompt() { - const result = spawnSync(BASH_BIN, [path.join(SCRIPTS_DIR, "inbox.sh"), this.identity.team, this.identity.name], { + try { + fs.writeFileSync(this.inlineInboxIdsFile, ""); + } catch (_) { + // Best effort. inbox-peek will report an error if the path cannot be used. + } + const result = spawnSync(BASH_BIN, [ + path.join(SCRIPTS_DIR, "inbox-peek.sh"), + this.identity.team, + this.identity.name, + "--quiet", + "--ids-file", + this.inlineInboxIdsFile, + ], { cwd: this.opts.project, encoding: "utf8", }); @@ -1061,6 +1094,28 @@ class CodexBridge { return result.stdout || ""; } + markDeliveredInboxRead() { + if (!this.opts.inlineInbox || !this.deliveredInboxPending) return; + this.deliveredInboxPending = false; + const result = spawnSync(BASH_BIN, [ + path.join(SCRIPTS_DIR, "mark-read.sh"), + this.identity.team, + this.identity.name, + "--ids-file", + this.inlineInboxIdsFile, + ], { + cwd: this.opts.project, + encoding: "utf8", + }); + if (result.error) { + console.error(`codex-bridge: mark-read.sh failed: ${result.error.message}`); + return; + } + if (result.status !== 0) { + console.error(`codex-bridge: mark-read.sh exited ${result.status}: ${(result.stderr || "").trim()}`); + } + } + async shutdown() { if (this.stopping) return; this.stopping = true; diff --git a/scripts/drivers/types/codex/restore-actas-monitor.sh b/scripts/drivers/types/codex/restore-actas-monitor.sh new file mode 100755 index 00000000..0e348a71 --- /dev/null +++ b/scripts/drivers/types/codex/restore-actas-monitor.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Global Codex SessionStart hook. +# +# Restores the receive-side agmsg monitor for the last `agmsg actas ` role +# used in this project. This is intentionally Codex.app-friendly: ordinary app +# sessions use codex-app-monitor.sh via actas-monitor.sh, while shim/app-server +# sessions can still use the bridge path selected there. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +RUN_DIR="$SKILL_DIR/run" + +# shellcheck source=../../../lib/hash.sh +source "$SCRIPT_DIR/../../../lib/hash.sh" +# shellcheck source=../../../lib/resolve-project.sh +source "$SCRIPT_DIR/../../../lib/resolve-project.sh" + +mkdir -p "$RUN_DIR" +LOG="$RUN_DIR/codex-actas-restore.log" + +INPUT="$(cat 2>/dev/null || true)" + +json_string_field() { + local key="$1" + printf '%s' "$INPUT" \ + | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" \ + | head -1 +} + +PROJECT="$(json_string_field cwd)" +if [ -z "$PROJECT" ]; then + PROJECT="$PWD" +fi +PROJECT="$(agmsg_resolve_project "$PROJECT" codex 2>/dev/null || printf '%s' "$PROJECT")" +if [ -d "$PROJECT" ]; then + PROJECT="$(cd "$PROJECT" && pwd -P)" +fi + +PROJECT_HASH="$(agmsg_sha1 <<<"$PROJECT")" +STATE="$RUN_DIR/codex-last-actas.$PROJECT_HASH.tsv" + +if [ ! -f "$STATE" ]; then + exit 0 +fi + +IFS=$'\t' read -r saved_project saved_type saved_team saved_name _saved_at < "$STATE" || exit 0 +[ "$saved_type" = "codex" ] || exit 0 +[ -n "$saved_team" ] && [ -n "$saved_name" ] || exit 0 + +if [ -d "$saved_project" ]; then + saved_project="$(cd "$saved_project" && pwd -P)" +fi +[ "$saved_project" = "$PROJECT" ] || exit 0 + +THREAD_ID="$(json_string_field session_id)" +if [ -z "$THREAD_ID" ]; then + THREAD_ID="$(json_string_field id)" +fi + +{ + printf 'restore-start project=%s team=%s name=%s thread=%s at=%s\n' \ + "$PROJECT" "$saved_team" "$saved_name" "${THREAD_ID:-auto}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + AGMSG_TEAM="$saved_team" AGMSG_CODEX_ACTAS_THREAD="$THREAD_ID" \ + "$SCRIPT_DIR/actas-monitor.sh" "$PROJECT" codex "$saved_name" "${THREAD_ID:-}" + printf 'restore-end status=ok at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} >> "$LOG" 2>&1 || { + status=$? + printf 'restore-end status=failed code=%s at=%s\n' "$status" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LOG" 2>&1 || true + exit 0 +} diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 8dcf1195..f22f0c6e 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -52,10 +52,10 @@ Four possible outputs: 2) off — No automatic delivery Manual $__SKILL_NAME__ only. - 3) monitor — Real-time push (BETA, advanced) - Prints a `codex` shell function that routes launches through an - app-server bridge. Opt in ONLY if you accept experimental behavior. - See docs/codex-monitor-beta.md. + 3) monitor — Real-time push (BETA, advanced) + Normal Codex.app sessions are woken with `codex exec resume`. + CLI sessions route through an app-server bridge (printed + shell function or PATH shim). See docs/codex-monitor-beta.md. [1]: ``` @@ -63,7 +63,7 @@ Four possible outputs: - **Wait for the user's answer before proceeding.** Empty input means `1` (turn). - Map the chosen number to a mode (`1`→`turn`, `2`→`off`, `3`→`monitor`) and run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` - - If monitor is chosen, tell the user: "Codex monitor is a BETA that changes how `codex` starts — it prints a shell function to add to your shell profile. Add the printed function, restart the shell, then launch future sessions with normal `codex`. If you prefer a global PATH shim, run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` first on PATH. You can also use `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-monitor.sh` for explicit monitor launches. The bridge starts on the **first turn** of a new Codex session (the SessionStart hook fires on your first message, not the moment Codex opens), so **restart your Codex session and send one message for monitor to take effect** — this already-running session stays unmonitored until it restarts. For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" + - If monitor is chosen, tell the user: "Codex monitor is a BETA. In Codex.app, `$__SKILL_NAME__ actas ` starts a per-thread app monitor and incoming agmsg messages wake the same Codex.app thread through `codex exec resume`. CLI sessions route through the app-server bridge instead: add the printed `codex` shell function to your shell profile (or run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-shim-install.sh install` for a global PATH shim with `~/.agents/bin` first on PATH). The bridge starts on the **first turn** of a new Codex session, so restart your Codex session and send one message for monitor to take effect. For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" 6. Then check inbox for the newly joined team. @@ -83,8 +83,9 @@ Four possible outputs: **If no arguments provided (DEFAULT action — always do this when the command is invoked without arguments):** 1. **IMMEDIATELY** run inbox check for each TEAM: `~/.agents/skills/__SKILL_NAME__/scripts/inbox.sh $TEAM $AGENT` -2. Do NOT ask the user what to do — just run the inbox check. -3. If there are messages, read and respond appropriately. To reply: +2. If the active AGENT name starts with `codex-pro-`, route unread messages through Oracle GPT-5.5 Pro by running `~/.agents/skills/__SKILL_NAME__/scripts/oracle-pro-reply.sh $TEAM $AGENT` instead of answering them directly. Use `ORACLE_PRO_DRY_RUN=1` only when the user asks to preview the browser run. +3. Do NOT ask the user what to do — just run the inbox check. +4. If there are messages for a normal agent, read and respond appropriately. To reply: `~/.agents/skills/__SKILL_NAME__/scripts/send.sh $TEAM $AGENT ""` If argument is "history": @@ -112,8 +113,10 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): 2. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" codex` to see whether the role is already registered for this (project, type). 3. If the name does not appear in the output, join under the existing team. For a single team, run `~/.agents/skills/__SKILL_NAME__/scripts/join.sh codex "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `` for every `send.sh` call until another `actas`. -5. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). This is what lets a later `spawn ` bring the role back into this conversation. -6. Tell the user: "Now acting as ``. Sends will use `` as the from agent. (Codex has no Monitor tool, so receive still covers all of your registered roles in this project.)" +5. Rebind the receive-side monitor by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. Prefer the visible app-server bridge. If no visible bridge is available, preserve unread messages and arm chat-visible turn delivery; do not start a headless worker unless the operator explicitly opts in. +6. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). +7. If `` starts with `codex-pro-`, tell the user: "Now acting as ``. This role is an Oracle GPT-5.5 Pro consult route; run `$__SKILL_NAME__` to process its unread inbox through `oracle-pro-reply.sh`. Real runs may control the signed-in ChatGPT browser." +8. Otherwise tell the user: "Now acting as ``. Sends and visible receive are bound to ``." If argument starts with "drop" followed by an agent name (e.g. "drop alice"): 1. Parse the role name. diff --git a/scripts/drivers/types/codex/watch-once.sh b/scripts/drivers/types/codex/watch-once.sh index c3737b9a..052a38e7 100755 --- a/scripts/drivers/types/codex/watch-once.sh +++ b/scripts/drivers/types/codex/watch-once.sh @@ -21,6 +21,8 @@ shift 2 ACTIVE_NAME="" TEAM_FILTER="" +OWNER_ID="" +CLAIM_MODE="" TIMEOUT="${AGMSG_WATCH_ONCE_TIMEOUT:-300}" INTERVAL="${AGMSG_WATCH_ONCE_INTERVAL:-}" @@ -28,10 +30,12 @@ while [ "$#" -gt 0 ]; do case "$1" in --name) ACTIVE_NAME="${2:?--name needs an agent name}"; shift 2 ;; --team) TEAM_FILTER="${2:?--team needs a team name}"; shift 2 ;; + --owner) OWNER_ID="${2:?--owner needs an owner id}"; shift 2 ;; + --claim) CLAIM_MODE="claim"; shift ;; --timeout) TIMEOUT="${2:?--timeout needs seconds}"; shift 2 ;; --interval) INTERVAL="${2:?--interval needs seconds}"; shift 2 ;; -h|--help) - echo "Usage: watch-once.sh [--name ] [--team ] [--timeout ] [--interval ]" + echo "Usage: watch-once.sh [--name ] [--team ] [--owner ] [--claim] [--timeout ] [--interval ]" exit 0 ;; *) echo "watch-once: unknown option: $1" >&2; exit 1 ;; @@ -58,7 +62,7 @@ source "$SCRIPT_DIR/../../../lib/subscription.sh" PROJECT_PATH="$(agmsg_resolve_project "$PROJECT_PATH" "$AGENT_TYPE")" DB="$(agmsg_db_path)" -PAIRS="$(agmsg_subscription_pairs "$PROJECT_PATH" "$AGENT_TYPE" "" "$ACTIVE_NAME")" || exit 1 +PAIRS="$(agmsg_subscription_pairs "$PROJECT_PATH" "$AGENT_TYPE" "$OWNER_ID" "$ACTIVE_NAME" "$CLAIM_MODE")" || exit 1 if [ -n "$TEAM_FILTER" ]; then PAIRS=$(printf '%s\n' "$PAIRS" | awk -v t="$TEAM_FILTER" -F'\t' 'NF >= 2 && $1 == t') fi From 56518e52b394874b383f4126b212784154b198c7 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Sun, 12 Jul 2026 04:47:26 +0900 Subject: [PATCH 02/12] [auto] fix(codex): persist visible agmsg delivery (#263) --- docs/codex-monitor-beta.md | 33 ++++- scripts/check-inbox.sh | 37 ++++- scripts/delivery.sh | 42 ++++-- scripts/drivers/types/codex/_delivery.sh | 85 ++++++++++- scripts/drivers/types/codex/_session-start.sh | 41 +++++- scripts/drivers/types/codex/actas-monitor.sh | 127 ++++++++++++---- .../drivers/types/codex/codex-app-monitor.sh | 139 +++++++++++++++++- scripts/drivers/types/codex/codex-bridge.js | 18 ++- .../types/codex/restore-actas-monitor.sh | 72 --------- scripts/drivers/types/codex/template.md | 14 +- scripts/drivers/types/codex/type.conf | 2 +- scripts/inbox-peek.sh | 66 +++++++++ scripts/inbox.sh | 35 ++++- scripts/mark-read.sh | 39 +++++ tests/test_codex_bridge.bats | 9 ++ tests/test_codex_visible_monitor.bats | 103 +++++++++++++ tests/test_delivery.bats | 13 +- 17 files changed, 709 insertions(+), 166 deletions(-) delete mode 100755 scripts/drivers/types/codex/restore-actas-monitor.sh create mode 100755 scripts/inbox-peek.sh create mode 100755 scripts/mark-read.sh create mode 100644 tests/test_codex_visible_monitor.bats diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 6eef241f..cd2d9f96 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -1,22 +1,24 @@ # Codex Monitor Beta Codex does not expose Claude Code's Monitor tool. agmsg's Codex monitor beta -approximates the same experience by launching Codex through an app-server bridge. +uses a visible app-server bridge when available and a visible Stop-hook fallback +for ordinary Codex.app sessions. It records the last explicit `actas` role and +rebinds that role from SessionStart after restart or compaction. > ⚠️ **Experimental beta — read before enabling.** This changes how Codex starts. > Enabling monitor mode prints a shell function that makes `codex` route through > agmsg's monitor shim in your interactive shell. In monitor-mode projects the > shim re-routes interactive launches through an app-server bridge; everywhere > else it passes straight through. **Only enable this if you are comfortable with -> the `codex` command being intercepted in that shell.** It also depends on Codex +> the `codex` command being intercepted in that shell.** Ordinary Codex.app does +> not require the shim; it uses the visible Stop-hook fallback. It also depends on Codex > app-server behavior and may break as Codex changes. Known rough edges: > enabling monitor takes effect only after you **restart Codex and send your > first message** — the SessionStart hook fires on the first turn, not the -> moment Codex opens, so the bridge is absent until you interact once; an -> already-running session stays unmonitored until you restart it (#151); the +> moment Codex opens, so the bridge is absent until you interact once; the > bridge is not torn down when you close the TUI (orphans linger until reboot -> or `mode off`/manual kill, see #149); and only one Codex identity per project -> is supported (#150). +> or `mode off`/manual kill, see #149). Multiple registered identities are +> supported by restoring the most recent explicit `actas` role. ## Quick Start @@ -28,10 +30,20 @@ Enable monitor mode in a project: The command: -1. Enables agmsg's Codex SessionStart/SessionEnd hooks for the project. -2. Prints a shell function that routes interactive `codex` launches through the +1. Enables Codex SessionStart/SessionEnd hooks plus the visible Stop-hook + fallback for the project. +2. Persists the last explicit `actas` role so SessionStart can rebind it. +3. Prints a shell function that routes interactive `codex` launches through the monitor shim. +Headless `codex exec resume` handling is disabled by default because it can +consume and answer messages outside the visible Codex thread. It remains +available only as a legacy explicit opt-in: + +```bash +export AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1 +``` + The Codex sandbox must allow writes to the installed skill's runtime state: ```text @@ -60,6 +72,11 @@ codex In monitor-mode projects, the function routes interactive Codex launches through the bridge. Outside monitor-mode projects, it passes through to the real Codex. +When Codex.app is opened normally, SessionStart restores the last `actas` role. +If no visible app-server is available, the Stop hook checks that role's inbox at +turn boundaries. Unread messages remain unread until the visible turn displays +them. + ## Optional PATH Shim If you prefer the previous global PATH shim setup, install it explicitly: diff --git a/scripts/check-inbox.sh b/scripts/check-inbox.sh index ec88e351..f36c0bbe 100755 --- a/scripts/check-inbox.sh +++ b/scripts/check-inbox.sh @@ -18,6 +18,8 @@ source "$SCRIPT_DIR/lib/actas-lock.sh" source "$SCRIPT_DIR/lib/resolve-project.sh" # agmsg_agent_pid, for instance-id derivation # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/type-registry.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/hash.sh" # Some Stop-hook runtimes (codex, copilot) want an explicit JSON status object # even when there is nothing to deliver; others (claude-code) stay silent. This @@ -90,6 +92,25 @@ if [ -z "$AGENT" ] || [ -z "$TEAMS" ]; then exit 0 fi +# Codex actas changes the visible role for sends. In turn delivery, use that +# same role for receives so a chat-visible fallback does not keep polling the +# first registered identity for the project. +if [ "$TYPE" = "codex" ]; then + PROJECT_RESOLVED="$(agmsg_resolve_project "$PROJECT" "$TYPE")" + PROJECT_HASH="$(printf '%s' "$PROJECT_RESOLVED" | agmsg_sha1)" + ACTAS_STATE="$SKILL_DIR/run/codex-last-actas.$PROJECT_HASH.tsv" + if [ -f "$ACTAS_STATE" ]; then + IFS=$'\t' read -r ACTAS_PROJECT ACTAS_TYPE ACTAS_TEAM ACTAS_NAME _ACTAS_TS < "$ACTAS_STATE" || true + if [ "$ACTAS_PROJECT" = "$PROJECT_RESOLVED" ] \ + && [ "$ACTAS_TYPE" = "$TYPE" ] \ + && [ -n "${ACTAS_TEAM:-}" ] \ + && [ -n "${ACTAS_NAME:-}" ]; then + AGENT="$ACTAS_NAME" + TEAMS="$ACTAS_TEAM" + fi + fi +fi + # Cooldown check. The marker is hook runtime state, not message storage, so it # lives in the skill's run dir — independent of AGMSG_STORAGE_PATH. Keeping it # out of the store means an overridden/sandboxed store still gets delivery even @@ -141,19 +162,27 @@ for team in "${TEAM_LIST[@]}"; do esac RESULT=$(agmsg_sqlite "$DB" " - SELECT from_agent || char(31) || replace(replace(body, char(10), '\n'), char(9), '\t') || char(31) || created_at + SELECT id || char(31) || from_agent || char(31) || replace(replace(body, char(10), '\n'), char(9), '\t') || char(31) || created_at FROM messages WHERE team='$team_sql' AND to_agent='$AGENT_SQL' AND read_at IS NULL ORDER BY created_at ASC; ") if [ -n "$RESULT" ]; then COUNT=$(echo "$RESULT" | wc -l | tr -d ' ') OUTPUT+="$COUNT new message(s) in $team:"$'\n' - while IFS=$'\x1f' read -r from body ts; do + IDS="" + while IFS=$'\x1f' read -r id from body ts; do OUTPUT+=" [$ts] $from: $body"$'\n' + case "$id" in + ''|*[!0-9]*) ;; # defensive: never splice a non-numeric value into SQL + *) IDS="${IDS:+$IDS,}$id" ;; + esac done <<< "$RESULT" OUTPUT+=$'\n' - # Mark as read - agmsg_sqlite "$DB" "UPDATE messages SET read_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE team='$team_sql' AND to_agent='$AGENT_SQL' AND read_at IS NULL;" 2>/dev/null || true + # Mark as read — only the ids displayed above (upstream PR #361): a blanket + # "WHERE read_at IS NULL" would swallow messages arriving between SELECT and UPDATE. + if [ -n "$IDS" ]; then + agmsg_sqlite "$DB" "UPDATE messages SET read_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id IN ($IDS);" 2>/dev/null || true + fi fi done diff --git a/scripts/delivery.sh b/scripts/delivery.sh index 9ae0e94d..c3fd3f52 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -320,28 +320,50 @@ by this command. EOF } -# Stop the Codex monitor bridge(s) for a project and remove their run artifacts, -# then tear down the project's shared app-server record too (it is keyed per -# project, so `off` should not leave it running). Used by `set off codex` (and -# the manual counterpart to the not-yet-wired auto teardown, #149). The global -# shim is left alone (it is cross-project). Echoes how many bridges were killed. +# Stop the Codex monitor receiver(s) for a project and remove their run +# artifacts, then tear down the project's shared app-server record too (it is +# keyed per project, so `off` should not leave it running). Used by `set off +# codex` and by Codex turn fallback cleanup. The global shim is left alone (it +# is cross-project). Echoes how many receiver processes were killed. stop_codex_bridge() { local project="$1" - local pairs team name pidfile bpid killed=0 + local pairs team name pidfile bpid killed=0 app_pidfile app_pid app_meta app_label pairs=$("$SCRIPT_DIR/identities.sh" "$project" codex 2>/dev/null || true) if [ -n "$pairs" ]; then while IFS=$'\t' read -r team name _rest; do [ -n "$team" ] && [ -n "$name" ] || continue pidfile="$RUN_DIR/codex-bridge.$team.$name.pid" - [ -f "$pidfile" ] || continue - bpid=$(cat "$pidfile" 2>/dev/null || true) - if [ -n "$bpid" ] && kill -0 "$bpid" 2>/dev/null; then - kill "$bpid" 2>/dev/null && killed=$((killed + 1)) + if [ -f "$pidfile" ]; then + bpid=$(cat "$pidfile" 2>/dev/null || true) + if [ -n "$bpid" ] && kill -0 "$bpid" 2>/dev/null; then + kill "$bpid" 2>/dev/null && killed=$((killed + 1)) + fi fi # .appserver records which app-server URL the bridge was bound to (the # launcher's stale-binding guard); drop it with the rest so it cannot # mislead a later launcher. rm -f "$pidfile" "${pidfile%.pid}.meta" "${pidfile%.pid}.log" "${pidfile%.pid}.appserver" + + app_pidfile="$RUN_DIR/codex-app-monitor.$team.$name.pid" + app_meta="$RUN_DIR/codex-app-monitor.$team.$name.meta" + if [ -f "$app_meta" ] && command -v launchctl >/dev/null 2>&1; then + app_label=$(sed -n 's/^launch_label=//p' "$app_meta" | head -1) + if [ -n "$app_label" ]; then + launchctl bootout "gui/$(id -u)/$app_label" >/dev/null 2>&1 || true + fi + fi + if [ -f "$app_pidfile" ]; then + app_pid=$(cat "$app_pidfile" 2>/dev/null || true) + if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then + kill "$app_pid" 2>/dev/null && killed=$((killed + 1)) + fi + fi + rm -f "$app_pidfile" "$app_meta" \ + "$RUN_DIR/codex-app-monitor.$team.$name.last-prompt.txt" \ + "$RUN_DIR/codex-app-monitor.$team.$name.last-message.txt" \ + "$RUN_DIR/codex-app-monitor.$team.$name.last-status" \ + "$RUN_DIR/codex-app-monitor.$team.$name.last-ids" \ + "$RUN_DIR/codex-chat-visible.$team.$name.meta" done < ; on_disable . +# Codex monitor mode always includes the visible Stop-hook fallback. The +# SessionStart hook preserves/rebinds the monitor after restart; the Stop hook +# is the safe path when no app-server can inject into the visible thread. +agmsg_delivery_apply() { + local type="$1" project="$2" mode="$3" + if [ "$mode" = "monitor" ]; then + agmsg_delivery_apply_default "$type" "$project" both + else + agmsg_delivery_apply_default "$type" "$project" "$mode" + fi +} + +# `monitor` is the user-facing mode name even though Codex installs both hook +# types internally. Keep status stable for callers and existing automation. +agmsg_delivery_status() { + agmsg_delivery_status_default "$@" | sed '1s/^mode: both$/mode: monitor/' +} + agmsg_delivery_on_enable() { echo "Codex monitor beta is enabled." echo "Add this shell function to your interactive shell profile, then restart the shell:" @@ -48,6 +67,24 @@ agmsg_delivery_on_disable() { echo " # then drop any agmsg Codex function or ~/.agents/bin PATH entry you added for monitor" } +agmsg_delivery_stop_directive() { + local project="${PROJECT:-}" + local mode="${MODE:-}" + if [ "$mode" = "turn" ] && [ -n "$project" ]; then + # The app monitor invokes `delivery.sh set turn` after repeated delivery + # failures. Let that process finish writing fallback health and chat-visible + # metadata before it exits on its own. + if [ "${AGMSG_CODEX_PRESERVE_CURRENT_MONITOR:-}" = "1" ]; then + return 0 + fi + local stopped + stopped=$(stop_codex_bridge "$project") + if [ "${stopped:-0}" -gt 0 ]; then + echo "Stopped $stopped Codex bridge/app monitor process(es) for this project and cleaned their run files." + fi + fi +} + agmsg_delivery_runtime_status() { local type="$1" project="$2" local pairs found=0 @@ -64,13 +101,53 @@ agmsg_delivery_runtime_status() { fi found=1 + local healthfile health_status health_failures health_error health_updated + healthfile="$RUN_DIR/codex-app-monitor.$team.$name.health" + if [ -f "$healthfile" ]; then + health_status=$(awk -F= '/^status=/{sub(/^status=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) + health_failures=$(awk -F= '/^consecutive_failures=/{sub(/^consecutive_failures=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) + health_error=$(awk -F= '/^last_error=/{sub(/^last_error=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) + health_updated=$(awk -F= '/^updated_at=/{sub(/^updated_at=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) + echo "Codex monitor health: $team/$name status=${health_status:-unknown} failures=${health_failures:-unknown} last_error=${health_error:-unknown} updated=${health_updated:-unknown}" + fi + local base pidfile metafile pid meta_pid meta_project meta_type meta_ok base="$RUN_DIR/codex-bridge.$team.$name" pidfile="$base.pid" metafile="$base.meta" + local app_base app_pidfile app_metafile app_pid app_thread app_transport + app_base="$RUN_DIR/codex-app-monitor.$team.$name" + app_pidfile="$app_base.pid" + app_metafile="$app_base.meta" + + local chat_metafile chat_project chat_type chat_transport chat_status chat_updated + chat_metafile="$RUN_DIR/codex-chat-visible.$team.$name.meta" + if [ ! -f "$pidfile" ]; then - echo "Codex bridge: $team/$name not running" + if [ -f "$app_pidfile" ] && [ -f "$app_metafile" ]; then + app_pid=$(cat "$app_pidfile" 2>/dev/null || true) + app_thread=$(awk -F= '/^thread=/{sub(/^thread=/, ""); print; exit}' "$app_metafile" 2>/dev/null || true) + app_transport=$(awk -F= '/^transport=/{sub(/^transport=/, ""); print; exit}' "$app_metafile" 2>/dev/null || true) + if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then + echo "Codex app monitor: $team/$name alive (pid $app_pid, thread $app_thread, transport ${app_transport:-codex-app-exec-resume})" + else + echo "Codex app monitor: $team/$name stale pidfile (pid ${app_pid:-empty} not running)" + fi + elif [ -f "$chat_metafile" ]; then + chat_project=$(awk -F= '/^project=/{sub(/^project=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + chat_type=$(awk -F= '/^type=/{sub(/^type=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + chat_transport=$(awk -F= '/^transport=/{sub(/^transport=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + chat_status=$(awk -F= '/^status=/{sub(/^status=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + chat_updated=$(awk -F= '/^updated_at=/{sub(/^updated_at=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + if [ "$chat_project" = "$project" ] && [ "$chat_type" = "$type" ]; then + echo "Codex chat-visible turn: $team/$name armed (transport ${chat_transport:-codex-chat-visible-turn}, status ${chat_status:-waiting_for_chat_turn}, updated ${chat_updated:-unknown})" + else + echo "Codex chat-visible turn: $team/$name stale metadata" + fi + else + echo "Codex bridge: $team/$name not running" + fi continue fi diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 560fbd6a..9ed6fc8a 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -62,6 +62,31 @@ INNER_EOF agmsg_session_start() { thread_id="$(agmsg_resolve_codex_thread "$PROJECT")" [ -n "$thread_id" ] || exit 0 + + # Prefer the last explicit `agmsg actas` role. A project can register many + # Codex identities, so picking the first row after restart would bind sends + # and receives to different agents. Fall back to the single registered pair + # only when no valid actas state exists. + team=""; name="" + project_hash="$(printf '%s' "$PROJECT" | agmsg_sha1)" + actas_state="$RUN_DIR/codex-last-actas.$project_hash.tsv" + if [ -f "$actas_state" ]; then + IFS=$'\t' read -r saved_project saved_type saved_team saved_name _saved_at < "$actas_state" || true + if [ "$saved_project" = "$PROJECT" ] \ + && [ "$saved_type" = "$TYPE" ] \ + && printf '%s\n' "$PAIRS" | grep -Fxq "$(printf '%s\t%s' "$saved_team" "$saved_name")"; then + team="$saved_team" + name="$saved_name" + fi + fi + if [ -z "$team" ] || [ -z "$name" ]; then + pair_count=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { c++ } END { print c + 0 }') + [ "$pair_count" = "1" ] || exit 0 + team=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $1; exit }') + name=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $2; exit }') + fi + [ -n "$team" ] && [ -n "$name" ] || exit 0 + app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" if [ -z "$app_server" ]; then agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true) @@ -79,13 +104,15 @@ agmsg_session_start() { app_server="unix://$socket_path" fi fi - [ -n "$app_server" ] || exit 0 - - pair_count=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { c++ } END { print c + 0 }') - [ "$pair_count" = "1" ] || exit 0 - team=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $1; exit }') - name=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $2; exit }') - [ -n "$team" ] && [ -n "$name" ] || exit 0 + if [ -z "$app_server" ]; then + # Ordinary Codex.app has no supported external wake transport. Re-arm the + # role without starting a hidden worker; `both` mode's Stop hook performs + # the actual inbox pull in the visible conversation. + log="$RUN_DIR/codex-actas-restore.log" + "$SKILL_DIR/scripts/drivers/types/codex/actas-monitor.sh" \ + "$PROJECT" "$TYPE" "$name" "$thread_id" >>"$log" 2>&1 || true + exit 0 + fi if [ "${AGMSG_CODEX_BRIDGE_LAUNCHER:-}" = "1" ]; then project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) diff --git a/scripts/drivers/types/codex/actas-monitor.sh b/scripts/drivers/types/codex/actas-monitor.sh index 3c191aac..74a1f2f3 100755 --- a/scripts/drivers/types/codex/actas-monitor.sh +++ b/scripts/drivers/types/codex/actas-monitor.sh @@ -8,10 +8,11 @@ set -euo pipefail # # Codex has no Monitor tool. Monitor mode is implemented by a bridge process # that watches one agmsg identity and wakes a Codex thread. Sessions launched -# through codex-monitor.sh use app-server; ordinary Codex.app sessions fall back -# to codex-app-monitor.sh, which wakes the Desktop thread via `codex exec resume`. -# actas must therefore start or rebind that receiver, otherwise sends use the new -# identity while receives keep watching the old one. +# through codex-monitor.sh use app-server and can report in the visible chat. +# Ordinary Codex.app sessions must not fall back to headless `codex exec resume` +# unless AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1 is explicitly set. +# actas must therefore start or rebind the visible receiver, otherwise sends use +# the new identity while receives keep watching the old one. PROJECT="${1:?Usage: actas-monitor.sh [session_id]}" TYPE="${2:?Missing type}" @@ -69,7 +70,7 @@ fi record_last_actas() { local project_hash state tmp - project_hash="$(agmsg_sha1 <<<"$PROJECT")" + project_hash="$(printf '%s' "$PROJECT" | agmsg_sha1)" state="$RUN_DIR/codex-last-actas.$project_hash.tsv" tmp="$state.$$" printf '%s\t%s\t%s\t%s\t%s\n' \ @@ -79,12 +80,15 @@ record_last_actas() { record_last_actas -# actas means "make this role live now", so enable monitor mode if needed. +# Codex uses both mode for persistent SessionStart rebind plus a visible Stop +# hook fallback. This keeps monitoring armed across restarts without allowing a +# headless worker to consume or answer messages outside the visible thread. MODE="$("$SCRIPT_DIR/../../../delivery.sh" status "$TYPE" "$PROJECT" 2>/dev/null \ | sed -n 's/^mode: //p')" -if [ "$MODE" != "monitor" ]; then - "$SCRIPT_DIR/../../../delivery.sh" set monitor "$TYPE" "$PROJECT" >/dev/null -fi +case "$MODE" in + monitor|both) ;; + *) "$SCRIPT_DIR/../../../delivery.sh" set both "$TYPE" "$PROJECT" >/dev/null ;; +esac resolve_thread_id() { if [ -n "${CODEX_THREAD_ID:-}" ]; then @@ -170,7 +174,7 @@ kill_receiver_files() { if [ -f "$meta" ] && command -v launchctl >/dev/null 2>&1; then label="$(sed -n 's/^launch_label=//p' "$meta" | head -1)" if [ -n "$label" ]; then - launchctl bootout "gui/$(id -u)/$label" >/dev/null 2>&1 || true + bootout_label_and_wait "gui/$(id -u)" "$label" fi fi [ -f "$pidfile" ] || return 0 @@ -201,11 +205,24 @@ codex_app_monitor_label() { printf 'com.agmsg.codex-app-monitor.%s.%s.%s' "$PROJECT_HASH" "$safe_team" "$safe_name" } +bootout_label_and_wait() { + local domain="$1" label="$2" check=0 + launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + while [ "$check" -lt 20 ] && launchctl print "$domain/$label" >/dev/null 2>&1; do + sleep 0.1 + check=$((check + 1)) + done +} + write_codex_app_monitor_plist() { local plist="$1" label="$2" thread_id="$3" log="$4" tmp local codex_bin path_value - if [ -x "/Applications/Codex.app/Contents/Resources/codex" ]; then + if [ -n "${AGMSG_CODEX_APP_MONITOR_CODEX:-}" ]; then + codex_bin="$AGMSG_CODEX_APP_MONITOR_CODEX" + elif [ -x "/Applications/Codex.app/Contents/Resources/codex" ]; then codex_bin="/Applications/Codex.app/Contents/Resources/codex" + elif [ -x "$HOME/.npm-global/bin/codex" ]; then + codex_bin="$HOME/.npm-global/bin/codex" else codex_bin="$(command -v codex 2>/dev/null || true)" fi @@ -237,6 +254,20 @@ write_codex_app_monitor_plist() { $(plist_string "$label") AGMSG_CODEX_APP_MONITOR_CODEX $(plist_string "${codex_bin:-codex}") + AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR + $(plist_string "${AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR:-}") + AGMSG_CODEX_APP_MONITOR_TIMEOUT + $(plist_string "${AGMSG_CODEX_APP_MONITOR_TIMEOUT:-}") + AGMSG_CODEX_APP_MONITOR_INTERVAL + $(plist_string "${AGMSG_CODEX_APP_MONITOR_INTERVAL:-}") + AGMSG_CODEX_APP_MONITOR_MAX_WAKES + $(plist_string "${AGMSG_CODEX_APP_MONITOR_MAX_WAKES:-}") + AGMSG_CODEX_APP_MONITOR_MAX_FAILURES + $(plist_string "${AGMSG_CODEX_APP_MONITOR_MAX_FAILURES:-}") + AGMSG_CODEX_APP_MONITOR_FLAGS + $(plist_string "${AGMSG_CODEX_APP_MONITOR_FLAGS:-}") + AGMSG_CODEX_APP_MONITOR_DISABLE_NOTIFY + $(plist_string "${AGMSG_CODEX_APP_MONITOR_DISABLE_NOTIFY:-}") WorkingDirectory $(plist_string "$PROJECT") @@ -256,7 +287,8 @@ EOF start_codex_app_monitor() { local thread_id="$1" - local pidfile log existing_pid existing_thread app_monitor_pid label plist domain + local pidfile log health existing_pid existing_thread existing_health app_monitor_pid label plist domain + local health_status ready_seconds ready_checks check if [ -z "$thread_id" ]; then actas_lock_gc_stale >/dev/null 2>&1 || true echo "status=no_thread team=$TEAM name=$NAME reason=codex_app_thread_id_unavailable" @@ -268,6 +300,7 @@ start_codex_app_monitor() { pidfile="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" log="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.log" + health="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.health" label="$(codex_app_monitor_label)" plist="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.plist" domain="gui/$(id -u)" @@ -275,8 +308,9 @@ start_codex_app_monitor() { existing_pid="$(cat "$pidfile" 2>/dev/null || true)" if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then existing_thread="$(sed -n 's/^thread=//p' "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" 2>/dev/null | head -1)" - if [ "$existing_thread" = "$thread_id" ]; then - echo "status=already_running team=$TEAM name=$NAME app_monitor_pid=$existing_pid thread=$thread_id transport=codex-app-exec-resume" + existing_health="$(sed -n 's/^status=//p' "$health" 2>/dev/null | head -1 || true)" + if [ "$existing_thread" = "$thread_id" ] && [ "$existing_health" = "ready" ]; then + echo "status=already_running team=$TEAM name=$NAME app_monitor_pid=$existing_pid thread=$thread_id transport=codex-app-exec-resume health=ready" exit 0 fi kill_receiver_files "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" @@ -284,27 +318,65 @@ start_codex_app_monitor() { rm -f "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" fi fi + rm -f "$health" "$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" if command -v launchctl >/dev/null 2>&1 && launchctl print "$domain" >/dev/null 2>&1; then write_codex_app_monitor_plist "$plist" "$label" "$thread_id" "$log" - launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + bootout_label_and_wait "$domain" "$label" if ! launchctl bootstrap "$domain" "$plist" >/dev/null 2>&1; then - echo "status=app_monitor_failed team=$TEAM name=$NAME reason=launchctl_bootstrap_failed log=$log plist=$plist" - exit 9 + start_chat_visible_turn_delivery "$thread_id" "app_monitor_launchctl_bootstrap_failed" fi else nohup "$SCRIPT_DIR/codex-app-monitor.sh" "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$thread_id" >>"$log" 2>&1 & fi - sleep 1.2 - app_monitor_pid="$(cat "$pidfile" 2>/dev/null || true)" - if ! kill -0 "$app_monitor_pid" 2>/dev/null; then - rm -f "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" - actas_lock_gc_stale >/dev/null 2>&1 || true - echo "status=app_monitor_failed team=$TEAM name=$NAME log=$log" - exit 9 - fi - echo "status=ok team=$TEAM name=$NAME app_monitor_pid=$app_monitor_pid thread=$thread_id transport=codex-app-exec-resume" + ready_seconds="${AGMSG_CODEX_APP_MONITOR_READY_SECONDS:-5}" + case "$ready_seconds" in ''|*[!0-9]*) ready_seconds=5 ;; esac + [ "$ready_seconds" -gt 0 ] || ready_seconds=1 + ready_checks=$((ready_seconds * 5)) + check=0 + while [ "$check" -lt "$ready_checks" ]; do + app_monitor_pid="$(cat "$pidfile" 2>/dev/null || true)" + health_status="$(sed -n 's/^status=//p' "$health" 2>/dev/null | head -1 || true)" + if [ "$health_status" = "ready" ] && [ -n "$app_monitor_pid" ] && kill -0 "$app_monitor_pid" 2>/dev/null; then + echo "status=ok team=$TEAM name=$NAME app_monitor_pid=$app_monitor_pid thread=$thread_id transport=codex-app-exec-resume health=ready" + exit 0 + fi + case "$health_status" in preflight_failed|fallback_failed) break ;; esac + if [ -n "$app_monitor_pid" ] && ! kill -0 "$app_monitor_pid" 2>/dev/null; then + break + fi + sleep 0.2 + check=$((check + 1)) + done + + bootout_label_and_wait "$domain" "$label" + rm -f "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + actas_lock_gc_stale >/dev/null 2>&1 || true + start_chat_visible_turn_delivery "$thread_id" "app_monitor_${health_status:-not_ready}" +} + +start_chat_visible_turn_delivery() { + local thread_id="$1" + local reason="${2:-headless_app_monitor_disabled}" + local meta="$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" + + kill_other_project_receivers + kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + + { + printf 'project=%s\n' "$PROJECT" + printf 'type=%s\n' "$TYPE" + printf 'team=%s\n' "$TEAM" + printf 'name=%s\n' "$NAME" + printf 'thread=%s\n' "${thread_id:-unresolved}" + printf 'transport=codex-chat-visible-turn\n' + printf 'status=waiting_for_chat_turn\n' + printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$meta" + + echo "status=visible_turn_only team=$TEAM name=$NAME thread=${thread_id:-unresolved} transport=codex-chat-visible-turn reason=$reason" exit 0 } @@ -334,6 +406,9 @@ if [ -z "$APP_SERVER" ] && [ -f "$PORT_FILE" ] && [ -f "$SERVER_PID" ]; then fi if [ -z "$APP_SERVER" ]; then + if [ "${AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR:-}" != "1" ]; then + start_chat_visible_turn_delivery "$THREAD_ID" + fi start_codex_app_monitor "$THREAD_ID" fi diff --git a/scripts/drivers/types/codex/codex-app-monitor.sh b/scripts/drivers/types/codex/codex-app-monitor.sh index 8edca3d6..a11585f4 100755 --- a/scripts/drivers/types/codex/codex-app-monitor.sh +++ b/scripts/drivers/types/codex/codex-app-monitor.sh @@ -1,18 +1,26 @@ #!/usr/bin/env bash set -euo pipefail -# Watch agmsg inbox rows for a Codex identity and wake an ordinary Codex.app -# thread via `codex exec resume`. This is the Desktop fallback for sessions that -# were opened directly in Codex.app rather than through codex-monitor.sh. +# Legacy headless fallback for sessions opened directly in Codex.app. +# +# This script wakes a Codex thread through `codex exec resume`, which does not +# report progress in the active Codex.app chat. It is therefore disabled by +# default. Use the app-server bridge for visible real-time delivery, or turn +# delivery for chat-visible handling at turn boundaries. usage() { cat < Environment: + AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1 + opt in to this legacy headless fallback AGMSG_CODEX_APP_MONITOR_TIMEOUT watch-once timeout seconds (default: 300) AGMSG_CODEX_APP_MONITOR_INTERVAL watch-once poll interval seconds (default: 2) AGMSG_CODEX_APP_MONITOR_MAX_WAKES stop after N wakes, useful for tests + AGMSG_CODEX_APP_MONITOR_MAX_FAILURES + switch to visible turn delivery after N + consecutive failures (default: 3) AGMSG_CODEX_APP_MONITOR_FLAGS extra flags passed to "codex exec" AGMSG_CODEX_APP_MONITOR_CODEX codex binary override EOF @@ -23,6 +31,13 @@ if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then exit 0 fi +if [ "${AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR:-}" != "1" ]; then + echo "codex-app-monitor: disabled; it handles messages via headless 'codex exec resume'." >&2 + echo "codex-app-monitor: use the Codex app-server bridge or turn delivery for visible chat status." >&2 + echo "codex-app-monitor: set AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1 only for legacy opt-in runs." >&2 + exit 64 +fi + PROJECT="${1:?Usage: codex-app-monitor.sh }" TYPE="${2:?Missing type}" TEAM="${3:?Missing team}" @@ -51,6 +66,7 @@ mkdir -p "$RUN_DIR" TIMEOUT="${AGMSG_CODEX_APP_MONITOR_TIMEOUT:-${AGMSG_WATCH_ONCE_TIMEOUT:-300}}" INTERVAL="${AGMSG_CODEX_APP_MONITOR_INTERVAL:-${AGMSG_WATCH_ONCE_INTERVAL:-2}}" MAX_WAKES="${AGMSG_CODEX_APP_MONITOR_MAX_WAKES:-0}" +MAX_FAILURES="${AGMSG_CODEX_APP_MONITOR_MAX_FAILURES:-3}" resolve_codex_bin() { if [ -n "${AGMSG_CODEX_APP_MONITOR_CODEX:-}" ]; then printf '%s\n' "$AGMSG_CODEX_APP_MONITOR_CODEX" @@ -60,6 +76,10 @@ resolve_codex_bin() { printf '%s\n' "/Applications/Codex.app/Contents/Resources/codex" return 0 fi + if [ -x "$HOME/.npm-global/bin/codex" ]; then + printf '%s\n' "$HOME/.npm-global/bin/codex" + return 0 + fi command -v codex 2>/dev/null || printf '%s\n' "codex" } @@ -69,7 +89,9 @@ OWNER_ID="agmsg-codex-app-monitor-$$.$$" case "$TIMEOUT" in ''|*[!0-9]*) echo "codex-app-monitor: timeout must be a whole number" >&2; exit 2 ;; esac case "$INTERVAL" in ''|*[!0-9]*) echo "codex-app-monitor: interval must be a whole number" >&2; exit 2 ;; esac case "$MAX_WAKES" in ''|*[!0-9]*) echo "codex-app-monitor: max wakes must be a whole number" >&2; exit 2 ;; esac +case "$MAX_FAILURES" in ''|*[!0-9]*) echo "codex-app-monitor: max failures must be a whole number" >&2; exit 2 ;; esac [ "$INTERVAL" -gt 0 ] || INTERVAL=1 +[ "$MAX_FAILURES" -gt 0 ] || MAX_FAILURES=1 PIDFILE="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" META="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" @@ -78,17 +100,93 @@ LAST_PROMPT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-prompt.txt" LAST_OUTPUT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-message.txt" LAST_STATUS="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-status" LAST_IDS="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-ids" +HEALTH="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.health" +PREFLIGHT_LOG="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.preflight.log" +CHAT_META="$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" + +consecutive_failures=0 +last_wake_at="" +last_success_at="" + +write_health() { + local status="$1" last_error="${2:-none}" tmp="$HEALTH.$$" + { + printf 'project=%s\n' "$PROJECT" + printf 'type=%s\n' "$TYPE" + printf 'team=%s\n' "$TEAM" + printf 'name=%s\n' "$NAME" + printf 'thread=%s\n' "$THREAD_ID" + printf 'transport=codex-app-exec-resume\n' + printf 'status=%s\n' "$status" + printf 'consecutive_failures=%s\n' "$consecutive_failures" + printf 'last_error=%s\n' "$last_error" + printf 'last_wake_at=%s\n' "${last_wake_at:-none}" + printf 'last_success_at=%s\n' "${last_success_at:-none}" + printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$tmp" + mv "$tmp" "$HEALTH" +} + +notify_delivery_failure() { + local message="${1:-未読メッセージを自動配送できません。turn 配送へ退避しました。}" + [ "${AGMSG_CODEX_APP_MONITOR_DISABLE_NOTIFY:-}" = "1" ] && return 0 + command -v osascript >/dev/null 2>&1 || return 0 + /usr/bin/osascript - "$TEAM/$NAME" "$message" <<'APPLESCRIPT' >/dev/null 2>&1 || true +on run argv + display notification (item 2 of argv) with title "agmsg monitor" subtitle (item 1 of argv) +end run +APPLESCRIPT +} + +write_chat_visible_meta() { + local tmp="$CHAT_META.$$" + { + printf 'project=%s\n' "$PROJECT" + printf 'type=%s\n' "$TYPE" + printf 'team=%s\n' "$TEAM" + printf 'name=%s\n' "$NAME" + printf 'thread=%s\n' "$THREAD_ID" + printf 'transport=codex-chat-visible-turn\n' + printf 'status=waiting_for_chat_turn\n' + printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$tmp" + mv "$tmp" "$CHAT_META" +} + +fallback_to_turn() { + local reason="$1" + write_health "fallback_turn" "$reason" + notify_delivery_failure + # Monitor mode already installs the visible Stop-hook fallback for Codex. + # Do not rewrite the project to turn mode here: that would remove the + # SessionStart hook and prevent automatic rebind after the next restart. + write_chat_visible_meta + write_health "fallback_turn" "$reason" + return 70 +} if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then + write_health "preflight_failed" "codex_binary_not_found" echo "codex-app-monitor: codex binary not found: $CODEX_BIN" >&2 exit 3 fi +write_health "starting" "none" +if ! "$CODEX_BIN" mcp list >"$PREFLIGHT_LOG" 2>&1; then + write_health "preflight_failed" "codex_config_invalid" + notify_delivery_failure "自動配送を開始できません。未読メッセージは保持しています。" + echo "codex-app-monitor: codex config preflight failed; see $PREFLIGHT_LOG" >&2 + exit 78 +fi +rm -f "$PREFLIGHT_LOG" +write_health "ready" "none" + cleanup() { actas_lock_release "$TEAM" "$NAME" "$OWNER_ID" 2>/dev/null || true rm -f "$PIDFILE" "$META" } terminate() { + write_health "stopped" "terminated" cleanup exit 0 } @@ -121,10 +219,19 @@ Continue the conversation in this Codex thread. You are currently acting as ${NA If a reply to an agmsg sender is needed, send it with: ${send_script} ${TEAM} ${NAME} -Visible UI requirement: in this assistant turn, briefly show the agmsg sender, -the message summary, what you did, and any reply target plus reply summary. -If you do not reply, state why. Do not treat DB writes or monitor delivery as -complete unless the handling is visible in the Codex thread UI. +Visible UI requirement: +1. Before the first tool call, post a short Japanese progress update in the + Codex thread UI starting with "agmsg対応状況:" and include sender, summary, + planned action, and whether you will reply. +2. Keep substantive work in the visible thread. Before each major action, post + a short Japanese progress update; do not complete the task in an unreported + background worker. +3. After handling the message, post a final Japanese status update with: + sender, received instruction, action taken, reply target, reply summary, + remaining blocker, and next step. +4. If you do not reply, state why in the visible status. +5. Do not treat DB writes, monitor delivery, or a send.sh result as complete + unless the handling is visible in the Codex thread UI. EOF } @@ -203,7 +310,13 @@ while :; do continue ;; *) + consecutive_failures=$((consecutive_failures + 1)) + write_health "degraded" "watch_once_exit_${watch_status}" printf 'codex-app-monitor: watch-once failed status=%s output=%s\n' "$watch_status" "$watch_output" >&2 + if [ "$consecutive_failures" -ge "$MAX_FAILURES" ]; then + fallback_to_turn "watch_once_failed_${watch_status}" || fallback_status=$? + exit "${fallback_status:-70}" + fi sleep 5 continue ;; @@ -215,6 +328,8 @@ while :; do build_prompt "$inbox_text" > "$LAST_PROMPT" wake_count=$((wake_count + 1)) + last_wake_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + write_health "delivering" "none" printf 'codex-app-monitor: wakeup %s for %s/%s thread=%s\n' "$wake_count" "$TEAM" "$NAME" "$THREAD_ID" set +e @@ -222,13 +337,23 @@ while :; do resume_status=$? set -e if [ "$resume_status" -ne 0 ]; then + consecutive_failures=$((consecutive_failures + 1)) + write_health "degraded" "codex_exec_resume_exit_${resume_status}" printf 'codex-app-monitor: codex exec resume failed status=%s prompt=%s output=%s\n' "$resume_status" "$LAST_PROMPT" "$LAST_OUTPUT" >&2 + if [ "$consecutive_failures" -ge "$MAX_FAILURES" ]; then + fallback_to_turn "codex_exec_resume_failed_${resume_status}" || fallback_status=$? + exit "${fallback_status:-70}" + fi sleep 30 else mark_delivered_ids_read + consecutive_failures=0 + last_success_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + write_health "ready" "none" fi if [ "$MAX_WAKES" -gt 0 ] && [ "$wake_count" -ge "$MAX_WAKES" ]; then + write_health "stopped" "max_wakes_reached" exit 0 fi done diff --git a/scripts/drivers/types/codex/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js index 64ac8f79..054dfd35 100755 --- a/scripts/drivers/types/codex/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -1038,6 +1038,14 @@ class CodexBridge { buildPrompt() { const inbox = path.join(SCRIPTS_DIR, "inbox.sh"); const send = path.join(SCRIPTS_DIR, "send.sh"); + const visibleUiRequirement = [ + "Visible UI requirement:", + '1. Before the first tool call, post a short Japanese progress update in the Codex thread UI starting with "agmsg対応状況:" and include sender, summary, planned action, and whether you will reply.', + "2. Keep substantive work in the visible thread. Before each major action, post a short Japanese progress update; do not complete the task in an unreported background worker.", + "3. After handling the message, post a final Japanese status update with: sender, received instruction, action taken, reply target, reply summary, remaining blocker, and next step.", + "4. If you do not reply, state why in the visible status.", + "5. Do not treat DB writes, monitor delivery, or a send.sh result as complete unless the handling is visible in the Codex thread UI.", + ].join("\n"); if (this.opts.inlineInbox) { return [ `agmsg delivered the following unread messages for ${this.identity.team}/${this.identity.name}:`, @@ -1047,10 +1055,7 @@ class CodexBridge { "Continue the conversation in this Codex thread. If a reply to an agmsg sender is needed, send it with:", `${send} ${this.identity.team} ${this.identity.name} `, "", - "Visible UI requirement: in this assistant turn, briefly show the agmsg sender,", - "the message summary, what you did, and any reply target plus reply summary.", - "If you do not reply, state why. Do not treat DB writes or monitor delivery as", - "complete unless the handling is visible in the Codex thread UI.", + visibleUiRequirement, ].join("\n"); } return [ @@ -1059,10 +1064,7 @@ class CodexBridge { "Read the messages and continue the conversation. If a reply is needed, send it with:", `${send} ${this.identity.team} ${this.identity.name} `, "", - "Visible UI requirement: in this assistant turn, briefly show the agmsg sender,", - "the message summary, what you did, and any reply target plus reply summary.", - "If you do not reply, state why. Do not treat DB writes or monitor delivery as", - "complete unless the handling is visible in the Codex thread UI.", + visibleUiRequirement, ].join("\n"); } diff --git a/scripts/drivers/types/codex/restore-actas-monitor.sh b/scripts/drivers/types/codex/restore-actas-monitor.sh deleted file mode 100755 index 0e348a71..00000000 --- a/scripts/drivers/types/codex/restore-actas-monitor.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Global Codex SessionStart hook. -# -# Restores the receive-side agmsg monitor for the last `agmsg actas ` role -# used in this project. This is intentionally Codex.app-friendly: ordinary app -# sessions use codex-app-monitor.sh via actas-monitor.sh, while shim/app-server -# sessions can still use the bridge path selected there. - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" -RUN_DIR="$SKILL_DIR/run" - -# shellcheck source=../../../lib/hash.sh -source "$SCRIPT_DIR/../../../lib/hash.sh" -# shellcheck source=../../../lib/resolve-project.sh -source "$SCRIPT_DIR/../../../lib/resolve-project.sh" - -mkdir -p "$RUN_DIR" -LOG="$RUN_DIR/codex-actas-restore.log" - -INPUT="$(cat 2>/dev/null || true)" - -json_string_field() { - local key="$1" - printf '%s' "$INPUT" \ - | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" \ - | head -1 -} - -PROJECT="$(json_string_field cwd)" -if [ -z "$PROJECT" ]; then - PROJECT="$PWD" -fi -PROJECT="$(agmsg_resolve_project "$PROJECT" codex 2>/dev/null || printf '%s' "$PROJECT")" -if [ -d "$PROJECT" ]; then - PROJECT="$(cd "$PROJECT" && pwd -P)" -fi - -PROJECT_HASH="$(agmsg_sha1 <<<"$PROJECT")" -STATE="$RUN_DIR/codex-last-actas.$PROJECT_HASH.tsv" - -if [ ! -f "$STATE" ]; then - exit 0 -fi - -IFS=$'\t' read -r saved_project saved_type saved_team saved_name _saved_at < "$STATE" || exit 0 -[ "$saved_type" = "codex" ] || exit 0 -[ -n "$saved_team" ] && [ -n "$saved_name" ] || exit 0 - -if [ -d "$saved_project" ]; then - saved_project="$(cd "$saved_project" && pwd -P)" -fi -[ "$saved_project" = "$PROJECT" ] || exit 0 - -THREAD_ID="$(json_string_field session_id)" -if [ -z "$THREAD_ID" ]; then - THREAD_ID="$(json_string_field id)" -fi - -{ - printf 'restore-start project=%s team=%s name=%s thread=%s at=%s\n' \ - "$PROJECT" "$saved_team" "$saved_name" "${THREAD_ID:-auto}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - AGMSG_TEAM="$saved_team" AGMSG_CODEX_ACTAS_THREAD="$THREAD_ID" \ - "$SCRIPT_DIR/actas-monitor.sh" "$PROJECT" codex "$saved_name" "${THREAD_ID:-}" - printf 'restore-end status=ok at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" -} >> "$LOG" 2>&1 || { - status=$? - printf 'restore-end status=failed code=%s at=%s\n' "$status" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LOG" 2>&1 || true - exit 0 -} diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index f22f0c6e..101265aa 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -52,10 +52,10 @@ Four possible outputs: 2) off — No automatic delivery Manual $__SKILL_NAME__ only. - 3) monitor — Real-time push (BETA, advanced) - Normal Codex.app sessions are woken with `codex exec resume`. - CLI sessions route through an app-server bridge (printed - shell function or PATH shim). See docs/codex-monitor-beta.md. + 3) monitor — Persistent visible delivery (BETA) + SessionStart rebinds the last actas role after restart. + An app-server provides real-time visible turns; otherwise + the Stop hook pulls visibly. Headless handling stays off. [1]: ``` @@ -63,7 +63,7 @@ Four possible outputs: - **Wait for the user's answer before proceeding.** Empty input means `1` (turn). - Map the chosen number to a mode (`1`→`turn`, `2`→`off`, `3`→`monitor`) and run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` - - If monitor is chosen, tell the user: "Codex monitor is a BETA. In Codex.app, `$__SKILL_NAME__ actas ` starts a per-thread app monitor and incoming agmsg messages wake the same Codex.app thread through `codex exec resume`. CLI sessions route through the app-server bridge instead: add the printed `codex` shell function to your shell profile (or run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-shim-install.sh install` for a global PATH shim with `~/.agents/bin` first on PATH). The bridge starts on the **first turn** of a new Codex session, so restart your Codex session and send one message for monitor to take effect. For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" + - If monitor is chosen, tell the user: "Codex monitor is a BETA. It persists the last `actas` role and rebinds it at SessionStart. When a visible app-server bridge is available, incoming messages become visible turns. Ordinary Codex.app sessions use the visible Stop-hook fallback and never start headless `codex exec resume` unless `AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1` is explicitly set." 6. Then check inbox for the newly joined team. @@ -145,9 +145,9 @@ If argument is "mode" (no further args): 2. Show the output to the user. If argument starts with "mode" followed by a mode name (e.g. "mode monitor"): -1. Parse the mode. Codex supports `monitor` (beta bridge), `turn`, and `off` — reject `both` with: "Codex bridge beta supports `monitor`, `turn`, or `off`; `both` is not supported yet." +1. Parse the mode. Codex supports `monitor`, `turn`, `both`, and `off`. `monitor` already installs the visible turn fallback; `both` is an explicit equivalent for diagnostics. 2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` -3. If mode is `monitor`, tell the user: "Codex monitor beta is enabled. Add the printed shell function to your shell profile, restart the shell, then launch future sessions with normal `codex`. If you prefer a global PATH shim, run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` first on PATH. You can also use `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-monitor.sh` for explicit monitor launches. The bridge starts on the **first turn** of a new Codex session (the SessionStart hook fires on your first message, not the moment Codex opens), so **restart your Codex session and send one message for monitor to take effect** — this already-running session stays unmonitored until it restarts. For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" +3. If mode is `monitor` or `both`, tell the user: "Persistent visible Codex delivery is enabled. SessionStart rebinds the last `actas` role after restart. The app-server bridge is used when available; otherwise the Stop hook pulls messages in the visible thread. Headless `codex exec resume` remains disabled unless explicitly opted in." If argument is "hook on" (legacy alias): 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set turn codex "$(pwd)"` diff --git a/scripts/drivers/types/codex/type.conf b/scripts/drivers/types/codex/type.conf index ce35a1ea..37f4d4d9 100644 --- a/scripts/drivers/types/codex/type.conf +++ b/scripts/drivers/types/codex/type.conf @@ -19,4 +19,4 @@ hooks_file=.codex/hooks.json monitor=no stop_output=json hook_windows_wrap=yes -delivery_modes=monitor turn off +delivery_modes=monitor turn both off diff --git a/scripts/inbox-peek.sh b/scripts/inbox-peek.sh new file mode 100755 index 00000000..ba2c93c9 --- /dev/null +++ b/scripts/inbox-peek.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: inbox-peek.sh [--quiet] [--ids-file ] +# Shows unread messages without marking them as read. + +TEAM="${1:?Usage: inbox-peek.sh [--quiet] [--ids-file ]}" +AGENT="${2:?Missing agent_id}" +shift 2 + +QUIET=false +IDS_FILE="" +while [ "$#" -gt 0 ]; do + case "$1" in + --quiet) QUIET=true; shift ;; + --ids-file) IDS_FILE="${2:?--ids-file needs a path}"; shift 2 ;; + *) echo "inbox-peek: unknown option: $1" >&2; exit 2 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/lib/storage.sh" +DB="$(agmsg_db_path)" + +sql_escape() { + printf '%s' "$1" | sed "s/'/''/g" +} + +[ -z "$IDS_FILE" ] || : > "$IDS_FILE" + +if [ ! -f "$DB" ]; then + if [ "$QUIET" = true ]; then exit 0; fi + echo "No messages (DB not initialized)" + exit 0 +fi + +TEAM_ESC="$(sql_escape "$TEAM")" +AGENT_ESC="$(sql_escape "$AGENT")" +UNREAD=$(agmsg_sqlite -separator $'\037' "$DB" " + SELECT id, + from_agent, + replace(replace(body, char(10), '\n'), char(9), '\t'), + created_at + FROM messages + WHERE team='$TEAM_ESC' AND to_agent='$AGENT_ESC' AND read_at IS NULL + ORDER BY created_at ASC; +") + +if [ -z "$UNREAD" ]; then + if [ "$QUIET" = true ]; then exit 0; fi + echo "No new messages." + exit 0 +fi + +if [ -n "$IDS_FILE" ]; then + printf '%s\n' "$UNREAD" | awk -F $'\037' '$1 ~ /^[0-9]+$/ { print $1 }' | paste -sd, - > "$IDS_FILE" +fi + +COUNT=$(printf '%s\n' "$UNREAD" | awk 'NF { c++ } END { print c + 0 }') +echo "$COUNT new message(s):" +echo "" +while IFS=$'\037' read -r _id from body ts; do + [ -n "$_id" ] || continue + echo " [$ts] $from: $body" +done <<< "$UNREAD" +echo "" diff --git a/scripts/inbox.sh b/scripts/inbox.sh index ad5c21df..68051cac 100755 --- a/scripts/inbox.sh +++ b/scripts/inbox.sh @@ -26,9 +26,10 @@ _agmsg_sqlesc() { printf %s "$1" | sed "s/'/''/g"; } TEAM_SQL="$(_agmsg_sqlesc "$TEAM")" AGENT_SQL="$(_agmsg_sqlesc "$AGENT")" -# Get unread messages — escape newlines/tabs in body to keep one record per line +# Get unread messages — id first so the mark step below targets exactly these +# rows; escape newlines/tabs in body to keep one record per line UNREAD=$(agmsg_sqlite "$DB" " - SELECT from_agent || char(31) || replace(replace(body, char(10), '\n'), char(9), '\t') || char(31) || created_at + SELECT id || char(31) || from_agent || char(31) || replace(replace(body, char(10), '\n'), char(9), '\t') || char(31) || created_at FROM messages WHERE team='$TEAM_SQL' AND to_agent='$AGENT_SQL' AND read_at IS NULL ORDER BY created_at ASC; ") @@ -39,14 +40,36 @@ if [ -z "$UNREAD" ]; then exit 0 fi -# Display +# Display, collecting the ids actually shown COUNT=$(echo "$UNREAD" | wc -l | tr -d ' ') echo "$COUNT new message(s):" echo "" -while IFS=$'\x1f' read -r from body ts; do +IDS="" +while IFS=$'\x1f' read -r id from body ts; do echo " [$ts] $from: $body" + case "$id" in + ''|*[!0-9]*) ;; # defensive: never splice a non-numeric value into SQL + *) IDS="${IDS:+$IDS,}$id" ;; + esac done <<< "$UNREAD" echo "" -# Mark as read (non-fatal — may fail in sandboxed environments) -agmsg_sqlite "$DB" "UPDATE messages SET read_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE team='$TEAM_SQL' AND to_agent='$AGENT_SQL' AND read_at IS NULL;" 2>/dev/null || true +# Test seam: a two-file barrier that lets the race regression test land a +# message deterministically between display and mark. No-op unless set. +if [ -n "${AGMSG_TEST_MARK_BARRIER:-}" ]; then + : > "$AGMSG_TEST_MARK_BARRIER.reached" + _agmsg_barrier_waited=0 + while [ ! -e "$AGMSG_TEST_MARK_BARRIER.release" ]; do + sleep 0.05 + _agmsg_barrier_waited=$((_agmsg_barrier_waited + 1)) + [ "$_agmsg_barrier_waited" -ge 200 ] && break # 10s safety cap + done +fi + +# Mark as read (non-fatal — may fail in sandboxed environments). +# Only the ids displayed above: a blanket "WHERE read_at IS NULL" would also +# swallow messages that arrived between the SELECT and this UPDATE — they +# would be marked read without ever having been shown. +if [ -n "$IDS" ]; then + agmsg_sqlite "$DB" "UPDATE messages SET read_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id IN ($IDS);" 2>/dev/null || true +fi diff --git a/scripts/mark-read.sh b/scripts/mark-read.sh new file mode 100755 index 00000000..af4d5d42 --- /dev/null +++ b/scripts/mark-read.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: mark-read.sh (--ids |--ids-file ) +# Marks only the supplied message ids as read. + +TEAM="${1:?Usage: mark-read.sh (--ids |--ids-file )}" +AGENT="${2:?Missing agent_id}" +shift 2 + +IDS="" +while [ "$#" -gt 0 ]; do + case "$1" in + --ids) IDS="${2:?--ids needs a comma-separated id list}"; shift 2 ;; + --ids-file) IDS="$(cat "${2:?--ids-file needs a path}" 2>/dev/null || true)"; shift 2 ;; + *) echo "mark-read: unknown option: $1" >&2; exit 2 ;; + esac +done + +case "$IDS" in + ''|*[!0-9,]*) exit 0 ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/lib/storage.sh" +DB="$(agmsg_db_path)" +[ -f "$DB" ] || exit 0 + +sql_escape() { + printf '%s' "$1" | sed "s/'/''/g" +} + +TEAM_ESC="$(sql_escape "$TEAM")" +AGENT_ESC="$(sql_escape "$AGENT")" +agmsg_sqlite "$DB" " + UPDATE messages + SET read_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') + WHERE team='$TEAM_ESC' AND to_agent='$AGENT_ESC' AND id IN ($IDS); +" >/dev/null 2>&1 || true diff --git a/tests/test_codex_bridge.bats b/tests/test_codex_bridge.bats index 4ce91eee..68c605d8 100644 --- a/tests/test_codex_bridge.bats +++ b/tests/test_codex_bridge.bats @@ -795,6 +795,14 @@ rl.on("line", (line) => { send({ jsonrpc: "2.0", id: message.id, error: { message: "missing inline inbox body" } }); return; } + if (!message.params.input[0].text.includes("agmsg対応状況:")) { + send({ jsonrpc: "2.0", id: message.id, error: { message: "missing visible progress contract" } }); + return; + } + if (!message.params.input[0].text.includes("Before each major action")) { + send({ jsonrpc: "2.0", id: message.id, error: { message: "missing visible major-action contract" } }); + return; + } send({ jsonrpc: "2.0", id: message.id, result: {} }); setTimeout(() => { send({ @@ -814,6 +822,7 @@ EOF [ "$status" -eq 0 ] [[ "$output" =~ "started turn" ]] + [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NOT NULL FROM messages WHERE body='inline body reaches prompt';")" = "1" ] } @test "codex-bridge: stops instead of looping on the same unread max_id" { diff --git a/tests/test_codex_visible_monitor.bats b/tests/test_codex_visible_monitor.bats new file mode 100644 index 00000000..df861ff3 --- /dev/null +++ b/tests/test_codex_visible_monitor.bats @@ -0,0 +1,103 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env + export TEST_PROJECT="$(mktemp -d)" +} + +teardown() { + teardown_test_env + rm -rf "$TEST_PROJECT" +} + +project_hash() { + local resolved + resolved="$(cd "$TEST_PROJECT" && pwd)" + printf '%s' "$resolved" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ) +} + +join_codex_roles() { + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null +} + +@test "codex actas persists the selected role and arms visible fallback" { + join_codex_roles + + run env CODEX_THREAD_ID=thread-123 \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-123 + [ "$status" -eq 0 ] + [[ "$output" == *"status=visible_turn_only"* ]] + [[ "$output" == *"name=bob"* ]] + + local state="$TEST_SKILL_DIR/run/codex-last-actas.$(project_hash).tsv" + [ -f "$state" ] + awk -F '\t' '$2 == "codex" && $3 == "team" && $4 == "bob" { found=1 } END { exit !found }' "$state" + + local hooks="$TEST_PROJECT/.codex/hooks.json" + grep -q "session-start.sh" "$hooks" + grep -q "check-inbox.sh" "$hooks" +} + +@test "codex SessionStart rebinds the last actas role after restart" { + join_codex_roles + env CODEX_THREAD_ID=thread-before \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-before >/dev/null + rm -f "$TEST_SKILL_DIR/run/codex-chat-visible.team.bob.meta" + + run env CODEX_THREAD_ID=thread-after \ + bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" /dev/null + bash "$SCRIPTS/send.sh" team alice bob "visible-bob-message" >/dev/null + + run bash "$SCRIPTS/check-inbox.sh" codex "$TEST_PROJECT" /dev/null + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + + sleep 60 & + local app_pid=$! + mkdir -p "$TEST_SKILL_DIR/run" + printf '%s\n' "$app_pid" > "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.pid" + cat > "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.meta" </dev/null + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.pid" ] +} diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index df4d2fb0..170e2125 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -1492,7 +1492,7 @@ EOF [ ! -f "$log" ] } -@test "delivery set monitor (codex): installs SessionStart and prints Codex shell function" { +@test "delivery set monitor (codex): installs persistent SessionStart plus visible Stop fallback" { run bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"Codex monitor beta is enabled"* ]] @@ -1506,14 +1506,15 @@ EOF local hook_file="$TEST_PROJECT/.codex/hooks.json" [ -f "$hook_file" ] grep -q "session-start.sh" "$hook_file" + grep -q "check-inbox.sh" "$hook_file" } -@test "delivery set both (codex): rejected by the delivery_modes gate" { - # codex's manifest omits 'both' (delivery_modes=monitor turn off), so the - # central gate in delivery.sh rejects it before any file is touched. +@test "delivery set both (codex): accepted as explicit persistent visible mode" { run bash "$SCRIPTS/delivery.sh" set both codex "$TEST_PROJECT" - [ "$status" -eq 1 ] - [[ "$output" == *"not supported for codex"* ]] + [ "$status" -eq 0 ] + local hook_file="$TEST_PROJECT/.codex/hooks.json" + grep -q "session-start.sh" "$hook_file" + grep -q "check-inbox.sh" "$hook_file" } @test "delivery status (codex): live bridge reports alive and suppresses watch count" { From 3a26cbb120b8d4627822de09e5a183da89d57c8a Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Mon, 13 Jul 2026 01:24:01 +0900 Subject: [PATCH 03/12] [auto] fix(codex): add opt-in monitor watchdog (#374) --- README.md | 2 +- docs/codex-monitor-beta.md | 26 +- scripts/drivers/types/codex/_delivery.sh | 7 +- scripts/drivers/types/codex/actas-monitor.sh | 13 + .../types/codex/codex-monitor-lease.sh | 473 ++++++++++++++++++ scripts/drivers/types/codex/template.md | 47 +- scripts/session-end.sh | 8 + tests/test_codex_monitor_lease.bats | 167 +++++++ tests/test_codex_visible_monitor.bats | 12 + 9 files changed, 739 insertions(+), 16 deletions(-) create mode 100755 scripts/drivers/types/codex/codex-monitor-lease.sh create mode 100644 tests/test_codex_monitor_lease.bats diff --git a/README.md b/README.md index 5c86155a..797a668c 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,7 @@ $agmsg — or /skills → agmsg Codex supports `mode monitor` as a **beta** app-server bridge, plus `mode turn` and `mode off`. -> ⚠️ **The monitor beta changes how Codex starts — opt in only if you understand it.** Codex has no Monitor tool, so `mode monitor` prints a shell function that makes `codex` route through agmsg's monitor shim in your interactive shell. In monitor-mode projects the shim routes interactive launches through a bridge that turns incoming agmsg messages into turns on the current Codex thread; `codex exec` and non-monitor projects pass straight through to the real Codex. It depends on experimental Codex app-server behavior and has known rough edges (orphans on TUI close — #149; one identity per project — #150). +> ⚠️ **The monitor beta changes how Codex starts — opt in only if you understand it.** Codex has no Monitor tool, so `mode monitor` prints a shell function that makes `codex` route through agmsg's monitor shim in your interactive shell. In monitor-mode projects the shim routes interactive launches through a bridge that turns incoming agmsg messages into turns on the current Codex thread; `codex exec` and non-monitor projects pass straight through to the real Codex. Codex Desktop can also attach an opt-in thread heartbeat plus an independent project watchdog, so a visible session can recover delivery when the bridge is unavailable. The watchdog only detects unread high-water marks and never consumes inbox bodies itself. It depends on experimental Codex app-server and app automation behavior and has known rough edges (orphans on TUI close — #149; one identity per project — #150). If you prefer a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index cd2d9f96..addff36a 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -1,9 +1,11 @@ # Codex Monitor Beta Codex does not expose Claude Code's Monitor tool. agmsg's Codex monitor beta -uses a visible app-server bridge when available and a visible Stop-hook fallback -for ordinary Codex.app sessions. It records the last explicit `actas` role and -rebinds that role from SessionStart after restart or compaction. +uses a visible app-server bridge when available. In Codex Desktop, an opt-in +thread heartbeat performs the normal scheduled inbox check and an independent +project watchdog wakes the visible thread only when the heartbeat is stale. +The Stop hook remains the final visible fallback. The last explicit `actas` +role is rebound from SessionStart after restart or compaction. > ⚠️ **Experimental beta — read before enabling.** This changes how Codex starts. > Enabling monitor mode prints a shell function that makes `codex` route through @@ -33,7 +35,9 @@ The command: 1. Enables Codex SessionStart/SessionEnd hooks plus the visible Stop-hook fallback for the project. 2. Persists the last explicit `actas` role so SessionStart can rebind it. -3. Prints a shell function that routes interactive `codex` launches through the +3. When Codex app automation is available, arms a session-scoped heartbeat and + a slower watchdog. Both are keyed by project, team, role, and thread id. +4. Prints a shell function that routes interactive `codex` launches through the monitor shim. Headless `codex exec resume` handling is disabled by default because it can @@ -73,9 +77,17 @@ In monitor-mode projects, the function routes interactive Codex launches through the bridge. Outside monitor-mode projects, it passes through to the real Codex. When Codex.app is opened normally, SessionStart restores the last `actas` role. -If no visible app-server is available, the Stop hook checks that role's inbox at -turn boundaries. Unread messages remain unread until the visible turn displays -them. +If no visible app-server is available, the heartbeat checks that role without +waiting for operator input. The watchdog uses unread-only detection and the +Codex app's visible thread wake capability if the heartbeat stops. Neither +collector marks a message read; the target thread owns `inbox.sh`. Repeated +wakes for the same unread high-water mark are suppressed for a bounded retry +window. + +`mode off`, `mode turn`, `drop`, and SessionEnd deactivate the lease before the +scheduled jobs are removed. A late watchdog run therefore cannot wake a thread +after monitoring has been stopped. Sessions that never opt into monitor mode do +not create scheduled jobs. ## Optional PATH Shim diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh index f98b1afc..55d3e704 100644 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -56,11 +56,14 @@ agmsg_delivery_on_enable() { agmsg_delivery_on_disable() { local project="$2" - local stopped + local stopped lease_cleanup stopped=$(stop_codex_bridge "$project") if [ "${stopped:-0}" -gt 0 ]; then echo "Stopped $stopped Codex bridge process(es) for this project and cleaned their run files." fi + lease_cleanup=$("$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ + disarm-project "$project" 2>/dev/null || true) + [ -n "$lease_cleanup" ] && printf '%s\n' "$lease_cleanup" echo "Note: shell profile functions are not changed automatically." echo " If you installed the optional global shim and no other project uses monitor mode, remove it:" echo " $SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh remove" @@ -82,6 +85,8 @@ agmsg_delivery_stop_directive() { if [ "${stopped:-0}" -gt 0 ]; then echo "Stopped $stopped Codex bridge/app monitor process(es) for this project and cleaned their run files." fi + "$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ + disarm-project "$project" 2>/dev/null || true fi } diff --git a/scripts/drivers/types/codex/actas-monitor.sh b/scripts/drivers/types/codex/actas-monitor.sh index 74a1f2f3..8717d271 100755 --- a/scripts/drivers/types/codex/actas-monitor.sh +++ b/scripts/drivers/types/codex/actas-monitor.sh @@ -376,6 +376,14 @@ start_chat_visible_turn_delivery() { printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > "$meta" + if [ -n "$thread_id" ] && [ "$thread_id" != "loaded" ] && [ "$thread_id" != "new" ]; then + # Arm the session-scoped scheduler fallback. The host agent reads this + # status and creates the Codex heartbeat/watchdog automations; the shell + # helper itself never calls app-internal tools or consumes the inbox. + "$SCRIPT_DIR/codex-monitor-lease.sh" arm \ + "$PROJECT" "$TEAM" "$NAME" "$thread_id" || true + fi + echo "status=visible_turn_only team=$TEAM name=$NAME thread=${thread_id:-unresolved} transport=codex-chat-visible-turn reason=$reason" exit 0 } @@ -385,6 +393,11 @@ if [ -z "$THREAD_ID" ]; then THREAD_ID="$(resolve_thread_id || true)" fi +if [ -n "$THREAD_ID" ] && [ "$THREAD_ID" != "loaded" ] && [ "$THREAD_ID" != "new" ]; then + "$SCRIPT_DIR/codex-monitor-lease.sh" arm \ + "$PROJECT" "$TEAM" "$NAME" "$THREAD_ID" || true +fi + port_alive() { local port="$1" (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null diff --git a/scripts/drivers/types/codex/codex-monitor-lease.sh b/scripts/drivers/types/codex/codex-monitor-lease.sh new file mode 100755 index 00000000..125e847b --- /dev/null +++ b/scripts/drivers/types/codex/codex-monitor-lease.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Coordinate Codex Desktop heartbeat and scheduled watchdog delivery. +# +# The collector never reads message bodies and never marks messages read. It +# only reserves an unread high-water mark long enough for one visible Codex +# turn to be started. The target thread remains responsible for inbox.sh. + +ACTION="${1:-}" +shift || true + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +RUN_DIR="$SKILL_DIR/run" + +# shellcheck source=/dev/null +source "$SCRIPT_DIR/../../../lib/hash.sh" +# shellcheck source=/dev/null +source "$SCRIPT_DIR/../../../lib/resolve-project.sh" +# shellcheck source=/dev/null +source "$SCRIPT_DIR/../../../lib/validate.sh" + +usage() { + cat >&2 <<'EOF' +Usage: + codex-monitor-lease.sh arm [--ttl ] + codex-monitor-lease.sh heartbeat [--ttl ] + codex-monitor-lease.sh claim [--fallback-after ] [--retry-after ] + codex-monitor-lease.sh delivered + codex-monitor-lease.sh failed + codex-monitor-lease.sh automation + codex-monitor-lease.sh prompt + codex-monitor-lease.sh status + codex-monitor-lease.sh disarm + codex-monitor-lease.sh deactivate-thread + codex-monitor-lease.sh disarm-project +EOF + exit 64 +} + +whole_number() { + case "$1" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac +} + +reject_line_breaks() { + case "$1" in *$'\n'*|*$'\r'*|*$'\t'*) return 1 ;; *) return 0 ;; esac +} + +PROJECT="" +TEAM="" +NAME="" +THREAD="" +STATE="" +LOCK="" +STATUS="active" +TTL="${AGMSG_CODEX_MONITOR_LEASE_TTL:-43200}" +EXPIRES_AT=0 +LAST_HEARTBEAT=0 +LAST_WAKE_ID=0 +LAST_WAKE_AT=0 +PENDING_WAKE_ID=0 +PENDING_WAKE_AT=0 +FAILURES=0 +HEARTBEAT_AUTOMATION_ID="" +WATCHDOG_AUTOMATION_ID="" + +state_field() { + local key="$1" file="$2" + sed -n "s/^${key}=//p" "$file" 2>/dev/null | head -1 +} + +load_state() { + [ -f "$STATE" ] || return 1 + STATUS="$(state_field status "$STATE")" + TTL="$(state_field ttl "$STATE")" + EXPIRES_AT="$(state_field expires_at "$STATE")" + LAST_HEARTBEAT="$(state_field last_heartbeat "$STATE")" + LAST_WAKE_ID="$(state_field last_wake_id "$STATE")" + LAST_WAKE_AT="$(state_field last_wake_at "$STATE")" + PENDING_WAKE_ID="$(state_field pending_wake_id "$STATE")" + PENDING_WAKE_AT="$(state_field pending_wake_at "$STATE")" + FAILURES="$(state_field failures "$STATE")" + HEARTBEAT_AUTOMATION_ID="$(state_field heartbeat_automation_id "$STATE")" + WATCHDOG_AUTOMATION_ID="$(state_field watchdog_automation_id "$STATE")" + whole_number "$TTL" || TTL=43200 + whole_number "$EXPIRES_AT" || EXPIRES_AT=0 + whole_number "$LAST_HEARTBEAT" || LAST_HEARTBEAT=0 + whole_number "$LAST_WAKE_ID" || LAST_WAKE_ID=0 + whole_number "$LAST_WAKE_AT" || LAST_WAKE_AT=0 + whole_number "$PENDING_WAKE_ID" || PENDING_WAKE_ID=0 + whole_number "$PENDING_WAKE_AT" || PENDING_WAKE_AT=0 + whole_number "$FAILURES" || FAILURES=0 +} + +write_state() { + local tmp="$STATE.$$" + mkdir -p "$RUN_DIR" + { + printf 'project=%s\n' "$PROJECT" + printf 'type=codex\n' + printf 'team=%s\n' "$TEAM" + printf 'name=%s\n' "$NAME" + printf 'thread=%s\n' "$THREAD" + printf 'status=%s\n' "$STATUS" + printf 'ttl=%s\n' "$TTL" + printf 'expires_at=%s\n' "$EXPIRES_AT" + printf 'last_heartbeat=%s\n' "$LAST_HEARTBEAT" + printf 'last_wake_id=%s\n' "$LAST_WAKE_ID" + printf 'last_wake_at=%s\n' "$LAST_WAKE_AT" + printf 'pending_wake_id=%s\n' "$PENDING_WAKE_ID" + printf 'pending_wake_at=%s\n' "$PENDING_WAKE_AT" + printf 'failures=%s\n' "$FAILURES" + printf 'heartbeat_automation_id=%s\n' "$HEARTBEAT_AUTOMATION_ID" + printf 'watchdog_automation_id=%s\n' "$WATCHDOG_AUTOMATION_ID" + } > "$tmp" + mv "$tmp" "$STATE" +} + +release_lock() { + if [ -n "$LOCK" ]; then + rm -f "$LOCK/pid" 2>/dev/null || true + rmdir "$LOCK" 2>/dev/null || true + fi +} + +acquire_lock() { + local attempts=0 owner + mkdir -p "$RUN_DIR" + while ! mkdir "$LOCK" 2>/dev/null; do + owner="$(cat "$LOCK/pid" 2>/dev/null || true)" + if [ -z "$owner" ] || ! kill -0 "$owner" 2>/dev/null; then + rm -rf "$LOCK" + continue + fi + attempts=$((attempts + 1)) + if [ "$attempts" -ge 40 ]; then + echo "status=busy lease_id=$(basename "${STATE%.state}")" + exit 75 + fi + sleep 0.05 + done + printf '%s\n' "$$" > "$LOCK/pid" + trap release_lock EXIT INT TERM +} + +init_identity() { + PROJECT="${1:?Missing project}" + TEAM="${2:?Missing team}" + NAME="${3:?Missing name}" + THREAD="${4:?Missing thread}" + agmsg_validate_team_name "$TEAM" + agmsg_validate_agent_name "$NAME" + reject_line_breaks "$PROJECT" || { echo "agmsg: invalid project path" >&2; exit 64; } + reject_line_breaks "$THREAD" || { echo "agmsg: invalid Codex thread id" >&2; exit 64; } + [ -n "$THREAD" ] || { echo "agmsg: Codex monitor needs a thread id" >&2; exit 64; } + PROJECT="$(agmsg_resolve_project "$PROJECT" codex)" + local key + key="$(printf '%s\t%s\t%s\t%s' "$PROJECT" "$TEAM" "$NAME" "$THREAD" | agmsg_sha1)" + STATE="$RUN_DIR/codex-monitor-lease.$key.state" + LOCK="$STATE.lock" +} + +parse_timing_options() { + FALLBACK_AFTER=0 + RETRY_AFTER="${AGMSG_CODEX_MONITOR_RETRY_AFTER:-90}" + while [ "$#" -gt 0 ]; do + case "$1" in + --ttl) TTL="${2:?--ttl needs seconds}"; shift 2 ;; + --fallback-after) FALLBACK_AFTER="${2:?--fallback-after needs seconds}"; shift 2 ;; + --retry-after) RETRY_AFTER="${2:?--retry-after needs seconds}"; shift 2 ;; + *) usage ;; + esac + done + whole_number "$TTL" || { echo "agmsg: --ttl must be a whole number" >&2; exit 64; } + whole_number "$FALLBACK_AFTER" || { echo "agmsg: --fallback-after must be a whole number" >&2; exit 64; } + whole_number "$RETRY_AFTER" || { echo "agmsg: --retry-after must be a whole number" >&2; exit 64; } +} + +emit_status() { + local lease_id + lease_id="$(basename "${STATE%.state}")" + printf 'status=%s lease_id=%s team=%s name=%s thread=%s heartbeat_automation_id=%s watchdog_automation_id=%s\n' \ + "$STATUS" "$lease_id" "$TEAM" "$NAME" "$THREAD" \ + "${HEARTBEAT_AUTOMATION_ID:-none}" "${WATCHDOG_AUTOMATION_ID:-none}" +} + +case "$ACTION" in + arm) + [ "$#" -ge 4 ] || usage + init_identity "$1" "$2" "$3" "$4" + shift 4 + parse_timing_options "$@" + acquire_lock + now="$(date +%s)" + load_state || true + STATUS="active" + LAST_HEARTBEAT="$now" + EXPIRES_AT=$((now + TTL)) + FAILURES=0 + write_state + emit_status + ;; + heartbeat) + [ "$#" -ge 4 ] || usage + init_identity "$1" "$2" "$3" "$4" + shift 4 + parse_timing_options "$@" + acquire_lock + if ! load_state; then + echo "status=inactive" + exit 3 + fi + if [ "$STATUS" = "inactive" ] || [ "$STATUS" = "expired" ]; then + emit_status + exit 3 + fi + now="$(date +%s)" + STATUS="active" + LAST_HEARTBEAT="$now" + EXPIRES_AT=$((now + TTL)) + FAILURES=0 + write_state + emit_status + ;; + claim) + [ "$#" -ge 4 ] || usage + init_identity "$1" "$2" "$3" "$4" + shift 4 + parse_timing_options "$@" + acquire_lock + if ! load_state; then + echo "status=inactive" + exit 3 + fi + now="$(date +%s)" + if [ "$STATUS" != "active" ]; then + emit_status + exit 3 + fi + if [ "$EXPIRES_AT" -le "$now" ]; then + STATUS="expired" + write_state + emit_status + exit 3 + fi + if [ "$FALLBACK_AFTER" -gt 0 ] && [ $((now - LAST_HEARTBEAT)) -lt "$FALLBACK_AFTER" ]; then + STATUS="healthy" + emit_status + exit 2 + fi + set +e + pending="$("$SCRIPT_DIR/watch-once.sh" "$PROJECT" codex \ + --team "$TEAM" --name "$NAME" --owner "$THREAD" --claim \ + --timeout 0 --interval 1 2>&1)" + pending_status=$? + set -e + if [ "$pending_status" -eq 2 ]; then + STATUS="active" + PENDING_WAKE_ID=0 + PENDING_WAKE_AT=0 + write_state + echo "status=idle" + exit 2 + fi + if [ "$pending_status" -ne 0 ]; then + printf 'status=error detail=%s\n' "$(printf '%s' "$pending" | tr '\n' ' ')" + exit 1 + fi + count="$(printf '%s\n' "$pending" | sed -n 's/.*count=\([0-9][0-9]*\).*/\1/p' | head -1)" + max_id="$(printf '%s\n' "$pending" | sed -n 's/.*max_id=\([0-9][0-9]*\).*/\1/p' | head -1)" + whole_number "$count" || count=0 + whole_number "$max_id" || max_id=0 + newest_at="$LAST_WAKE_AT" + [ "$PENDING_WAKE_AT" -gt "$newest_at" ] && newest_at="$PENDING_WAKE_AT" + newest_id="$LAST_WAKE_ID" + [ "$PENDING_WAKE_ID" -gt "$newest_id" ] && newest_id="$PENDING_WAKE_ID" + if [ "$max_id" -le "$newest_id" ] && [ $((now - newest_at)) -lt "$RETRY_AFTER" ]; then + STATUS="active" + write_state + printf 'status=waiting count=%s max_id=%s retry_in=%s\n' \ + "$count" "$max_id" "$((RETRY_AFTER - (now - newest_at)))" + exit 2 + fi + STATUS="active" + PENDING_WAKE_ID="$max_id" + PENDING_WAKE_AT="$now" + write_state + printf 'status=wake count=%s max_id=%s thread=%s team=%s name=%s\n' \ + "$count" "$max_id" "$THREAD" "$TEAM" "$NAME" + ;; + delivered|failed) + [ "$#" -eq 5 ] || usage + init_identity "$1" "$2" "$3" "$4" + max_id="$5" + whole_number "$max_id" || { echo "agmsg: max_id must be a whole number" >&2; exit 64; } + acquire_lock + if ! load_state; then + echo "status=inactive" + exit 3 + fi + now="$(date +%s)" + if [ "$ACTION" = "delivered" ]; then + LAST_WAKE_ID="$max_id" + LAST_WAKE_AT="$now" + FAILURES=0 + STATUS="active" + else + FAILURES=$((FAILURES + 1)) + if [ "$FAILURES" -ge 3 ]; then + STATUS="error" + else + STATUS="active" + fi + fi + PENDING_WAKE_ID=0 + PENDING_WAKE_AT=0 + write_state + emit_status + ;; + automation) + [ "$#" -eq 6 ] || usage + init_identity "$1" "$2" "$3" "$4" + acquire_lock + load_state || { echo "status=inactive"; exit 3; } + HEARTBEAT_AUTOMATION_ID="$5" + WATCHDOG_AUTOMATION_ID="$6" + reject_line_breaks "$HEARTBEAT_AUTOMATION_ID" || exit 64 + reject_line_breaks "$WATCHDOG_AUTOMATION_ID" || exit 64 + write_state + emit_status + ;; + prompt) + [ "$#" -eq 5 ] || usage + init_identity "$1" "$2" "$3" "$4" + prompt_kind="$5" + printf -v lease_cmd '%q %q %q %q %q' \ + "$SCRIPT_DIR/codex-monitor-lease.sh" heartbeat "$PROJECT" "$TEAM" "$NAME" + lease_cmd="$lease_cmd $(printf '%q' "$THREAD")" + printf -v claim_cmd '%q %q %q %q %q %q' \ + "$SCRIPT_DIR/codex-monitor-lease.sh" claim "$PROJECT" "$TEAM" "$NAME" "$THREAD" + printf -v inbox_cmd '%q %q %q' \ + "$SKILL_DIR/scripts/inbox.sh" "$TEAM" "$NAME" + printf -v delivered_cmd '%q %q %q %q %q' \ + "$SCRIPT_DIR/codex-monitor-lease.sh" delivered "$PROJECT" "$TEAM" "$NAME" + delivered_cmd="$delivered_cmd $(printf '%q' "$THREAD") " + printf -v failed_cmd '%q %q %q %q %q' \ + "$SCRIPT_DIR/codex-monitor-lease.sh" failed "$PROJECT" "$TEAM" "$NAME" + failed_cmd="$failed_cmd $(printf '%q' "$THREAD") " + case "$prompt_kind" in + heartbeat) + cat < heartbeat` + and use its stdout verbatim as a one-minute `heartbeat` automation attached + to the current thread. +2. Run `codex-monitor-lease.sh prompt watchdog` + and use its stdout verbatim as a three-minute local `cron` automation for the + current project. The watchdog may use the app-internal + `send_message_to_thread` capability, but it must not run `inbox.sh` itself. +3. Name both jobs with the lease id so an existing pair can be updated instead + of duplicated. Inspect existing Codex automation records before creating a + job. Keep the heartbeat on the current thread and the watchdog on the current + project. +4. Record the two returned automation ids with + `codex-monitor-lease.sh automation `. +5. If the Codex automation tools are unavailable, keep the app-server / visible + turn fallback and say that scheduled recovery could not be armed. Never + replace it with a hidden `codex exec resume` worker. + +When `drop`, `mode turn`, `mode off`, `reset`, or a lease status reports +`inactive` / `expired`, delete or pause both recorded automation ids. The shell +scripts deactivate the lease first, so a late automation run cannot wake the +thread after teardown. + **If no arguments provided (DEFAULT action — always do this when the command is invoked without arguments):** 1. **IMMEDIATELY** run inbox check for each TEAM: `~/.agents/skills/__SKILL_NAME__/scripts/inbox.sh $TEAM $AGENT` 2. If the active AGENT name starts with `codex-pro-`, route unread messages through Oracle GPT-5.5 Pro by running `~/.agents/skills/__SKILL_NAME__/scripts/oracle-pro-reply.sh $TEAM $AGENT` instead of answering them directly. Use `ORACLE_PRO_DRY_RUN=1` only when the user asks to preview the browser run. @@ -114,15 +143,17 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): 3. If the name does not appear in the output, join under the existing team. For a single team, run `~/.agents/skills/__SKILL_NAME__/scripts/join.sh codex "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `` for every `send.sh` call until another `actas`. 5. Rebind the receive-side monitor by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. Prefer the visible app-server bridge. If no visible bridge is available, preserve unread messages and arm chat-visible turn delivery; do not start a headless worker unless the operator explicitly opts in. -6. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). -7. If `` starts with `codex-pro-`, tell the user: "Now acting as ``. This role is an Oracle GPT-5.5 Pro consult route; run `$__SKILL_NAME__` to process its unread inbox through `oracle-pro-reply.sh`. Real runs may control the signed-in ChatGPT browser." -8. Otherwise tell the user: "Now acting as ``. Sends and visible receive are bound to ``." +6. If that command reports an active monitor lease, follow **Codex Desktop scheduled receiver** above before returning to the user. +7. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). +8. If `` starts with `codex-pro-`, tell the user: "Now acting as ``. This role is an Oracle GPT-5.5 Pro consult route; run `$__SKILL_NAME__` to process its unread inbox through `oracle-pro-reply.sh`. Real runs may control the signed-in ChatGPT browser." +9. Otherwise tell the user: "Now acting as ``. Sends and visible receive are bound to ``." If argument starts with "drop" followed by an agent name (e.g. "drop alice"): 1. Parse the role name. -2. Run `~/.agents/skills/__SKILL_NAME__/scripts/reset.sh "$(pwd)" codex ` to remove that role's registration. -3. If the session's active FROM was ``, clear that state. -4. Tell the user: "Dropped role `` from this project." +2. Disarm the role's lease and delete or pause its recorded Codex automations as described above. +3. Run `~/.agents/skills/__SKILL_NAME__/scripts/reset.sh "$(pwd)" codex ` to remove that role's registration. +4. If the session's active FROM was ``, clear that state. +5. Tell the user: "Dropped role `` from this project." If argument starts with "spawn" (e.g. "spawn claude-code alice", "spawn codex reviewer --window"): 1. Parse `` (must be `claude-code` or `codex`), ``, and any options (`--project`, `--team`, `--window`, `--split h|v`, `--terminal`, `--no-wait`, `--ready-timeout `). @@ -147,7 +178,9 @@ If argument is "mode" (no further args): If argument starts with "mode" followed by a mode name (e.g. "mode monitor"): 1. Parse the mode. Codex supports `monitor`, `turn`, `both`, and `off`. `monitor` already installs the visible turn fallback; `both` is an explicit equivalent for diagnostics. 2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` -3. If mode is `monitor` or `both`, tell the user: "Persistent visible Codex delivery is enabled. SessionStart rebinds the last `actas` role after restart. The app-server bridge is used when available; otherwise the Stop hook pulls messages in the visible thread. Headless `codex exec resume` remains disabled unless explicitly opted in." +3. If mode is `monitor` or `both` and an active role can be resolved, run `actas-monitor.sh` for that role and follow **Codex Desktop scheduled receiver** above. +4. If mode is `turn` or `off`, delete or pause the automation ids printed by `delivery.sh` teardown. +5. If mode is `monitor` or `both`, tell the user: "Persistent visible Codex delivery is enabled. The app-server bridge is the low-latency path; a thread heartbeat and independent watchdog provide scheduled recovery when Codex app automation is available. Headless `codex exec resume` remains disabled unless explicitly opted in." If argument is "hook on" (legacy alias): 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set turn codex "$(pwd)"` diff --git a/scripts/session-end.sh b/scripts/session-end.sh index bc863630..8ac0815e 100755 --- a/scripts/session-end.sh +++ b/scripts/session-end.sh @@ -87,4 +87,12 @@ done # a sibling resume process's locks are not released out from under it. See #62. actas_lock_release_all "$INSTANCE_ID" 2>/dev/null || true +# A Codex Desktop heartbeat/watchdog must not keep waking a thread after its +# session ends. Keep an inactive tombstone so the independent watchdog can read +# the automation ids and delete both scheduled jobs on its next run. +if [ "$TYPE" = "codex" ]; then + "$SCRIPT_DIR/drivers/types/codex/codex-monitor-lease.sh" \ + deactivate-thread "$PROJECT" "$SESSION_ID" >/dev/null 2>&1 || true +fi + exit 0 diff --git a/tests/test_codex_monitor_lease.bats b/tests/test_codex_monitor_lease.bats new file mode 100644 index 00000000..20643cf1 --- /dev/null +++ b/tests/test_codex_monitor_lease.bats @@ -0,0 +1,167 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env + export TEST_PROJECT="$(mktemp -d)" + export LEASE="$TYPES/codex/codex-monitor-lease.sh" + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null +} + +teardown() { + teardown_test_env + rm -rf "$TEST_PROJECT" +} + +arm() { + bash "$LEASE" arm "$TEST_PROJECT" team alice thread-123 --ttl 60 +} + +@test "Codex monitor lease is opt-in" { + run bash "$LEASE" claim "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 3 ] + [[ "$output" == *"status=inactive"* ]] + + run arm + [ "$status" -eq 0 ] + [[ "$output" == *"status=active"* ]] +} + +@test "heartbeat renews an active lease and fallback stays dormant" { + arm >/dev/null + + run bash "$LEASE" heartbeat "$TEST_PROJECT" team alice thread-123 --ttl 60 + [ "$status" -eq 0 ] + [[ "$output" == *"status=active"* ]] + + run bash "$LEASE" claim "$TEST_PROJECT" team alice thread-123 --fallback-after 30 + [ "$status" -eq 2 ] + [[ "$output" == *"status=healthy"* ]] +} + +@test "claim reserves an unread high-water mark without consuming the inbox" { + arm >/dev/null + bash "$SCRIPTS/send.sh" team bob alice "lease pending" >/dev/null + + run bash "$LEASE" claim "$TEST_PROJECT" team alice thread-123 --retry-after 60 + [ "$status" -eq 0 ] + [[ "$output" == *"status=wake"* ]] + [[ "$output" == *"max_id="* ]] + + run bash "$SCRIPTS/inbox.sh" team alice --quiet + [ "$status" -eq 0 ] + [[ "$output" == *"lease pending"* ]] +} + +@test "heartbeat and watchdog cannot reserve the same unread message twice" { + arm >/dev/null + bash "$SCRIPTS/send.sh" team bob alice "only once" >/dev/null + + run bash "$LEASE" claim "$TEST_PROJECT" team alice thread-123 --retry-after 60 + [ "$status" -eq 0 ] + max_id="$(printf '%s\n' "$output" | sed -n 's/.*max_id=\([0-9][0-9]*\).*/\1/p')" + + run bash "$LEASE" claim "$TEST_PROJECT" team alice thread-123 --retry-after 60 + [ "$status" -eq 2 ] + [[ "$output" == *"status=waiting"* ]] + + run bash "$LEASE" delivered "$TEST_PROJECT" team alice thread-123 "$max_id" + [ "$status" -eq 0 ] + + run bash "$LEASE" claim "$TEST_PROJECT" team alice thread-123 --retry-after 60 + [ "$status" -eq 2 ] + [[ "$output" == *"status=waiting"* ]] +} + +@test "three failed visible wakes stop automatic retries until heartbeat recovery" { + arm >/dev/null + bash "$SCRIPTS/send.sh" team bob alice "wake failure" >/dev/null + run bash "$LEASE" claim "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 0 ] + max_id="$(printf '%s\n' "$output" | sed -n 's/.*max_id=\([0-9][0-9]*\).*/\1/p')" + + for _ in 1 2 3; do + bash "$LEASE" failed "$TEST_PROJECT" team alice thread-123 "$max_id" >/dev/null + done + + run bash "$LEASE" claim "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 3 ] + [[ "$output" == *"status=error"* ]] + + run bash "$LEASE" heartbeat "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 0 ] + [[ "$output" == *"status=active"* ]] +} + +@test "automation ids survive status and are returned during teardown" { + arm >/dev/null + bash "$LEASE" automation "$TEST_PROJECT" team alice thread-123 heartbeat-id watchdog-id >/dev/null + + run bash "$LEASE" disarm "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 0 ] + [[ "$output" == *"heartbeat_automation_id=heartbeat-id"* ]] + [[ "$output" == *"watchdog_automation_id=watchdog-id"* ]] + + run bash "$LEASE" status "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 3 ] + [[ "$output" == *"status=inactive"* ]] +} + +@test "project teardown removes only that project's leases" { + other_project="$(mktemp -d)" + bash "$SCRIPTS/join.sh" other-team other-agent codex "$other_project" >/dev/null + arm >/dev/null + bash "$LEASE" arm "$other_project" other-team other-agent other-thread >/dev/null + + run bash "$LEASE" disarm-project "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" == *"team=team name=alice"* ]] + + run bash "$LEASE" status "$other_project" other-team other-agent other-thread + [ "$status" -eq 0 ] + [[ "$output" == *"status=active"* ]] + rm -rf "$other_project" +} + +@test "session teardown leaves an inactive tombstone for automation cleanup" { + arm >/dev/null + bash "$LEASE" automation "$TEST_PROJECT" team alice thread-123 heartbeat-id watchdog-id >/dev/null + + run bash "$LEASE" deactivate-thread "$TEST_PROJECT" thread-123 + [ "$status" -eq 0 ] + [[ "$output" == *"status=inactive"* ]] + [[ "$output" == *"heartbeat_automation_id=heartbeat-id"* ]] + + run bash "$LEASE" status "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 0 ] + [[ "$output" == *"status=inactive"* ]] + + run bash "$LEASE" heartbeat "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 3 ] + [[ "$output" == *"status=inactive"* ]] +} + +@test "heartbeat and watchdog prompts preserve inbox ownership" { + run bash "$LEASE" prompt "$TEST_PROJECT" team alice thread-123 heartbeat + [ "$status" -eq 0 ] + [[ "$output" == *"inbox.sh"* ]] + [[ "$output" == *"visible Codex thread"* ]] + + run bash "$LEASE" prompt "$TEST_PROJECT" team alice thread-123 watchdog + [ "$status" -eq 0 ] + [[ "$output" == *"send_message_to_thread"* ]] + [[ "$output" == *"must never run inbox.sh"* ]] +} + +@test "stale lease lock does not block monitor recovery" { + arm_output="$(arm)" + lease_id="$(printf '%s\n' "$arm_output" | sed -n 's/.*lease_id=\([^ ]*\).*/\1/p')" + mkdir "$TEST_SKILL_DIR/run/$lease_id.state.lock" + printf '99999999\n' > "$TEST_SKILL_DIR/run/$lease_id.state.lock/pid" + + run bash "$LEASE" heartbeat "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 0 ] + [[ "$output" == *"status=active"* ]] +} diff --git a/tests/test_codex_visible_monitor.bats b/tests/test_codex_visible_monitor.bats index df861ff3..ef878506 100644 --- a/tests/test_codex_visible_monitor.bats +++ b/tests/test_codex_visible_monitor.bats @@ -39,6 +39,7 @@ join_codex_roles() { local hooks="$TEST_PROJECT/.codex/hooks.json" grep -q "session-start.sh" "$hooks" grep -q "check-inbox.sh" "$hooks" + [[ "$output" == *"lease_id=codex-monitor-lease."* ]] } @test "codex SessionStart rebinds the last actas role after restart" { @@ -94,10 +95,21 @@ name=alice thread=thread-123 transport=codex-app-exec-resume EOF + bash "$TYPES/codex/codex-monitor-lease.sh" arm \ + "$TEST_PROJECT" team alice thread-123 >/dev/null + bash "$TYPES/codex/codex-monitor-lease.sh" automation \ + "$TEST_PROJECT" team alice thread-123 heartbeat-id watchdog-id >/dev/null run bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"Stopped 1 Codex bridge/app monitor process"* ]] + [[ "$output" == *"heartbeat_automation_id=heartbeat-id"* ]] + [[ "$output" == *"watchdog_automation_id=watchdog-id"* ]] ! kill -0 "$app_pid" 2>/dev/null [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.pid" ] + + run bash "$TYPES/codex/codex-monitor-lease.sh" status \ + "$TEST_PROJECT" team alice thread-123 + [ "$status" -eq 3 ] + [[ "$output" == *"status=inactive"* ]] } From 68bb8236fe83966e089602dff003e13864a338e0 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Mon, 13 Jul 2026 01:49:52 +0900 Subject: [PATCH 04/12] [auto] fix(codex): lower scheduled monitor cost (#374) --- README.md | 2 +- docs/codex-monitor-beta.md | 21 ++++++++++--------- .../types/codex/codex-monitor-lease.sh | 8 +++---- scripts/drivers/types/codex/template.md | 10 +++++---- tests/test_codex_monitor_lease.bats | 2 ++ 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 797a668c..924b1759 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,7 @@ $agmsg — or /skills → agmsg Codex supports `mode monitor` as a **beta** app-server bridge, plus `mode turn` and `mode off`. -> ⚠️ **The monitor beta changes how Codex starts — opt in only if you understand it.** Codex has no Monitor tool, so `mode monitor` prints a shell function that makes `codex` route through agmsg's monitor shim in your interactive shell. In monitor-mode projects the shim routes interactive launches through a bridge that turns incoming agmsg messages into turns on the current Codex thread; `codex exec` and non-monitor projects pass straight through to the real Codex. Codex Desktop can also attach an opt-in thread heartbeat plus an independent project watchdog, so a visible session can recover delivery when the bridge is unavailable. The watchdog only detects unread high-water marks and never consumes inbox bodies itself. It depends on experimental Codex app-server and app automation behavior and has known rough edges (orphans on TUI close — #149; one identity per project — #150). +> ⚠️ **The monitor beta changes how Codex starts — opt in only if you understand it.** Codex has no Monitor tool, so `mode monitor` prints a shell function that makes `codex` route through agmsg's monitor shim in your interactive shell. In monitor-mode projects the shim routes interactive launches through a bridge that turns incoming agmsg messages into turns on the current Codex thread; `codex exec` and non-monitor projects pass straight through to the real Codex. Codex Desktop can also attach an opt-in 15-minute thread heartbeat plus an independent one-minute `gpt-5.4-mini` project collector, so a visible session can recover delivery without paying for a full thread-model turn every minute. The collector only detects unread high-water marks and never consumes inbox bodies itself. It depends on experimental Codex app-server and app automation behavior and has known rough edges (orphans on TUI close — #149; one identity per project — #150). If you prefer a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index addff36a..95c7fa7e 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -2,8 +2,8 @@ Codex does not expose Claude Code's Monitor tool. agmsg's Codex monitor beta uses a visible app-server bridge when available. In Codex Desktop, an opt-in -thread heartbeat performs the normal scheduled inbox check and an independent -project watchdog wakes the visible thread only when the heartbeat is stale. +low-frequency thread heartbeat renews the session lease and an independent +low-cost project collector checks unread state and wakes the visible thread. The Stop hook remains the final visible fallback. The last explicit `actas` role is rebound from SessionStart after restart or compaction. @@ -35,8 +35,9 @@ The command: 1. Enables Codex SessionStart/SessionEnd hooks plus the visible Stop-hook fallback for the project. 2. Persists the last explicit `actas` role so SessionStart can rebind it. -3. When Codex app automation is available, arms a session-scoped heartbeat and - a slower watchdog. Both are keyed by project, team, role, and thread id. +3. When Codex app automation is available, arms a 15-minute session-scoped + heartbeat and a one-minute `gpt-5.4-mini` collector. Both are keyed by + project, team, role, and thread id. 4. Prints a shell function that routes interactive `codex` launches through the monitor shim. @@ -77,12 +78,12 @@ In monitor-mode projects, the function routes interactive Codex launches through the bridge. Outside monitor-mode projects, it passes through to the real Codex. When Codex.app is opened normally, SessionStart restores the last `actas` role. -If no visible app-server is available, the heartbeat checks that role without -waiting for operator input. The watchdog uses unread-only detection and the -Codex app's visible thread wake capability if the heartbeat stops. Neither -collector marks a message read; the target thread owns `inbox.sh`. Repeated -wakes for the same unread high-water mark are suppressed for a bounded retry -window. +If no visible app-server is available, the one-minute low-cost collector uses +unread-only detection and the Codex app's visible thread wake capability. The +15-minute heartbeat renews the session lease and provides a second recovery +path without paying for a full thread turn every minute. Neither automation +marks a message read; the target thread owns `inbox.sh`. Repeated wakes for the +same unread high-water mark are suppressed for a bounded retry window. `mode off`, `mode turn`, `drop`, and SessionEnd deactivate the lease before the scheduled jobs are removed. A late watchdog run therefore cannot wake a thread diff --git a/scripts/drivers/types/codex/codex-monitor-lease.sh b/scripts/drivers/types/codex/codex-monitor-lease.sh index 125e847b..e16e5b4f 100755 --- a/scripts/drivers/types/codex/codex-monitor-lease.sh +++ b/scripts/drivers/types/codex/codex-monitor-lease.sh @@ -367,16 +367,16 @@ EOF ;; watchdog) cat < heartbeat` - and use its stdout verbatim as a one-minute `heartbeat` automation attached - to the current thread. + and use its stdout verbatim as a 15-minute `heartbeat` automation attached + to the current thread. A one-minute thread heartbeat can be unexpectedly + expensive because it runs the thread's full model even when the inbox is + empty. 2. Run `codex-monitor-lease.sh prompt watchdog` - and use its stdout verbatim as a three-minute local `cron` automation for the - current project. The watchdog may use the app-internal + and use its stdout verbatim as a one-minute local `cron` automation for the + current project, with `gpt-5.4-mini` and low reasoning. The watchdog may use the app-internal `send_message_to_thread` capability, but it must not run `inbox.sh` itself. 3. Name both jobs with the lease id so an existing pair can be updated instead of duplicated. Inspect existing Codex automation records before creating a diff --git a/tests/test_codex_monitor_lease.bats b/tests/test_codex_monitor_lease.bats index 20643cf1..27f68b68 100644 --- a/tests/test_codex_monitor_lease.bats +++ b/tests/test_codex_monitor_lease.bats @@ -152,6 +152,8 @@ arm() { run bash "$LEASE" prompt "$TEST_PROJECT" team alice thread-123 watchdog [ "$status" -eq 0 ] [[ "$output" == *"send_message_to_thread"* ]] + [[ "$output" == *"--fallback-after 0"* ]] + [[ "$output" == *"low-cost independent agmsg collector"* ]] [[ "$output" == *"must never run inbox.sh"* ]] } From 1ec0ec9f0564cf8307205ee75d7adfce8b1625d6 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Mon, 13 Jul 2026 23:38:18 +0900 Subject: [PATCH 05/12] [auto] complete event-driven Codex monitor --- README.ja.md | 15 +- README.md | 6 +- docs/codex-monitor-beta.md | 88 ++-- scripts/delivery.sh | 68 +++- scripts/drivers/types/codex/_delivery.sh | 20 +- scripts/drivers/types/codex/_session-start.sh | 14 +- scripts/drivers/types/codex/actas-monitor.sh | 184 +++++---- .../drivers/types/codex/codex-app-monitor.sh | 287 ++++++++----- .../types/codex/codex-bridge-launcher.sh | 1 - scripts/drivers/types/codex/codex-bridge.js | 16 +- scripts/drivers/types/codex/template.md | 76 ++-- scripts/reset.sh | 85 ++++ scripts/session-end.sh | 98 +++++ tests/test_codex_bridge.bats | 8 + tests/test_codex_visible_monitor.bats | 382 +++++++++++++++++- tests/test_delivery.bats | 3 +- tests/test_team.bats | 17 +- 17 files changed, 1068 insertions(+), 300 deletions(-) diff --git a/README.ja.md b/README.ja.md index b0501179..e8a5526d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -190,7 +190,9 @@ $agmsg # Codex, Gemini CLI, Antigravity `spawn ` は事前に `` を参加させ、actasのスラッシュコマンド(`/ actas `、インストールしたコマンド名に合わせる)を初期プロンプトとして対象のCLIを起動する。現在のセッションが**tmux**内であれば新しいペイン(`--window` で新しいウィンドウ、`--split h|v` で分割方向)を開き、そうでなければ新しい**OSターミナル**ウィンドウを開く。 -`--boot-prompt ` を渡すと、新しいエージェントに初期タスクを渡せる — ブートプロンプトはactasのスラッシュコマンドに続けて(改行区切りで)指定したテキストになるので、エージェントは同じ最初のターンでアイデンティティを主張し**かつ**タスクに着手する。Monitorを持たず、アイドルになった後の `send` メッセージに気づくことが決してない**codex**ピアに対してワンショットのゴールを渡す唯一の方法がこれだ。 +`--boot-prompt ` を渡すと、新しいエージェントに初期タスクを渡せる — ブートプロンプトはactasのスラッシュコマンドに続けて(改行区切りで)指定したテキストになるので、エージェントは同じ最初のターンでアイデンティティを主張し**かつ**タスクに着手する。 +これは**codex**ピアへワンショットのゴールを確実に渡す方法である。 +アイドル中に後続メッセージを受信するには、`mode monitor` への明示的なオプトインが必要になる。 デフォルトでは `spawn` は**新しいエージェントが実際にリッスンし始めるまでブロックする** — ウォッチャーがアタッチし、レディネスの目印に触れる — その後 `status=ready` を表示するので、`spawn` が返ってきた瞬間にエージェントの起動直後の空白時間を気にせず作業を送れる。フックアンドフォーゲットなら `--no-wait`、待機時間の上限を決めたいなら `--ready-timeout `(デフォルト90、タイムアウト時は `status=timeout` を表示して終了コード3、呼び出し側は再spawnできる)を使う。Codexはこの待機をスキップする(Monitorがないため)。 @@ -286,9 +288,16 @@ despawnは指定されたメンバーにのみ作用する — `despawn` を実 $agmsg — または /skills → agmsg ``` -Codexは `mode monitor` を**ベータ**のapp-serverブリッジとしてサポートし、加えて `mode turn` と `mode off` にも対応している。 +Codexは `mode monitor` を**ベータ**のイベント駆動受信としてサポートし、加えて `mode turn` と `mode off` にも対応している。 -> ⚠️ **monitorベータはCodexの起動方法を変える — 理解した上でのみオプトインすること。** CodexにはMonitorツールがないため、`mode monitor` はインタラクティブシェル内で `codex` をagmsgのmonitorシム経由にルーティングするシェル関数を表示する。monitorモードのプロジェクトでは、このシムがインタラクティブな起動を、受信したagmsgメッセージを現在のCodexスレッドのターンに変換するブリッジ経由にルーティングする。`codex exec` とmonitor対象外のプロジェクトは実物のCodexにそのまま通る。これは実験的なCodex app-serverの挙動に依存しており、既知の粗さがある(TUIを閉じるとオーファンが残る — #149、プロジェクトごとに1アイデンティティのみ — #150)。 +> ⚠️ **monitorは、1つのCodexタスクを無人で継続させる明示的なオプトインである。** +> 受信箱が空の間はシェルだけで待機し、モデルのターンを作らない。 +> 未読が届いた時だけ同じタスクを再開し、そのタスクが最初のツール呼び出しで公式の `inbox.sh` を実行する。 +> 本文の取得と既読化、実作業、検証、返信は、再開されたタスクだけが担う。 +> ACKだけのメッセージには返信せず、無限往復を防ぐ。 +> heartbeat、cron、定期ポーリングによる自動実行は作らない。 +> 既存の安全境界と承認境界は維持される。 +> 利用可能な場合は、可視のapp-serverブリッジを優先する。 グローバルなPATHシムを好むなら、`~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` を実行し、`~/.agents/bin` を実物のCodexバイナリより前にPATHに置く。`~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh` で直接起動することもできる。Codexのサンドボックスはスキルの `db/`、`teams/`、`run/` ディレクトリへの書き込みを許可する必要がある — `~/.codex/config.toml` が存在する場合、`install.sh` がその `writable_roots` を設定する。セットアップの詳細と内部動作: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md)。 diff --git a/README.md b/README.md index 924b1759..97879b28 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ Where `actas` switches *this* session to a different role, `spawn` brings up a * `spawn ` pre-joins ``, then launches the target CLI with the actas slash command (`/ actas `, matching your install command name) as its initial prompt. If the current session is inside **tmux**, it opens in a new pane (or `--window` for a new window, `--split h|v` for the direction); otherwise it opens a new **OS terminal** window. -Pass `--boot-prompt ` to hand the new agent an initial task: the boot prompt becomes the actas slash command followed (newline-separated) by your text, so the agent claims its identity **and** acts on the task in the same first turn. This is the only way to give a one-shot goal to a **codex** peer, which has no Monitor and so never notices a message you `send` after it goes idle. +Pass `--boot-prompt ` to hand the new agent an initial task: the boot prompt becomes the actas slash command followed (newline-separated) by your text, so the agent claims its identity **and** acts on the task in the same first turn. This is the reliable way to give a one-shot goal to a **codex** peer; receiving later messages while idle requires explicit `mode monitor` opt-in. By default `spawn` **blocks until the new agent is actually listening** — its watcher attaches and touches a readiness sentinel — then prints `status=ready`, so you can send work the moment `spawn` returns without losing it to the agent's cold start. Use `--no-wait` for fire-and-forget, or `--ready-timeout ` to bound the wait (default 90; on timeout it prints `status=timeout` and exits 3 so a caller can re-spawn). Codex skips the wait (it has no Monitor). @@ -300,9 +300,9 @@ The command updates `db/config.yaml`, rewrites the project's hook entries, and p $agmsg — or /skills → agmsg ``` -Codex supports `mode monitor` as a **beta** app-server bridge, plus `mode turn` and `mode off`. +Codex supports `mode monitor` as a **beta** event-driven receiver, plus `mode turn` and `mode off`. -> ⚠️ **The monitor beta changes how Codex starts — opt in only if you understand it.** Codex has no Monitor tool, so `mode monitor` prints a shell function that makes `codex` route through agmsg's monitor shim in your interactive shell. In monitor-mode projects the shim routes interactive launches through a bridge that turns incoming agmsg messages into turns on the current Codex thread; `codex exec` and non-monitor projects pass straight through to the real Codex. Codex Desktop can also attach an opt-in 15-minute thread heartbeat plus an independent one-minute `gpt-5.4-mini` project collector, so a visible session can recover delivery without paying for a full thread-model turn every minute. The collector only detects unread high-water marks and never consumes inbox bodies itself. It depends on experimental Codex app-server and app automation behavior and has known rough edges (orphans on TUI close — #149; one identity per project — #150). +> ⚠️ **Monitor is explicit opt-in for unattended continuation of one persisted Codex task.** A shell-only watcher costs no model turn while the inbox is empty. On unread mail it resumes the same task, whose first tool call is the official `inbox.sh`; only that task reads or marks the message, continues substantive work, verifies it, and replies. ACK-only mail is not answered, preventing ping-pong. No heartbeat, cron, or scheduled polling task is created. Existing safety and approval boundaries still apply. A visible app-server bridge remains the preferred path when available. If you prefer a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 95c7fa7e..96d18bfc 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -1,26 +1,25 @@ # Codex Monitor Beta -Codex does not expose Claude Code's Monitor tool. agmsg's Codex monitor beta -uses a visible app-server bridge when available. In Codex Desktop, an opt-in -low-frequency thread heartbeat renews the session lease and an independent -low-cost project collector checks unread state and wakes the visible thread. -The Stop hook remains the final visible fallback. The last explicit `actas` -role is rebound from SessionStart after restart or compaction. +Codex does not expose Claude Code's Monitor tool. agmsg's Codex monitor beta is +an explicit opt-in, event-driven receiver bound to one persisted Codex thread. +A shell-only watcher waits for unread state without starting a model. When mail +arrives, it resumes that same thread once; the resumed thread runs the official +`inbox.sh`, continues substantive work, verifies it, and replies when needed. +A visible app-server bridge is still preferred when available. The last +explicit `actas` role is rebound from SessionStart after restart or compaction. > ⚠️ **Experimental beta — read before enabling.** This changes how Codex starts. -> Enabling monitor mode prints a shell function that makes `codex` route through -> agmsg's monitor shim in your interactive shell. In monitor-mode projects the -> shim re-routes interactive launches through an app-server bridge; everywhere -> else it passes straight through. **Only enable this if you are comfortable with -> the `codex` command being intercepted in that shell.** Ordinary Codex.app does -> not require the shim; it uses the visible Stop-hook fallback. It also depends on Codex -> app-server behavior and may break as Codex changes. Known rough edges: -> enabling monitor takes effect only after you **restart Codex and send your -> first message** — the SessionStart hook fires on the first turn, not the -> moment Codex opens, so the bridge is absent until you interact once; the -> bridge is not torn down when you close the TUI (orphans linger until reboot -> or `mode off`/manual kill, see #149). Multiple registered identities are -> supported by restoring the most recent explicit `actas` role. +> Enabling monitor mode permits unread agmsg mail to resume the selected Codex +> thread in the background. **Only enable it for a role and thread whose +> existing scope is safe to continue unattended.** The receiver preserves the +> thread's approval, production, customer-data, secret, and external-mutation +> boundaries. Enabling also prints an optional shell function that routes +> interactive launches through the app-server bridge. A concrete thread id +> supplied by `actas` binds immediately. After a Codex restart, SessionStart +> rebinds the role on the first turn because opening the app alone does not fire +> that hook. SessionEnd stops the receiver for the ending thread. Multiple +> registered identities are supported by restoring the most recent explicit +> `actas` role. ## Quick Start @@ -35,19 +34,15 @@ The command: 1. Enables Codex SessionStart/SessionEnd hooks plus the visible Stop-hook fallback for the project. 2. Persists the last explicit `actas` role so SessionStart can rebind it. -3. When Codex app automation is available, arms a 15-minute session-scoped - heartbeat and a one-minute `gpt-5.4-mini` collector. Both are keyed by - project, team, role, and thread id. +3. After `actas` binds a concrete thread, starts a persistent shell-only watcher + for that exact team/role/thread tuple. 4. Prints a shell function that routes interactive `codex` launches through the monitor shim. -Headless `codex exec resume` handling is disabled by default because it can -consume and answer messages outside the visible Codex thread. It remains -available only as a legacy explicit opt-in: - -```bash -export AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1 -``` +Monitor mode itself is the explicit opt-in for background thread resumption. +`turn` and `off` never start it. The watcher only observes unread count and +high-water id; it never reads message bodies or marks messages read. The same +persisted thread owns the official inbox read and any reply. The Codex sandbox must allow writes to the installed skill's runtime state: @@ -78,17 +73,15 @@ In monitor-mode projects, the function routes interactive Codex launches through the bridge. Outside monitor-mode projects, it passes through to the real Codex. When Codex.app is opened normally, SessionStart restores the last `actas` role. -If no visible app-server is available, the one-minute low-cost collector uses -unread-only detection and the Codex app's visible thread wake capability. The -15-minute heartbeat renews the session lease and provides a second recovery -path without paying for a full thread turn every minute. Neither automation -marks a message read; the target thread owns `inbox.sh`. Repeated wakes for the -same unread high-water mark are suppressed for a bounded retry window. - -`mode off`, `mode turn`, `drop`, and SessionEnd deactivate the lease before the -scheduled jobs are removed. A late watchdog run therefore cannot wake a thread -after monitoring has been stopped. Sessions that never opt into monitor mode do -not create scheduled jobs. +If no visible app-server is available, `watch-once.sh` remains blocked in a +shell process until unread mail exists, then invokes `codex exec resume` for the +same thread id. Empty inboxes therefore create no model turn and no new Codex +task. Repeated failure to consume the same unread high-water mark stops active +delivery and leaves the message unread. + +`mode off`, `mode turn`, `drop`/`reset`, and SessionEnd stop the matching +receiver and remove its LaunchAgent/runtime files. No cron, heartbeat, or +scheduled polling automation is created for this path. ## Optional PATH Shim @@ -168,9 +161,11 @@ connect to the unix socket (EPERM). Instead: sandbox, reads the request file and starts `codex-bridge.js`. 3. The bridge connects to the same app-server over **WebSocket-over-UDS**, resumes the thread, and arms `watch-once.sh` via the app-server `process/spawn` - API (which polls the agmsg DB for unread rows, `read_at IS NULL`). -4. On an unread message it inlines the text into a `turn/start` on that thread — - surfacing it in the live Codex TUI — then re-arms after the turn ends. + API (which checks the agmsg DB for unread rows, `read_at IS NULL`). +4. On unread state it starts a turn on that thread with an instruction to run + the official `inbox.sh`. The bridge does not read the message body or mark it + read on the normal path. The visible Codex turn owns reading, substantive + work, verification, and any reply, then the bridge re-arms after the turn. Turns are serialized (one per thread): a message that arrives while a turn is running stays unread and is delivered after the turn completes. The turn ends @@ -202,10 +197,11 @@ flowchart TD watch --> db[("agmsg SQLite DB (read_at IS NULL)")] db --> unread{"Unread message?"} unread -- "no (timeout)" --> watch - unread -- "yes" --> inbox["inline unread inbox text"] - inbox --> turn["turn/start on the thread"] + unread -- "yes" --> turn["turn/start with official inbox instruction"] turn --> tui["Current Codex TUI thread"] - tui --> ended["turn ends: completed / idle / watchdog"] + tui --> inbox["official inbox.sh reads and marks messages"] + inbox --> work["substantive work, verification, and reply"] + work --> ended["turn ends: completed / idle / watchdog"] ended --> watch ``` diff --git a/scripts/delivery.sh b/scripts/delivery.sh index c3fd3f52..d3a18799 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -325,9 +325,42 @@ EOF # keyed per project, so `off` should not leave it running). Used by `set off # codex` and by Codex turn fallback cleanup. The global shim is left alone (it # is cross-project). Echoes how many receiver processes were killed. +wait_for_recorded_pid_exit() { + local pid="$1" check=0 state + while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac + sleep 0.1 + check=$((check + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || return 1 + check=0 + while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac + sleep 0.1 + check=$((check + 1)) + done + fi + ! kill -0 "$pid" 2>/dev/null +} + +bootout_recorded_label() { + local label="$1" check=0 domain + [ -n "$label" ] || return 0 + command -v launchctl >/dev/null 2>&1 || return 0 + domain="gui/$(id -u)" + launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + while [ "$check" -lt 20 ] && launchctl print "$domain/$label" >/dev/null 2>&1; do + sleep 0.1 + check=$((check + 1)) + done +} + stop_codex_bridge() { local project="$1" - local pairs team name pidfile bpid killed=0 app_pidfile app_pid app_meta app_label + local pairs team name pidfile bpid killed=0 app_pidfile app_pid app_meta app_label app_plist pairs=$("$SCRIPT_DIR/identities.sh" "$project" codex 2>/dev/null || true) if [ -n "$pairs" ]; then while IFS=$'\t' read -r team name _rest; do @@ -336,7 +369,13 @@ stop_codex_bridge() { if [ -f "$pidfile" ]; then bpid=$(cat "$pidfile" 2>/dev/null || true) if [ -n "$bpid" ] && kill -0 "$bpid" 2>/dev/null; then - kill "$bpid" 2>/dev/null && killed=$((killed + 1)) + if kill "$bpid" 2>/dev/null; then + killed=$((killed + 1)) + if ! wait_for_recorded_pid_exit "$bpid"; then + echo "delivery: Codex bridge pid $bpid did not stop; preserving its run files" >&2 + return 1 + fi + fi fi fi # .appserver records which app-server URL the bridge was bound to (the @@ -346,19 +385,32 @@ stop_codex_bridge() { app_pidfile="$RUN_DIR/codex-app-monitor.$team.$name.pid" app_meta="$RUN_DIR/codex-app-monitor.$team.$name.meta" - if [ -f "$app_meta" ] && command -v launchctl >/dev/null 2>&1; then + app_plist="$RUN_DIR/codex-app-monitor.$team.$name.plist" + app_label="" + if [ -f "$app_meta" ]; then app_label=$(sed -n 's/^launch_label=//p' "$app_meta" | head -1) - if [ -n "$app_label" ]; then - launchctl bootout "gui/$(id -u)/$app_label" >/dev/null 2>&1 || true - fi fi + if [ -z "$app_label" ] && [ -f "$app_plist" ]; then + app_label=$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$app_plist") + fi + bootout_recorded_label "$app_label" if [ -f "$app_pidfile" ]; then app_pid=$(cat "$app_pidfile" 2>/dev/null || true) if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then - kill "$app_pid" 2>/dev/null && killed=$((killed + 1)) + if kill "$app_pid" 2>/dev/null; then + killed=$((killed + 1)) + if ! wait_for_recorded_pid_exit "$app_pid"; then + echo "delivery: Codex app monitor pid $app_pid did not stop; preserving its run files" >&2 + return 1 + fi + fi fi fi - rm -f "$app_pidfile" "$app_meta" \ + rm -f "$app_pidfile" "$app_meta" "$app_plist" \ + "$RUN_DIR/codex-app-monitor.$team.$name.log" \ + "$RUN_DIR/codex-app-monitor.$team.$name.health" \ + "$RUN_DIR/codex-app-monitor.$team.$name.preflight.log" \ + "$RUN_DIR/codex-app-monitor.$team.$name.watch-output" \ "$RUN_DIR/codex-app-monitor.$team.$name.last-prompt.txt" \ "$RUN_DIR/codex-app-monitor.$team.$name.last-message.txt" \ "$RUN_DIR/codex-app-monitor.$team.$name.last-status" \ diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh index 55d3e704..8c03c482 100644 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -3,9 +3,10 @@ # # codex keeps the default JSON event-hooks apply (agmsg_delivery_apply); it adds # enable/disable side effects (print the monitor shim setup on enable, stop the -# bridge on disable) and replaces the runtime status summary with Codex bridge -# liveness. Ordinary Codex.app fallback is chat-visible turn delivery; the -# legacy headless app monitor is opt-in only. Sourced into delivery.sh's context, +# receiver on disable) and replaces the runtime status summary with Codex +# receiver liveness. Monitor mode is explicit opt-in to event-driven resumption +# of the same persisted thread; turn/off never start a background receiver. +# Sourced into delivery.sh's context, # so SKILL_DIR, SCRIPT_DIR, RUN_DIR, agmsg_resolve_node, CODEX_MONITOR_DOC_URL # and stop_codex_bridge are in scope. # Args (both hooks): on_enable ; on_disable . @@ -30,6 +31,8 @@ agmsg_delivery_status() { agmsg_delivery_on_enable() { echo "Codex monitor beta is enabled." + echo "After actas binds a concrete role/thread, a shell-only watcher waits without model calls." + echo "Unread mail resumes that same Codex thread, which owns inbox reading and replies." echo "Add this shell function to your interactive shell profile, then restart the shell:" if "$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" function; then echo "Future Codex sessions: launch with codex. In monitor-mode projects, the agmsg function routes interactive Codex sessions through the bridge." @@ -48,9 +51,8 @@ agmsg_delivery_on_enable() { echo "WARNING: Node.js ('$codex_node') was not found. The Codex bridge needs Node —" echo " monitor delivery will NOT start until Node is installed (or set AGMSG_NODE)." fi - echo "Restart your Codex session (quit and relaunch \`codex\`), then send your first" - echo " message — the bridge starts on your first turn, not the moment Codex opens." - echo " Already-running sessions stay unmonitored until they restart." + echo "Run agmsg actas in the intended Codex task to bind the receiver now." + echo "SessionStart rebinds the last role after a later restart." echo "For more info: $CODEX_MONITOR_DOC_URL" } @@ -61,6 +63,7 @@ agmsg_delivery_on_disable() { if [ "${stopped:-0}" -gt 0 ]; then echo "Stopped $stopped Codex bridge process(es) for this project and cleaned their run files." fi + # Remove any legacy scheduled-receiver lease left by pre-event-driven builds. lease_cleanup=$("$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ disarm-project "$project" 2>/dev/null || true) [ -n "$lease_cleanup" ] && printf '%s\n' "$lease_cleanup" @@ -74,8 +77,7 @@ agmsg_delivery_stop_directive() { local project="${PROJECT:-}" local mode="${MODE:-}" if [ "$mode" = "turn" ] && [ -n "$project" ]; then - # The app monitor invokes `delivery.sh set turn` after repeated delivery - # failures. Let that process finish writing fallback health and chat-visible + # Let a failing receiver finish writing fallback health and chat-visible # metadata before it exits on its own. if [ "${AGMSG_CODEX_PRESERVE_CURRENT_MONITOR:-}" = "1" ]; then return 0 @@ -135,7 +137,7 @@ agmsg_delivery_runtime_status() { app_thread=$(awk -F= '/^thread=/{sub(/^thread=/, ""); print; exit}' "$app_metafile" 2>/dev/null || true) app_transport=$(awk -F= '/^transport=/{sub(/^transport=/, ""); print; exit}' "$app_metafile" 2>/dev/null || true) if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then - echo "Codex app monitor: $team/$name alive (pid $app_pid, thread $app_thread, transport ${app_transport:-codex-app-exec-resume})" + echo "Codex app monitor: $team/$name alive (pid $app_pid, thread $app_thread, transport ${app_transport:-codex-background-thread-resume})" else echo "Codex app monitor: $team/$name stale pidfile (pid ${app_pid:-empty} not running)" fi diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 9ed6fc8a..ab11c58d 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -60,6 +60,13 @@ INNER_EOF } agmsg_session_start() { + # `codex exec resume` runs the normal Codex hooks. It is already the child of + # this role's receiver, so rebinding here would stop or duplicate the parent + # receiver while it is delivering the unread batch. + if [ "${AGMSG_CODEX_BACKGROUND_RESUME:-}" = "1" ]; then + exit 0 + fi + thread_id="$(agmsg_resolve_codex_thread "$PROJECT")" [ -n "$thread_id" ] || exit 0 @@ -105,9 +112,9 @@ agmsg_session_start() { fi fi if [ -z "$app_server" ]; then - # Ordinary Codex.app has no supported external wake transport. Re-arm the - # role without starting a hidden worker; `both` mode's Stop hook performs - # the actual inbox pull in the visible conversation. + # Ordinary Codex.app has no app-server endpoint. Rebind the explicit + # monitor role to the event-driven same-thread receiver; turn/off mode is + # enforced inside actas-monitor.sh and never starts that receiver. log="$RUN_DIR/codex-actas-restore.log" "$SKILL_DIR/scripts/drivers/types/codex/actas-monitor.sh" \ "$PROJECT" "$TYPE" "$name" "$thread_id" >>"$log" 2>&1 || true @@ -151,7 +158,6 @@ agmsg_session_start() { --name "$name" \ --thread "$thread_id" \ --app-server "$app_server" \ - --inline-inbox \ >>"$log" 2>&1 & exit 0 } diff --git a/scripts/drivers/types/codex/actas-monitor.sh b/scripts/drivers/types/codex/actas-monitor.sh index 8717d271..22c10772 100755 --- a/scripts/drivers/types/codex/actas-monitor.sh +++ b/scripts/drivers/types/codex/actas-monitor.sh @@ -6,13 +6,10 @@ set -euo pipefail # Usage: # actas-monitor.sh [session_id] # -# Codex has no Monitor tool. Monitor mode is implemented by a bridge process -# that watches one agmsg identity and wakes a Codex thread. Sessions launched -# through codex-monitor.sh use app-server and can report in the visible chat. -# Ordinary Codex.app sessions must not fall back to headless `codex exec resume` -# unless AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1 is explicitly set. -# actas must therefore start or rebind the visible receiver, otherwise sends use -# the new identity while receives keep watching the old one. +# Codex has no native Monitor tool. An explicitly selected monitor/both mode +# binds one agmsg identity to a persisted Codex thread. A live app-server bridge +# is preferred; otherwise a shell-only watcher resumes that same thread only +# when unread mail exists. turn/off never start the background receiver. PROJECT="${1:?Usage: actas-monitor.sh [session_id]}" TYPE="${2:?Missing type}" @@ -27,6 +24,8 @@ RUN_DIR="$SKILL_DIR/run" source "$SCRIPT_DIR/../../../lib/hash.sh" # shellcheck source=../../../lib/node.sh source "$SCRIPT_DIR/../../../lib/node.sh" +# shellcheck source=../../../lib/compat.sh +source "$SCRIPT_DIR/../../../lib/compat.sh" # shellcheck source=../../../lib/resolve-project.sh source "$SCRIPT_DIR/../../../lib/resolve-project.sh" # shellcheck source=../../../lib/actas-lock.sh @@ -80,15 +79,8 @@ record_last_actas() { record_last_actas -# Codex uses both mode for persistent SessionStart rebind plus a visible Stop -# hook fallback. This keeps monitoring armed across restarts without allowing a -# headless worker to consume or answer messages outside the visible thread. MODE="$("$SCRIPT_DIR/../../../delivery.sh" status "$TYPE" "$PROJECT" 2>/dev/null \ | sed -n 's/^mode: //p')" -case "$MODE" in - monitor|both) ;; - *) "$SCRIPT_DIR/../../../delivery.sh" set both "$TYPE" "$PROJECT" >/dev/null ;; -esac resolve_thread_id() { if [ -n "${CODEX_THREAD_ID:-}" ]; then @@ -148,7 +140,7 @@ NODE } kill_other_project_receivers() { - local meta pidfile pid meta_project meta_type meta_team meta_name + local meta pidfile meta_project meta_type meta_team meta_name for meta in "$RUN_DIR"/codex-bridge.*.meta "$RUN_DIR"/codex-app-monitor.*.meta; do [ -f "$meta" ] || continue meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" @@ -159,30 +151,64 @@ kill_other_project_receivers() { [ "$meta_type" = "$TYPE" ] || continue [ "$meta_team" = "$TEAM" ] && [ "$meta_name" = "$NAME" ] && continue pidfile="${meta%.meta}.pid" - [ -f "$pidfile" ] || continue - pid="$(cat "$pidfile" 2>/dev/null || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - kill "$pid" 2>/dev/null || true - fi - rm -f "$pidfile" "$meta" + kill_receiver_files "$pidfile" "$meta" done } +wait_for_pid_exit() { + local pid="$1" check=0 state + while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac + sleep 0.1 + check=$((check + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || return 1 + check=0 + while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac + sleep 0.1 + check=$((check + 1)) + done + fi + ! kill -0 "$pid" 2>/dev/null +} + kill_receiver_files() { - local pidfile="$1" meta="$2" pid - local label - if [ -f "$meta" ] && command -v launchctl >/dev/null 2>&1; then + local pidfile="$1" meta="$2" pid="" cmd="" label="" kind base plist + base="${pidfile%.pid}" + kind="${base##*/}" + plist="$base.plist" + if [ -f "$meta" ]; then label="$(sed -n 's/^launch_label=//p' "$meta" | head -1)" - if [ -n "$label" ]; then - bootout_label_and_wait "gui/$(id -u)" "$label" - fi fi - [ -f "$pidfile" ] || return 0 - pid="$(cat "$pidfile" 2>/dev/null || true)" + if [ -z "$label" ] && [ -f "$plist" ]; then + label="$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$plist")" + fi + if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then + bootout_label_and_wait "gui/$(id -u)" "$label" + fi + if [ -f "$pidfile" ]; then + pid="$(cat "$pidfile" 2>/dev/null || true)" + fi if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - kill "$pid" 2>/dev/null || true + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case "$kind:$cmd" in + codex-bridge.*:*codex-bridge.js*|codex-app-monitor.*:*codex-app-monitor.sh*) + kill "$pid" 2>/dev/null || true + if ! wait_for_pid_exit "$pid"; then + echo "actas-monitor: receiver pid $pid did not stop; preserving its run files" >&2 + return 1 + fi + ;; + esac fi - rm -f "$pidfile" "$meta" + rm -f "$pidfile" "$meta" "$base.appserver" "$base.log" "$plist" \ + "$base.health" "$base.preflight.log" "$base.last-prompt.txt" \ + "$base.last-message.txt" "$base.last-status" "$base.last-ids" \ + "$base.watch-output" } xml_escape() { @@ -219,6 +245,8 @@ write_codex_app_monitor_plist() { local codex_bin path_value if [ -n "${AGMSG_CODEX_APP_MONITOR_CODEX:-}" ]; then codex_bin="$AGMSG_CODEX_APP_MONITOR_CODEX" + elif [ -x "/Applications/ChatGPT.app/Contents/Resources/codex" ]; then + codex_bin="/Applications/ChatGPT.app/Contents/Resources/codex" elif [ -x "/Applications/Codex.app/Contents/Resources/codex" ]; then codex_bin="/Applications/Codex.app/Contents/Resources/codex" elif [ -x "$HOME/.npm-global/bin/codex" ]; then @@ -254,8 +282,10 @@ write_codex_app_monitor_plist() { $(plist_string "$label") AGMSG_CODEX_APP_MONITOR_CODEX $(plist_string "${codex_bin:-codex}") - AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR - $(plist_string "${AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR:-}") + AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME + 1 + AGMSG_CODEX_APP_MONITOR_SUPERVISED + 1 AGMSG_CODEX_APP_MONITOR_TIMEOUT $(plist_string "${AGMSG_CODEX_APP_MONITOR_TIMEOUT:-}") AGMSG_CODEX_APP_MONITOR_INTERVAL @@ -278,7 +308,12 @@ write_codex_app_monitor_plist() { RunAtLoad KeepAlive - + + SuccessfulExit + + + ThrottleInterval + 2 EOF @@ -291,8 +326,7 @@ start_codex_app_monitor() { local health_status ready_seconds ready_checks check if [ -z "$thread_id" ]; then actas_lock_gc_stale >/dev/null 2>&1 || true - echo "status=no_thread team=$TEAM name=$NAME reason=codex_app_thread_id_unavailable" - exit 8 + start_chat_visible_turn_delivery "" "codex_app_thread_id_unavailable" fi kill_other_project_receivers @@ -309,8 +343,8 @@ start_codex_app_monitor() { if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then existing_thread="$(sed -n 's/^thread=//p' "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" 2>/dev/null | head -1)" existing_health="$(sed -n 's/^status=//p' "$health" 2>/dev/null | head -1 || true)" - if [ "$existing_thread" = "$thread_id" ] && [ "$existing_health" = "ready" ]; then - echo "status=already_running team=$TEAM name=$NAME app_monitor_pid=$existing_pid thread=$thread_id transport=codex-app-exec-resume health=ready" + if [ "$existing_thread" = "$thread_id" ]; then + echo "status=already_running team=$TEAM name=$NAME app_monitor_pid=$existing_pid thread=$thread_id transport=codex-background-thread-resume health=${existing_health:-unknown}" exit 0 fi kill_receiver_files "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" @@ -327,7 +361,8 @@ start_codex_app_monitor() { start_chat_visible_turn_delivery "$thread_id" "app_monitor_launchctl_bootstrap_failed" fi else - nohup "$SCRIPT_DIR/codex-app-monitor.sh" "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$thread_id" >>"$log" 2>&1 & + nohup env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + "$SCRIPT_DIR/codex-app-monitor.sh" "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$thread_id" >>"$log" 2>&1 & fi ready_seconds="${AGMSG_CODEX_APP_MONITOR_READY_SECONDS:-5}" @@ -339,7 +374,7 @@ start_codex_app_monitor() { app_monitor_pid="$(cat "$pidfile" 2>/dev/null || true)" health_status="$(sed -n 's/^status=//p' "$health" 2>/dev/null | head -1 || true)" if [ "$health_status" = "ready" ] && [ -n "$app_monitor_pid" ] && kill -0 "$app_monitor_pid" 2>/dev/null; then - echo "status=ok team=$TEAM name=$NAME app_monitor_pid=$app_monitor_pid thread=$thread_id transport=codex-app-exec-resume health=ready" + echo "status=ok team=$TEAM name=$NAME app_monitor_pid=$app_monitor_pid thread=$thread_id transport=codex-background-thread-resume health=ready" exit 0 fi case "$health_status" in preflight_failed|fallback_failed) break ;; esac @@ -358,7 +393,7 @@ start_codex_app_monitor() { start_chat_visible_turn_delivery() { local thread_id="$1" - local reason="${2:-headless_app_monitor_disabled}" + local reason="${2:-foreground_turn_mode}" local meta="$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" kill_other_project_receivers @@ -376,14 +411,6 @@ start_chat_visible_turn_delivery() { printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > "$meta" - if [ -n "$thread_id" ] && [ "$thread_id" != "loaded" ] && [ "$thread_id" != "new" ]; then - # Arm the session-scoped scheduler fallback. The host agent reads this - # status and creates the Codex heartbeat/watchdog automations; the shell - # helper itself never calls app-internal tools or consumes the inbox. - "$SCRIPT_DIR/codex-monitor-lease.sh" arm \ - "$PROJECT" "$TEAM" "$NAME" "$thread_id" || true - fi - echo "status=visible_turn_only team=$TEAM name=$NAME thread=${thread_id:-unresolved} transport=codex-chat-visible-turn reason=$reason" exit 0 } @@ -393,10 +420,24 @@ if [ -z "$THREAD_ID" ]; then THREAD_ID="$(resolve_thread_id || true)" fi -if [ -n "$THREAD_ID" ] && [ "$THREAD_ID" != "loaded" ] && [ "$THREAD_ID" != "new" ]; then - "$SCRIPT_DIR/codex-monitor-lease.sh" arm \ - "$PROJECT" "$TEAM" "$NAME" "$THREAD_ID" || true -fi +case "$MODE" in + monitor|both) + ;; + turn) + start_chat_visible_turn_delivery "$THREAD_ID" "foreground_turn_mode" + ;; + off|"") + kill_other_project_receivers + kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + echo "status=receive_disabled team=$TEAM name=$NAME mode=${MODE:-off}" + exit 0 + ;; + *) + echo "status=invalid_mode team=$TEAM name=$NAME mode=$MODE" >&2 + exit 4 + ;; +esac port_alive() { local port="$1" @@ -404,7 +445,6 @@ port_alive() { } PROJECT_HASH="$(agmsg_sha1 <<<"$PROJECT")" -SERVER_LOG="$RUN_DIR/codex-app-server.$PROJECT_HASH.log" SERVER_PID="$RUN_DIR/codex-app-server.$PROJECT_HASH.pid" PORT_FILE="$RUN_DIR/codex-app-server.$PROJECT_HASH.port" @@ -419,9 +459,6 @@ if [ -z "$APP_SERVER" ] && [ -f "$PORT_FILE" ] && [ -f "$SERVER_PID" ]; then fi if [ -z "$APP_SERVER" ]; then - if [ "${AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR:-}" != "1" ]; then - start_chat_visible_turn_delivery "$THREAD_ID" - fi start_codex_app_monitor "$THREAD_ID" fi @@ -452,7 +489,6 @@ start_bridge() { --team "$TEAM" --name "$NAME" --app-server "$APP_SERVER" - --inline-inbox ) if [ -n "$thread_id" ]; then args+=(--thread "$thread_id") @@ -466,18 +502,16 @@ BRIDGE_PID="$(start_bridge "$THREAD_ID")" sleep 0.8 if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then # Codex Desktop rollouts can be unreadable to a standalone app-server while - # the Desktop owns them. Fall back to a new bridge-owned thread instead of - # leaving actas with no receive side. + # the Desktop owns them. Never retry without a thread: thread/start would + # create a new Codex task. Preserve a concrete thread through the explicit + # background receiver, or fall back to foreground turn delivery when the + # loaded thread could not be resolved. rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" - printf 'actas-monitor: retrying without thread attach after failed thread=%s\n' "$THREAD_ID" >>"$BRIDGE_LOG" - THREAD_ID="new" - BRIDGE_PID="$(start_bridge "")" - sleep 0.8 -fi - -if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then - echo "status=bridge_failed team=$TEAM name=$NAME log=$BRIDGE_LOG" - exit 5 + printf 'actas-monitor: thread attach failed; refusing thread/start for thread=%s\n' "$THREAD_ID" >>"$BRIDGE_LOG" + case "$THREAD_ID" in + ""|loaded) start_chat_visible_turn_delivery "$THREAD_ID" "app_server_thread_attach_failed" ;; + *) start_codex_app_monitor "$THREAD_ID" ;; + esac fi READY_SECONDS="${AGMSG_CODEX_ACTAS_READY_SECONDS:-8}" @@ -485,8 +519,10 @@ sleep "$READY_SECONDS" if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" actas_lock_gc_stale >/dev/null 2>&1 || true - echo "status=bridge_exited team=$TEAM name=$NAME log=$BRIDGE_LOG" - exit 5 + case "$THREAD_ID" in + ""|loaded) start_chat_visible_turn_delivery "$THREAD_ID" "app_server_bridge_exited" ;; + *) start_codex_app_monitor "$THREAD_ID" ;; + esac fi case "$APP_SERVER" in ws://127.0.0.1:*) @@ -495,8 +531,10 @@ case "$APP_SERVER" in if ! port_alive "$check_port"; then rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" actas_lock_gc_stale >/dev/null 2>&1 || true - echo "status=app_server_exited team=$TEAM name=$NAME app_server=$APP_SERVER log=$SERVER_LOG" - exit 6 + case "$THREAD_ID" in + ""|loaded) start_chat_visible_turn_delivery "$THREAD_ID" "app_server_exited" ;; + *) start_codex_app_monitor "$THREAD_ID" ;; + esac fi ;; esac diff --git a/scripts/drivers/types/codex/codex-app-monitor.sh b/scripts/drivers/types/codex/codex-app-monitor.sh index a11585f4..4a14b9b5 100755 --- a/scripts/drivers/types/codex/codex-app-monitor.sh +++ b/scripts/drivers/types/codex/codex-app-monitor.sh @@ -1,20 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -# Legacy headless fallback for sessions opened directly in Codex.app. +# Event-driven background receiver for an explicitly monitored Codex thread. # -# This script wakes a Codex thread through `codex exec resume`, which does not -# report progress in the active Codex.app chat. It is therefore disabled by -# default. Use the app-server bridge for visible real-time delivery, or turn -# delivery for chat-visible handling at turn boundaries. +# The shell-only watcher does not start a model while the inbox is empty. When +# unread mail appears it resumes the same persisted Codex thread, whose first +# action is the official inbox.sh command. The receiver never reads message +# bodies and never marks them read itself. usage() { cat < Environment: - AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1 - opt in to this legacy headless fallback + AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 + required explicit monitor opt-in AGMSG_CODEX_APP_MONITOR_TIMEOUT watch-once timeout seconds (default: 300) AGMSG_CODEX_APP_MONITOR_INTERVAL watch-once poll interval seconds (default: 2) AGMSG_CODEX_APP_MONITOR_MAX_WAKES stop after N wakes, useful for tests @@ -23,6 +23,10 @@ Environment: consecutive failures (default: 3) AGMSG_CODEX_APP_MONITOR_FLAGS extra flags passed to "codex exec" AGMSG_CODEX_APP_MONITOR_CODEX codex binary override + AGMSG_CODEX_APP_MONITOR_DANGEROUS_BYPASS=1 + legacy escape hatch; disabled by default + AGMSG_CODEX_APP_MONITOR_SUPERVISED=1 restart unexpected exits through launchd; + terminal fallback still exits successfully EOF } @@ -31,10 +35,9 @@ if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then exit 0 fi -if [ "${AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR:-}" != "1" ]; then - echo "codex-app-monitor: disabled; it handles messages via headless 'codex exec resume'." >&2 - echo "codex-app-monitor: use the Codex app-server bridge or turn delivery for visible chat status." >&2 - echo "codex-app-monitor: set AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1 only for legacy opt-in runs." >&2 +if [ "${AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME:-${AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR:-}}" != "1" ]; then + echo "codex-app-monitor: disabled; background thread resume requires explicit monitor opt-in." >&2 + echo "codex-app-monitor: use mode monitor to opt in, or turn delivery for foreground-only handling." >&2 exit 64 fi @@ -72,6 +75,10 @@ resolve_codex_bin() { printf '%s\n' "$AGMSG_CODEX_APP_MONITOR_CODEX" return 0 fi + if [ -x "/Applications/ChatGPT.app/Contents/Resources/codex" ]; then + printf '%s\n' "/Applications/ChatGPT.app/Contents/Resources/codex" + return 0 + fi if [ -x "/Applications/Codex.app/Contents/Resources/codex" ]; then printf '%s\n' "/Applications/Codex.app/Contents/Resources/codex" return 0 @@ -85,6 +92,8 @@ resolve_codex_bin() { CODEX_BIN="$(resolve_codex_bin)" OWNER_ID="agmsg-codex-app-monitor-$$.$$" +ACTIVE_CHILD="" +LOCK_CLAIMED=0 case "$TIMEOUT" in ''|*[!0-9]*) echo "codex-app-monitor: timeout must be a whole number" >&2; exit 2 ;; esac case "$INTERVAL" in ''|*[!0-9]*) echo "codex-app-monitor: interval must be a whole number" >&2; exit 2 ;; esac @@ -99,9 +108,9 @@ LOG="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.log" LAST_PROMPT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-prompt.txt" LAST_OUTPUT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-message.txt" LAST_STATUS="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-status" -LAST_IDS="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-ids" HEALTH="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.health" PREFLIGHT_LOG="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.preflight.log" +WATCH_OUTPUT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.watch-output" CHAT_META="$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" consecutive_failures=0 @@ -116,7 +125,7 @@ write_health() { printf 'team=%s\n' "$TEAM" printf 'name=%s\n' "$NAME" printf 'thread=%s\n' "$THREAD_ID" - printf 'transport=codex-app-exec-resume\n' + printf 'transport=codex-background-thread-resume\n' printf 'status=%s\n' "$status" printf 'consecutive_failures=%s\n' "$consecutive_failures" printf 'last_error=%s\n' "$last_error" @@ -162,34 +171,62 @@ fallback_to_turn() { # SessionStart hook and prevent automatic rebind after the next restart. write_chat_visible_meta write_health "fallback_turn" "$reason" - return 70 + return "$(terminal_exit_code 70)" } -if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then - write_health "preflight_failed" "codex_binary_not_found" - echo "codex-app-monitor: codex binary not found: $CODEX_BIN" >&2 - exit 3 -fi +terminal_exit_code() { + if [ "${AGMSG_CODEX_APP_MONITOR_SUPERVISED:-}" = "1" ]; then + printf '0\n' + else + printf '%s\n' "$1" + fi +} -write_health "starting" "none" -if ! "$CODEX_BIN" mcp list >"$PREFLIGHT_LOG" 2>&1; then - write_health "preflight_failed" "codex_config_invalid" - notify_delivery_failure "自動配送を開始できません。未読メッセージは保持しています。" - echo "codex-app-monitor: codex config preflight failed; see $PREFLIGHT_LOG" >&2 - exit 78 -fi -rm -f "$PREFLIGHT_LOG" -write_health "ready" "none" +exit_after_fallback() { + local status=0 + fallback_to_turn "$1" || status=$? + exit "$status" +} + +stop_active_child() { + local child="${ACTIVE_CHILD:-}" check=0 + [ -n "$child" ] || return 0 + if kill -0 "$child" 2>/dev/null; then + kill "$child" 2>/dev/null || true + while [ "$check" -lt 20 ] && kill -0 "$child" 2>/dev/null; do + sleep 0.1 + check=$((check + 1)) + done + if kill -0 "$child" 2>/dev/null; then + kill -KILL "$child" 2>/dev/null || true + fi + fi + wait "$child" 2>/dev/null || true + ACTIVE_CHILD="" +} cleanup() { - actas_lock_release "$TEAM" "$NAME" "$OWNER_ID" 2>/dev/null || true - rm -f "$PIDFILE" "$META" + stop_active_child + if [ "$LOCK_CLAIMED" = "1" ]; then + actas_lock_release "$TEAM" "$NAME" "$OWNER_ID" 2>/dev/null || true + fi + if [ "$(cat "$PIDFILE" 2>/dev/null || true)" = "$$" ]; then + rm -f "$PIDFILE" "$META" + fi + rm -f "$WATCH_OUTPUT" } terminate() { write_health "stopped" "terminated" + trap - EXIT cleanup exit 0 } + +if ! lock_result="$(actas_lock_claim "$TEAM" "$NAME" "$OWNER_ID" 2>&1)"; then + echo "codex-app-monitor: receiver already owns $TEAM/$NAME (${lock_result:-held})" >&2 + exit "$(terminal_exit_code 73)" +fi +LOCK_CLAIMED=1 trap cleanup EXIT trap terminate INT TERM @@ -200,54 +237,56 @@ printf '%s\n' "$$" > "$PIDFILE" printf 'team=%s\n' "$TEAM" printf 'name=%s\n' "$NAME" printf 'thread=%s\n' "$THREAD_ID" - printf 'transport=codex-app-exec-resume\n' + printf 'transport=codex-background-thread-resume\n' printf 'owner=%s\n' "$OWNER_ID" if [ -n "${AGMSG_CODEX_APP_MONITOR_LABEL:-}" ]; then printf 'launch_label=%s\n' "$AGMSG_CODEX_APP_MONITOR_LABEL" fi } > "$META" +if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then + write_health "preflight_failed" "codex_binary_not_found" + echo "codex-app-monitor: codex binary not found: $CODEX_BIN" >&2 + exit "$(terminal_exit_code 3)" +fi + +write_health "starting" "none" +if ! "$CODEX_BIN" mcp list >"$PREFLIGHT_LOG" 2>&1; then + write_health "preflight_failed" "codex_config_invalid" + notify_delivery_failure "自動配送を開始できません。未読メッセージは保持しています。" + echo "codex-app-monitor: codex config preflight failed; see $PREFLIGHT_LOG" >&2 + exit "$(terminal_exit_code 78)" +fi +rm -f "$PREFLIGHT_LOG" +write_health "ready" "none" + build_prompt() { - local inbox_text="$1" + local inbox_script="$SKILL_DIR/scripts/inbox.sh" local send_script="$SKILL_DIR/scripts/send.sh" cat < -Visible UI requirement: -1. Before the first tool call, post a short Japanese progress update in the - Codex thread UI starting with "agmsg対応状況:" and include sender, summary, - planned action, and whether you will reply. -2. Keep substantive work in the visible thread. Before each major action, post - a short Japanese progress update; do not complete the task in an unreported - background worker. -3. After handling the message, post a final Japanese status update with: - sender, received instruction, action taken, reply target, reply summary, - remaining blocker, and next step. -4. If you do not reply, state why in the visible status. -5. Do not treat DB writes, monitor delivery, or a send.sh result as complete - unless the handling is visible in the Codex thread UI. +Autonomous collaboration contract: +1. For a substantive request, review, or new evidence, continue the requested + work through verification and reply with concrete evidence. Do not stop at + an ACK when safe in-scope work remains. +2. Do not reply to ACK-only, thanks-only, or status-only mail that contains no + new request, evidence, correction, or blocker. This prevents reply loops. +3. Preserve all existing safety, approval, production, customer-data, secret, + and scope boundaries. Stop and report a real blocker when new authority or + an unsafe/external mutation would be required. +4. Keep the handling visible in this same Codex thread with concise Japanese + progress and a final status. Never claim completion from delivery alone. EOF - } - -sql_escape() { - printf '%s' "$1" | sed "s/'/''/g" -} - -read_unread_for_prompt() { - : > "$LAST_IDS" - "$SKILL_DIR/scripts/inbox-peek.sh" "$TEAM" "$NAME" --quiet --ids-file "$LAST_IDS" - [ -s "$LAST_IDS" ] || return 1 -} - -mark_delivered_ids_read() { - [ -f "$LAST_IDS" ] || return 0 - "$SKILL_DIR/scripts/mark-read.sh" "$TEAM" "$NAME" --ids-file "$LAST_IDS" } run_resume() { @@ -256,11 +295,15 @@ run_resume() { local child wait_status cmd=("$CODEX_BIN" exec) - # Non-interactive wakeups cannot answer approval prompts. The default mirrors - # Codex.app's automation context; callers can override or append with - # AGMSG_CODEX_APP_MONITOR_FLAGS when they need a stricter profile. - if [ -z "${AGMSG_CODEX_APP_MONITOR_NO_BYPASS:-}" ]; then + # Background turns cannot answer approval prompts. Keep them non-interactive + # and workspace-bounded by default; the agmsg runtime dirs are added so the + # official inbox/send scripts can update their own state. The dangerous + # bypass remains an explicit compatibility escape hatch only. + if [ "${AGMSG_CODEX_APP_MONITOR_DANGEROUS_BYPASS:-}" = "1" ]; then cmd+=(--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust) + else + cmd+=(-c 'approval_policy="never"' -s workspace-write) + cmd+=(--add-dir "$SKILL_DIR/run" --add-dir "$SKILL_DIR/db" --add-dir "$SKILL_DIR/teams") fi if [ -n "${AGMSG_CODEX_APP_MONITOR_FLAGS:-}" ]; then # shellcheck disable=SC2206 @@ -274,17 +317,32 @@ run_resume() { printf ' %q' "${cmd[@]:1}" printf '\n' - ( - set +e - "${cmd[@]}" < "$prompt_file" - printf '%s\n' "$?" > "$LAST_STATUS" - ) >>"$LOG" 2>&1 & + AGMSG_CODEX_BACKGROUND_RESUME=1 \ + "${cmd[@]}" < "$prompt_file" >>"$LOG" 2>&1 & child="$!" + ACTIVE_CHILD="$child" wait "$child" wait_status=$? - if [ -s "$LAST_STATUS" ]; then - wait_status="$(cat "$LAST_STATUS" 2>/dev/null || printf '%s' "$wait_status")" - fi + ACTIVE_CHILD="" + printf '%s\n' "$wait_status" > "$LAST_STATUS" + return "$wait_status" +} + +run_watch_once() { + local timeout="$1" interval="$2" child wait_status + : > "$WATCH_OUTPUT" + "$SCRIPT_DIR/watch-once.sh" "$PROJECT" "$TYPE" \ + --team "$TEAM" \ + --name "$NAME" \ + --owner "$OWNER_ID" \ + --claim \ + --timeout "$timeout" \ + --interval "$interval" >"$WATCH_OUTPUT" 2>&1 & + child="$!" + ACTIVE_CHILD="$child" + wait "$child" + wait_status=$? + ACTIVE_CHILD="" return "$wait_status" } @@ -293,15 +351,11 @@ printf 'codex-app-monitor: started team=%s name=%s thread=%s pid=%s\n' "$TEAM" " while :; do set +e - watch_output="$("$SCRIPT_DIR/watch-once.sh" "$PROJECT" "$TYPE" \ - --team "$TEAM" \ - --name "$NAME" \ - --owner "$OWNER_ID" \ - --claim \ - --timeout "$TIMEOUT" \ - --interval "$INTERVAL" 2>&1)" + run_watch_once "$TIMEOUT" "$INTERVAL" watch_status=$? set -e + watch_output="$(cat "$WATCH_OUTPUT" 2>/dev/null || true)" + rm -f "$WATCH_OUTPUT" case "$watch_status" in 0) @@ -314,19 +368,26 @@ while :; do write_health "degraded" "watch_once_exit_${watch_status}" printf 'codex-app-monitor: watch-once failed status=%s output=%s\n' "$watch_status" "$watch_output" >&2 if [ "$consecutive_failures" -ge "$MAX_FAILURES" ]; then - fallback_to_turn "watch_once_failed_${watch_status}" || fallback_status=$? - exit "${fallback_status:-70}" + exit_after_fallback "watch_once_failed_${watch_status}" fi sleep 5 continue ;; esac - if ! inbox_text="$(read_unread_for_prompt)"; then + pending_max_id="$(printf '%s\n' "$watch_output" | sed -n 's/.*max_id=\([0-9][0-9]*\).*/\1/p' | head -1)" + case "$pending_max_id" in ''|*[!0-9]*) pending_max_id=0 ;; esac + if [ "$pending_max_id" -le 0 ]; then + consecutive_failures=$((consecutive_failures + 1)) + write_health "degraded" "watch_once_missing_max_id" + printf 'codex-app-monitor: watch-once wake omitted a valid max_id: %s\n' "$watch_output" >&2 + if [ "$consecutive_failures" -ge "$MAX_FAILURES" ]; then + exit_after_fallback "watch_once_missing_max_id" + fi + sleep 5 continue fi - - build_prompt "$inbox_text" > "$LAST_PROMPT" + build_prompt > "$LAST_PROMPT" wake_count=$((wake_count + 1)) last_wake_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" write_health "delivering" "none" @@ -340,16 +401,48 @@ while :; do consecutive_failures=$((consecutive_failures + 1)) write_health "degraded" "codex_exec_resume_exit_${resume_status}" printf 'codex-app-monitor: codex exec resume failed status=%s prompt=%s output=%s\n' "$resume_status" "$LAST_PROMPT" "$LAST_OUTPUT" >&2 - if [ "$consecutive_failures" -ge "$MAX_FAILURES" ]; then - fallback_to_turn "codex_exec_resume_failed_${resume_status}" || fallback_status=$? - exit "${fallback_status:-70}" - fi - sleep 30 + # A non-zero CLI status does not prove that the app rejected the turn + # before creating it. Retrying the same unread high-water mark could wake + # the visible task twice, so preserve the inbox and stop after one attempt. + exit_after_fallback "codex_exec_resume_failed_${resume_status}" else - mark_delivered_ids_read - consecutive_failures=0 - last_success_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - write_health "ready" "none" + # Success means the resumed thread ran. Verify that it also consumed the + # high-water mark through inbox.sh; otherwise keep the message unread and + # degrade instead of silently declaring delivery. + set +e + run_watch_once 0 1 + post_status=$? + set -e + post_output="$(cat "$WATCH_OUTPUT" 2>/dev/null || true)" + rm -f "$WATCH_OUTPUT" + post_max_id="$(printf '%s\n' "$post_output" | sed -n 's/.*max_id=\([0-9][0-9]*\).*/\1/p' | head -1)" + case "$post_max_id" in ''|*[!0-9]*) post_max_id=0 ;; esac + if [ "$post_status" -eq 0 ] && [ "$post_max_id" -eq "$pending_max_id" ]; then + consecutive_failures=$((consecutive_failures + 1)) + write_health "degraded" "thread_did_not_consume_inbox" + printf 'codex-app-monitor: resumed thread did not consume max_id=%s\n' "$pending_max_id" >&2 + # A successful resume already created a turn. Never wake the same unread + # high-water mark twice; preserve it and fall back for operator recovery. + exit_after_fallback "thread_did_not_consume_inbox" + elif [ "$post_status" -eq 0 ]; then + # A different high-water mark means the resumed turn consumed the + # original batch while newer mail arrived (or left another batch). Re-arm + # immediately so the new mail gets its own serialized turn. + consecutive_failures=0 + last_success_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + write_health "pending" "new_mail_after_resume" + elif [ "$post_status" -eq 2 ]; then + consecutive_failures=0 + last_success_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + write_health "ready" "none" + else + consecutive_failures=$((consecutive_failures + 1)) + write_health "degraded" "post_watch_exit_${post_status}" + printf 'codex-app-monitor: post-resume watch failed status=%s output=%s\n' "$post_status" "$post_output" >&2 + # The wake already ran and consumption cannot be proved. Fail closed + # instead of risking a second turn for the same unread message. + exit_after_fallback "post_watch_failed_${post_status}" + fi fi if [ "$MAX_WAKES" -gt 0 ] && [ "$wake_count" -ge "$MAX_WAKES" ]; then diff --git a/scripts/drivers/types/codex/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh index 6e6b31d2..2f06f68b 100755 --- a/scripts/drivers/types/codex/codex-bridge-launcher.sh +++ b/scripts/drivers/types/codex/codex-bridge-launcher.sh @@ -113,7 +113,6 @@ EOF --name "$name" \ --thread "$thread_id" \ --app-server "$req_app_server" \ - --inline-inbox \ >>"$log" 2>&1 & # Record what this bridge is bound to so a later launcher can detect staleness. printf '%s' "$req_app_server" > "$appserver_file" diff --git a/scripts/drivers/types/codex/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js index 054dfd35..61c655ab 100755 --- a/scripts/drivers/types/codex/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -1038,6 +1038,14 @@ class CodexBridge { buildPrompt() { const inbox = path.join(SCRIPTS_DIR, "inbox.sh"); const send = path.join(SCRIPTS_DIR, "send.sh"); + const autonomousHandlingContract = [ + "Autonomous handling contract:", + `1. Your first tool call must be this official inbox command: ${inbox} ${this.identity.team} ${this.identity.name}`, + "2. Do not read the agmsg database or team files directly. The resumed Codex task alone owns message reading and acknowledgement through inbox.sh.", + "3. For a substantive request, new evidence, correction, or blocker, continue the in-scope work through verification and send an evidence-backed reply with the official send.sh command below. Do not stop after an ACK or status-only reply.", + "4. Do not reply to ACK-only, thanks-only, or status-only mail that contains no new request, evidence, correction, or blocker. This prevents autonomous ping-pong.", + "5. Preserve existing approval, production, customer-data, credential, and destructive-action boundaries. Stop and report a real blocker when new authority is required.", + ].join("\n"); const visibleUiRequirement = [ "Visible UI requirement:", '1. Before the first tool call, post a short Japanese progress update in the Codex thread UI starting with "agmsg対応状況:" and include sender, summary, planned action, and whether you will reply.', @@ -1055,15 +1063,19 @@ class CodexBridge { "Continue the conversation in this Codex thread. If a reply to an agmsg sender is needed, send it with:", `${send} ${this.identity.team} ${this.identity.name} `, "", + autonomousHandlingContract, + "", visibleUiRequirement, ].join("\n"); } return [ `agmsg has unread messages for ${this.identity.team}/${this.identity.name}.`, - `Run: ${inbox} ${this.identity.team} ${this.identity.name}`, - "Read the messages and continue the conversation. If a reply is needed, send it with:", + "The bridge did not read or acknowledge their contents.", + "Continue the conversation in this same Codex thread. If a reply is needed, send it with:", `${send} ${this.identity.team} ${this.identity.name} `, "", + autonomousHandlingContract, + "", visibleUiRequirement, ].join("\n"); } diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 20348ccf..f36358d8 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -52,10 +52,10 @@ Four possible outputs: 2) off — No automatic delivery Manual $__SKILL_NAME__ only. - 3) monitor — Persistent visible delivery (BETA) + 3) monitor — Persistent autonomous delivery (BETA) + A shell-only watcher is free while idle. Unread mail + resumes this same Codex task to read, work, and reply. SessionStart rebinds the last actas role after restart. - An app-server provides real-time visible turns; otherwise - the Stop hook pulls visibly. Headless handling stays off. [1]: ``` @@ -63,7 +63,9 @@ Four possible outputs: - **Wait for the user's answer before proceeding.** Empty input means `1` (turn). - Map the chosen number to a mode (`1`→`turn`, `2`→`off`, `3`→`monitor`) and run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` - - If monitor is chosen, tell the user: "Codex monitor is a BETA. It persists the last `actas` role and rebinds it at SessionStart. When a visible app-server bridge is available, incoming messages become visible turns. Ordinary Codex.app sessions use the visible Stop-hook fallback and never start headless `codex exec resume` unless `AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR=1` is explicitly set." + - If monitor is chosen, immediately bind the newly joined role by running: + `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"` + - If monitor is chosen, tell the user: "Codex monitor is a BETA. It persistently binds the last `actas` role to this task. Waiting is shell-only; unread mail resumes this same task, whose first action is the official inbox command. Monitor mode is explicit opt-in and remains active until mode turn/off or drop." 6. Then check inbox for the newly joined team. @@ -81,36 +83,20 @@ Four possible outputs: **Only use scripts in `~/.agents/skills/__SKILL_NAME__/scripts/` — do not read or modify files under `teams/` or `db/` directly.** -### Codex Desktop scheduled receiver - -When `actas-monitor.sh` prints an active `codex-monitor-lease.*` lease and the -Codex app automation tools are available, install both jobs below. This is -opt-in: never create either job for a session that did not explicitly run -`actas` while monitor mode is active, or explicitly run `mode monitor`. - -1. Run `codex-monitor-lease.sh prompt heartbeat` - and use its stdout verbatim as a 15-minute `heartbeat` automation attached - to the current thread. A one-minute thread heartbeat can be unexpectedly - expensive because it runs the thread's full model even when the inbox is - empty. -2. Run `codex-monitor-lease.sh prompt watchdog` - and use its stdout verbatim as a one-minute local `cron` automation for the - current project, with `gpt-5.4-mini` and low reasoning. The watchdog may use the app-internal - `send_message_to_thread` capability, but it must not run `inbox.sh` itself. -3. Name both jobs with the lease id so an existing pair can be updated instead - of duplicated. Inspect existing Codex automation records before creating a - job. Keep the heartbeat on the current thread and the watchdog on the current - project. -4. Record the two returned automation ids with - `codex-monitor-lease.sh automation `. -5. If the Codex automation tools are unavailable, keep the app-server / visible - turn fallback and say that scheduled recovery could not be armed. Never - replace it with a hidden `codex exec resume` worker. - -When `drop`, `mode turn`, `mode off`, `reset`, or a lease status reports -`inactive` / `expired`, delete or pause both recorded automation ids. The shell -scripts deactivate the lease first, so a late automation run cannot wake the -thread after teardown. +### Codex event-driven receiver + +`mode monitor` is an explicit opt-in to background resumption of the same +persisted Codex task. After `actas-monitor.sh` binds a concrete role and thread: + +1. `watch-once.sh` waits for unread state in a shell-only process. It does not + start a model, read a message body, or mark a message read while idle. +2. On unread mail, the receiver runs `codex exec resume ` once. +3. The resumed task must run the official `inbox.sh` first. Only that command + reads the body and marks it read. +4. Substantive requests continue through work, verification, and an evidence- + backed reply. ACK-only mail does not trigger a reply, preventing ping-pong. +5. `mode turn`, `mode off`, and `drop` stop the receiver. Do not create cron, + heartbeat, or scheduled polling jobs for Codex agmsg delivery. **If no arguments provided (DEFAULT action — always do this when the command is invoked without arguments):** 1. **IMMEDIATELY** run inbox check for each TEAM: `~/.agents/skills/__SKILL_NAME__/scripts/inbox.sh $TEAM $AGENT` @@ -144,18 +130,16 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): 2. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" codex` to see whether the role is already registered for this (project, type). 3. If the name does not appear in the output, join under the existing team. For a single team, run `~/.agents/skills/__SKILL_NAME__/scripts/join.sh codex "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `` for every `send.sh` call until another `actas`. -5. Rebind the receive-side monitor by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. Prefer the visible app-server bridge. If no visible bridge is available, preserve unread messages and arm chat-visible turn delivery; do not start a headless worker unless the operator explicitly opts in. -6. If that command reports an active monitor lease, follow **Codex Desktop scheduled receiver** above before returning to the user. -7. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). -8. If `` starts with `codex-pro-`, tell the user: "Now acting as ``. This role is an Oracle GPT-5.5 Pro consult route; run `$__SKILL_NAME__` to process its unread inbox through `oracle-pro-reply.sh`. Real runs may control the signed-in ChatGPT browser." -9. Otherwise tell the user: "Now acting as ``. Sends and visible receive are bound to ``." +5. Rebind the receive side by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. In monitor/both mode, prefer a visible app-server bridge; otherwise start the event-driven same-task receiver. In turn/off mode, never start a background receiver. +6. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). +7. If `` starts with `codex-pro-`, tell the user: "Now acting as ``. This role is an Oracle GPT-5.5 Pro consult route; run `$__SKILL_NAME__` to process its unread inbox through `oracle-pro-reply.sh`. Real runs may control the signed-in ChatGPT browser." +8. Otherwise tell the user: "Now acting as ``. Sends and receive are bound to ``." If argument starts with "drop" followed by an agent name (e.g. "drop alice"): 1. Parse the role name. -2. Disarm the role's lease and delete or pause its recorded Codex automations as described above. -3. Run `~/.agents/skills/__SKILL_NAME__/scripts/reset.sh "$(pwd)" codex ` to remove that role's registration. -4. If the session's active FROM was ``, clear that state. -5. Tell the user: "Dropped role `` from this project." +2. Run `~/.agents/skills/__SKILL_NAME__/scripts/reset.sh "$(pwd)" codex ` to remove that role's registration and stop its receiver. +3. If the session's active FROM was ``, clear that state. +4. Tell the user: "Dropped role `` from this project." If argument starts with "spawn" (e.g. "spawn claude-code alice", "spawn codex reviewer --window"): 1. Parse `` (must be `claude-code` or `codex`), ``, and any options (`--project`, `--team`, `--window`, `--split h|v`, `--terminal`, `--no-wait`, `--ready-timeout `). @@ -180,9 +164,9 @@ If argument is "mode" (no further args): If argument starts with "mode" followed by a mode name (e.g. "mode monitor"): 1. Parse the mode. Codex supports `monitor`, `turn`, `both`, and `off`. `monitor` already installs the visible turn fallback; `both` is an explicit equivalent for diagnostics. 2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` -3. If mode is `monitor` or `both` and an active role can be resolved, run `actas-monitor.sh` for that role and follow **Codex Desktop scheduled receiver** above. -4. If mode is `turn` or `off`, delete or pause the automation ids printed by `delivery.sh` teardown. -5. If mode is `monitor` or `both`, tell the user: "Persistent visible Codex delivery is enabled. The app-server bridge is the low-latency path; a thread heartbeat and independent watchdog provide scheduled recovery when Codex app automation is available. Headless `codex exec resume` remains disabled unless explicitly opted in." +3. If mode is `monitor` or `both` and an active role can be resolved, run `actas-monitor.sh` for that role and concrete thread id. +4. If mode is `turn` or `off`, confirm that the background receiver stopped. +5. If mode is `monitor` or `both`, tell the user: "Persistent autonomous Codex delivery is enabled. Idle waiting is shell-only; unread mail resumes this same task to run the official inbox, continue substantive work, and reply. No scheduled polling jobs are created." If argument is "hook on" (legacy alias): 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set turn codex "$(pwd)"` diff --git a/scripts/reset.sh b/scripts/reset.sh index c3c8f811..b8a1f6cf 100755 --- a/scripts/reset.sh +++ b/scripts/reset.sh @@ -26,6 +26,8 @@ source "$SCRIPT_DIR/lib/resolve-project.sh" source "$SCRIPT_DIR/lib/storage.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/registry-lock.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/compat.sh" # Resolve the session's real project root (see #92) so a drop issued from a # subdir/worktree clears the registration on the project the session lives in. @@ -65,6 +67,84 @@ REMOVED=0 TOUCHED_TEAMS=0 LOCK_FAILED=0 +wait_for_codex_receiver_exit() { + local pid="$1" check=0 state + while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac + sleep 0.1 + check=$((check + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || return 1 + check=0 + while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac + sleep 0.1 + check=$((check + 1)) + done + fi + ! kill -0 "$pid" 2>/dev/null +} + +stop_codex_role_receiver() { + local team="$1" name="$2" kind base pidfile metafile pid cmd label thread plist check domain + [ "$AGENT_TYPE" = "codex" ] || return 0 + + for kind in codex-bridge codex-app-monitor; do + base="$SKILL_DIR/run/$kind.$team.$name" + pidfile="$base.pid" + metafile="$base.meta" + plist="$base.plist" + label="" + if [ "$kind" = "codex-app-monitor" ]; then + if [ -f "$metafile" ]; then + label="$(sed -n 's/^launch_label=//p' "$metafile" | head -1)" + fi + if [ -z "$label" ] && [ -f "$plist" ]; then + label="$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$plist")" + fi + if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then + domain="gui/$(id -u)" + launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + check=0 + while [ "$check" -lt 20 ] && launchctl print "$domain/$label" >/dev/null 2>&1; do + sleep 0.1 + check=$((check + 1)) + done + fi + fi + if [ -f "$pidfile" ]; then + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case "$kind:$cmd" in + codex-bridge:*codex-bridge.js*|codex-app-monitor:*codex-app-monitor.sh*) + kill "$pid" 2>/dev/null || true + if ! wait_for_codex_receiver_exit "$pid"; then + echo "reset: Codex receiver pid $pid did not stop; preserving its run files" >&2 + return 1 + fi + ;; + esac + fi + fi + if [ "$kind" = "codex-app-monitor" ] && [ -f "$metafile" ]; then + thread="$(sed -n 's/^thread=//p' "$metafile" | head -1)" + if [ -n "$thread" ]; then + "$SCRIPT_DIR/drivers/types/codex/codex-monitor-lease.sh" disarm \ + "$PROJECT_PATH" "$team" "$name" "$thread" >/dev/null 2>&1 || true + fi + fi + rm -f "$pidfile" "$metafile" "$base.appserver" "$base.log" "$plist" \ + "$base.health" "$base.preflight.log" "$base.last-prompt.txt" \ + "$base.last-message.txt" "$base.last-status" "$base.last-ids" \ + "$base.watch-output" + done + rm -f "$SKILL_DIR/run/codex-chat-visible.$team.$name.meta" +} + for TEAM_CONFIG in "$TEAMS_DIR"/*/config.json; do [ -f "$TEAM_CONFIG" ] || continue TEAM_DIR="$(dirname "$TEAM_CONFIG")" @@ -160,6 +240,11 @@ for TEAM_CONFIG in "$TEAMS_DIR"/*/config.json; do TOUCHED_TEAMS=$((TOUCHED_TEAMS + 1)) echo "Cleared $MATCH_COUNT registration(s) for $TARGET_AGENT from $TEAM_NAME" + # A dropped Codex role must stop receiving immediately. Mode-level teardown + # handles whole projects; reset is role-scoped, so remove only this role's + # bridge/background receiver and leave peer identities untouched. + stop_codex_role_receiver "$TEAM_NAME" "$TARGET_AGENT" + # Release the actas lock for this (team, agent) pair so peer sessions can # claim it without waiting for owner-session-end / stale GC. if [ -n "$SESSION_ID" ]; then diff --git a/scripts/session-end.sh b/scripts/session-end.sh index 8ac0815e..9193c8fa 100755 --- a/scripts/session-end.sh +++ b/scripts/session-end.sh @@ -29,6 +29,13 @@ source "$SCRIPT_DIR/lib/actas-lock.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/resolve-project.sh" +# A background `codex exec resume` fires the same hooks as the visible task. +# Its parent receiver owns lifecycle cleanup, so the internal SessionEnd must +# not stop that receiver or release its role while it is handling a batch. +if [ "$TYPE" = "codex" ] && [ "${AGMSG_CODEX_BACKGROUND_RESUME:-}" = "1" ]; then + exit 0 +fi + # Drop project markers (#92) whose agent process has exited. Liveness-based, so # a session that persists across /clear keeps its marker until the process dies. agmsg_marker_gc_stale 2>/dev/null || true @@ -42,6 +49,96 @@ if [ -n "$INPUT" ]; then fi [ -z "$SESSION_ID" ] && exit 0 +PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")" + +wait_for_codex_receiver_exit() { + local pid="$1" check=0 state + while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac + sleep 0.1 + check=$((check + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || return 1 + check=0 + while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac + sleep 0.1 + check=$((check + 1)) + done + fi + ! kill -0 "$pid" 2>/dev/null +} + +stop_codex_thread_receivers() { + local meta base kind pidfile pid cmd meta_project meta_type meta_thread team name + local label plist domain check chat + for meta in "$RUN_DIR"/codex-bridge.*.meta "$RUN_DIR"/codex-app-monitor.*.meta; do + [ -f "$meta" ] || continue + meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" + meta_type="$(sed -n 's/^type=//p' "$meta" | head -1)" + meta_thread="$(sed -n 's/^thread=//p' "$meta" | head -1)" + [ "$(agmsg_canonical_path "$meta_project")" = "$(agmsg_canonical_path "$PROJECT")" ] || continue + [ "$meta_type" = "$TYPE" ] || continue + [ "$meta_thread" = "$SESSION_ID" ] || continue + + base="${meta%.meta}" + kind="${base##*/}" + pidfile="$base.pid" + plist="$base.plist" + label="$(sed -n 's/^launch_label=//p' "$meta" | head -1)" + if [ -z "$label" ] && [ -f "$plist" ]; then + label="$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$plist")" + fi + if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then + domain="gui/$(id -u)" + launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + check=0 + while [ "$check" -lt 20 ] && launchctl print "$domain/$label" >/dev/null 2>&1; do + sleep 0.1 + check=$((check + 1)) + done + fi + + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case "$kind:$cmd" in + codex-bridge.*:*codex-bridge.js*|codex-app-monitor.*:*codex-app-monitor.sh*) + kill "$pid" 2>/dev/null || true + if ! wait_for_codex_receiver_exit "$pid"; then + echo "session-end: Codex receiver pid $pid did not stop; preserving its run files" >&2 + continue + fi + ;; + esac + fi + + team="$(sed -n 's/^team=//p' "$meta" | head -1)" + name="$(sed -n 's/^name=//p' "$meta" | head -1)" + rm -f "$pidfile" "$meta" "$base.appserver" "$base.log" "$plist" \ + "$base.health" "$base.preflight.log" "$base.last-prompt.txt" \ + "$base.last-message.txt" "$base.last-status" "$base.last-ids" \ + "$base.watch-output" + if [ -n "$team" ] && [ -n "$name" ]; then + rm -f "$RUN_DIR/codex-chat-visible.$team.$name.meta" + fi + done + + for chat in "$RUN_DIR"/codex-chat-visible.*.meta; do + [ -f "$chat" ] || continue + meta_project="$(sed -n 's/^project=//p' "$chat" | head -1)" + meta_type="$(sed -n 's/^type=//p' "$chat" | head -1)" + meta_thread="$(sed -n 's/^thread=//p' "$chat" | head -1)" + if [ "$(agmsg_canonical_path "$meta_project")" = "$(agmsg_canonical_path "$PROJECT")" ] \ + && [ "$meta_type" = "$TYPE" ] && [ "$meta_thread" = "$SESSION_ID" ]; then + rm -f "$chat" + fi + done +} + # Re-derive the per-process instance id this session's watcher/locks are keyed # under (#93). The enclosing agent process is still alive during the hook, so # agmsg_instance_id normally resolves the same "." that session-start @@ -91,6 +188,7 @@ actas_lock_release_all "$INSTANCE_ID" 2>/dev/null || true # session ends. Keep an inactive tombstone so the independent watchdog can read # the automation ids and delete both scheduled jobs on its next run. if [ "$TYPE" = "codex" ]; then + stop_codex_thread_receivers "$SCRIPT_DIR/drivers/types/codex/codex-monitor-lease.sh" \ deactivate-thread "$PROJECT" "$SESSION_ID" >/dev/null 2>&1 || true fi diff --git a/tests/test_codex_bridge.bats b/tests/test_codex_bridge.bats index 68c605d8..ed50a491 100644 --- a/tests/test_codex_bridge.bats +++ b/tests/test_codex_bridge.bats @@ -601,6 +601,14 @@ rl.on("line", (line) => { send({ jsonrpc: "2.0", id: message.id, error: { message: "missing wakeup prompt" } }); return; } + if (!message.params.input[0].text.includes("first tool call must be this official inbox command")) { + send({ jsonrpc: "2.0", id: message.id, error: { message: "missing official inbox ownership" } }); + return; + } + if (!message.params.input[0].text.includes("ACK-only")) { + send({ jsonrpc: "2.0", id: message.id, error: { message: "missing ping-pong guard" } }); + return; + } send({ jsonrpc: "2.0", id: message.id, result: {} }); setTimeout(() => { send({ diff --git a/tests/test_codex_visible_monitor.bats b/tests/test_codex_visible_monitor.bats index ef878506..03347ba6 100644 --- a/tests/test_codex_visible_monitor.bats +++ b/tests/test_codex_visible_monitor.bats @@ -1,13 +1,23 @@ #!/usr/bin/env bats +bats_require_minimum_version 1.5.0 + load test_helper setup() { setup_test_env export TEST_PROJECT="$(mktemp -d)" + TEST_RECEIVER_PIDS="" + # Failure-path tests must not emit real macOS notifications to the user. + export AGMSG_CODEX_APP_MONITOR_DISABLE_NOTIFY=1 } teardown() { + local pid + for pid in ${TEST_RECEIVER_PIDS:-}; do + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done teardown_test_env rm -rf "$TEST_PROJECT" } @@ -23,8 +33,9 @@ join_codex_roles() { bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null } -@test "codex actas persists the selected role and arms visible fallback" { +@test "codex actas in turn mode persists the selected role without a background receiver" { join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null run env CODEX_THREAD_ID=thread-123 \ bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-123 @@ -37,13 +48,14 @@ join_codex_roles() { awk -F '\t' '$2 == "codex" && $3 == "team" && $4 == "bob" { found=1 } END { exit !found }' "$state" local hooks="$TEST_PROJECT/.codex/hooks.json" - grep -q "session-start.sh" "$hooks" grep -q "check-inbox.sh" "$hooks" - [[ "$output" == *"lease_id=codex-monitor-lease."* ]] + [[ "$output" != *"lease_id=codex-monitor-lease."* ]] + run ! grep -q "session-start.sh" "$hooks" } @test "codex SessionStart rebinds the last actas role after restart" { join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null env CODEX_THREAD_ID=thread-before \ bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-before >/dev/null rm -f "$TEST_SKILL_DIR/run/codex-chat-visible.team.bob.meta" @@ -61,6 +73,7 @@ join_codex_roles() { @test "codex visible Stop hook reads the last actas inbox, not the first registration" { join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null env CODEX_THREAD_ID=thread-123 \ bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-123 >/dev/null bash "$SCRIPTS/send.sh" team alice bob "visible-bob-message" >/dev/null @@ -70,7 +83,7 @@ join_codex_roles() { [[ "$output" == *"visible-bob-message"* ]] } -@test "codex headless app monitor is disabled unless explicitly opted in" { +@test "codex background thread receiver is disabled unless monitor explicitly opts in" { run bash "$TYPES/codex/codex-app-monitor.sh" \ "$TEST_PROJECT" codex team bob thread-123 [ "$status" -eq 64 ] @@ -78,6 +91,181 @@ join_codex_roles() { [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" ] } +make_fake_codex() { + local fake="$TEST_SKILL_DIR/fake-codex" + cat > "$fake" <<'EOF' +#!/usr/bin/env bash +printf 'ARGS' >> "$AGMSG_FAKE_CODEX_LOG" +printf ' <%s>' "$@" >> "$AGMSG_FAKE_CODEX_LOG" +printf '\n' >> "$AGMSG_FAKE_CODEX_LOG" +printf 'BACKGROUND <%s>\n' "${AGMSG_CODEX_BACKGROUND_RESUME:-}" >> "$AGMSG_FAKE_CODEX_LOG" +if [ "${1:-}" = "mcp" ]; then + exit 0 +fi +if [ "${1:-}" = "exec" ]; then + prompt="$(cat)" + printf '%s\n' "$prompt" > "$AGMSG_FAKE_CODEX_PROMPT" + if [ "${AGMSG_FAKE_CODEX_CONSUME:-}" = "1" ]; then + bash "$AGMSG_FAKE_CODEX_INBOX" team bob >/dev/null + fi + exit "${AGMSG_FAKE_CODEX_EXEC_STATUS:-0}" +fi +exit 0 +EOF + chmod +x "$fake" + printf '%s\n' "$fake" +} + +@test "codex monitor does not start a model while inbox is empty" { + skip_on_windows "background receiver liveness uses POSIX signals" + join_codex_roles + local fake log prompt monitor_pid + fake="$(make_fake_codex)" + log="$TEST_SKILL_DIR/fake-codex.log" + prompt="$TEST_SKILL_DIR/fake-codex.prompt" + + env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + AGMSG_CODEX_APP_MONITOR_TIMEOUT=1 \ + AGMSG_CODEX_APP_MONITOR_INTERVAL=1 \ + AGMSG_FAKE_CODEX_LOG="$log" \ + AGMSG_FAKE_CODEX_PROMPT="$prompt" \ + AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ + bash "$TYPES/codex/codex-app-monitor.sh" \ + "$TEST_PROJECT" codex team bob thread-empty >/dev/null 2>&1 & + monitor_pid=$! + + for _ in {1..30}; do + grep -qx "status=ready" "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" 2>/dev/null && break + sleep 0.1 + done + sleep 1.2 + kill "$monitor_pid" 2>/dev/null || true + wait "$monitor_pid" 2>/dev/null || true + + grep -q " " "$log" + run ! grep -q "" "$log" + [ ! -e "$prompt" ] +} + +@test "codex monitor resumes the same thread and leaves body ownership to inbox.sh" { + join_codex_roles + bash "$SCRIPTS/send.sh" team alice bob "secret-body-must-not-be-in-wake-prompt" >/dev/null + local fake log prompt + fake="$(make_fake_codex)" + log="$TEST_SKILL_DIR/fake-codex.log" + prompt="$TEST_SKILL_DIR/fake-codex.prompt" + + run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ + AGMSG_CODEX_APP_MONITOR_MAX_WAKES=1 \ + AGMSG_FAKE_CODEX_LOG="$log" \ + AGMSG_FAKE_CODEX_PROMPT="$prompt" \ + AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ + AGMSG_FAKE_CODEX_CONSUME=1 \ + bash "$TYPES/codex/codex-app-monitor.sh" \ + "$TEST_PROJECT" codex team bob thread-123 + [ "$status" -eq 0 ] + + grep -q " " "$log" + grep -q '<-s> ' "$log" + grep -q '<-c> ' "$log" + grep -q 'BACKGROUND <1>' "$log" + run ! grep -q -- "--dangerously-bypass-approvals-and-sandbox" "$log" + grep -q "$SCRIPTS/inbox.sh team bob" "$prompt" + run ! grep -q "secret-body-must-not-be-in-wake-prompt" "$prompt" + + run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ + --team team --name bob --timeout 0 + [ "$status" -eq 2 ] +} + +@test "codex monitor never marks unread mail when resumed thread fails to consume it" { + join_codex_roles + bash "$SCRIPTS/send.sh" team alice bob "preserve-me" >/dev/null + local fake log prompt + fake="$(make_fake_codex)" + log="$TEST_SKILL_DIR/fake-codex.log" + prompt="$TEST_SKILL_DIR/fake-codex.prompt" + + run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ + AGMSG_CODEX_APP_MONITOR_MAX_FAILURES=1 \ + AGMSG_FAKE_CODEX_LOG="$log" \ + AGMSG_FAKE_CODEX_PROMPT="$prompt" \ + AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ + bash "$TYPES/codex/codex-app-monitor.sh" \ + "$TEST_PROJECT" codex team bob thread-123 + [ "$status" -eq 70 ] + [ "$(grep -c '' "$log")" -eq 1 ] + grep -qx "last_error=thread_did_not_consume_inbox" \ + "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" + + run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ + --team team --name bob --timeout 0 + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] +} + +@test "codex monitor never retries an unread id after resume returns an error" { + join_codex_roles + bash "$SCRIPTS/send.sh" team alice bob "one-wake-only" >/dev/null + local fake log prompt + fake="$(make_fake_codex)" + log="$TEST_SKILL_DIR/fake-codex.log" + prompt="$TEST_SKILL_DIR/fake-codex.prompt" + + run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ + AGMSG_CODEX_APP_MONITOR_MAX_FAILURES=3 \ + AGMSG_FAKE_CODEX_LOG="$log" \ + AGMSG_FAKE_CODEX_PROMPT="$prompt" \ + AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ + AGMSG_FAKE_CODEX_EXEC_STATUS=42 \ + bash "$TYPES/codex/codex-app-monitor.sh" \ + "$TEST_PROJECT" codex team bob thread-123 + [ "$status" -eq 70 ] + [ "$(grep -c '' "$log")" -eq 1 ] + + run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ + --team team --name bob --timeout 0 + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] +} + +@test "supervised codex monitor exits successfully after one failed wake" { + join_codex_roles + bash "$SCRIPTS/send.sh" team alice bob "supervisor-must-not-restart" >/dev/null + local fake log prompt + fake="$(make_fake_codex)" + log="$TEST_SKILL_DIR/fake-codex.log" + prompt="$TEST_SKILL_DIR/fake-codex.prompt" + + run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_SUPERVISED=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ + AGMSG_CODEX_APP_MONITOR_MAX_FAILURES=3 \ + AGMSG_FAKE_CODEX_LOG="$log" \ + AGMSG_FAKE_CODEX_PROMPT="$prompt" \ + AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ + AGMSG_FAKE_CODEX_EXEC_STATUS=42 \ + bash "$TYPES/codex/codex-app-monitor.sh" \ + "$TEST_PROJECT" codex team bob thread-123 + [ "$status" -eq 0 ] + [ "$(grep -c '' "$log")" -eq 1 ] + grep -qx "last_error=codex_exec_resume_failed_42" \ + "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" + + run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ + --team team --name bob --timeout 0 + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] +} + @test "delivery turn stops an app monitor even when no bridge pidfile exists" { skip_on_windows "process liveness assertion uses POSIX kill semantics" bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null @@ -93,8 +281,11 @@ type=codex team=team name=alice thread=thread-123 -transport=codex-app-exec-resume +transport=codex-background-thread-resume EOF + : > "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.plist" + : > "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.health" + : > "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.log" bash "$TYPES/codex/codex-monitor-lease.sh" arm \ "$TEST_PROJECT" team alice thread-123 >/dev/null bash "$TYPES/codex/codex-monitor-lease.sh" automation \ @@ -105,11 +296,190 @@ EOF [[ "$output" == *"Stopped 1 Codex bridge/app monitor process"* ]] [[ "$output" == *"heartbeat_automation_id=heartbeat-id"* ]] [[ "$output" == *"watchdog_automation_id=watchdog-id"* ]] - ! kill -0 "$app_pid" 2>/dev/null + run ! kill -0 "$app_pid" [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.pid" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.plist" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.health" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.log" ] run bash "$TYPES/codex/codex-monitor-lease.sh" status \ "$TEST_PROJECT" team alice thread-123 [ "$status" -eq 3 ] [[ "$output" == *"status=inactive"* ]] } + +@test "dropping one codex role stops only that role's background receiver" { + skip_on_windows "background receiver liveness uses POSIX signals" + join_codex_roles + local sleeper="$TEST_SKILL_DIR/codex-app-monitor.sh" bob_pid alice_pid + cat > "$sleeper" <<'EOF' +#!/usr/bin/env bash +trap 'exit 0' TERM INT +while :; do + sleep 1 +done +EOF + chmod +x "$sleeper" + + "$sleeper" & + bob_pid=$! + "$sleeper" & + alice_pid=$! + TEST_RECEIVER_PIDS="$bob_pid $alice_pid" + mkdir -p "$TEST_SKILL_DIR/run" + printf '%s\n' "$bob_pid" > "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" + cat > "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.meta" < "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.pid" + cat > "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.meta" </dev/null + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" ] + [ -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.pid" ] + + kill "$alice_pid" 2>/dev/null || true + wait "$alice_pid" 2>/dev/null || true + TEST_RECEIVER_PIDS="" +} + +@test "codex monitor rejects a second receiver for the same role" { + skip_on_windows "background receiver liveness uses POSIX signals" + join_codex_roles + local fake log prompt first_pid owner_pid + fake="$(make_fake_codex)" + log="$TEST_SKILL_DIR/fake-codex.log" + prompt="$TEST_SKILL_DIR/fake-codex.prompt" + + env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + AGMSG_CODEX_APP_MONITOR_TIMEOUT=30 \ + AGMSG_FAKE_CODEX_LOG="$log" \ + AGMSG_FAKE_CODEX_PROMPT="$prompt" \ + AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ + bash "$TYPES/codex/codex-app-monitor.sh" \ + "$TEST_PROJECT" codex team bob thread-owner >/dev/null 2>&1 & + first_pid=$! + TEST_RECEIVER_PIDS="$first_pid" + for _ in {1..30}; do + grep -qx "status=ready" "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" 2>/dev/null && break + sleep 0.1 + done + owner_pid="$(cat "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid")" + + run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ + AGMSG_FAKE_CODEX_LOG="$log" \ + AGMSG_FAKE_CODEX_PROMPT="$prompt" \ + AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ + bash "$TYPES/codex/codex-app-monitor.sh" \ + "$TEST_PROJECT" codex team bob thread-duplicate + [ "$status" -eq 73 ] + [[ "$output" == *"receiver already owns team/bob"* ]] + [ "$(cat "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid")" = "$owner_pid" ] + kill -0 "$first_pid" 2>/dev/null +} + +@test "background resume hooks do not rebind or stop their parent receiver" { + skip_on_windows "background receiver liveness uses POSIX signals" + join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null + env CODEX_THREAD_ID=thread-parent \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-parent >/dev/null + rm -f "$TEST_SKILL_DIR/run/codex-chat-visible.team.bob.meta" + + env AGMSG_CODEX_BACKGROUND_RESUME=1 CODEX_THREAD_ID=thread-parent \ + bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" "$sleeper" <<'EOF' +#!/usr/bin/env bash +trap 'exit 0' TERM INT +while :; do sleep 1; done +EOF + chmod +x "$sleeper" + "$sleeper" & + receiver_pid=$! + TEST_RECEIVER_PIDS="$receiver_pid" + mkdir -p "$TEST_SKILL_DIR/run" + printf '%s\n' "$receiver_pid" > "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" + cat > "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.meta" </dev/null + [ -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" ] +} + +@test "codex SessionEnd stops only the receiver bound to that visible thread" { + skip_on_windows "background receiver liveness uses POSIX signals" + join_codex_roles + local sleeper="$TEST_SKILL_DIR/codex-app-monitor.sh" receiver_pid + cat > "$sleeper" <<'EOF' +#!/usr/bin/env bash +trap 'exit 0' TERM INT +while :; do sleep 1; done +EOF + chmod +x "$sleeper" + "$sleeper" & + receiver_pid=$! + TEST_RECEIVER_PIDS="$receiver_pid" + mkdir -p "$TEST_SKILL_DIR/run" + printf '%s\n' "$receiver_pid" > "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" + cat > "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.meta" < "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.plist" + : > "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" + : > "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.log" + + printf '%s\n' '{"session_id":"thread-ending"}' | \ + bash "$SCRIPTS/session-end.sh" codex "$TEST_PROJECT" + + run ! kill -0 "$receiver_pid" + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.meta" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.plist" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.log" ] + TEST_RECEIVER_PIDS="" +} + +@test "codex monitor supervisor restarts crashes but never falls back to a new task" { + grep -q 'AGMSG_CODEX_APP_MONITOR_SUPERVISED' "$TYPES/codex/actas-monitor.sh" + grep -A3 -q 'SuccessfulExit' "$TYPES/codex/actas-monitor.sh" + run grep -q 'start_bridge ""' "$TYPES/codex/actas-monitor.sh" + [ "$status" -ne 0 ] + run grep -q 'THREAD_ID="new"' "$TYPES/codex/actas-monitor.sh" + [ "$status" -ne 0 ] +} diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index 170e2125..b3497cf4 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -1473,7 +1473,8 @@ EOF grep -q -- "--project $TEST_PROJECT" "$log" grep -q -- "--thread thread-123" "$log" grep -q -- "--app-server unix://$TEST_SKILL_DIR/run/codex-app-server.test.sock" "$log" - grep -q -- "--inline-inbox" "$log" + run grep -q -- "--inline-inbox" "$log" + [ "$status" -ne 0 ] } @test "session-start.sh for codex stays quiet without monitor launcher env" { diff --git a/tests/test_team.bats b/tests/test_team.bats index e39aba15..77809ad6 100644 --- a/tests/test_team.bats +++ b/tests/test_team.bats @@ -218,7 +218,22 @@ teardown() { @test "whoami: defaults to claude-code when no env vars set" { bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj - run bash "$SCRIPTS/whoami.sh" /tmp/proj + # Bats may itself run under Codex/Claude/Gemini, so both the inherited + # detector variables and the parent agent process must be hidden to exercise + # the actual no-runtime fallback. + local fake_bin="$TEST_SKILL_DIR/no-agent-bin" + mkdir -p "$fake_bin" + cat > "$fake_bin/ps" <<'EOF' +#!/usr/bin/env bash +case "$*" in + *comm=*) printf 'bash\n' ;; + *ppid=*) printf '1\n' ;; +esac +EOF + chmod +x "$fake_bin/ps" + run env -u CLAUDE_CODE_SESSION_ID -u CODEX_SANDBOX -u CODEX_THREAD_ID \ + -u GEMINI_CLI -u GEMINI_API_KEY -u GROK_SESSION_ID \ + PATH="$fake_bin:$PATH" bash "$SCRIPTS/whoami.sh" /tmp/proj [ "$status" -eq 0 ] [[ "$output" =~ "agent=alice" ]] [[ "$output" =~ "type=claude-code" ]] From 62f2ef7fdf8b777d3841ca26ffc64ebc9f329688 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Tue, 14 Jul 2026 02:08:53 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[auto]=20Codex=E7=9B=A3=E8=A6=96=E3=81=AE?= =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=AF=E3=82=B0=E3=83=A9=E3=82=A6=E3=83=B3?= =?UTF-8?q?=E3=83=89=E5=AE=8C=E7=B5=90=E3=82=92=E7=A6=81=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/codex-monitor-beta.md | 78 +-- scripts/drivers/types/codex/_delivery.sh | 8 +- scripts/drivers/types/codex/_session-start.sh | 12 +- scripts/drivers/types/codex/actas-monitor.sh | 41 +- .../drivers/types/codex/codex-app-monitor.sh | 448 +----------------- scripts/drivers/types/codex/codex-bridge.js | 11 +- scripts/drivers/types/codex/template.md | 39 +- scripts/session-end.sh | 6 +- tests/test_codex_bridge.bats | 2 +- tests/test_codex_visible_monitor.bats | 182 +++---- 11 files changed, 151 insertions(+), 680 deletions(-) diff --git a/README.md b/README.md index 97879b28..bb2c7b9c 100644 --- a/README.md +++ b/README.md @@ -300,9 +300,9 @@ The command updates `db/config.yaml`, rewrites the project's hook entries, and p $agmsg — or /skills → agmsg ``` -Codex supports `mode monitor` as a **beta** event-driven receiver, plus `mode turn` and `mode off`. +Codex supports `mode monitor` as a **beta** visible app-server receiver, plus `mode turn` and `mode off`. -> ⚠️ **Monitor is explicit opt-in for unattended continuation of one persisted Codex task.** A shell-only watcher costs no model turn while the inbox is empty. On unread mail it resumes the same task, whose first tool call is the official `inbox.sh`; only that task reads or marks the message, continues substantive work, verifies it, and replies. ACK-only mail is not answered, preventing ping-pong. No heartbeat, cron, or scheduled polling task is created. Existing safety and approval boundaries still apply. A visible app-server bridge remains the preferred path when available. +> ⚠️ **Monitor is active only when a visible app-server bridge attaches to the persisted Codex task.** Background `codex exec resume` delivery is prohibited because a successful CLI turn can consume and answer mail without displaying the handling in Codex Desktop. When no visible bridge is available, agmsg keeps mail unread and downgrades the effective mode to `turn`. No heartbeat, cron, or scheduled polling task is created. If you prefer a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 96d18bfc..156d203c 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -1,25 +1,16 @@ # Codex Monitor Beta -Codex does not expose Claude Code's Monitor tool. agmsg's Codex monitor beta is -an explicit opt-in, event-driven receiver bound to one persisted Codex thread. -A shell-only watcher waits for unread state without starting a model. When mail -arrives, it resumes that same thread once; the resumed thread runs the official -`inbox.sh`, continues substantive work, verifies it, and replies when needed. -A visible app-server bridge is still preferred when available. The last -explicit `actas` role is rebound from SessionStart after restart or compaction. +Codex does not expose Claude Code's Monitor tool. agmsg's Codex monitor beta can +deliver mail only through an app-server bridge that renders the handling in the +same visible Codex thread. The last explicit `actas` role is rebound from +SessionStart after restart or compaction. > ⚠️ **Experimental beta — read before enabling.** This changes how Codex starts. -> Enabling monitor mode permits unread agmsg mail to resume the selected Codex -> thread in the background. **Only enable it for a role and thread whose -> existing scope is safe to continue unattended.** The receiver preserves the -> thread's approval, production, customer-data, secret, and external-mutation -> boundaries. Enabling also prints an optional shell function that routes -> interactive launches through the app-server bridge. A concrete thread id -> supplied by `actas` binds immediately. After a Codex restart, SessionStart -> rebinds the role on the first turn because opening the app alone does not fire -> that hook. SessionEnd stops the receiver for the ending thread. Multiple -> registered identities are supported by restoring the most recent explicit -> `actas` role. +> Monitor mode is active only after a visible app-server bridge attaches to the +> selected thread. If no bridge is available, agmsg keeps mail unread and changes +> the effective mode to `turn`. Background `codex exec resume`, cron, heartbeat, +> and scheduled polling are prohibited because they can process mail without +> showing the work to the human operator. ## Quick Start @@ -34,15 +25,14 @@ The command: 1. Enables Codex SessionStart/SessionEnd hooks plus the visible Stop-hook fallback for the project. 2. Persists the last explicit `actas` role so SessionStart can rebind it. -3. After `actas` binds a concrete thread, starts a persistent shell-only watcher - for that exact team/role/thread tuple. +3. After `actas` binds a concrete thread, attaches the visible app-server bridge + for that exact team/role/thread tuple or downgrades to `turn`. 4. Prints a shell function that routes interactive `codex` launches through the monitor shim. -Monitor mode itself is the explicit opt-in for background thread resumption. -`turn` and `off` never start it. The watcher only observes unread count and -high-water id; it never reads message bodies or marks messages read. The same -persisted thread owns the official inbox read and any reply. +The bridge may observe unread count and high-water id, but it does not read +message bodies or mark messages read. The visible persisted thread owns the +official inbox read, substantive work, progress reporting, and any reply. The Codex sandbox must allow writes to the installed skill's runtime state: @@ -73,11 +63,9 @@ In monitor-mode projects, the function routes interactive Codex launches through the bridge. Outside monitor-mode projects, it passes through to the real Codex. When Codex.app is opened normally, SessionStart restores the last `actas` role. -If no visible app-server is available, `watch-once.sh` remains blocked in a -shell process until unread mail exists, then invokes `codex exec resume` for the -same thread id. Empty inboxes therefore create no model turn and no new Codex -task. Repeated failure to consume the same unread high-water mark stops active -delivery and leaves the message unread. +If no visible app-server is available, the effective delivery mode becomes +`turn`; the Stop hook checks the inbox on a later visible assistant turn. agmsg +does not claim that autonomous monitoring is active in this state. `mode off`, `mode turn`, `drop`/`reset`, and SessionEnd stop the matching receiver and remove its LaunchAgent/runtime files. No cron, heartbeat, or @@ -230,12 +218,10 @@ shape: a short-interval scheduler that runs a heavyweight agent as the poller, with no cheap no-op path, so empty inboxes still pay the full cost — and Codex Desktop's per-session UI/log accumulation amplifies it. -### Gate with `watch-once.sh`, launch the agent only on a hit +### `watch-once.sh` is not a Codex Desktop delivery fallback -agmsg already ships the cheap gate this needs. `watch-once.sh` is a shell-only, -one-shot inbox oracle — no agent, no Codex turn. It is the same primitive the -Codex monitor bridge uses (see [Bridge Mechanics](#bridge-mechanics)) to avoid -starting a turn on an empty inbox. +`watch-once.sh` is a shell-only, one-shot inbox oracle. The visible app-server +bridge uses it to avoid starting a turn on an empty inbox. ```text exit 0 unread inbound exists (prints: status=pending count= max_id=) @@ -243,27 +229,13 @@ exit 2 nothing pending (prints: status=timeout) exit 1 configuration / runtime error ``` -Two-stage worker — the shell gate decides whether the expensive agent runs: - -```bash -#!/usr/bin/env bash -set -euo pipefail -SKILL=~/.agents/skills/agmsg/scripts -PROJECT="/path/to/project" - -# 1. Cheap shell-only check. --timeout 0 makes it a single poll, then exit. -# --team/--name scope the gate to one identity (matches the single-flight -# key below, and disambiguates when the same agent name exists in two teams). -if "$SKILL/watch-once.sh" "$PROJECT" codex --team myteam --name myagent --timeout 0; then - # 2. Unread exists — only now pay for a full Codex/Claude session. - codex exec "Handle the new agmsg messages for this project." -fi -# exit 2 (nothing pending) falls through and the worker ends cheaply. -``` +Do not combine it with a scheduler and `codex exec` as a substitute for Codex +Desktop delivery. That path cannot guarantee that the received message, +progress, reply, and result appear in the user's visible thread. ### Defense in depth -For an unattended worker, layer these on top of the gate: +For a separately authorized non-Desktop worker, layer these on top of the gate: - **Single-flight lock per `(team, agent)`** so overlapping ticks don't stack concurrent agents: @@ -289,7 +261,7 @@ For an unattended worker, layer these on top of the gate: 1. Make the worker inactive / unschedule the `cron` job so it stops spawning. 2. Back off delivery: `delivery.sh set turn codex "$PROJECT"` (or `off`) to stop - monitor-driven launches. + monitor delivery. 3. Kill stale monitors / spawned sessions and any orphaned bridge (`mode off` tears the bridge down; see #149). 4. Inspect Codex Desktop log-DB bloat: `~/.codex/logs_*.sqlite` and its WAL. diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh index 8c03c482..65ae89a5 100644 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -4,8 +4,8 @@ # codex keeps the default JSON event-hooks apply (agmsg_delivery_apply); it adds # enable/disable side effects (print the monitor shim setup on enable, stop the # receiver on disable) and replaces the runtime status summary with Codex -# receiver liveness. Monitor mode is explicit opt-in to event-driven resumption -# of the same persisted thread; turn/off never start a background receiver. +# receiver liveness. Monitor mode requires a visible app-server bridge; +# turn/off never start a background receiver. # Sourced into delivery.sh's context, # so SKILL_DIR, SCRIPT_DIR, RUN_DIR, agmsg_resolve_node, CODEX_MONITOR_DOC_URL # and stop_codex_bridge are in scope. @@ -31,8 +31,8 @@ agmsg_delivery_status() { agmsg_delivery_on_enable() { echo "Codex monitor beta is enabled." - echo "After actas binds a concrete role/thread, a shell-only watcher waits without model calls." - echo "Unread mail resumes that same Codex thread, which owns inbox reading and replies." + echo "After actas binds a concrete role/thread, monitor requires a visible app-server bridge." + echo "Without that bridge, actas downgrades the effective mode to turn and keeps mail unread." echo "Add this shell function to your interactive shell profile, then restart the shell:" if "$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" function; then echo "Future Codex sessions: launch with codex. In monitor-mode projects, the agmsg function routes interactive Codex sessions through the bridge." diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index ab11c58d..53030457 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -60,9 +60,9 @@ INNER_EOF } agmsg_session_start() { - # `codex exec resume` runs the normal Codex hooks. It is already the child of - # this role's receiver, so rebinding here would stop or duplicate the parent - # receiver while it is delivering the unread batch. + # Compatibility guard for rollouts created by older background receivers. + # Current builds prohibit that transport, but must not duplicate a legacy + # parent receiver during an in-flight upgrade. if [ "${AGMSG_CODEX_BACKGROUND_RESUME:-}" = "1" ]; then exit 0 fi @@ -112,9 +112,9 @@ agmsg_session_start() { fi fi if [ -z "$app_server" ]; then - # Ordinary Codex.app has no app-server endpoint. Rebind the explicit - # monitor role to the event-driven same-thread receiver; turn/off mode is - # enforced inside actas-monitor.sh and never starts that receiver. + # Ordinary Codex.app has no externally reachable app-server endpoint. + # actas-monitor.sh records visible-turn fallback and downgrades monitor to + # turn instead of starting an invisible background receiver. log="$RUN_DIR/codex-actas-restore.log" "$SKILL_DIR/scripts/drivers/types/codex/actas-monitor.sh" \ "$PROJECT" "$TYPE" "$name" "$thread_id" >>"$log" 2>&1 || true diff --git a/scripts/drivers/types/codex/actas-monitor.sh b/scripts/drivers/types/codex/actas-monitor.sh index 22c10772..ec54b319 100755 --- a/scripts/drivers/types/codex/actas-monitor.sh +++ b/scripts/drivers/types/codex/actas-monitor.sh @@ -7,9 +7,9 @@ set -euo pipefail # actas-monitor.sh [session_id] # # Codex has no native Monitor tool. An explicitly selected monitor/both mode -# binds one agmsg identity to a persisted Codex thread. A live app-server bridge -# is preferred; otherwise a shell-only watcher resumes that same thread only -# when unread mail exists. turn/off never start the background receiver. +# binds one agmsg identity to a persisted Codex thread only when a visible +# app-server bridge is available. Without that bridge the effective mode is +# changed to turn, so unread mail waits for the next visible assistant turn. PROJECT="${1:?Usage: actas-monitor.sh [session_id]}" TYPE="${2:?Missing type}" @@ -395,11 +395,21 @@ start_chat_visible_turn_delivery() { local thread_id="$1" local reason="${2:-foreground_turn_mode}" local meta="$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" + local requested_mode="$MODE" kill_other_project_receivers kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + # A queued foreground check is not a monitor. Preserve an honest delivery + # state so operators do not mistake waiting_for_chat_turn for active receipt. + case "$requested_mode" in + monitor|both) + "$SCRIPT_DIR/../../../delivery.sh" set turn "$TYPE" "$PROJECT" >/dev/null + MODE="turn" + ;; + esac + { printf 'project=%s\n' "$PROJECT" printf 'type=%s\n' "$TYPE" @@ -411,7 +421,7 @@ start_chat_visible_turn_delivery() { printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > "$meta" - echo "status=visible_turn_only team=$TEAM name=$NAME thread=${thread_id:-unresolved} transport=codex-chat-visible-turn reason=$reason" + echo "status=visible_turn_only team=$TEAM name=$NAME thread=${thread_id:-unresolved} transport=codex-chat-visible-turn requested_mode=$requested_mode effective_mode=$MODE reason=$reason" exit 0 } @@ -459,7 +469,7 @@ if [ -z "$APP_SERVER" ] && [ -f "$PORT_FILE" ] && [ -f "$SERVER_PID" ]; then fi if [ -z "$APP_SERVER" ]; then - start_codex_app_monitor "$THREAD_ID" + start_chat_visible_turn_delivery "$THREAD_ID" "visible_app_server_unavailable" fi if [ -z "$THREAD_ID" ]; then @@ -502,16 +512,11 @@ BRIDGE_PID="$(start_bridge "$THREAD_ID")" sleep 0.8 if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then # Codex Desktop rollouts can be unreadable to a standalone app-server while - # the Desktop owns them. Never retry without a thread: thread/start would - # create a new Codex task. Preserve a concrete thread through the explicit - # background receiver, or fall back to foreground turn delivery when the - # loaded thread could not be resolved. + # the Desktop owns them. Never retry through a background CLI resume because + # that can consume mail without updating the visible Codex thread. rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" printf 'actas-monitor: thread attach failed; refusing thread/start for thread=%s\n' "$THREAD_ID" >>"$BRIDGE_LOG" - case "$THREAD_ID" in - ""|loaded) start_chat_visible_turn_delivery "$THREAD_ID" "app_server_thread_attach_failed" ;; - *) start_codex_app_monitor "$THREAD_ID" ;; - esac + start_chat_visible_turn_delivery "$THREAD_ID" "app_server_thread_attach_failed" fi READY_SECONDS="${AGMSG_CODEX_ACTAS_READY_SECONDS:-8}" @@ -519,10 +524,7 @@ sleep "$READY_SECONDS" if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" actas_lock_gc_stale >/dev/null 2>&1 || true - case "$THREAD_ID" in - ""|loaded) start_chat_visible_turn_delivery "$THREAD_ID" "app_server_bridge_exited" ;; - *) start_codex_app_monitor "$THREAD_ID" ;; - esac + start_chat_visible_turn_delivery "$THREAD_ID" "app_server_bridge_exited" fi case "$APP_SERVER" in ws://127.0.0.1:*) @@ -531,10 +533,7 @@ case "$APP_SERVER" in if ! port_alive "$check_port"; then rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" actas_lock_gc_stale >/dev/null 2>&1 || true - case "$THREAD_ID" in - ""|loaded) start_chat_visible_turn_delivery "$THREAD_ID" "app_server_exited" ;; - *) start_codex_app_monitor "$THREAD_ID" ;; - esac + start_chat_visible_turn_delivery "$THREAD_ID" "app_server_exited" fi ;; esac diff --git a/scripts/drivers/types/codex/codex-app-monitor.sh b/scripts/drivers/types/codex/codex-app-monitor.sh index 4a14b9b5..763e3549 100755 --- a/scripts/drivers/types/codex/codex-app-monitor.sh +++ b/scripts/drivers/types/codex/codex-app-monitor.sh @@ -1,32 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -# Event-driven background receiver for an explicitly monitored Codex thread. -# -# The shell-only watcher does not start a model while the inbox is empty. When -# unread mail appears it resumes the same persisted Codex thread, whose first -# action is the official inbox.sh command. The receiver never reads message -# bodies and never marks them read itself. +# This entry point is retained only so older installations fail closed. +# A successful background `codex exec resume` can read and answer agmsg mail +# without rendering the work in Codex Desktop. That violates the visible +# monitor contract, so no environment variable can re-enable this transport. usage() { - cat < -Environment: - AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 - required explicit monitor opt-in - AGMSG_CODEX_APP_MONITOR_TIMEOUT watch-once timeout seconds (default: 300) - AGMSG_CODEX_APP_MONITOR_INTERVAL watch-once poll interval seconds (default: 2) - AGMSG_CODEX_APP_MONITOR_MAX_WAKES stop after N wakes, useful for tests - AGMSG_CODEX_APP_MONITOR_MAX_FAILURES - switch to visible turn delivery after N - consecutive failures (default: 3) - AGMSG_CODEX_APP_MONITOR_FLAGS extra flags passed to "codex exec" - AGMSG_CODEX_APP_MONITOR_CODEX codex binary override - AGMSG_CODEX_APP_MONITOR_DANGEROUS_BYPASS=1 - legacy escape hatch; disabled by default - AGMSG_CODEX_APP_MONITOR_SUPERVISED=1 restart unexpected exits through launchd; - terminal fallback still exits successfully +This legacy background receiver is disabled. +Use a visible app-server bridge, or use turn delivery when no bridge is +available. EOF } @@ -35,418 +21,6 @@ if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then exit 0 fi -if [ "${AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME:-${AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR:-}}" != "1" ]; then - echo "codex-app-monitor: disabled; background thread resume requires explicit monitor opt-in." >&2 - echo "codex-app-monitor: use mode monitor to opt in, or turn delivery for foreground-only handling." >&2 - exit 64 -fi - -PROJECT="${1:?Usage: codex-app-monitor.sh }" -TYPE="${2:?Missing type}" -TEAM="${3:?Missing team}" -NAME="${4:?Missing name}" -THREAD_ID="${5:-}" - -if [ -z "$THREAD_ID" ] || [ "$THREAD_ID" = "loaded" ] || [ "$THREAD_ID" = "new" ]; then - echo "codex-app-monitor: a concrete Codex thread id is required" >&2 - exit 2 -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" -RUN_DIR="$SKILL_DIR/run" - -# shellcheck source=../../../lib/resolve-project.sh -source "$SCRIPT_DIR/../../../lib/resolve-project.sh" -# shellcheck source=../../../lib/storage.sh -source "$SCRIPT_DIR/../../../lib/storage.sh" -# shellcheck source=../../../lib/actas-lock.sh -source "$SCRIPT_DIR/../../../lib/actas-lock.sh" - -PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")" -mkdir -p "$RUN_DIR" - -TIMEOUT="${AGMSG_CODEX_APP_MONITOR_TIMEOUT:-${AGMSG_WATCH_ONCE_TIMEOUT:-300}}" -INTERVAL="${AGMSG_CODEX_APP_MONITOR_INTERVAL:-${AGMSG_WATCH_ONCE_INTERVAL:-2}}" -MAX_WAKES="${AGMSG_CODEX_APP_MONITOR_MAX_WAKES:-0}" -MAX_FAILURES="${AGMSG_CODEX_APP_MONITOR_MAX_FAILURES:-3}" -resolve_codex_bin() { - if [ -n "${AGMSG_CODEX_APP_MONITOR_CODEX:-}" ]; then - printf '%s\n' "$AGMSG_CODEX_APP_MONITOR_CODEX" - return 0 - fi - if [ -x "/Applications/ChatGPT.app/Contents/Resources/codex" ]; then - printf '%s\n' "/Applications/ChatGPT.app/Contents/Resources/codex" - return 0 - fi - if [ -x "/Applications/Codex.app/Contents/Resources/codex" ]; then - printf '%s\n' "/Applications/Codex.app/Contents/Resources/codex" - return 0 - fi - if [ -x "$HOME/.npm-global/bin/codex" ]; then - printf '%s\n' "$HOME/.npm-global/bin/codex" - return 0 - fi - command -v codex 2>/dev/null || printf '%s\n' "codex" -} - -CODEX_BIN="$(resolve_codex_bin)" -OWNER_ID="agmsg-codex-app-monitor-$$.$$" -ACTIVE_CHILD="" -LOCK_CLAIMED=0 - -case "$TIMEOUT" in ''|*[!0-9]*) echo "codex-app-monitor: timeout must be a whole number" >&2; exit 2 ;; esac -case "$INTERVAL" in ''|*[!0-9]*) echo "codex-app-monitor: interval must be a whole number" >&2; exit 2 ;; esac -case "$MAX_WAKES" in ''|*[!0-9]*) echo "codex-app-monitor: max wakes must be a whole number" >&2; exit 2 ;; esac -case "$MAX_FAILURES" in ''|*[!0-9]*) echo "codex-app-monitor: max failures must be a whole number" >&2; exit 2 ;; esac -[ "$INTERVAL" -gt 0 ] || INTERVAL=1 -[ "$MAX_FAILURES" -gt 0 ] || MAX_FAILURES=1 - -PIDFILE="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" -META="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" -LOG="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.log" -LAST_PROMPT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-prompt.txt" -LAST_OUTPUT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-message.txt" -LAST_STATUS="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.last-status" -HEALTH="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.health" -PREFLIGHT_LOG="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.preflight.log" -WATCH_OUTPUT="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.watch-output" -CHAT_META="$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" - -consecutive_failures=0 -last_wake_at="" -last_success_at="" - -write_health() { - local status="$1" last_error="${2:-none}" tmp="$HEALTH.$$" - { - printf 'project=%s\n' "$PROJECT" - printf 'type=%s\n' "$TYPE" - printf 'team=%s\n' "$TEAM" - printf 'name=%s\n' "$NAME" - printf 'thread=%s\n' "$THREAD_ID" - printf 'transport=codex-background-thread-resume\n' - printf 'status=%s\n' "$status" - printf 'consecutive_failures=%s\n' "$consecutive_failures" - printf 'last_error=%s\n' "$last_error" - printf 'last_wake_at=%s\n' "${last_wake_at:-none}" - printf 'last_success_at=%s\n' "${last_success_at:-none}" - printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - } > "$tmp" - mv "$tmp" "$HEALTH" -} - -notify_delivery_failure() { - local message="${1:-未読メッセージを自動配送できません。turn 配送へ退避しました。}" - [ "${AGMSG_CODEX_APP_MONITOR_DISABLE_NOTIFY:-}" = "1" ] && return 0 - command -v osascript >/dev/null 2>&1 || return 0 - /usr/bin/osascript - "$TEAM/$NAME" "$message" <<'APPLESCRIPT' >/dev/null 2>&1 || true -on run argv - display notification (item 2 of argv) with title "agmsg monitor" subtitle (item 1 of argv) -end run -APPLESCRIPT -} - -write_chat_visible_meta() { - local tmp="$CHAT_META.$$" - { - printf 'project=%s\n' "$PROJECT" - printf 'type=%s\n' "$TYPE" - printf 'team=%s\n' "$TEAM" - printf 'name=%s\n' "$NAME" - printf 'thread=%s\n' "$THREAD_ID" - printf 'transport=codex-chat-visible-turn\n' - printf 'status=waiting_for_chat_turn\n' - printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - } > "$tmp" - mv "$tmp" "$CHAT_META" -} - -fallback_to_turn() { - local reason="$1" - write_health "fallback_turn" "$reason" - notify_delivery_failure - # Monitor mode already installs the visible Stop-hook fallback for Codex. - # Do not rewrite the project to turn mode here: that would remove the - # SessionStart hook and prevent automatic rebind after the next restart. - write_chat_visible_meta - write_health "fallback_turn" "$reason" - return "$(terminal_exit_code 70)" -} - -terminal_exit_code() { - if [ "${AGMSG_CODEX_APP_MONITOR_SUPERVISED:-}" = "1" ]; then - printf '0\n' - else - printf '%s\n' "$1" - fi -} - -exit_after_fallback() { - local status=0 - fallback_to_turn "$1" || status=$? - exit "$status" -} - -stop_active_child() { - local child="${ACTIVE_CHILD:-}" check=0 - [ -n "$child" ] || return 0 - if kill -0 "$child" 2>/dev/null; then - kill "$child" 2>/dev/null || true - while [ "$check" -lt 20 ] && kill -0 "$child" 2>/dev/null; do - sleep 0.1 - check=$((check + 1)) - done - if kill -0 "$child" 2>/dev/null; then - kill -KILL "$child" 2>/dev/null || true - fi - fi - wait "$child" 2>/dev/null || true - ACTIVE_CHILD="" -} - -cleanup() { - stop_active_child - if [ "$LOCK_CLAIMED" = "1" ]; then - actas_lock_release "$TEAM" "$NAME" "$OWNER_ID" 2>/dev/null || true - fi - if [ "$(cat "$PIDFILE" 2>/dev/null || true)" = "$$" ]; then - rm -f "$PIDFILE" "$META" - fi - rm -f "$WATCH_OUTPUT" -} -terminate() { - write_health "stopped" "terminated" - trap - EXIT - cleanup - exit 0 -} - -if ! lock_result="$(actas_lock_claim "$TEAM" "$NAME" "$OWNER_ID" 2>&1)"; then - echo "codex-app-monitor: receiver already owns $TEAM/$NAME (${lock_result:-held})" >&2 - exit "$(terminal_exit_code 73)" -fi -LOCK_CLAIMED=1 -trap cleanup EXIT -trap terminate INT TERM - -printf '%s\n' "$$" > "$PIDFILE" -{ - printf 'project=%s\n' "$PROJECT" - printf 'type=%s\n' "$TYPE" - printf 'team=%s\n' "$TEAM" - printf 'name=%s\n' "$NAME" - printf 'thread=%s\n' "$THREAD_ID" - printf 'transport=codex-background-thread-resume\n' - printf 'owner=%s\n' "$OWNER_ID" - if [ -n "${AGMSG_CODEX_APP_MONITOR_LABEL:-}" ]; then - printf 'launch_label=%s\n' "$AGMSG_CODEX_APP_MONITOR_LABEL" - fi -} > "$META" - -if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then - write_health "preflight_failed" "codex_binary_not_found" - echo "codex-app-monitor: codex binary not found: $CODEX_BIN" >&2 - exit "$(terminal_exit_code 3)" -fi - -write_health "starting" "none" -if ! "$CODEX_BIN" mcp list >"$PREFLIGHT_LOG" 2>&1; then - write_health "preflight_failed" "codex_config_invalid" - notify_delivery_failure "自動配送を開始できません。未読メッセージは保持しています。" - echo "codex-app-monitor: codex config preflight failed; see $PREFLIGHT_LOG" >&2 - exit "$(terminal_exit_code 78)" -fi -rm -f "$PREFLIGHT_LOG" -write_health "ready" "none" - -build_prompt() { - local inbox_script="$SKILL_DIR/scripts/inbox.sh" - local send_script="$SKILL_DIR/scripts/send.sh" - cat < - -Autonomous collaboration contract: -1. For a substantive request, review, or new evidence, continue the requested - work through verification and reply with concrete evidence. Do not stop at - an ACK when safe in-scope work remains. -2. Do not reply to ACK-only, thanks-only, or status-only mail that contains no - new request, evidence, correction, or blocker. This prevents reply loops. -3. Preserve all existing safety, approval, production, customer-data, secret, - and scope boundaries. Stop and report a real blocker when new authority or - an unsafe/external mutation would be required. -4. Keep the handling visible in this same Codex thread with concise Japanese - progress and a final status. Never claim completion from delivery alone. -EOF -} - -run_resume() { - local prompt_file="$1" - local -a cmd - local child wait_status - cmd=("$CODEX_BIN" exec) - - # Background turns cannot answer approval prompts. Keep them non-interactive - # and workspace-bounded by default; the agmsg runtime dirs are added so the - # official inbox/send scripts can update their own state. The dangerous - # bypass remains an explicit compatibility escape hatch only. - if [ "${AGMSG_CODEX_APP_MONITOR_DANGEROUS_BYPASS:-}" = "1" ]; then - cmd+=(--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust) - else - cmd+=(-c 'approval_policy="never"' -s workspace-write) - cmd+=(--add-dir "$SKILL_DIR/run" --add-dir "$SKILL_DIR/db" --add-dir "$SKILL_DIR/teams") - fi - if [ -n "${AGMSG_CODEX_APP_MONITOR_FLAGS:-}" ]; then - # shellcheck disable=SC2206 - extra_flags=(${AGMSG_CODEX_APP_MONITOR_FLAGS}) - cmd+=("${extra_flags[@]}") - fi - cmd+=(-C "$PROJECT" -o "$LAST_OUTPUT" resume "$THREAD_ID" -) - - : > "$LAST_STATUS" - printf 'codex-app-monitor: running %q' "${cmd[0]}" - printf ' %q' "${cmd[@]:1}" - printf '\n' - - AGMSG_CODEX_BACKGROUND_RESUME=1 \ - "${cmd[@]}" < "$prompt_file" >>"$LOG" 2>&1 & - child="$!" - ACTIVE_CHILD="$child" - wait "$child" - wait_status=$? - ACTIVE_CHILD="" - printf '%s\n' "$wait_status" > "$LAST_STATUS" - return "$wait_status" -} - -run_watch_once() { - local timeout="$1" interval="$2" child wait_status - : > "$WATCH_OUTPUT" - "$SCRIPT_DIR/watch-once.sh" "$PROJECT" "$TYPE" \ - --team "$TEAM" \ - --name "$NAME" \ - --owner "$OWNER_ID" \ - --claim \ - --timeout "$timeout" \ - --interval "$interval" >"$WATCH_OUTPUT" 2>&1 & - child="$!" - ACTIVE_CHILD="$child" - wait "$child" - wait_status=$? - ACTIVE_CHILD="" - return "$wait_status" -} - -wake_count=0 -printf 'codex-app-monitor: started team=%s name=%s thread=%s pid=%s\n' "$TEAM" "$NAME" "$THREAD_ID" "$$" - -while :; do - set +e - run_watch_once "$TIMEOUT" "$INTERVAL" - watch_status=$? - set -e - watch_output="$(cat "$WATCH_OUTPUT" 2>/dev/null || true)" - rm -f "$WATCH_OUTPUT" - - case "$watch_status" in - 0) - ;; - 2) - continue - ;; - *) - consecutive_failures=$((consecutive_failures + 1)) - write_health "degraded" "watch_once_exit_${watch_status}" - printf 'codex-app-monitor: watch-once failed status=%s output=%s\n' "$watch_status" "$watch_output" >&2 - if [ "$consecutive_failures" -ge "$MAX_FAILURES" ]; then - exit_after_fallback "watch_once_failed_${watch_status}" - fi - sleep 5 - continue - ;; - esac - - pending_max_id="$(printf '%s\n' "$watch_output" | sed -n 's/.*max_id=\([0-9][0-9]*\).*/\1/p' | head -1)" - case "$pending_max_id" in ''|*[!0-9]*) pending_max_id=0 ;; esac - if [ "$pending_max_id" -le 0 ]; then - consecutive_failures=$((consecutive_failures + 1)) - write_health "degraded" "watch_once_missing_max_id" - printf 'codex-app-monitor: watch-once wake omitted a valid max_id: %s\n' "$watch_output" >&2 - if [ "$consecutive_failures" -ge "$MAX_FAILURES" ]; then - exit_after_fallback "watch_once_missing_max_id" - fi - sleep 5 - continue - fi - build_prompt > "$LAST_PROMPT" - wake_count=$((wake_count + 1)) - last_wake_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - write_health "delivering" "none" - printf 'codex-app-monitor: wakeup %s for %s/%s thread=%s\n' "$wake_count" "$TEAM" "$NAME" "$THREAD_ID" - - set +e - run_resume "$LAST_PROMPT" - resume_status=$? - set -e - if [ "$resume_status" -ne 0 ]; then - consecutive_failures=$((consecutive_failures + 1)) - write_health "degraded" "codex_exec_resume_exit_${resume_status}" - printf 'codex-app-monitor: codex exec resume failed status=%s prompt=%s output=%s\n' "$resume_status" "$LAST_PROMPT" "$LAST_OUTPUT" >&2 - # A non-zero CLI status does not prove that the app rejected the turn - # before creating it. Retrying the same unread high-water mark could wake - # the visible task twice, so preserve the inbox and stop after one attempt. - exit_after_fallback "codex_exec_resume_failed_${resume_status}" - else - # Success means the resumed thread ran. Verify that it also consumed the - # high-water mark through inbox.sh; otherwise keep the message unread and - # degrade instead of silently declaring delivery. - set +e - run_watch_once 0 1 - post_status=$? - set -e - post_output="$(cat "$WATCH_OUTPUT" 2>/dev/null || true)" - rm -f "$WATCH_OUTPUT" - post_max_id="$(printf '%s\n' "$post_output" | sed -n 's/.*max_id=\([0-9][0-9]*\).*/\1/p' | head -1)" - case "$post_max_id" in ''|*[!0-9]*) post_max_id=0 ;; esac - if [ "$post_status" -eq 0 ] && [ "$post_max_id" -eq "$pending_max_id" ]; then - consecutive_failures=$((consecutive_failures + 1)) - write_health "degraded" "thread_did_not_consume_inbox" - printf 'codex-app-monitor: resumed thread did not consume max_id=%s\n' "$pending_max_id" >&2 - # A successful resume already created a turn. Never wake the same unread - # high-water mark twice; preserve it and fall back for operator recovery. - exit_after_fallback "thread_did_not_consume_inbox" - elif [ "$post_status" -eq 0 ]; then - # A different high-water mark means the resumed turn consumed the - # original batch while newer mail arrived (or left another batch). Re-arm - # immediately so the new mail gets its own serialized turn. - consecutive_failures=0 - last_success_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - write_health "pending" "new_mail_after_resume" - elif [ "$post_status" -eq 2 ]; then - consecutive_failures=0 - last_success_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - write_health "ready" "none" - else - consecutive_failures=$((consecutive_failures + 1)) - write_health "degraded" "post_watch_exit_${post_status}" - printf 'codex-app-monitor: post-resume watch failed status=%s output=%s\n' "$post_status" "$post_output" >&2 - # The wake already ran and consumption cannot be proved. Fail closed - # instead of risking a second turn for the same unread message. - exit_after_fallback "post_watch_failed_${post_status}" - fi - fi - - if [ "$MAX_WAKES" -gt 0 ] && [ "$wake_count" -ge "$MAX_WAKES" ]; then - write_health "stopped" "max_wakes_reached" - exit 0 - fi -done +echo "codex-app-monitor: disabled; background-only handling is prohibited." >&2 +echo "codex-app-monitor: use a visible app-server bridge or visible turn delivery." >&2 +exit 64 diff --git a/scripts/drivers/types/codex/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js index 61c655ab..11d1df7e 100755 --- a/scripts/drivers/types/codex/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -1048,11 +1048,12 @@ class CodexBridge { ].join("\n"); const visibleUiRequirement = [ "Visible UI requirement:", - '1. Before the first tool call, post a short Japanese progress update in the Codex thread UI starting with "agmsg対応状況:" and include sender, summary, planned action, and whether you will reply.', - "2. Keep substantive work in the visible thread. Before each major action, post a short Japanese progress update; do not complete the task in an unreported background worker.", - "3. After handling the message, post a final Japanese status update with: sender, received instruction, action taken, reply target, reply summary, remaining blocker, and next step.", - "4. If you do not reply, state why in the visible status.", - "5. Do not treat DB writes, monitor delivery, or a send.sh result as complete unless the handling is visible in the Codex thread UI.", + '1. Before the inbox tool call, post "agmsg受信を検知しました。内容を確認します。" in the visible Codex thread.', + '2. Immediately after inbox.sh and before any other tool call, post a Japanese update starting with "agmsg受信:" and include sender, received body or safe summary, planned action, and whether you will reply.', + "3. Keep substantive work in the visible thread. Before each major action, post a short Japanese progress update; never complete the task in an unreported background worker.", + "4. After handling the message, post a final Japanese status update with: sender, received instruction, action taken, reply target, reply summary, remaining blocker, and next step.", + "5. If you do not reply, state why in the visible status. ACK-only mail still requires a visible receipt notice.", + "6. Do not treat inbox consumption, DB writes, monitor delivery, send.sh, or a successful process exit as complete unless the handling result is visible in the Codex thread UI.", ].join("\n"); if (this.opts.inlineInbox) { return [ diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index f36358d8..4a99e1fb 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -52,10 +52,9 @@ Four possible outputs: 2) off — No automatic delivery Manual $__SKILL_NAME__ only. - 3) monitor — Persistent autonomous delivery (BETA) - A shell-only watcher is free while idle. Unread mail - resumes this same Codex task to read, work, and reply. - SessionStart rebinds the last actas role after restart. + 3) monitor — Visible app-server delivery (BETA) + Requires a bridge attached to this Codex task. If the + bridge is unavailable, delivery safely degrades to turn. [1]: ``` @@ -65,7 +64,7 @@ Four possible outputs: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` - If monitor is chosen, immediately bind the newly joined role by running: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"` - - If monitor is chosen, tell the user: "Codex monitor is a BETA. It persistently binds the last `actas` role to this task. Waiting is shell-only; unread mail resumes this same task, whose first action is the official inbox command. Monitor mode is explicit opt-in and remains active until mode turn/off or drop." + - If monitor is chosen, report whether the visible app-server bridge attached. If it did not, say that the effective mode was downgraded to `turn`. 6. Then check inbox for the newly joined team. @@ -83,20 +82,22 @@ Four possible outputs: **Only use scripts in `~/.agents/skills/__SKILL_NAME__/scripts/` — do not read or modify files under `teams/` or `db/` directly.** -### Codex event-driven receiver +### Codex visible monitor invariant -`mode monitor` is an explicit opt-in to background resumption of the same -persisted Codex task. After `actas-monitor.sh` binds a concrete role and thread: +`mode monitor` may deliver mail only through an app-server bridge that inserts +the handling into the same visible Codex thread. -1. `watch-once.sh` waits for unread state in a shell-only process. It does not - start a model, read a message body, or mark a message read while idle. -2. On unread mail, the receiver runs `codex exec resume ` once. -3. The resumed task must run the official `inbox.sh` first. Only that command - reads the body and marks it read. -4. Substantive requests continue through work, verification, and an evidence- - backed reply. ACK-only mail does not trigger a reply, preventing ping-pong. -5. `mode turn`, `mode off`, and `drop` stop the receiver. Do not create cron, - heartbeat, or scheduled polling jobs for Codex agmsg delivery. +1. A shell watcher may detect unread state, but it must not read the body, mark + it read, start substantive work, or reply by itself. +2. Before `inbox.sh`, show a short visible notice that agmsg mail was detected. +3. Immediately after `inbox.sh`, show the sender, body or safe summary, planned + action, and whether a reply is needed before any other tool call. +4. Keep progress, decisions, blockers, replies, and the final result visible in + the same Codex thread. ACK-only mail still requires a visible receipt notice. +5. Background `codex exec resume` delivery is prohibited. +6. If a visible bridge cannot attach, keep mail unread, change the effective + mode to `turn`, and fall back to the next visible turn. +7. Do not create cron, heartbeat, or scheduled polling jobs for Codex delivery. **If no arguments provided (DEFAULT action — always do this when the command is invoked without arguments):** 1. **IMMEDIATELY** run inbox check for each TEAM: `~/.agents/skills/__SKILL_NAME__/scripts/inbox.sh $TEAM $AGENT` @@ -130,7 +131,7 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): 2. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" codex` to see whether the role is already registered for this (project, type). 3. If the name does not appear in the output, join under the existing team. For a single team, run `~/.agents/skills/__SKILL_NAME__/scripts/join.sh codex "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `` for every `send.sh` call until another `actas`. -5. Rebind the receive side by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. In monitor/both mode, prefer a visible app-server bridge; otherwise start the event-driven same-task receiver. In turn/off mode, never start a background receiver. +5. Rebind the receive side by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. In monitor/both mode, allow only a visible app-server bridge. If it cannot attach, keep mail unread and downgrade to `turn`. Never start a background receiver. 6. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). 7. If `` starts with `codex-pro-`, tell the user: "Now acting as ``. This role is an Oracle GPT-5.5 Pro consult route; run `$__SKILL_NAME__` to process its unread inbox through `oracle-pro-reply.sh`. Real runs may control the signed-in ChatGPT browser." 8. Otherwise tell the user: "Now acting as ``. Sends and receive are bound to ``." @@ -166,7 +167,7 @@ If argument starts with "mode" followed by a mode name (e.g. "mode monitor"): 2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` 3. If mode is `monitor` or `both` and an active role can be resolved, run `actas-monitor.sh` for that role and concrete thread id. 4. If mode is `turn` or `off`, confirm that the background receiver stopped. -5. If mode is `monitor` or `both`, tell the user: "Persistent autonomous Codex delivery is enabled. Idle waiting is shell-only; unread mail resumes this same task to run the official inbox, continue substantive work, and reply. No scheduled polling jobs are created." +5. If mode is `monitor` or `both`, report whether a visible app-server bridge attached. If it did not attach, report that the effective mode was downgraded to `turn`. Never describe visible-turn fallback as an active monitor. If argument is "hook on" (legacy alias): 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set turn codex "$(pwd)"` diff --git a/scripts/session-end.sh b/scripts/session-end.sh index 9193c8fa..5a0444f3 100755 --- a/scripts/session-end.sh +++ b/scripts/session-end.sh @@ -29,9 +29,9 @@ source "$SCRIPT_DIR/lib/actas-lock.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/resolve-project.sh" -# A background `codex exec resume` fires the same hooks as the visible task. -# Its parent receiver owns lifecycle cleanup, so the internal SessionEnd must -# not stop that receiver or release its role while it is handling a batch. +# Compatibility guard for legacy background-resume processes created before +# that transport was disabled. Their parent owns cleanup, so this hook must not +# change role state while an old process is still unwinding. if [ "$TYPE" = "codex" ] && [ "${AGMSG_CODEX_BACKGROUND_RESUME:-}" = "1" ]; then exit 0 fi diff --git a/tests/test_codex_bridge.bats b/tests/test_codex_bridge.bats index ed50a491..d7979133 100644 --- a/tests/test_codex_bridge.bats +++ b/tests/test_codex_bridge.bats @@ -803,7 +803,7 @@ rl.on("line", (line) => { send({ jsonrpc: "2.0", id: message.id, error: { message: "missing inline inbox body" } }); return; } - if (!message.params.input[0].text.includes("agmsg対応状況:")) { + if (!message.params.input[0].text.includes('starting with "agmsg受信:"')) { send({ jsonrpc: "2.0", id: message.id, error: { message: "missing visible progress contract" } }); return; } diff --git a/tests/test_codex_visible_monitor.bats b/tests/test_codex_visible_monitor.bats index 03347ba6..80b23221 100644 --- a/tests/test_codex_visible_monitor.bats +++ b/tests/test_codex_visible_monitor.bats @@ -83,11 +83,11 @@ join_codex_roles() { [[ "$output" == *"visible-bob-message"* ]] } -@test "codex background thread receiver is disabled unless monitor explicitly opts in" { +@test "codex background thread receiver is always disabled" { run bash "$TYPES/codex/codex-app-monitor.sh" \ "$TEST_PROJECT" codex team bob thread-123 [ "$status" -eq 64 ] - [[ "$output" == *"disabled"* ]] + [[ "$output" == *"background-only handling is prohibited"* ]] [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" ] } @@ -116,74 +116,9 @@ EOF printf '%s\n' "$fake" } -@test "codex monitor does not start a model while inbox is empty" { - skip_on_windows "background receiver liveness uses POSIX signals" - join_codex_roles - local fake log prompt monitor_pid - fake="$(make_fake_codex)" - log="$TEST_SKILL_DIR/fake-codex.log" - prompt="$TEST_SKILL_DIR/fake-codex.prompt" - - env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ - AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ - AGMSG_CODEX_APP_MONITOR_TIMEOUT=1 \ - AGMSG_CODEX_APP_MONITOR_INTERVAL=1 \ - AGMSG_FAKE_CODEX_LOG="$log" \ - AGMSG_FAKE_CODEX_PROMPT="$prompt" \ - AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ - bash "$TYPES/codex/codex-app-monitor.sh" \ - "$TEST_PROJECT" codex team bob thread-empty >/dev/null 2>&1 & - monitor_pid=$! - - for _ in {1..30}; do - grep -qx "status=ready" "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" 2>/dev/null && break - sleep 0.1 - done - sleep 1.2 - kill "$monitor_pid" 2>/dev/null || true - wait "$monitor_pid" 2>/dev/null || true - - grep -q " " "$log" - run ! grep -q "" "$log" - [ ! -e "$prompt" ] -} - -@test "codex monitor resumes the same thread and leaves body ownership to inbox.sh" { - join_codex_roles - bash "$SCRIPTS/send.sh" team alice bob "secret-body-must-not-be-in-wake-prompt" >/dev/null - local fake log prompt - fake="$(make_fake_codex)" - log="$TEST_SKILL_DIR/fake-codex.log" - prompt="$TEST_SKILL_DIR/fake-codex.prompt" - - run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ - AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ - AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ - AGMSG_CODEX_APP_MONITOR_MAX_WAKES=1 \ - AGMSG_FAKE_CODEX_LOG="$log" \ - AGMSG_FAKE_CODEX_PROMPT="$prompt" \ - AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ - AGMSG_FAKE_CODEX_CONSUME=1 \ - bash "$TYPES/codex/codex-app-monitor.sh" \ - "$TEST_PROJECT" codex team bob thread-123 - [ "$status" -eq 0 ] - - grep -q " " "$log" - grep -q '<-s> ' "$log" - grep -q '<-c> ' "$log" - grep -q 'BACKGROUND <1>' "$log" - run ! grep -q -- "--dangerously-bypass-approvals-and-sandbox" "$log" - grep -q "$SCRIPTS/inbox.sh team bob" "$prompt" - run ! grep -q "secret-body-must-not-be-in-wake-prompt" "$prompt" - - run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ - --team team --name bob --timeout 0 - [ "$status" -eq 2 ] -} - -@test "codex monitor never marks unread mail when resumed thread fails to consume it" { +@test "legacy background opt-in cannot resume a Codex thread or consume unread mail" { join_codex_roles - bash "$SCRIPTS/send.sh" team alice bob "preserve-me" >/dev/null + bash "$SCRIPTS/send.sh" team alice bob "preserve-visible-mail" >/dev/null local fake log prompt fake="$(make_fake_codex)" log="$TEST_SKILL_DIR/fake-codex.log" @@ -191,17 +126,15 @@ EOF run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ - AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ - AGMSG_CODEX_APP_MONITOR_MAX_FAILURES=1 \ AGMSG_FAKE_CODEX_LOG="$log" \ AGMSG_FAKE_CODEX_PROMPT="$prompt" \ AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ bash "$TYPES/codex/codex-app-monitor.sh" \ "$TEST_PROJECT" codex team bob thread-123 - [ "$status" -eq 70 ] - [ "$(grep -c '' "$log")" -eq 1 ] - grep -qx "last_error=thread_did_not_consume_inbox" \ - "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" + [ "$status" -eq 64 ] + [[ "$output" == *"background-only handling is prohibited"* ]] + [ ! -e "$log" ] + [ ! -e "$prompt" ] run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ --team team --name bob --timeout 0 @@ -209,26 +142,34 @@ EOF [[ "$output" == *"count=1"* ]] } -@test "codex monitor never retries an unread id after resume returns an error" { +@test "monitor request without a visible app-server downgrades to turn and preserves unread mail" { join_codex_roles - bash "$SCRIPTS/send.sh" team alice bob "one-wake-only" >/dev/null + bash "$SCRIPTS/send.sh" team alice bob "visible-turn-required" >/dev/null + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null local fake log prompt fake="$(make_fake_codex)" log="$TEST_SKILL_DIR/fake-codex.log" prompt="$TEST_SKILL_DIR/fake-codex.prompt" - run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + run env CODEX_THREAD_ID=thread-123 \ + AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ - AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ - AGMSG_CODEX_APP_MONITOR_MAX_FAILURES=3 \ AGMSG_FAKE_CODEX_LOG="$log" \ AGMSG_FAKE_CODEX_PROMPT="$prompt" \ AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ - AGMSG_FAKE_CODEX_EXEC_STATUS=42 \ - bash "$TYPES/codex/codex-app-monitor.sh" \ - "$TEST_PROJECT" codex team bob thread-123 - [ "$status" -eq 70 ] - [ "$(grep -c '' "$log")" -eq 1 ] + bash "$TYPES/codex/actas-monitor.sh" \ + "$TEST_PROJECT" codex bob thread-123 + [ "$status" -eq 0 ] + [[ "$output" == *"requested_mode=monitor"* ]] + [[ "$output" == *"effective_mode=turn"* ]] + [[ "$output" == *"reason=visible_app_server_unavailable"* ]] + [ ! -e "$log" ] + [ ! -e "$prompt" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" ] + + run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" == *"mode: turn"* ]] run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ --team team --name bob --timeout 0 @@ -236,34 +177,25 @@ EOF [[ "$output" == *"count=1"* ]] } -@test "supervised codex monitor exits successfully after one failed wake" { +@test "repeated monitor requests without a visible app-server never start a receiver" { join_codex_roles - bash "$SCRIPTS/send.sh" team alice bob "supervisor-must-not-restart" >/dev/null - local fake log prompt - fake="$(make_fake_codex)" - log="$TEST_SKILL_DIR/fake-codex.log" - prompt="$TEST_SKILL_DIR/fake-codex.prompt" + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ - AGMSG_CODEX_APP_MONITOR_SUPERVISED=1 \ - AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ - AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ - AGMSG_CODEX_APP_MONITOR_MAX_FAILURES=3 \ - AGMSG_FAKE_CODEX_LOG="$log" \ - AGMSG_FAKE_CODEX_PROMPT="$prompt" \ - AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ - AGMSG_FAKE_CODEX_EXEC_STATUS=42 \ - bash "$TYPES/codex/codex-app-monitor.sh" \ - "$TEST_PROJECT" codex team bob thread-123 + run env CODEX_THREAD_ID=thread-123 \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-123 [ "$status" -eq 0 ] - [ "$(grep -c '' "$log")" -eq 1 ] - grep -qx "last_error=codex_exec_resume_failed_42" \ - "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" + [[ "$output" == *"effective_mode=turn"* ]] - run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ - --team team --name bob --timeout 0 + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + run env CODEX_THREAD_ID=thread-123 \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-123 [ "$status" -eq 0 ] - [[ "$output" == *"count=1"* ]] + [[ "$output" == *"effective_mode=turn"* ]] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" ] + + run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" == *"mode: turn"* ]] } @test "delivery turn stops an app monitor even when no bridge pidfile exists" { @@ -359,42 +291,32 @@ EOF TEST_RECEIVER_PIDS="" } -@test "codex monitor rejects a second receiver for the same role" { - skip_on_windows "background receiver liveness uses POSIX signals" +@test "legacy monitor opt-in cannot create a second hidden receiver" { join_codex_roles - local fake log prompt first_pid owner_pid + local fake log prompt fake="$(make_fake_codex)" log="$TEST_SKILL_DIR/fake-codex.log" prompt="$TEST_SKILL_DIR/fake-codex.prompt" - env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ - AGMSG_CODEX_APP_MONITOR_TIMEOUT=30 \ AGMSG_FAKE_CODEX_LOG="$log" \ AGMSG_FAKE_CODEX_PROMPT="$prompt" \ AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ bash "$TYPES/codex/codex-app-monitor.sh" \ - "$TEST_PROJECT" codex team bob thread-owner >/dev/null 2>&1 & - first_pid=$! - TEST_RECEIVER_PIDS="$first_pid" - for _ in {1..30}; do - grep -qx "status=ready" "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.health" 2>/dev/null && break - sleep 0.1 - done - owner_pid="$(cat "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid")" + "$TEST_PROJECT" codex team bob thread-owner + [ "$status" -eq 64 ] run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ - AGMSG_CODEX_APP_MONITOR_TIMEOUT=0 \ AGMSG_FAKE_CODEX_LOG="$log" \ AGMSG_FAKE_CODEX_PROMPT="$prompt" \ AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ bash "$TYPES/codex/codex-app-monitor.sh" \ "$TEST_PROJECT" codex team bob thread-duplicate - [ "$status" -eq 73 ] - [[ "$output" == *"receiver already owns team/bob"* ]] - [ "$(cat "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid")" = "$owner_pid" ] - kill -0 "$first_pid" 2>/dev/null + [ "$status" -eq 64 ] + [ ! -e "$log" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-monitor.team.bob.pid" ] } @test "background resume hooks do not rebind or stop their parent receiver" { @@ -475,9 +397,11 @@ EOF TEST_RECEIVER_PIDS="" } -@test "codex monitor supervisor restarts crashes but never falls back to a new task" { - grep -q 'AGMSG_CODEX_APP_MONITOR_SUPERVISED' "$TYPES/codex/actas-monitor.sh" - grep -A3 -q 'SuccessfulExit' "$TYPES/codex/actas-monitor.sh" +@test "codex monitor never falls back to hidden resume or a new task" { + grep -q 'background-only handling is prohibited' "$TYPES/codex/codex-app-monitor.sh" + grep -q 'visible_app_server_unavailable' "$TYPES/codex/actas-monitor.sh" + run grep -q 'start_codex_app_monitor "$THREAD_ID"' "$TYPES/codex/actas-monitor.sh" + [ "$status" -ne 0 ] run grep -q 'start_bridge ""' "$TYPES/codex/actas-monitor.sh" [ "$status" -ne 0 ] run grep -q 'THREAD_ID="new"' "$TYPES/codex/actas-monitor.sh" From 1a0a668b4ee6fc17af6a2ff7e270ce1092847f8c Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Tue, 14 Jul 2026 02:11:14 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[auto]=20Codex=E7=9B=A3=E8=A6=96=E3=81=AE?= =?UTF-8?q?=E9=99=8D=E6=A0=BC=E8=A1=A8=E7=A4=BA=E3=82=92=E6=98=8E=E7=A2=BA?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/drivers/types/codex/_delivery.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh index 65ae89a5..d1653af2 100644 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -4,8 +4,8 @@ # codex keeps the default JSON event-hooks apply (agmsg_delivery_apply); it adds # enable/disable side effects (print the monitor shim setup on enable, stop the # receiver on disable) and replaces the runtime status summary with Codex -# receiver liveness. Monitor mode requires a visible app-server bridge; -# turn/off never start a background receiver. +# receiver liveness. Monitor mode may use only a visible app-server bridge; +# turn/off and visible-turn fallback never start a background receiver. # Sourced into delivery.sh's context, # so SKILL_DIR, SCRIPT_DIR, RUN_DIR, agmsg_resolve_node, CODEX_MONITOR_DOC_URL # and stop_codex_bridge are in scope. @@ -30,9 +30,10 @@ agmsg_delivery_status() { } agmsg_delivery_on_enable() { - echo "Codex monitor beta is enabled." - echo "After actas binds a concrete role/thread, monitor requires a visible app-server bridge." - echo "Without that bridge, actas downgrades the effective mode to turn and keeps mail unread." + echo "Codex visible monitor beta is enabled." + echo "After actas binds a role, only a visible app-server bridge may deliver unread mail." + echo "If the bridge cannot attach, mail stays unread until the next visible Codex turn." + echo "Background codex exec resume handling is prohibited." echo "Add this shell function to your interactive shell profile, then restart the shell:" if "$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" function; then echo "Future Codex sessions: launch with codex. In monitor-mode projects, the agmsg function routes interactive Codex sessions through the bridge." From aac4a798412d23aaee47febe9cf7a676fafd33b1 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Tue, 14 Jul 2026 22:19:57 +0900 Subject: [PATCH 08/12] [auto] Add adaptive Codex scheduled monitor --- .../types/codex/codex-scheduled-monitor.sh | 414 ++++++++++++++++++ tests/test_codex_scheduled_monitor.bats | 116 +++++ 2 files changed, 530 insertions(+) create mode 100755 scripts/drivers/types/codex/codex-scheduled-monitor.sh create mode 100644 tests/test_codex_scheduled_monitor.bats diff --git a/scripts/drivers/types/codex/codex-scheduled-monitor.sh b/scripts/drivers/types/codex/codex-scheduled-monitor.sh new file mode 100755 index 00000000..5fda592d --- /dev/null +++ b/scripts/drivers/types/codex/codex-scheduled-monitor.sh @@ -0,0 +1,414 @@ +#!/usr/bin/env bash +set -euo pipefail + +# State machine for one ChatGPT Scheduled task that returns to the same Codex +# conversation. It only checks unread metadata. ChatGPT remains responsible for +# running inbox.sh visibly and for changing this task's own recurrence. + +ACTION="${1:-}" +shift || true + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +RUN_DIR="${AGMSG_RUN_PATH:-$SKILL_DIR/run}" +WATCH_ONCE="${AGMSG_WATCH_ONCE:-$SCRIPT_DIR/watch-once.sh}" + +# shellcheck source=/dev/null +source "$SCRIPT_DIR/../../../lib/hash.sh" +# shellcheck source=/dev/null +source "$SCRIPT_DIR/../../../lib/resolve-project.sh" +# shellcheck source=/dev/null +source "$SCRIPT_DIR/../../../lib/validate.sh" + +FAST_WINDOW="${AGMSG_CODEX_SCHEDULED_FAST_WINDOW:-1800}" +MEDIUM_WINDOW="${AGMSG_CODEX_SCHEDULED_MEDIUM_WINDOW:-14400}" +TTL="${AGMSG_CODEX_SCHEDULED_TTL:-86400}" +FAST_INTERVAL="${AGMSG_CODEX_SCHEDULED_FAST_INTERVAL:-120}" +MEDIUM_INTERVAL="${AGMSG_CODEX_SCHEDULED_MEDIUM_INTERVAL:-900}" +SLOW_INTERVAL="${AGMSG_CODEX_SCHEDULED_SLOW_INTERVAL:-3600}" +RETRY_AFTER="${AGMSG_CODEX_SCHEDULED_RETRY_AFTER:-300}" + +usage() { + cat >&2 <<'EOF' +Usage: + codex-scheduled-monitor.sh arm [--now ] + codex-scheduled-monitor.sh check [--now ] [--force] + codex-scheduled-monitor.sh delivered [--now ] + codex-scheduled-monitor.sh scheduled + codex-scheduled-monitor.sh status [--now ] + codex-scheduled-monitor.sh stop + codex-scheduled-monitor.sh prompt +EOF + exit 64 +} + +whole_number() { + case "$1" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac +} + +reject_line_breaks() { + case "$1" in *$'\n'*|*$'\r'*|*$'\t'*) return 1 ;; *) return 0 ;; esac +} + +for value in "$FAST_WINDOW" "$MEDIUM_WINDOW" "$TTL" \ + "$FAST_INTERVAL" "$MEDIUM_INTERVAL" "$SLOW_INTERVAL" "$RETRY_AFTER"; do + whole_number "$value" || { echo "agmsg: scheduled timing values must be whole numbers" >&2; exit 64; } +done +[ "$FAST_WINDOW" -lt "$MEDIUM_WINDOW" ] || { echo "agmsg: fast window must end before medium window" >&2; exit 64; } +[ "$MEDIUM_WINDOW" -lt "$TTL" ] || { echo "agmsg: medium window must end before ttl" >&2; exit 64; } + +PROJECT="" +TEAM="" +NAME="" +OWNER="" +STATE="" +LOCK="" +STATUS="active" +CYCLE_STARTED_AT=0 +EXPIRES_AT=0 +LAST_CHECKED_AT=0 +LAST_SEEN_ID=0 +LAST_DELIVERED_ID=0 +PENDING_ID=0 +PENDING_AT=0 +FAILURES=0 +SCHEDULED_INTERVAL="$FAST_INTERVAL" +NOW="" +FORCE=0 + +state_field() { + local key="$1" file="$2" + sed -n "s/^${key}=//p" "$file" 2>/dev/null | head -1 +} + +load_state() { + [ -f "$STATE" ] || return 1 + STATUS="$(state_field status "$STATE")" + CYCLE_STARTED_AT="$(state_field cycle_started_at "$STATE")" + EXPIRES_AT="$(state_field expires_at "$STATE")" + LAST_CHECKED_AT="$(state_field last_checked_at "$STATE")" + LAST_SEEN_ID="$(state_field last_seen_id "$STATE")" + LAST_DELIVERED_ID="$(state_field last_delivered_id "$STATE")" + PENDING_ID="$(state_field pending_id "$STATE")" + PENDING_AT="$(state_field pending_at "$STATE")" + FAILURES="$(state_field failures "$STATE")" + SCHEDULED_INTERVAL="$(state_field scheduled_interval "$STATE")" + for value in CYCLE_STARTED_AT EXPIRES_AT LAST_CHECKED_AT LAST_SEEN_ID \ + LAST_DELIVERED_ID PENDING_ID PENDING_AT FAILURES SCHEDULED_INTERVAL; do + whole_number "${!value:-}" || printf -v "$value" 0 + done + [ "$SCHEDULED_INTERVAL" -gt 0 ] || SCHEDULED_INTERVAL="$FAST_INTERVAL" +} + +write_state() { + local tmp="$STATE.$$" + mkdir -p "$RUN_DIR" + { + printf 'project=%s\n' "$PROJECT" + printf 'type=codex\n' + printf 'team=%s\n' "$TEAM" + printf 'name=%s\n' "$NAME" + printf 'owner=%s\n' "$OWNER" + printf 'status=%s\n' "$STATUS" + printf 'cycle_started_at=%s\n' "$CYCLE_STARTED_AT" + printf 'expires_at=%s\n' "$EXPIRES_AT" + printf 'last_checked_at=%s\n' "$LAST_CHECKED_AT" + printf 'last_seen_id=%s\n' "$LAST_SEEN_ID" + printf 'last_delivered_id=%s\n' "$LAST_DELIVERED_ID" + printf 'pending_id=%s\n' "$PENDING_ID" + printf 'pending_at=%s\n' "$PENDING_AT" + printf 'failures=%s\n' "$FAILURES" + printf 'scheduled_interval=%s\n' "$SCHEDULED_INTERVAL" + } > "$tmp" + mv "$tmp" "$STATE" +} + +release_lock() { + [ -n "$LOCK" ] || return 0 + rm -f "$LOCK/pid" 2>/dev/null || true + rmdir "$LOCK" 2>/dev/null || true +} + +acquire_lock() { + local attempts=0 owner + mkdir -p "$RUN_DIR" + while ! mkdir "$LOCK" 2>/dev/null; do + owner="$(cat "$LOCK/pid" 2>/dev/null || true)" + if [ -z "$owner" ] || ! kill -0 "$owner" 2>/dev/null; then + rm -rf "$LOCK" + continue + fi + attempts=$((attempts + 1)) + if [ "$attempts" -ge 40 ]; then + echo "status=busy" + exit 75 + fi + sleep 0.05 + done + printf '%s\n' "$$" > "$LOCK/pid" + trap release_lock EXIT INT TERM +} + +init_identity() { + PROJECT="${1:?Missing project}" + TEAM="${2:?Missing team}" + NAME="${3:?Missing name}" + OWNER="${4:?Missing owner}" + agmsg_validate_team_name "$TEAM" + agmsg_validate_agent_name "$NAME" + reject_line_breaks "$PROJECT" || { echo "agmsg: invalid project path" >&2; exit 64; } + reject_line_breaks "$OWNER" || { echo "agmsg: invalid owner" >&2; exit 64; } + [ -n "$OWNER" ] || { echo "agmsg: scheduled monitor needs an exact owner" >&2; exit 64; } + PROJECT="$(agmsg_resolve_project "$PROJECT" codex)" + local key + key="$(printf '%s\t%s\t%s\t%s' "$PROJECT" "$TEAM" "$NAME" "$OWNER" | agmsg_sha1)" + STATE="$RUN_DIR/codex-scheduled-monitor.$key.state" + LOCK="$STATE.lock" +} + +parse_now_options() { + NOW="" + FORCE=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --now) NOW="${2:?--now needs epoch seconds}"; shift 2 ;; + --force) FORCE=1; shift ;; + *) usage ;; + esac + done + [ -n "$NOW" ] || NOW="$(date +%s)" + whole_number "$NOW" || { echo "agmsg: --now must be epoch seconds" >&2; exit 64; } +} + +stage_for_elapsed() { + local elapsed="$1" + if [ "$elapsed" -lt "$FAST_WINDOW" ]; then + echo fast + elif [ "$elapsed" -lt "$MEDIUM_WINDOW" ]; then + echo medium + else + echo slow + fi +} + +interval_for_stage() { + case "$1" in + fast) echo "$FAST_INTERVAL" ;; + medium) echo "$MEDIUM_INTERVAL" ;; + slow) echo "$SLOW_INTERVAL" ;; + *) return 1 ;; + esac +} + +rrule_for_interval() { + case "$1" in + 120) echo 'FREQ=MINUTELY;INTERVAL=2' ;; + 900) echo 'FREQ=MINUTELY;INTERVAL=15' ;; + 3600) echo 'FREQ=HOURLY;INTERVAL=1' ;; + *) printf 'FREQ=SECONDLY;INTERVAL=%s\n' "$1" ;; + esac +} + +emit_runtime_status() { + local now="$1" elapsed stage interval change next_due + elapsed=$((now - CYCLE_STARTED_AT)) + [ "$elapsed" -ge 0 ] || elapsed=0 + stage="$(stage_for_elapsed "$elapsed")" + interval="$(interval_for_stage "$stage")" + change=0 + [ "$interval" -eq "$SCHEDULED_INTERVAL" ] || change=1 + next_due=$((LAST_CHECKED_AT + SCHEDULED_INTERVAL)) + printf 'stage=%s elapsed=%s next_interval=%s next_rrule=%s schedule_change=%s next_due_at=%s expires_at=%s' \ + "$stage" "$elapsed" "$interval" "$(rrule_for_interval "$interval")" \ + "$change" "$next_due" "$EXPIRES_AT" +} + +case "$ACTION" in + arm) + [ "$#" -ge 4 ] || usage + init_identity "$1" "$2" "$3" "$4" + shift 4 + parse_now_options "$@" + acquire_lock + STATUS="active" + CYCLE_STARTED_AT="$NOW" + EXPIRES_AT=$((NOW + TTL)) + # The Scheduled task is created at the two-minute cadence, so the first + # check is due two minutes after arming. Recording NOW also rejects an + # accidental duplicate run immediately after task creation. + LAST_CHECKED_AT="$NOW" + LAST_SEEN_ID=0 + LAST_DELIVERED_ID=0 + PENDING_ID=0 + PENDING_AT=0 + FAILURES=0 + SCHEDULED_INTERVAL="$FAST_INTERVAL" + write_state + printf 'status=active ' + emit_runtime_status "$NOW" + printf '\n' + ;; + check) + [ "$#" -ge 4 ] || usage + init_identity "$1" "$2" "$3" "$4" + shift 4 + parse_now_options "$@" + acquire_lock + load_state || { echo "status=inactive"; exit 3; } + [ "$STATUS" = "active" ] || { printf 'status=%s\n' "$STATUS"; exit 3; } + if [ "$NOW" -gt "$EXPIRES_AT" ]; then + STATUS="expired" + write_state + printf 'status=expired schedule_action=pause ' + emit_runtime_status "$NOW" + printf '\n' + exit 3 + fi + if [ "$FORCE" -ne 1 ] && [ "$NOW" -lt $((LAST_CHECKED_AT + SCHEDULED_INTERVAL)) ]; then + printf 'status=not_due ' + emit_runtime_status "$NOW" + printf '\n' + exit 2 + fi + + set +e + pending="$("$WATCH_ONCE" "$PROJECT" codex \ + --team "$TEAM" --name "$NAME" --owner "$OWNER" --claim \ + --timeout 0 --interval 1 2>&1)" + pending_status=$? + set -e + LAST_CHECKED_AT="$NOW" + + if [ "$pending_status" -eq 2 ]; then + PENDING_ID=0 + PENDING_AT=0 + if [ "$NOW" -ge "$EXPIRES_AT" ]; then + STATUS="expired" + write_state + printf 'status=expired schedule_action=pause ' + emit_runtime_status "$NOW" + printf '\n' + exit 3 + fi + write_state + printf 'status=idle ' + emit_runtime_status "$NOW" + printf '\n' + exit 2 + fi + if [ "$pending_status" -ne 0 ]; then + FAILURES=$((FAILURES + 1)) + write_state + printf 'status=error failures=%s detail=%s\n' \ + "$FAILURES" "$(printf '%s' "$pending" | tr '\n' ' ')" + exit 1 + fi + + count="$(printf '%s\n' "$pending" | sed -n 's/.*count=\([0-9][0-9]*\).*/\1/p' | head -1)" + max_id="$(printf '%s\n' "$pending" | sed -n 's/.*max_id=\([0-9][0-9]*\).*/\1/p' | head -1)" + whole_number "$count" || count=0 + whole_number "$max_id" || max_id=0 + is_new=0 + if [ "$max_id" -gt "$LAST_SEEN_ID" ]; then + is_new=1 + LAST_SEEN_ID="$max_id" + CYCLE_STARTED_AT="$NOW" + EXPIRES_AT=$((NOW + TTL)) + PENDING_ID="$max_id" + PENDING_AT="$NOW" + FAILURES=0 + elif [ "$PENDING_ID" -eq "$max_id" ] && [ $((NOW - PENDING_AT)) -ge "$RETRY_AFTER" ]; then + PENDING_AT="$NOW" + else + write_state + printf 'status=waiting count=%s max_id=%s new_message=0 ' "$count" "$max_id" + emit_runtime_status "$NOW" + printf '\n' + exit 2 + fi + write_state + printf 'status=wake count=%s max_id=%s new_message=%s ' "$count" "$max_id" "$is_new" + emit_runtime_status "$NOW" + printf '\n' + ;; + delivered) + [ "$#" -ge 5 ] || usage + init_identity "$1" "$2" "$3" "$4" + max_id="$5" + shift 5 + parse_now_options "$@" + whole_number "$max_id" || { echo "agmsg: max_id must be a whole number" >&2; exit 64; } + acquire_lock + load_state || { echo "status=inactive"; exit 3; } + [ "$max_id" -le "$LAST_SEEN_ID" ] || { echo "agmsg: max_id was not observed by this monitor" >&2; exit 64; } + [ "$max_id" -gt "$LAST_DELIVERED_ID" ] && LAST_DELIVERED_ID="$max_id" + [ "$PENDING_ID" -le "$max_id" ] && { PENDING_ID=0; PENDING_AT=0; } + FAILURES=0 + write_state + printf 'status=delivered max_id=%s ' "$max_id" + emit_runtime_status "$NOW" + printf '\n' + ;; + scheduled) + [ "$#" -eq 5 ] || usage + init_identity "$1" "$2" "$3" "$4" + interval="$5" + whole_number "$interval" || { echo "agmsg: interval must be a whole number" >&2; exit 64; } + case "$interval" in + "$FAST_INTERVAL"|"$MEDIUM_INTERVAL"|"$SLOW_INTERVAL") ;; + *) echo "agmsg: interval is outside the adaptive schedule" >&2; exit 64 ;; + esac + acquire_lock + load_state || { echo "status=inactive"; exit 3; } + SCHEDULED_INTERVAL="$interval" + write_state + printf 'status=scheduled interval=%s rrule=%s\n' "$interval" "$(rrule_for_interval "$interval")" + ;; + status) + [ "$#" -ge 4 ] || usage + init_identity "$1" "$2" "$3" "$4" + shift 4 + parse_now_options "$@" + load_state || { echo "status=inactive"; exit 3; } + printf 'status=%s last_seen_id=%s last_delivered_id=%s pending_id=%s ' \ + "$STATUS" "$LAST_SEEN_ID" "$LAST_DELIVERED_ID" "$PENDING_ID" + emit_runtime_status "$NOW" + printf '\n' + ;; + stop) + [ "$#" -eq 4 ] || usage + init_identity "$1" "$2" "$3" "$4" + acquire_lock + rm -f "$STATE" + echo "status=inactive schedule_action=pause" + ;; + prompt) + [ "$#" -eq 4 ] || usage + init_identity "$1" "$2" "$3" "$4" + printf -v check_cmd '%q %q %q %q %q %q' \ + "$SCRIPT_DIR/codex-scheduled-monitor.sh" check "$PROJECT" "$TEAM" "$NAME" "$OWNER" + printf -v inbox_cmd '%q %q %q' "$SKILL_DIR/scripts/inbox.sh" "$TEAM" "$NAME" + printf -v delivered_cmd '%q %q %q %q %q' \ + "$SCRIPT_DIR/codex-scheduled-monitor.sh" delivered "$PROJECT" "$TEAM" "$NAME" + delivered_cmd="$delivered_cmd $(printf '%q' "$OWNER") " + printf -v scheduled_cmd '%q %q %q %q %q' \ + "$SCRIPT_DIR/codex-scheduled-monitor.sh" scheduled "$PROJECT" "$TEAM" "$NAME" + scheduled_cmd="$scheduled_cmd $(printf '%q' "$OWNER") " + cat </dev/null + bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null +} + +teardown() { + teardown_test_env + rm -rf "$TEST_PROJECT" +} + +arm() { + bash "$MONITOR" arm "$TEST_PROJECT" team alice owner-1 --now 1000 +} + +check_at() { + bash "$MONITOR" check "$TEST_PROJECT" team alice owner-1 --now "$1" --force +} + +@test "scheduled monitor starts at two minutes and rejects an early duplicate run" { + run arm + [ "$status" -eq 0 ] + [[ "$output" == *"stage=fast"* ]] + [[ "$output" == *"next_interval=120"* ]] + [[ "$output" == *"next_rrule=FREQ=MINUTELY;INTERVAL=2"* ]] + + run bash "$MONITOR" check "$TEST_PROJECT" team alice owner-1 --now 1001 + [ "$status" -eq 2 ] + [[ "$output" == *"status=not_due"* ]] +} + +@test "scheduled monitor backs off at thirty minutes and four hours" { + arm >/dev/null + + run check_at 2800 + [ "$status" -eq 2 ] + [[ "$output" == *"stage=medium"* ]] + [[ "$output" == *"next_interval=900"* ]] + [[ "$output" == *"schedule_change=1"* ]] + + bash "$MONITOR" scheduled "$TEST_PROJECT" team alice owner-1 900 >/dev/null + run check_at 15400 + [ "$status" -eq 2 ] + [[ "$output" == *"stage=slow"* ]] + [[ "$output" == *"next_interval=3600"* ]] + [[ "$output" == *"next_rrule=FREQ=HOURLY;INTERVAL=1"* ]] +} + +@test "new unread mail resets a slow cycle to two minutes without consuming it" { + arm >/dev/null + bash "$MONITOR" scheduled "$TEST_PROJECT" team alice owner-1 3600 >/dev/null + bash "$SCRIPTS/send.sh" team bob alice "scheduled reset" >/dev/null + + run check_at 20000 + [ "$status" -eq 0 ] + [[ "$output" == *"status=wake"* ]] + [[ "$output" == *"new_message=1"* ]] + [[ "$output" == *"stage=fast"* ]] + [[ "$output" == *"next_interval=120"* ]] + [[ "$output" == *"schedule_change=1"* ]] + max_id="$(printf '%s\n' "$output" | sed -n 's/.*max_id=\([0-9][0-9]*\).*/\1/p')" + + run bash "$SCRIPTS/inbox.sh" team alice --quiet + [ "$status" -eq 0 ] + [[ "$output" == *"scheduled reset"* ]] + + run bash "$MONITOR" delivered "$TEST_PROJECT" team alice owner-1 "$max_id" --now 20000 + [ "$status" -eq 0 ] + [[ "$output" == *"status=delivered"* ]] + + run bash "$MONITOR" status "$TEST_PROJECT" team alice owner-1 --now 20120 + [ "$status" -eq 0 ] + [[ "$output" == *"elapsed=120"* ]] +} + +@test "the same unread high-water mark does not reset the cycle twice" { + arm >/dev/null + bash "$SCRIPTS/send.sh" team bob alice "only one reset" >/dev/null + check_at 20000 >/dev/null + + run check_at 20120 + [ "$status" -eq 2 ] + [[ "$output" == *"status=waiting"* ]] + [[ "$output" == *"new_message=0"* ]] + [[ "$output" == *"elapsed=120"* ]] +} + +@test "scheduled monitor checks once at twenty four hours and then expires" { + arm >/dev/null + + run check_at 87400 + [ "$status" -eq 3 ] + [[ "$output" == *"status=expired"* ]] + [[ "$output" == *"schedule_action=pause"* ]] + + run check_at 87401 + [ "$status" -eq 3 ] + [[ "$output" == *"status=expired"* ]] +} + +@test "scheduled prompt keeps one task and prohibits relay and app restart paths" { + run bash "$MONITOR" prompt "$TEST_PROJECT" team alice owner-1 + [ "$status" -eq 0 ] + [[ "$output" == *"do not notify the user"* ]] + [[ "$output" == *"Do not create another task"* ]] + [[ "$output" == *"Never edit automation files directly"* ]] + [[ "$output" == *"Never use Desktop relay"* ]] + [[ "$output" == *"ChatGPT.app restart"* ]] +} From 8e8adf2bb629d11046ebfeba48a1765c58397b39 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Tue, 14 Jul 2026 23:20:12 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[auto]=20Codex=20Desktop=E5=8F=AF?= =?UTF-8?q?=E8=A6=96=E7=9B=A3=E8=A6=96=E3=82=92=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 15 +- docs/codex-monitor-beta.md | 326 ++--- install.sh | 12 +- scripts/check-inbox.sh | 60 +- scripts/delivery.sh | 227 ++- scripts/drivers/types/codex/_delivery.sh | 213 +-- scripts/drivers/types/codex/_session-start.sh | 164 +-- scripts/drivers/types/codex/actas-monitor.sh | 793 +++++------ .../types/codex/codex-bridge-launcher.sh | 55 +- scripts/drivers/types/codex/codex-bridge.js | 817 +++++++---- .../types/codex/codex-desktop-relay-run.sh | 61 + .../types/codex/codex-desktop-relay.js | 1223 +++++++++++++++++ .../types/codex/codex-desktop-relayctl.sh | 336 +++++ .../drivers/types/codex/codex-shim-install.sh | 11 +- scripts/drivers/types/codex/codex-shim.sh | 10 +- scripts/drivers/types/codex/template.md | 37 +- scripts/lib/actas-lock.sh | 120 +- scripts/lib/subscription.sh | 20 +- scripts/reset.sh | 178 ++- scripts/session-end.sh | 118 +- tests/test_actas_lock.bats | 20 + tests/test_codex_bridge.bats | 202 +-- tests/test_codex_bridge_persistence.bats | 210 +++ tests/test_codex_desktop_relay.bats | 1155 ++++++++++++++++ tests/test_codex_desktop_relayctl.bats | 238 ++++ tests/test_codex_monitor_lease.bats | 12 + tests/test_codex_shim.bats | 21 +- tests/test_codex_visible_monitor.bats | 425 +++++- tests/test_delivery.bats | 167 +-- tests/test_uninstall_codex_monitor.bats | 83 ++ uninstall.sh | 93 ++ 32 files changed, 5894 insertions(+), 1529 deletions(-) mode change 100644 => 100755 scripts/drivers/types/codex/_delivery.sh mode change 100644 => 100755 scripts/drivers/types/codex/_session-start.sh create mode 100755 scripts/drivers/types/codex/codex-desktop-relay-run.sh create mode 100755 scripts/drivers/types/codex/codex-desktop-relay.js create mode 100755 scripts/drivers/types/codex/codex-desktop-relayctl.sh create mode 100644 tests/test_codex_bridge_persistence.bats create mode 100644 tests/test_codex_desktop_relay.bats create mode 100644 tests/test_codex_desktop_relayctl.bats create mode 100644 tests/test_uninstall_codex_monitor.bats diff --git a/.gitignore b/.gitignore index 8745b97f..01e91225 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Runtime data (created at install destination, not in repo) db/ teams/ +/run/ .DS_Store .codex/ .claude/ diff --git a/README.md b/README.md index bb2c7b9c..b2493179 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ How incoming messages reach your agent. Pick one at first join via the prompt, o | mode | mechanism | latency | who it's for | |---|---|---|---| -| **`monitor`** (default on Claude Code) | SessionStart hook → Monitor tool → blocking SQLite stream | ~5s | Claude Code users wanting real-time push | +| **`monitor`** (default on Claude Code) | Claude Code: Monitor tool stream. Codex beta: authenticated Desktop relay + exact-thread role bridge | ~5s after attachment | Claude Code users; opted-in Codex Desktop users | | **`turn`** (default on Codex / Copilot CLI / OpenCode) | Stop hook fires `check-inbox.sh` between assistant turns | until your next interaction | Codex / Copilot CLI / OpenCode (no Monitor tool); Claude Code users on a quieter loop | | **`both`** | monitor primary, turn as per-session safety net | ~5s; falls back to turn-end on watcher failure | belt-and-suspenders | | **`off`** | no automatic delivery | manual `/agmsg` only | minimalists | @@ -302,9 +302,9 @@ $agmsg — or /skills → agmsg Codex supports `mode monitor` as a **beta** visible app-server receiver, plus `mode turn` and `mode off`. -> ⚠️ **Monitor is active only when a visible app-server bridge attaches to the persisted Codex task.** Background `codex exec resume` delivery is prohibited because a successful CLI turn can consume and answer mail without displaying the handling in Codex Desktop. When no visible bridge is available, agmsg keeps mail unread and downgrades the effective mode to `turn`. No heartbeat, cron, or scheduled polling task is created. +> ⚠️ **Monitor is active only when the Codex Desktop relay and the exact-thread role bridge both report ready.** The relay multiplexes Desktop and the bridge onto one app-server, so a bridge-initiated turn is rendered in the same task. Bridge access requires both the relay's private seed and a `0600` per-role binding fixed to the canonical project, role, and exact thread; the shared seed alone cannot attach. Enabling it requires one Codex Desktop restart. A temporary fallback keeps the requested mode as `monitor`, uses the visible turn path for that task, and leaves mail unread. Background `codex exec resume`, heartbeat, cron, rollout inference, and scheduled polling are prohibited. -If you prefer a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). +Run `agmsg actas ` in the intended visible task to bind its exact thread. SessionStart restores that role only in the same exact task; another task cannot infer or steal the binding. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). ### GitHub Copilot CLI @@ -473,6 +473,7 @@ sandbox_mode = "workspace-write" writable_roots = [ "~/.agents/skills/agmsg/db", "~/.agents/skills/agmsg/teams", + "~/.agents/skills/agmsg/run", ] ``` @@ -483,6 +484,7 @@ If you installed agmsg under a custom command name, adjust the path accordingly: writable_roots = [ "~/.agents/skills/m/db", "~/.agents/skills/m/teams", + "~/.agents/skills/m/run", ] ``` @@ -495,9 +497,10 @@ writable_roots = [ ] ``` -Codex only supports `mode turn` and `mode off`; it does not have Claude Code's -Monitor tool. The sandbox allowlist is still required for writes performed by -manual `$agmsg` commands and turn-end inbox checks. +Codex supports `mode turn`, `mode off`, and an opted-in Desktop `mode monitor` +beta. The beta uses an authenticated local relay because Codex does not expose +Claude Code's Monitor tool. The sandbox allowlist is required for manual +commands, turn-end checks, and the monitor's private runtime state. Some Codex runtimes or automations may inject a managed permission profile for a single run. In that case, the run-specific writable roots must also include the diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 156d203c..dcfd7301 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -1,38 +1,25 @@ # Codex Monitor Beta -Codex does not expose Claude Code's Monitor tool. agmsg's Codex monitor beta can -deliver mail only through an app-server bridge that renders the handling in the -same visible Codex thread. The last explicit `actas` role is rebound from -SessionStart after restart or compaction. +Codex does not expose Claude Code's Monitor tool. +agmsg therefore uses one authenticated loopback relay to let Codex Desktop and a role bridge share the same app-server. +An inbound wake starts a turn only in the exact visible Codex task selected by `actas`. -> ⚠️ **Experimental beta — read before enabling.** This changes how Codex starts. -> Monitor mode is active only after a visible app-server bridge attaches to the -> selected thread. If no bridge is available, agmsg keeps mail unread and changes -> the effective mode to `turn`. Background `codex exec resume`, cron, heartbeat, -> and scheduled polling are prohibited because they can process mail without -> showing the work to the human operator. +> **Experimental beta.** Monitor is active only while the Desktop relay and the exact-thread role bridge both report `ready`. +> If Desktop, the relay, or the bridge is unavailable, the requested mode remains `monitor`, the current task uses the visible turn fallback, and mail remains unread. -## Quick Start - -Enable monitor mode in a project: +## Enable ```bash ~/.agents/skills/agmsg/scripts/delivery.sh set monitor codex "$PWD" ``` -The command: - -1. Enables Codex SessionStart/SessionEnd hooks plus the visible Stop-hook - fallback for the project. -2. Persists the last explicit `actas` role so SessionStart can rebind it. -3. After `actas` binds a concrete thread, attaches the visible app-server bridge - for that exact team/role/thread tuple or downgrades to `turn`. -4. Prints a shell function that routes interactive `codex` launches through the - monitor shim. +Then run `agmsg actas ` in the Codex task that should receive the role's messages. +The task must provide an exact `CODEX_THREAD_ID`; agmsg never guesses it from rollout files, a loaded-task list, or a newly created thread. -The bridge may observe unread count and high-water id, but it does not read -message bodies or mark messages read. The visible persisted thread owns the -official inbox read, substantive work, progress reporting, and any reply. +On macOS, enabling monitor mode installs the per-user Desktop relay LaunchAgent. +Restart Codex Desktop once so it inherits the relay endpoint. +Until Desktop reconnects, `actas-monitor.sh` reports a visible-turn fallback instead of claiming that monitoring is active. +Enabling this relay also disarms any lease state left by the earlier heartbeat/watchdog design, so both delivery paths cannot remain active for the same project. The Codex sandbox must allow writes to the installed skill's runtime state: @@ -42,233 +29,134 @@ The Codex sandbox must allow writes to the installed skill's runtime state: ~/.agents/skills//run ``` -`install.sh` and `install.sh --update` add these writable roots to -`~/.codex/config.toml` when that file exists. - -Add the printed function to your shell profile. It looks like: +## Architecture -```bash -codex() { - ~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-shim.sh "$@" -} +```mermaid +flowchart LR + desktop["Visible Codex Desktop"] -->|private desktop capability| relay["loopback Desktop relay"] + bridge["exact-thread role bridge"] -->|bridge seed + private role capability| relay + relay --> appserver["one stdio app-server"] + bridge --> watch["one watch-once process"] + watch --> db[("agmsg SQLite")] + bridge -->|deterministic wake dispatch| relay + relay -->|turn/start with exact thread id| desktop ``` -Restart the shell, then launch Codex normally: +The relay listens only on `127.0.0.1`. +Desktop uses one private capability, while bridges require both a shared bridge seed and a random per-role capability. +The Desktop capability, bridge seed, role binding, and role endpoint are regular non-symlink `0600` files. +Capabilities are not written to plists, logs, health files, status output, or public bridge metadata. -```bash -codex -``` +The role binding fixes the canonical project, type, team, role, exact thread id, and role state key before the relay accepts a bridge connection. +Possession of the shared bridge seed alone cannot open a role connection. +Only one bridge may use a role binding at a time. -In monitor-mode projects, the function routes interactive Codex launches through -the bridge. Outside monitor-mode projects, it passes through to the real Codex. +The bridge is scoped by that binding. +It may call only the relay methods needed to resume that thread, dispatch a deterministic wake, and manage its owned `watch-once.sh` process. +The relay rejects bridge attempts to create a thread, address another thread, override project roots or history, spawn another command, or kill another client's process. +Approval and other server requests are routed only to the visible Desktop client. -When Codex.app is opened normally, SessionStart restores the last `actas` role. -If no visible app-server is available, the effective delivery mode becomes -`turn`; the Stop hook checks the inbox on a later visible assistant turn. agmsg -does not claim that autonomous monitoring is active in this state. +## Exact-task lease -`mode off`, `mode turn`, `drop`/`reset`, and SessionEnd stop the matching -receiver and remove its LaunchAgent/runtime files. No cron, heartbeat, or -scheduled polling automation is created for this path. +An explicit `actas` stores the project, type, team, role, and exact thread id in a global `(team, role)` Codex seat. +It also claims the same atomic role-lock namespace used by generic `actas`, so a Claude and a Codex task cannot concurrently own the same inbox role. +SessionStart restores the role only when the current exact thread id equals the stored id. +A different or new Codex task cannot take the role implicitly and must run `actas` explicitly. +If another task still holds the role, that explicit claim is rejected until the original task ends or the role is dropped/reset. -## Optional PATH Shim +SessionEnd, `drop`/`reset`, `mode turn`, and `mode off` stop the matching role bridge and release its lease. +Cleanup derives the launchd label from the project and role, so it still removes a job when a crash has already deleted `.meta` or `.pid`. +Project mode changes do not stop the global relay; uninstall or an explicit `codex-desktop-relayctl.sh disable` does. -If you prefer the previous global PATH shim setup, install it explicitly: +## Unread and acknowledgement boundary -```bash -~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-shim-install.sh install -``` +The bridge observes only unread count and `max_id` through `watch-once.sh`. +It does not read message bodies and never updates `read_at`. +The Codex Stop hook is also peek-only. -Then put `~/.agents/bin` before the real Codex binary on PATH: +Only the official command below, run inside the visible task, acknowledges Codex mail: ```bash -export PATH="$HOME/.agents/bin:$PATH" +~/.agents/skills//scripts/inbox.sh ``` -If `~/.agents/bin/codex` already exists and is not the agmsg shim, agmsg leaves -it untouched. You can either move that command aside and run the install command -again, or launch monitor sessions explicitly: +The bridge starts a turn that instructs the visible task to run that command, handle the message, show progress and results in the same task, and send any reply through `send.sh`. -```bash -~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-monitor.sh -``` +Each role and exact thread has a private `0600` wake-state file. +The bridge records `observed`, `accepted`, and `ack_confirmed` phases with an atomic rename and a file `fsync`. +The wake's `clientUserMessageId` is a deterministic SHA-256 marker derived from the role state key, exact thread, and unread `max_id`; it contains none of those raw values. -For custom command names, replace `agmsg` with the installed skill name: +The relay keeps a separate private, directory-synced dispatch record. +It writes `dispatching` before `turn/start` and `accepted` after app-server accepts the turn. +After a relay or Desktop restart, an existing dispatch record permits history reconciliation only; it never permits a blind second `turn/start`. -```bash -~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh -``` +Before the relay calls `turn/start`, it reads the exact thread history and checks that marker. +If the first response was lost after app-server accepted the turn, a retry returns only `reconciled`; it never exposes thread history to the bridge and never starts a second turn. -## What The Shim Does +| Condition | Health/status | Automatic action | Unread handling | +| --- | --- | --- | --- | +| New `max_id` | `ready` → `observed` internally | Dispatch once with the deterministic marker | Remains unread | +| Dispatch response or history check is unavailable | `paused_ambiguous_wake` | Retry reconciliation with capped exponential delay and the same marker | Remains unread; no blind resend | +| The same accepted `max_id` is still unread | `waiting_for_ack` | Re-arm after a bounded delay without another dispatch | Remains unread | +| `watch-once.sh` reports no unread mail | `ready`; state becomes `ack_confirmed` | Continue watching | The visible task already acknowledged it through `inbox.sh` | +| Watcher fails below the configured limit | `waiting_watch_retry` | Retry with capped exponential delay | Remains unread | +| Watcher repeatedly fails | `paused_watch_failure` | Keep the bridge alive and continue bounded retries | Remains unread | +| app-server or relay connection is lost | `retrying_transient` | Reconnect in-process with capped exponential delay | Remains unread | +| Exact `thread/resume` is invalid | `terminal_thread_error` | Exit successfully and retain a tombstone | Remains unread | -The shell function and optional PATH shim only wrap interactive Codex TUI -launches: +`paused_ambiguous_wake` is a fail-closed transient state, not permission to resend. +The bridge stays attached and retries only the history reconciliation path. +It does not wait for another SessionStart or explicit `actas` to recover. -```bash -codex -codex resume -codex "fix this bug" -``` +The role and relay LaunchAgents use `KeepAlive.SuccessfulExit=false`. +Unexpected crashes are restarted, while terminal thread errors and permanent configuration failures exit successfully so launchd does not loop. +The relay runner owns capped app-server retry backoff and passes its pid to the relay; if the runner disappears, the relay exits nonzero and reaps its app-server process group before launchd restarts the pair. -Noninteractive subcommands pass through to the real Codex binary: +## Fallback behavior -```bash -codex exec ... -codex app-server ... -codex login -codex logout -``` +Fallback is per task and does not rewrite the project's requested mode from `monitor` to `turn`. +This matters during the initial Desktop restart: a pre-restart fallback must be able to attach automatically on the next SessionStart of the same task. -The shim also passes through when the current project is not in Codex monitor -mode. - -## Bridge Mechanics - -`codex-monitor.sh` starts (or reuses) an agmsg-managed Codex app-server socket -under `~/.agents/skills//run/`, starts the out-of-sandbox bridge launcher, -and then connects the Codex TUI to that socket with `--remote`. - -Codex fires the SessionStart hook on the session's **first turn** (the first -message you send), not the moment the TUI opens — so the bridge does not exist -until you interact once after a restart. - -The SessionStart hook is designed to **not** start the bridge directly — a -hook-launched process was observed to run inside the Codex sandbox and fail to -connect to the unix socket (EPERM). Instead: - -> Note: this EPERM-avoidance design (the launcher + request-file rendezvous -> below) is under review — in practice the hook has been seen to launch a -> detached bridge directly and connect fine, suggesting the launcher layer may -> be redundant. See #153. - -1. `session-start.sh` (the hook) resolves the thread id — `CODEX_THREAD_ID` when - set, otherwise the newest Codex rollout whose `session_meta` cwd matches the - project (fresh / `codex exec` sessions never export `CODEX_THREAD_ID`) — and - writes a **request file** under `run/` (it never touches the socket). -2. `codex-bridge-launcher.sh`, started by `codex-monitor.sh` **outside** the - sandbox, reads the request file and starts `codex-bridge.js`. -3. The bridge connects to the same app-server over **WebSocket-over-UDS**, - resumes the thread, and arms `watch-once.sh` via the app-server `process/spawn` - API (which checks the agmsg DB for unread rows, `read_at IS NULL`). -4. On unread state it starts a turn on that thread with an instruction to run - the official `inbox.sh`. The bridge does not read the message body or mark it - read on the normal path. The visible Codex turn owns reading, substantive - work, verification, and any reply, then the bridge re-arms after the turn. - -Turns are serialized (one per thread): a message that arrives while a turn is -running stays unread and is delivered after the turn completes. The turn ends -via `turn/completed`, a `thread/status` idle, or a watchdog (the real app-server -does not reliably send `turn/completed`); only then is the next `watch-once` -armed. If a turn does not consume the unread message, the same `max_id` reappears -and the bridge stops instead of looping. +Fallback writes a project/role-scoped visible status record and leaves unread rows untouched. +It never invokes `codex exec resume`, starts a hidden Codex session, creates a new app-server thread, scans rollout files, or schedules polling. -```mermaid -flowchart TD - user["User runs codex"] --> shim["codex shell function or ~/.agents/bin/codex shim"] - shim --> mode{"Project delivery mode?"} - mode -- "not monitor / codex exec / --version" --> real["real codex"] - mode -- "monitor (interactive)" --> monitor["codex-monitor.sh"] - - monitor --> server{"app-server socket exists?"} - server -- "no" --> startServer["codex app-server --listen unix://..."] - server -- "yes" --> reuseServer["reuse socket"] - monitor --> launcher["codex-bridge-launcher.sh (outside sandbox)"] - startServer --> remote["codex --remote unix://..."] - reuseServer --> remote - - remote --> hook["SessionStart hook → session-start.sh (in sandbox)"] - hook --> thread["resolve thread: CODEX_THREAD_ID || newest matching rollout"] - thread --> request["write request file under run/ (no socket — EPERM)"] - request -.-> launcher - launcher --> bridge["codex-bridge.js → app-server (WebSocket-over-UDS)"] - bridge --> watch["arm watch-once.sh (process/spawn)"] - watch --> db[("agmsg SQLite DB (read_at IS NULL)")] - db --> unread{"Unread message?"} - unread -- "no (timeout)" --> watch - unread -- "yes" --> turn["turn/start with official inbox instruction"] - turn --> tui["Current Codex TUI thread"] - tui --> inbox["official inbox.sh reads and marks messages"] - inbox --> work["substantive work, verification, and reply"] - work --> ended["turn ends: completed / idle / watchdog"] - ended --> watch -``` - -## Worker Guardrails +## Status and shutdown -> ⚠️ **Never poll agmsg by launching a full Codex/Claude session on a short -> interval.** Use a shell-only gate first and start the heavy agent only when -> there is actually something to handle. +```bash +~/.agents/skills/agmsg/scripts/delivery.sh status codex "$PWD" +~/.agents/skills/agmsg/scripts/delivery.sh set turn codex "$PWD" +~/.agents/skills/agmsg/scripts/delivery.sh set off codex "$PWD" +~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-desktop-relayctl.sh status +~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-desktop-relayctl.sh disable +``` -### Case study: empty-poll OOM (#163) +A healthy monitor requires all of the following: -A user wired an autonomous worker as a `cron` job (`FREQ=MINUTELY;INTERVAL=3`) -that launched a **full Codex session every 3 minutes** to check the agmsg inbox, -git state, GitHub issues, and so on. The approval/away-window had already -expired, so almost every tick returned `No new messages.` — yet each tick still -spun up a complete Codex session with a long prompt and project context. +- relay health is `ready`; +- a visible Desktop client is connected and initialized; +- the role bridge process and its owned metadata are live; +- bridge health is `ready` for the stored exact thread; +- the role's private endpoint matches the relay's current private endpoint. -About 60 Codex sessions were created in under three hours. Codex Desktop keeps a -transcript / trace / tool-output / local log DB per session, so the no-op runs -accumulated: `~/.codex/logs_2.sqlite` grew to ~2.2 GB (plus ~1.1 GB WAL), Codex -memory climbed to ~158 GB, and macOS hit a Low-Memory / jetsam state that forced -a hard restart. +`mode turn` and `mode off` stop only bridges owned by the selected project. +Another project's bridge and the shared relay remain running. -This is **not** an agmsg transport or SQLite bug. The root cause is the worker -shape: a short-interval scheduler that runs a heavyweight agent as the poller, -with no cheap no-op path, so empty inboxes still pay the full cost — and Codex -Desktop's per-session UI/log accumulation amplifies it. +## Prohibited worker shape -### `watch-once.sh` is not a Codex Desktop delivery fallback +Do not combine `watch-once.sh` with cron, heartbeat, scheduled automation, or `codex exec resume`. +That pattern creates heavyweight Codex sessions even when no useful work is visible, can consume mail outside the user's task, and previously caused runaway task and log-database growth. -`watch-once.sh` is a shell-only, one-shot inbox oracle. The visible app-server -bridge uses it to avoid starting a turn on an empty inbox. +`watch-once.sh` is only a shell gate used by the exact visible bridge: ```text -exit 0 unread inbound exists (prints: status=pending count= max_id=) -exit 2 nothing pending (prints: status=timeout) -exit 1 configuration / runtime error +exit 0 unread inbound exists (status=pending count= max_id=) +exit 2 nothing pending +exit 1 configuration or runtime error ``` -Do not combine it with a scheduler and `codex exec` as a substitute for Codex -Desktop delivery. That path cannot guarantee that the received message, -progress, reply, and result appear in the user's visible thread. - -### Defense in depth - -For a separately authorized non-Desktop worker, layer these on top of the gate: - -- **Single-flight lock per `(team, agent)`** so overlapping ticks don't stack - concurrent agents: - ```bash - exec 9>"/tmp/agmsg-worker.myteam.myagent.lock" - flock -n 9 || exit 0 # another tick is still running; skip this one - ``` -- **Approval / away-window expiry check before launch.** If the worker is only - authorized for a window, verify it hasn't expired *before* starting the agent, - and disable the worker (or exit) once it has — don't leave it `ACTIVE` past its - window. -- **Exponential backoff on repeated no-ops.** After N consecutive empty gates, - widen the interval so an idle worker stops hammering. -- **Max-run cap.** Bound total runs (e.g. `COUNT` for `cron`) and prefer - intervals measured in minutes, not seconds. -- **Codex Desktop note.** Codex Desktop retains transcript / tool-output / trace - per session and a local log DB (`~/.codex/logs_*.sqlite`). Even short no-op - sessions accumulate there, so a high-frequency spawner is far heavier than the - per-run wall-clock suggests. The shell gate above avoids creating those - sessions at all on empty ticks. - -### Emergency stop (runaway worker) - -1. Make the worker inactive / unschedule the `cron` job so it stops spawning. -2. Back off delivery: `delivery.sh set turn codex "$PROJECT"` (or `off`) to stop - monitor delivery. -3. Kill stale monitors / spawned sessions and any orphaned bridge - (`mode off` tears the bridge down; see #149). -4. Inspect Codex Desktop log-DB bloat: `~/.codex/logs_*.sqlite` and its WAL. - -## Related Details - -- [Delivery modes](../README.md#delivery-modes) -- [Codex bridge implementation](../scripts/drivers/types/codex/codex-bridge.js) -- [Monitor launcher](../scripts/drivers/types/codex/codex-monitor.sh) -- [Codex shim](../scripts/drivers/types/codex/codex-shim.sh) +## Related files + +- [Desktop relay](../scripts/drivers/types/codex/codex-desktop-relay.js) +- [Relay controller](../scripts/drivers/types/codex/codex-desktop-relayctl.sh) +- [Role bridge](../scripts/drivers/types/codex/codex-bridge.js) +- [Role binding](../scripts/drivers/types/codex/actas-monitor.sh) diff --git a/install.sh b/install.sh index f9ce147d..9b078b66 100755 --- a/install.sh +++ b/install.sh @@ -296,7 +296,9 @@ if [ "$UPDATE_ONLY" = true ]; then cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true chmod +x "$SKILL_DIR/scripts/"*.sh chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true - # Refresh the Codex monitor shim (~/.agents/bin/codex) if it's ours. --update + # Refresh the optional Codex compatibility shim (~/.agents/bin/codex) if + # it is already installed. The current Desktop relay does not install or + # require this pass-through wrapper. # cp's the new codex-shim-install.sh but does not re-run it, so a shim from an # older install keeps its stale baked exec path after the # types/ -> scripts/drivers/types/ move. Re-running install regenerates it with @@ -305,7 +307,7 @@ if [ "$UPDATE_ONLY" = true ]; then CODEX_SHIM="$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" if [ -x "$CODEX_SHIM" ] && AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" status 2>/dev/null | grep -q '^installed:'; then AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" install >/dev/null 2>&1 \ - && echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" + && echo " + refreshed optional Codex compatibility shim (~/.agents/bin/codex)" fi install_windows_helpers INSTALLED_VERSION="$(agmsg_source_version)" @@ -371,12 +373,12 @@ cp "$SCRIPT_DIR/uninstall.sh" "$SKILL_DIR/uninstall.sh" 2>/dev/null && chmod +x cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true chmod +x "$SKILL_DIR/scripts/"*.sh chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true -# Re-point an existing Codex monitor shim at the new path on a reinstall over an -# older layout (no-op when no agmsg shim is present). See the --update block above. +# Re-point an existing optional Codex compatibility shim at the new path on a +# reinstall over an older layout (no-op when no agmsg shim is present). CODEX_SHIM="$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" if [ -x "$CODEX_SHIM" ] && AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" status 2>/dev/null | grep -q '^installed:'; then AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" install >/dev/null 2>&1 \ - && echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" + && echo " + refreshed optional Codex compatibility shim (~/.agents/bin/codex)" fi install_windows_helpers diff --git a/scripts/check-inbox.sh b/scripts/check-inbox.sh index f36c0bbe..a26a8a22 100755 --- a/scripts/check-inbox.sh +++ b/scripts/check-inbox.sh @@ -18,8 +18,6 @@ source "$SCRIPT_DIR/lib/actas-lock.sh" source "$SCRIPT_DIR/lib/resolve-project.sh" # agmsg_agent_pid, for instance-id derivation # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/type-registry.sh" -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/hash.sh" # Some Stop-hook runtimes (codex, copilot) want an explicit JSON status object # even when there is nothing to deliver; others (claude-code) stay silent. This @@ -92,23 +90,25 @@ if [ -z "$AGENT" ] || [ -z "$TEAMS" ]; then exit 0 fi -# Codex actas changes the visible role for sends. In turn delivery, use that -# same role for receives so a chat-visible fallback does not keep polling the -# first registered identity for the project. +# Codex seats are globally keyed by team + role and contain one project plus +# exact task id. Select only the seat owned by this visible task; never fall +# back to another role in the same project when the exact id is unavailable. if [ "$TYPE" = "codex" ]; then PROJECT_RESOLVED="$(agmsg_resolve_project "$PROJECT" "$TYPE")" - PROJECT_HASH="$(printf '%s' "$PROJECT_RESOLVED" | agmsg_sha1)" - ACTAS_STATE="$SKILL_DIR/run/codex-last-actas.$PROJECT_HASH.tsv" - if [ -f "$ACTAS_STATE" ]; then - IFS=$'\t' read -r ACTAS_PROJECT ACTAS_TYPE ACTAS_TEAM ACTAS_NAME _ACTAS_TS < "$ACTAS_STATE" || true - if [ "$ACTAS_PROJECT" = "$PROJECT_RESOLVED" ] \ - && [ "$ACTAS_TYPE" = "$TYPE" ] \ - && [ -n "${ACTAS_TEAM:-}" ] \ - && [ -n "${ACTAS_NAME:-}" ]; then + ACTAS_THREAD="${AGMSG_CODEX_ACTAS_THREAD:-${CODEX_THREAD_ID:-}}" + case "$ACTAS_THREAD" in ""|loaded|current|unresolved) exit 0 ;; esac + ACTAS_MATCHES=0 + for ACTAS_STATE in "$SKILL_DIR/run"/codex-seat.*.tsv; do + [ -f "$ACTAS_STATE" ] || continue + IFS=$'\t' read -r ACTAS_PROJECT ACTAS_TYPE ACTAS_TEAM ACTAS_NAME ACTAS_SAVED_THREAD _ACTAS_TS < "$ACTAS_STATE" || true + if [ "$ACTAS_PROJECT" = "$PROJECT_RESOLVED" ] && [ "$ACTAS_TYPE" = "$TYPE" ] \ + && [ "$ACTAS_SAVED_THREAD" = "$ACTAS_THREAD" ]; then AGENT="$ACTAS_NAME" TEAMS="$ACTAS_TEAM" + ACTAS_MATCHES=$((ACTAS_MATCHES + 1)) fi - fi + done + [ "$ACTAS_MATCHES" = "1" ] || exit 0 fi # Cooldown check. The marker is hook runtime state, not message storage, so it @@ -134,7 +134,9 @@ fi mkdir -p "$SKILL_DIR/run" touch "$MARKER" -# Check for unread messages and mark as read +# Check for unread messages. Codex Stop hooks only surface a pending notice; +# they must not acknowledge mail on behalf of the visible task. The explicit +# official inbox command remains the Codex read boundary. DB="$(agmsg_db_path)" if [ ! -f "$DB" ]; then exit 0; fi @@ -156,10 +158,27 @@ for team in "${TEAM_LIST[@]}"; do # role. That asymmetry is the Codex caveat documented in README — if a # Codex session actas'd into , check-inbox is still polling # whatever whoami chose first, not . - state=$(actas_lock_state "$team" "$AGENT" "${SESSION_ID:-}") - case "$state" in - other:*) continue ;; - esac + if [ "$TYPE" != "codex" ]; then + state=$(actas_lock_state "$team" "$AGENT" "${SESSION_ID:-}") + case "$state" in + other:*) continue ;; + esac + fi + + if [ "$TYPE" = "codex" ]; then + # The Stop hook is only a visibility nudge. Do not select message ids, + # senders, timestamps, or bodies here; the explicit inbox.sh invocation in + # the visible task is the sole Codex content-read and acknowledgement path. + COUNT=$(agmsg_sqlite "$DB" " + SELECT count(*) FROM messages + WHERE team='$team_sql' AND to_agent='$AGENT_SQL' AND read_at IS NULL; + ") + case "$COUNT" in ''|0|*[!0-9]*) ;; *) + OUTPUT+="$COUNT unread agmsg message(s) pending for team=$team role=$AGENT."$'\n' + ;; + esac + continue + fi RESULT=$(agmsg_sqlite "$DB" " SELECT id || char(31) || from_agent || char(31) || replace(replace(body, char(10), '\n'), char(9), '\t') || char(31) || created_at @@ -194,6 +213,9 @@ fi # New messages found if [ -n "$OUTPUT" ]; then + if [ "$TYPE" = "codex" ]; then + OUTPUT+="Run the official inbox command in this visible Codex task before handling these messages: $SCRIPT_DIR/inbox.sh $AGENT"$'\n' + fi # Escape for JSON: backslash, double-quote, newlines, tabs (macOS/Linux compatible) ESCAPED=$(printf '%s' "$OUTPUT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | awk '{if(NR>1) printf "\\n"; printf "%s",$0}') cat </dev/null; do state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" case "$state" in Z*) return 0 ;; esac @@ -334,6 +339,9 @@ wait_for_recorded_pid_exit() { check=$((check + 1)) done if kill -0 "$pid" 2>/dev/null; then + # Re-prove ownership immediately before escalation. The original process + # can exit after TERM and its PID can be recycled during this wait. + "$matcher" "$pid" "$@" || return 0 kill -KILL "$pid" 2>/dev/null || return 1 check=0 while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do @@ -346,9 +354,14 @@ wait_for_recorded_pid_exit() { ! kill -0 "$pid" 2>/dev/null } -bootout_recorded_label() { - local label="$1" check=0 domain - [ -n "$label" ] || return 0 +bootout_managed_codex_label() { + local label="$1" label_suffix check=0 domain + case "$label" in + com.agmsg.codex-bridge.*) label_suffix="${label#com.agmsg.codex-bridge.}" ;; + com.agmsg.codex-app-monitor.*) label_suffix="${label#com.agmsg.codex-app-monitor.}" ;; + *) return 0 ;; + esac + case "$label_suffix" in ""|*[!A-Za-z0-9._%-]*) return 0 ;; esac command -v launchctl >/dev/null 2>&1 || return 0 domain="gui/$(id -u)" launchctl bootout "$domain/$label" >/dev/null 2>&1 || true @@ -358,9 +371,151 @@ bootout_recorded_label() { done } +codex_bridge_label_for_base() { + local base_name="${1##*/}" suffix + case "$base_name" in codex-bridge.*) suffix="${base_name#codex-bridge.}" ;; *) return 1 ;; esac + case "$suffix" in ""|*[!A-Za-z0-9._%-]*) return 1 ;; esac + printf 'com.agmsg.codex-bridge.%s' "$suffix" +} + +codex_app_monitor_label() { + local project="$1" team="$2" name="$3" hash safe_team safe_name + hash="$(printf '%s' "$(agmsg_canonical_path "$project")" | agmsg_sha1)" + safe_team="$(printf '%s' "$team" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + safe_name="$(printf '%s' "$name" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + [ -n "$hash" ] && [ -n "$safe_team" ] && [ -n "$safe_name" ] || return 1 + printf 'com.agmsg.codex-app-monitor.%s.%s.%s' "$hash" "$safe_team" "$safe_name" +} + +codex_current_bridge_pid_matches() { + local pid="$1" base="$2" project="$3" team="$4" name="$5" thread="$6" state_key cmd expected canonical_project + state_key="${base##*/codex-bridge.}" + [ -n "$project" ] && [ -n "$state_key" ] && [ -n "$team" ] \ + && [ -n "$name" ] && [ -n "$thread" ] || return 1 + case "$state_key" in *[!A-Za-z0-9._%-]*) return 1 ;; esac + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + canonical_project="$(agmsg_canonical_path "$project")" + [ -n "$canonical_project" ] || return 1 + expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --state-key $state_key --app-server-file $base.appserver --thread $thread" + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case " $cmd " in *" $expected "*) return 0 ;; esac + return 1 +} + +codex_legacy_bridge_pid_matches() { + local pid="$1" base="$2" project="$3" team="$4" name="$5" thread="$6" cmd prefix canonical_project + [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + canonical_project="$(agmsg_canonical_path "$project")" + [ -n "$canonical_project" ] || return 1 + prefix="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --app-server " + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case " $cmd " in *" $prefix"*" --thread $thread "*) return 0 ;; esac + return 1 +} + +codex_app_monitor_pid_matches() { + local pid="$1" _base="$2" project="$3" team="$4" name="$5" thread="$6" cmd expected + [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + project="$(agmsg_canonical_path "$project")" + [ -n "$project" ] || return 1 + expected="$SKILL_DIR/scripts/drivers/types/codex/codex-app-monitor.sh $project codex $team $name $thread" + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case " $cmd " in *" $expected "*) return 0 ;; esac + return 1 +} + stop_codex_bridge() { local project="$1" local pairs team name pidfile bpid killed=0 app_pidfile app_pid app_meta app_label app_plist + local scoped_meta scoped_base scoped_pid scoped_label scoped_project chat scoped_plist + local scoped_type scoped_team scoped_name scoped_thread legacy_thread + local project_hash seat saved_project saved_type saved_team saved_name saved_thread _saved_at + + project_hash="$(printf '%s' "$project" | agmsg_sha1)" + for seat in "$RUN_DIR"/codex-seat.*.tsv; do + [ -f "$seat" ] || continue + IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true + if [ "$saved_project" = "$project" ] && [ "$saved_type" = "codex" ]; then + actas_codex_seat_release "$saved_team" "$saved_name" "$saved_project" "$saved_thread" + fi + done + actas_codex_release_markers "$project" + + # A launchd job can outlive the bridge metadata during a crash/shutdown + # window. Plist names are project-hash scoped, so boot out and remove every + # deterministically owned job for this project even when .meta/.pid is gone. + for scoped_plist in "$RUN_DIR"/codex-bridge."$project_hash".*.plist; do + [ -f "$scoped_plist" ] || continue + scoped_base="${scoped_plist%.plist}" + scoped_label="$(codex_bridge_label_for_base "$scoped_base" 2>/dev/null || true)" + [ -n "$scoped_label" ] && bootout_managed_codex_label "$scoped_label" + scoped_pid="$(cat "$scoped_base.pid" 2>/dev/null || true)" + if [ -n "$scoped_pid" ] && kill -0 "$scoped_pid" 2>/dev/null; then + scoped_meta="$scoped_base.meta" + scoped_project="$(sed -n 's/^project=//p' "$scoped_meta" 2>/dev/null | head -1)" + scoped_type="$(sed -n 's/^type=//p' "$scoped_meta" 2>/dev/null | head -1)" + scoped_team="$(sed -n 's/^team=//p' "$scoped_meta" 2>/dev/null | head -1)" + scoped_name="$(sed -n 's/^name=//p' "$scoped_meta" 2>/dev/null | head -1)" + scoped_thread="$(sed -n 's/^thread=//p' "$scoped_meta" 2>/dev/null | head -1)" + if [ -n "$scoped_project" ] && [ "$scoped_type" = "codex" ] \ + && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] \ + && codex_current_bridge_pid_matches "$scoped_pid" "$scoped_base" \ + "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" \ + && kill "$scoped_pid" 2>/dev/null; then + killed=$((killed + 1)) + wait_for_recorded_pid_exit "$scoped_pid" codex_current_bridge_pid_matches \ + "$scoped_base" "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" || return 1 + fi + fi + rm -f "$scoped_base.pid" "$scoped_base.meta" "$scoped_base.log" \ + "$scoped_base.appserver" "$scoped_base.health" "$scoped_base.plist" \ + "$scoped_base.last-ids" "$scoped_base.binding" + rm -f "$scoped_base".wake.*.json "$scoped_base.relay-wake.json" + done + + # Current bridges are keyed by project hash + role. Select them by the owned + # metadata boundary, not by a team/name filename that can collide across + # projects. The global Desktop relay is intentionally not stopped here. + for scoped_meta in "$RUN_DIR"/codex-bridge.*.meta; do + [ -f "$scoped_meta" ] || continue + scoped_project="$(sed -n 's/^project=//p' "$scoped_meta" | head -1)" + [ -n "$scoped_project" ] || continue + [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] || continue + [ "$(sed -n 's/^type=//p' "$scoped_meta" | head -1)" = "codex" ] || continue + scoped_base="${scoped_meta%.meta}" + scoped_label="$(codex_bridge_label_for_base "$scoped_base" 2>/dev/null || true)" + [ -n "$scoped_label" ] || continue + bootout_managed_codex_label "$scoped_label" + scoped_pid="$(cat "$scoped_base.pid" 2>/dev/null || true)" + if [ -n "$scoped_pid" ] && kill -0 "$scoped_pid" 2>/dev/null; then + scoped_team="$(sed -n 's/^team=//p' "$scoped_meta" | head -1)" + scoped_name="$(sed -n 's/^name=//p' "$scoped_meta" | head -1)" + scoped_thread="$(sed -n 's/^thread=//p' "$scoped_meta" | head -1)" + if codex_current_bridge_pid_matches "$scoped_pid" "$scoped_base" \ + "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" \ + && kill "$scoped_pid" 2>/dev/null; then + killed=$((killed + 1)) + if ! wait_for_recorded_pid_exit "$scoped_pid" codex_current_bridge_pid_matches \ + "$scoped_base" "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then + echo "delivery: Codex bridge pid $scoped_pid did not stop; preserving its run files" >&2 + return 1 + fi + fi + fi + rm -f "$scoped_base.pid" "$scoped_base.meta" "$scoped_base.log" \ + "$scoped_base.appserver" "$scoped_base.health" "$scoped_base.plist" \ + "$scoped_base.last-ids" "$scoped_base.binding" + rm -f "$scoped_base".wake.*.json "$scoped_base.relay-wake.json" + done + for chat in "$RUN_DIR"/codex-chat-visible.*.meta; do + [ -f "$chat" ] || continue + scoped_project="$(sed -n 's/^project=//p' "$chat" | head -1)" + [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] || continue + [ "$(sed -n 's/^type=//p' "$chat" | head -1)" = "codex" ] || continue + rm -f "$chat" + done pairs=$("$SCRIPT_DIR/identities.sh" "$project" codex 2>/dev/null || true) if [ -n "$pairs" ]; then while IFS=$'\t' read -r team name _rest; do @@ -369,9 +524,21 @@ stop_codex_bridge() { if [ -f "$pidfile" ]; then bpid=$(cat "$pidfile" 2>/dev/null || true) if [ -n "$bpid" ] && kill -0 "$bpid" 2>/dev/null; then - if kill "$bpid" 2>/dev/null; then + legacy_thread="$(sed -n 's/^thread=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" + scoped_project="$(sed -n 's/^project=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" + scoped_type="$(sed -n 's/^type=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" + scoped_team="$(sed -n 's/^team=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" + scoped_name="$(sed -n 's/^name=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" + if [ -n "$scoped_project" ] && [ "$scoped_type" = "codex" ] \ + && [ "$scoped_team" = "$team" ] \ + && [ "$scoped_name" = "$name" ] \ + && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] \ + && codex_legacy_bridge_pid_matches "$bpid" "${pidfile%.pid}" \ + "$scoped_project" "$team" "$name" "$legacy_thread" \ + && kill "$bpid" 2>/dev/null; then killed=$((killed + 1)) - if ! wait_for_recorded_pid_exit "$bpid"; then + if ! wait_for_recorded_pid_exit "$bpid" codex_legacy_bridge_pid_matches \ + "${pidfile%.pid}" "$scoped_project" "$team" "$name" "$legacy_thread"; then echo "delivery: Codex bridge pid $bpid did not stop; preserving its run files" >&2 return 1 fi @@ -381,25 +548,32 @@ stop_codex_bridge() { # .appserver records which app-server URL the bridge was bound to (the # launcher's stale-binding guard); drop it with the rest so it cannot # mislead a later launcher. - rm -f "$pidfile" "${pidfile%.pid}.meta" "${pidfile%.pid}.log" "${pidfile%.pid}.appserver" + rm -f "$pidfile" "${pidfile%.pid}.meta" "${pidfile%.pid}.log" \ + "${pidfile%.pid}.appserver" "${pidfile%.pid}.health" app_pidfile="$RUN_DIR/codex-app-monitor.$team.$name.pid" app_meta="$RUN_DIR/codex-app-monitor.$team.$name.meta" app_plist="$RUN_DIR/codex-app-monitor.$team.$name.plist" - app_label="" - if [ -f "$app_meta" ]; then - app_label=$(sed -n 's/^launch_label=//p' "$app_meta" | head -1) - fi - if [ -z "$app_label" ] && [ -f "$app_plist" ]; then - app_label=$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$app_plist") - fi - bootout_recorded_label "$app_label" + app_label="$(codex_app_monitor_label "$project" "$team" "$name" 2>/dev/null || true)" + [ -n "$app_label" ] && bootout_managed_codex_label "$app_label" if [ -f "$app_pidfile" ]; then app_pid=$(cat "$app_pidfile" 2>/dev/null || true) if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then - if kill "$app_pid" 2>/dev/null; then + scoped_project="$(sed -n 's/^project=//p' "$app_meta" 2>/dev/null | head -1)" + scoped_type="$(sed -n 's/^type=//p' "$app_meta" 2>/dev/null | head -1)" + scoped_team="$(sed -n 's/^team=//p' "$app_meta" 2>/dev/null | head -1)" + scoped_name="$(sed -n 's/^name=//p' "$app_meta" 2>/dev/null | head -1)" + legacy_thread="$(sed -n 's/^thread=//p' "$app_meta" 2>/dev/null | head -1)" + if [ -n "$scoped_project" ] && [ "$scoped_type" = "codex" ] \ + && [ "$scoped_team" = "$team" ] \ + && [ "$scoped_name" = "$name" ] \ + && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] \ + && codex_app_monitor_pid_matches "$app_pid" "${app_pidfile%.pid}" \ + "$scoped_project" "$team" "$name" "$legacy_thread" \ + && kill "$app_pid" 2>/dev/null; then killed=$((killed + 1)) - if ! wait_for_recorded_pid_exit "$app_pid"; then + if ! wait_for_recorded_pid_exit "$app_pid" codex_app_monitor_pid_matches \ + "${app_pidfile%.pid}" "$scoped_project" "$team" "$name" "$legacy_thread"; then echo "delivery: Codex app monitor pid $app_pid did not stop; preserving its run files" >&2 return 1 fi @@ -423,22 +597,13 @@ EOF # Tear down the project's shared app-server too. It is keyed per project # (codex-app-server..{pid,port,version}); turning delivery off means no - # bridge needs it, and leaving it running keeps a stale port the next launch - # would have to recreate anyway. Only kill the recorded pid when its cmdline - # confirms it is our app-server (a recycled pid could be unrelated); drop the - # record either way. - local project_hash server_pidfile server_pid server_cmd - project_hash="$(printf '%s' "$project" | agmsg_sha1 2>/dev/null || true)" + # bridge needs its stale records. The app-server process itself has no agmsg-owned script + # or project/role argv, so a pidfile alone is intentionally insufficient + # proof to signal it; remove the project-scoped records without signaling. + local server_pidfile if [ -n "$project_hash" ]; then server_pidfile="$RUN_DIR/codex-app-server.$project_hash.pid" if [ -f "$server_pidfile" ]; then - server_pid="$(cat "$server_pidfile" 2>/dev/null || true)" - if [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then - server_cmd="$(compat_get_cmdline "$server_pid" 2>/dev/null || true)" - case "$server_cmd" in - *codex*app-server*) kill "$server_pid" 2>/dev/null || true ;; - esac - fi rm -f "$RUN_DIR/codex-app-server.$project_hash.pid" \ "$RUN_DIR/codex-app-server.$project_hash.port" \ "$RUN_DIR/codex-app-server.$project_hash.version" \ diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh old mode 100644 new mode 100755 index d1653af2..636476a7 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -1,19 +1,9 @@ #!/usr/bin/env bash -# codex delivery plug. -# -# codex keeps the default JSON event-hooks apply (agmsg_delivery_apply); it adds -# enable/disable side effects (print the monitor shim setup on enable, stop the -# receiver on disable) and replaces the runtime status summary with Codex -# receiver liveness. Monitor mode may use only a visible app-server bridge; -# turn/off and visible-turn fallback never start a background receiver. -# Sourced into delivery.sh's context, -# so SKILL_DIR, SCRIPT_DIR, RUN_DIR, agmsg_resolve_node, CODEX_MONITOR_DOC_URL -# and stop_codex_bridge are in scope. -# Args (both hooks): on_enable ; on_disable . -# Codex monitor mode always includes the visible Stop-hook fallback. The -# SessionStart hook preserves/rebinds the monitor after restart; the Stop hook -# is the safe path when no app-server can inject into the visible thread. +# Codex delivery plug. Monitor means one authenticated Desktop relay plus one +# exact-thread, project-owned bridge. Foreground Stop delivery remains the safe +# fallback and does not mark Codex mail read. + agmsg_delivery_apply() { local type="$1" project="$2" mode="$3" if [ "$mode" = "monitor" ]; then @@ -23,70 +13,63 @@ agmsg_delivery_apply() { fi } -# `monitor` is the user-facing mode name even though Codex installs both hook -# types internally. Keep status stable for callers and existing automation. agmsg_delivery_status() { agmsg_delivery_status_default "$@" | sed '1s/^mode: both$/mode: monitor/' } agmsg_delivery_on_enable() { - echo "Codex visible monitor beta is enabled." - echo "After actas binds a role, only a visible app-server bridge may deliver unread mail." - echo "If the bridge cannot attach, mail stays unread until the next visible Codex turn." - echo "Background codex exec resume handling is prohibited." - echo "Add this shell function to your interactive shell profile, then restart the shell:" - if "$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" function; then - echo "Future Codex sessions: launch with codex. In monitor-mode projects, the agmsg function routes interactive Codex sessions through the bridge." - echo "Optional global PATH shim is still available with:" - echo " $SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh install" - else - echo "Codex monitor mode is enabled, but the codex shell function could not be printed." - echo "Future Codex sessions: launch with $SKILL_DIR/scripts/drivers/types/codex/codex-monitor.sh, or resolve the setup issue above." + local project="$3" legacy_cleanup + legacy_cleanup=$("$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ + disarm-project "$project" 2>/dev/null || true) + if printf '%s\n' "$legacy_cleanup" | grep -q 'lease_id='; then + echo "Disabled legacy Codex heartbeat/watchdog lease state for this project." fi - # Node preflight: the bridge (codex-bridge.js) is a Node program, so without - # Node it silently never starts — flag it at enable time. Resolve via the same - # path the runtime uses (lib/node.sh). AGMSG_NODE / AGMSG_CODEX_NODE override. + echo "Codex visible monitor beta is enabled." + echo "Unread mail is delivered only through the authenticated Desktop relay into the exact visible task." + echo "If the relay, Desktop, exact thread, or role bridge is unavailable, delivery downgrades to turn and mail remains unread." + case "$SKILL_DIR" in + "$HOME"/.agents/skills/*) + if [ "$(uname -s)" = "Darwin" ] && command -v launchctl >/dev/null 2>&1; then + local relay_status + relay_status="$("$SKILL_DIR/scripts/drivers/types/codex/codex-desktop-relayctl.sh" enable 2>&1 || true)" + if printf '%s\n' "$relay_status" | grep -q '^status='; then + echo "Codex Desktop relay: $relay_status" + echo "Restart Codex Desktop once if it is not yet connected through the relay." + else + echo "WARNING: Codex Desktop relay did not start: $relay_status" + fi + fi + ;; + esac local codex_node codex_node="$(agmsg_resolve_node)" if ! command -v "$codex_node" >/dev/null 2>&1 && [ ! -x "$codex_node" ]; then - echo "WARNING: Node.js ('$codex_node') was not found. The Codex bridge needs Node —" - echo " monitor delivery will NOT start until Node is installed (or set AGMSG_NODE)." + echo "WARNING: Node.js ('$codex_node') was not found; monitor delivery will NOT start." fi - echo "Run agmsg actas in the intended Codex task to bind the receiver now." - echo "SessionStart rebinds the last role after a later restart." + echo "Run agmsg actas in the intended visible Codex task to bind its exact thread." echo "For more info: $CODEX_MONITOR_DOC_URL" } agmsg_delivery_on_disable() { - local project="$2" - local stopped lease_cleanup - stopped=$(stop_codex_bridge "$project") + local project="$2" stopped lease_cleanup + stopped="$(stop_codex_bridge "$project")" if [ "${stopped:-0}" -gt 0 ]; then - echo "Stopped $stopped Codex bridge process(es) for this project and cleaned their run files." + echo "Stopped $stopped project-owned Codex bridge process(es) and cleaned their private run files." fi - # Remove any legacy scheduled-receiver lease left by pre-event-driven builds. lease_cleanup=$("$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ disarm-project "$project" 2>/dev/null || true) [ -n "$lease_cleanup" ] && printf '%s\n' "$lease_cleanup" - echo "Note: shell profile functions are not changed automatically." - echo " If you installed the optional global shim and no other project uses monitor mode, remove it:" - echo " $SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh remove" - echo " # then drop any agmsg Codex function or ~/.agents/bin PATH entry you added for monitor" + echo "The shared Codex Desktop relay remains installed for other projects and future monitor sessions." } agmsg_delivery_stop_directive() { - local project="${PROJECT:-}" - local mode="${MODE:-}" + local project="${PROJECT:-}" mode="${MODE:-}" if [ "$mode" = "turn" ] && [ -n "$project" ]; then - # Let a failing receiver finish writing fallback health and chat-visible - # metadata before it exits on its own. - if [ "${AGMSG_CODEX_PRESERVE_CURRENT_MONITOR:-}" = "1" ]; then - return 0 - fi + [ "${AGMSG_CODEX_PRESERVE_CURRENT_MONITOR:-}" = "1" ] && return 0 local stopped - stopped=$(stop_codex_bridge "$project") + stopped="$(stop_codex_bridge "$project")" if [ "${stopped:-0}" -gt 0 ]; then - echo "Stopped $stopped Codex bridge/app monitor process(es) for this project and cleaned their run files." + echo "Stopped $stopped project-owned Codex bridge process(es) and cleaned their run files." fi "$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ disarm-project "$project" 2>/dev/null || true @@ -94,102 +77,38 @@ agmsg_delivery_stop_directive() { } agmsg_delivery_runtime_status() { - local type="$1" project="$2" - local pairs found=0 - pairs=$("$SCRIPT_DIR/identities.sh" "$project" "$type" 2>/dev/null || true) - - if [ -z "$pairs" ]; then - echo "Codex bridge: no identities registered for this project" - return 0 - fi - - while IFS=$'\t' read -r team name _rest; do - if [ -z "$team" ] || [ -z "$name" ]; then - continue - fi + local type="$1" project="$2" relay_status meta meta_project status thread team name pid found=0 chat + relay_status="$("$SKILL_DIR/scripts/drivers/types/codex/codex-desktop-relayctl.sh" status 2>/dev/null || true)" + echo "Codex Desktop relay: ${relay_status:-status=unknown app_server=ws://127.0.0.1/}" + + for meta in "$RUN_DIR"/codex-bridge.*.meta; do + [ -f "$meta" ] || continue + meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" + [ "$(agmsg_canonical_path "$meta_project")" = "$(agmsg_canonical_path "$project")" ] || continue + [ "$(sed -n 's/^type=//p' "$meta" | head -1)" = "$type" ] || continue found=1 - - local healthfile health_status health_failures health_error health_updated - healthfile="$RUN_DIR/codex-app-monitor.$team.$name.health" - if [ -f "$healthfile" ]; then - health_status=$(awk -F= '/^status=/{sub(/^status=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) - health_failures=$(awk -F= '/^consecutive_failures=/{sub(/^consecutive_failures=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) - health_error=$(awk -F= '/^last_error=/{sub(/^last_error=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) - health_updated=$(awk -F= '/^updated_at=/{sub(/^updated_at=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) - echo "Codex monitor health: $team/$name status=${health_status:-unknown} failures=${health_failures:-unknown} last_error=${health_error:-unknown} updated=${health_updated:-unknown}" - fi - - local base pidfile metafile pid meta_pid meta_project meta_type meta_ok - base="$RUN_DIR/codex-bridge.$team.$name" - pidfile="$base.pid" - metafile="$base.meta" - - local app_base app_pidfile app_metafile app_pid app_thread app_transport - app_base="$RUN_DIR/codex-app-monitor.$team.$name" - app_pidfile="$app_base.pid" - app_metafile="$app_base.meta" - - local chat_metafile chat_project chat_type chat_transport chat_status chat_updated - chat_metafile="$RUN_DIR/codex-chat-visible.$team.$name.meta" - - if [ ! -f "$pidfile" ]; then - if [ -f "$app_pidfile" ] && [ -f "$app_metafile" ]; then - app_pid=$(cat "$app_pidfile" 2>/dev/null || true) - app_thread=$(awk -F= '/^thread=/{sub(/^thread=/, ""); print; exit}' "$app_metafile" 2>/dev/null || true) - app_transport=$(awk -F= '/^transport=/{sub(/^transport=/, ""); print; exit}' "$app_metafile" 2>/dev/null || true) - if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then - echo "Codex app monitor: $team/$name alive (pid $app_pid, thread $app_thread, transport ${app_transport:-codex-background-thread-resume})" - else - echo "Codex app monitor: $team/$name stale pidfile (pid ${app_pid:-empty} not running)" - fi - elif [ -f "$chat_metafile" ]; then - chat_project=$(awk -F= '/^project=/{sub(/^project=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) - chat_type=$(awk -F= '/^type=/{sub(/^type=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) - chat_transport=$(awk -F= '/^transport=/{sub(/^transport=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) - chat_status=$(awk -F= '/^status=/{sub(/^status=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) - chat_updated=$(awk -F= '/^updated_at=/{sub(/^updated_at=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) - if [ "$chat_project" = "$project" ] && [ "$chat_type" = "$type" ]; then - echo "Codex chat-visible turn: $team/$name armed (transport ${chat_transport:-codex-chat-visible-turn}, status ${chat_status:-waiting_for_chat_turn}, updated ${chat_updated:-unknown})" - else - echo "Codex chat-visible turn: $team/$name stale metadata" - fi - else - echo "Codex bridge: $team/$name not running" - fi - continue - fi - - pid=$(cat "$pidfile" 2>/dev/null || true) - if [ -z "$pid" ]; then - echo "Codex bridge: $team/$name stale pidfile (empty pid)" - continue - fi - - if [ ! -f "$metafile" ]; then - echo "Codex bridge: $team/$name stale pidfile (missing metadata)" - continue - fi - - meta_ok=1 - meta_pid=$(awk -F= '/^pid=/{sub(/^pid=/, ""); print; exit}' "$metafile" 2>/dev/null || true) - meta_project=$(awk -F= '/^project=/{sub(/^project=/, ""); print; exit}' "$metafile" 2>/dev/null || true) - meta_type=$(awk -F= '/^type=/{sub(/^type=/, ""); print; exit}' "$metafile" 2>/dev/null || true) - [ -n "$meta_pid" ] && [ "$meta_pid" != "$pid" ] && meta_ok=0 - [ -n "$meta_project" ] && [ "$meta_project" != "$project" ] && meta_ok=0 - [ -n "$meta_type" ] && [ "$meta_type" != "$type" ] && meta_ok=0 - if [ "$meta_ok" -ne 1 ]; then - echo "Codex bridge: $team/$name stale pidfile (metadata mismatch)" - continue - fi - - if kill -0 "$pid" 2>/dev/null; then - echo "Codex bridge: $team/$name alive (pid $pid)" + team="$(sed -n 's/^team=//p' "$meta" | head -1)" + name="$(sed -n 's/^name=//p' "$meta" | head -1)" + thread="$(sed -n 's/^thread=//p' "$meta" | head -1)" + pid="$(sed -n 's/^pid=//p' "$meta" | head -1)" + status="$(sed -n 's/^status=//p' "${meta%.meta}.health" 2>/dev/null | head -1 || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "Codex visible bridge: $team/$name active pid=$pid thread=$thread health=${status:-unknown} app_server=ws://127.0.0.1/" else - echo "Codex bridge: $team/$name stale pidfile (pid $pid not running)" + echo "Codex visible bridge: $team/$name stale thread=$thread health=${status:-unknown}" fi - done <<< "$pairs" + done - if [ "$found" -eq 0 ]; then - echo "Codex bridge: no identities registered for this project" - fi + for chat in "$RUN_DIR"/codex-chat-visible.*.meta; do + [ -f "$chat" ] || continue + meta_project="$(sed -n 's/^project=//p' "$chat" | head -1)" + [ "$(agmsg_canonical_path "$meta_project")" = "$(agmsg_canonical_path "$project")" ] || continue + [ "$(sed -n 's/^type=//p' "$chat" | head -1)" = "$type" ] || continue + found=1 + team="$(sed -n 's/^team=//p' "$chat" | head -1)" + name="$(sed -n 's/^name=//p' "$chat" | head -1)" + thread="$(sed -n 's/^thread=//p' "$chat" | head -1)" + echo "Codex visible turn fallback: $team/$name waiting thread=$thread unread_preserved=true" + done + [ "$found" -eq 1 ] || echo "Codex visible bridge: no role is currently bound for this project" } diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh old mode 100644 new mode 100755 index 53030457..d59c0eee --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -1,163 +1,37 @@ #!/usr/bin/env bash -# codex SessionStart plug — hand the session off to the Codex bridge. -# -# Sourced by session-start.sh in its global context (so it sees TYPE, PROJECT, -# RUN_DIR, SKILL_DIR, SCRIPT_DIR, PAIRS and the helpers agmsg_sha1, -# agmsg_sqlite_mem, agmsg_resolve_node, agmsg_canonical_path, agmsg_agent_pid). -# Defines agmsg_session_start, overriding session-start.sh's default no-op. -# -# Codex has no Monitor tool. When launched through codex-monitor.sh, the TUI is -# attached to a shared app-server. Hand the bridge off so incoming agmsg rows -# become turns in the current Codex thread without exposing socket/thread -# plumbing to the user. With AGMSG_CODEX_BRIDGE_LAUNCHER=1 (set by -# codex-monitor.sh) we only write a request file and let the out-of-sandbox -# launcher start the bridge — a hook-launched bridge cannot connect to the unix -# socket from inside the Codex sandbox (#41). -# Resolve the current Codex thread id. CODEX_THREAD_ID is only exported on the -# interactive --remote path; fresh and `codex exec` sessions never export it, so -# fall back to the newest rollout file whose session_meta cwd matches the -# project. Codex writes that rollout ~1s before SessionStart, so it is already -# present; a short bounded retry covers the race if it is not. See #41. -agmsg_resolve_codex_thread() { - local project="$1" - if [ -n "${CODEX_THREAD_ID:-}" ]; then - printf '%s' "$CODEX_THREAD_ID" - return 0 - fi - local sessions_dir="$HOME/.codex/sessions" - [ -d "$sessions_dir" ] || return 0 - # Compare PHYSICAL paths. agmsg may open the project via a symlinked/logical - # path (e.g. a workspace under a symlinked home) while Codex records the - # canonical cwd in session_meta. A raw string compare then misses every - # rollout, so the thread is never resolved and the bridge never starts. See - # #160. Canonicalize the project once; canonicalize each rollout cwd per row. - local project_phys - project_phys=$(agmsg_canonical_path "$project") - local waited=0 f first esc cwd cwd_phys tid - while :; do - while IFS= read -r f; do - [ -f "$f" ] || continue - first=$(head -1 "$f" 2>/dev/null) - case "$first" in *'"session_meta"'*) ;; *) continue ;; esac - esc=$(printf '%s' "$first" | sed "s/'/''/g") - cwd=$(agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.cwd'),'')" 2>/dev/null) - cwd_phys=$(agmsg_canonical_path "$cwd") - [ "$cwd_phys" = "$project_phys" ] || continue - tid=$(agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.id'),'')" 2>/dev/null) - if [ -n "$tid" ]; then - printf '%s' "$tid" - return 0 - fi - done </dev/null | head -20) -INNER_EOF - [ "$waited" -ge 2 ] && break - waited=$((waited + 1)) - sleep 1 - done - return 0 -} +# Codex SessionStart plug. Rebind only when the current process exports an exact +# thread id. Rollout scanning, command-line socket discovery, launcher request +# files, and thread/start fallback can attach a different task and are forbidden. agmsg_session_start() { - # Compatibility guard for rollouts created by older background receivers. - # Current builds prohibit that transport, but must not duplicate a legacy - # parent receiver during an in-flight upgrade. if [ "${AGMSG_CODEX_BACKGROUND_RESUME:-}" = "1" ]; then exit 0 fi - thread_id="$(agmsg_resolve_codex_thread "$PROJECT")" - [ -n "$thread_id" ] || exit 0 + local thread_id team="" name="" seat log match_count=0 + thread_id="${AGMSG_CODEX_ACTAS_THREAD:-${CODEX_THREAD_ID:-}}" + case "$thread_id" in ""|loaded|current|unresolved) thread_id="" ;; esac - # Prefer the last explicit `agmsg actas` role. A project can register many - # Codex identities, so picking the first row after restart would bind sends - # and receives to different agents. Fall back to the single registered pair - # only when no valid actas state exists. - team=""; name="" - project_hash="$(printf '%s' "$PROJECT" | agmsg_sha1)" - actas_state="$RUN_DIR/codex-last-actas.$project_hash.tsv" - if [ -f "$actas_state" ]; then - IFS=$'\t' read -r saved_project saved_type saved_team saved_name _saved_at < "$actas_state" || true - if [ "$saved_project" = "$PROJECT" ] \ - && [ "$saved_type" = "$TYPE" ] \ + [ -n "$thread_id" ] || exit 0 + for seat in "$RUN_DIR"/codex-seat.*.tsv; do + [ -f "$seat" ] || continue + IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true + if [ "$saved_project" = "$PROJECT" ] && [ "$saved_type" = "$TYPE" ] \ + && [ "$saved_thread" = "$thread_id" ] \ && printf '%s\n' "$PAIRS" | grep -Fxq "$(printf '%s\t%s' "$saved_team" "$saved_name")"; then team="$saved_team" name="$saved_name" + match_count=$((match_count + 1)) fi - fi - if [ -z "$team" ] || [ -z "$name" ]; then - pair_count=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { c++ } END { print c + 0 }') - [ "$pair_count" = "1" ] || exit 0 - team=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $1; exit }') - name=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $2; exit }') - fi + done + [ "$match_count" = "1" ] || exit 0 [ -n "$team" ] && [ -n "$name" ] || exit 0 - app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" - if [ -z "$app_server" ]; then - agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true) - if [ -n "$agent_pid" ]; then - agent_cmd=$(compat_get_cmdline "$agent_pid" 2>/dev/null || true) - app_server=$(printf '%s\n' "$agent_cmd" \ - | sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \ - | head -1) - fi - fi - if [ -z "$app_server" ]; then - project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) - socket_path="$RUN_DIR/codex-app-server.$project_hash.sock" - if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then - app_server="unix://$socket_path" - fi - fi - if [ -z "$app_server" ]; then - # Ordinary Codex.app has no externally reachable app-server endpoint. - # actas-monitor.sh records visible-turn fallback and downgrades monitor to - # turn instead of starting an invisible background receiver. - log="$RUN_DIR/codex-actas-restore.log" - "$SKILL_DIR/scripts/drivers/types/codex/actas-monitor.sh" \ - "$PROJECT" "$TYPE" "$name" "$thread_id" >>"$log" 2>&1 || true - exit 0 - fi - - if [ "${AGMSG_CODEX_BRIDGE_LAUNCHER:-}" = "1" ]; then - project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) - request_file="$RUN_DIR/codex-bridge-request.$project_hash" - tmp_request="$request_file.$$" - mkdir -p "$RUN_DIR" 2>/dev/null || true - printf '%s\t%s\t%s\t%s\t%s\n' \ - "$TYPE" "$team" "$name" "$thread_id" "$app_server" > "$tmp_request" - mv "$tmp_request" "$request_file" - exit 0 - fi - mkdir -p "$RUN_DIR" 2>/dev/null || true - pidfile="$RUN_DIR/codex-bridge.$team.$name.pid" - if [ -f "$pidfile" ]; then - bridge_pid=$(cat "$pidfile" 2>/dev/null || true) - if [ -n "$bridge_pid" ] && kill -0 "$bridge_pid" 2>/dev/null; then - exit 0 - fi - fi - - log="$RUN_DIR/codex-bridge.$team.$name.log" - # An explicit AGMSG_CODEX_BRIDGE_CMD is a complete runnable (tests, custom - # wrappers) — run it as-is. Only the default codex-bridge.js is launched - # through a resolved Node, since its env-node shebang fails in shells where a - # version-manager Node is not on PATH (#170). - if [ -n "${AGMSG_CODEX_BRIDGE_CMD:-}" ]; then - bridge_run=("$AGMSG_CODEX_BRIDGE_CMD") - else - bridge_run=("$(agmsg_resolve_node)" "$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js") - fi - nohup "${bridge_run[@]}" \ - --project "$PROJECT" \ - --type "$TYPE" \ - --team "$team" \ - --name "$name" \ - --thread "$thread_id" \ - --app-server "$app_server" \ - >>"$log" 2>&1 & + log="$RUN_DIR/codex-actas-restore.log" + AGMSG_CODEX_ACTAS_THREAD="$thread_id" \ + "$SKILL_DIR/scripts/drivers/types/codex/actas-monitor.sh" \ + "$PROJECT" "$TYPE" "$name" "$thread_id" >> "$log" 2>&1 || true exit 0 } diff --git a/scripts/drivers/types/codex/actas-monitor.sh b/scripts/drivers/types/codex/actas-monitor.sh index ec54b319..2462f741 100755 --- a/scripts/drivers/types/codex/actas-monitor.sh +++ b/scripts/drivers/types/codex/actas-monitor.sh @@ -1,15 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +umask 077 -# Rebind Codex monitor delivery to an explicit actas identity. -# -# Usage: -# actas-monitor.sh [session_id] -# -# Codex has no native Monitor tool. An explicitly selected monitor/both mode -# binds one agmsg identity to a persisted Codex thread only when a visible -# app-server bridge is available. Without that bridge the effective mode is -# changed to turn, so unread mail waits for the next visible assistant turn. +# Bind one agmsg identity to one exact visible Codex Desktop task. Monitor mode +# has one transport only: a role-scoped bridge connects through the authenticated +# global Desktop relay. Any missing/ambiguous state downgrades to foreground turn +# delivery and leaves unread messages untouched. PROJECT="${1:?Usage: actas-monitor.sh [session_id]}" TYPE="${2:?Missing type}" @@ -18,7 +14,7 @@ SESSION_ID="${4:-${CODEX_THREAD_ID:-}}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" -RUN_DIR="$SKILL_DIR/run" +RUN_DIR="${AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR:-$SKILL_DIR/run}" # shellcheck source=../../../lib/hash.sh source "$SCRIPT_DIR/../../../lib/hash.sh" @@ -33,23 +29,15 @@ source "$SCRIPT_DIR/../../../lib/actas-lock.sh" PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")" mkdir -p "$RUN_DIR" - NODE_BIN="$(agmsg_resolve_node)" TAB="$(printf '\t')" -find_identity() { - "$SCRIPT_DIR/../../../identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ - | awk -v want="$NAME" -v tab="$TAB" 'NF >= 2 && $2 == want { print $1 tab $2 }' \ - | sort -u -} - -IDS="$(find_identity || true)" +IDS=$("$SCRIPT_DIR/../../../identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ + | awk -F "$TAB" -v want="$NAME" -v tab="$TAB" 'NF >= 2 && $2 == want { print $1 tab $2 }' \ + | sort -u || true) COUNT="$(printf '%s\n' "$IDS" | grep -c . || true)" case "$COUNT" in - 0) - echo "status=not_registered name=$NAME" - exit 2 - ;; + 0) echo "status=not_registered name=$NAME"; exit 2 ;; 1) IFS="$TAB" read -r TEAM _agent </dev/null || true -fi - -record_last_actas() { - local project_hash state tmp - project_hash="$(printf '%s' "$PROJECT" | agmsg_sha1)" - state="$RUN_DIR/codex-last-actas.$project_hash.tsv" - tmp="$state.$$" - printf '%s\t%s\t%s\t%s\t%s\n' \ - "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp" - mv "$tmp" "$state" -} - -record_last_actas - -MODE="$("$SCRIPT_DIR/../../../delivery.sh" status "$TYPE" "$PROJECT" 2>/dev/null \ - | sed -n 's/^mode: //p')" - -resolve_thread_id() { - if [ -n "${CODEX_THREAD_ID:-}" ]; then - printf '%s\n' "$CODEX_THREAD_ID" - return 0 - fi - "$NODE_BIN" - "$PROJECT" <<'NODE' -const fs = require("fs"); -const path = require("path"); -const project = fs.realpathSync(process.argv[2]); -const root = path.join(process.env.HOME || "", ".codex", "sessions"); -const files = []; -function walk(dir) { - let entries = []; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) { - try { - files.push({ full, mtime: fs.statSync(full).mtimeMs }); - } catch {} - } - } -} -walk(root); -files.sort((a, b) => b.mtime - a.mtime); -for (const { full } of files.slice(0, 80)) { - let first = ""; - try { - first = fs.readFileSync(full, "utf8").split(/\r?\n/, 1)[0] || ""; - } catch { - continue; - } - try { - const row = JSON.parse(first); - const payload = row && row.payload; - if (!payload || !payload.cwd || !payload.id) continue; - let cwd = ""; - try { - cwd = fs.realpathSync(payload.cwd); - } catch { - cwd = path.resolve(payload.cwd); - } - if (cwd === project) { - console.log(payload.id); - process.exit(0); - } - } catch {} -} -NODE -} - -kill_other_project_receivers() { - local meta pidfile meta_project meta_type meta_team meta_name - for meta in "$RUN_DIR"/codex-bridge.*.meta "$RUN_DIR"/codex-app-monitor.*.meta; do - [ -f "$meta" ] || continue - meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" - meta_type="$(sed -n 's/^type=//p' "$meta" | head -1)" - meta_team="$(sed -n 's/^team=//p' "$meta" | head -1)" - meta_name="$(sed -n 's/^name=//p' "$meta" | head -1)" - [ "$meta_project" = "$PROJECT" ] || continue - [ "$meta_type" = "$TYPE" ] || continue - [ "$meta_team" = "$TEAM" ] && [ "$meta_name" = "$NAME" ] && continue - pidfile="${meta%.meta}.pid" - kill_receiver_files "$pidfile" "$meta" - done -} - -wait_for_pid_exit() { - local pid="$1" check=0 state - while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do - state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" - case "$state" in Z*) return 0 ;; esac - sleep 0.1 - check=$((check + 1)) - done - if kill -0 "$pid" 2>/dev/null; then - kill -KILL "$pid" 2>/dev/null || return 1 - check=0 - while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do - state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" - case "$state" in Z*) return 0 ;; esac - sleep 0.1 - check=$((check + 1)) - done - fi - ! kill -0 "$pid" 2>/dev/null -} - -kill_receiver_files() { - local pidfile="$1" meta="$2" pid="" cmd="" label="" kind base plist - base="${pidfile%.pid}" - kind="${base##*/}" - plist="$base.plist" - if [ -f "$meta" ]; then - label="$(sed -n 's/^launch_label=//p' "$meta" | head -1)" - fi - if [ -z "$label" ] && [ -f "$plist" ]; then - label="$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$plist")" - fi - if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then - bootout_label_and_wait "gui/$(id -u)" "$label" - fi - if [ -f "$pidfile" ]; then - pid="$(cat "$pidfile" 2>/dev/null || true)" - fi - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case "$kind:$cmd" in - codex-bridge.*:*codex-bridge.js*|codex-app-monitor.*:*codex-app-monitor.sh*) - kill "$pid" 2>/dev/null || true - if ! wait_for_pid_exit "$pid"; then - echo "actas-monitor: receiver pid $pid did not stop; preserving its run files" >&2 - return 1 - fi - ;; - esac - fi - rm -f "$pidfile" "$meta" "$base.appserver" "$base.log" "$plist" \ - "$base.health" "$base.preflight.log" "$base.last-prompt.txt" \ - "$base.last-message.txt" "$base.last-status" "$base.last-ids" \ - "$base.watch-output" -} - -xml_escape() { - sed \ - -e 's/&/\&/g' \ - -e 's//\>/g' \ - -e 's/"/\"/g' \ - -e "s/'/\'/g" -} - -plist_string() { - printf '%s' "$1" | xml_escape -} - -codex_app_monitor_label() { - local safe_team safe_name - safe_team="$(printf '%s' "$TEAM" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" - safe_name="$(printf '%s' "$NAME" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" - printf 'com.agmsg.codex-app-monitor.%s.%s.%s' "$PROJECT_HASH" "$safe_team" "$safe_name" -} - -bootout_label_and_wait() { - local domain="$1" label="$2" check=0 - launchctl bootout "$domain/$label" >/dev/null 2>&1 || true - while [ "$check" -lt 20 ] && launchctl print "$domain/$label" >/dev/null 2>&1; do +PROJECT_HASH="$(printf '%s' "$PROJECT" | agmsg_sha1)" +SAFE_TEAM="$(_actas_lock_encode "$TEAM")" +SAFE_NAME="$(_actas_lock_encode "$NAME")" +STATE_KEY="$PROJECT_HASH.$SAFE_TEAM.$SAFE_NAME" +BASE="$RUN_DIR/codex-bridge.$STATE_KEY" +PIDFILE="$BASE.pid" +META="$BASE.meta" +HEALTH="$BASE.health" +APPSERVER_FILE="$BASE.appserver" +BINDING_FILE="$BASE.binding" +PLIST="$BASE.plist" +LOG="$BASE.log" +LABEL="com.agmsg.codex-bridge.$STATE_KEY" +DOMAIN="gui/$(id -u)" +CHAT_META="$RUN_DIR/codex-chat-visible.$STATE_KEY.meta" +SEAT="$(codex_seat_path "$TEAM" "$NAME")" +RELAY_HEALTH="$RUN_DIR/codex-desktop-relay.health" +RELAY_ENDPOINT_FILE="${AGMSG_CODEX_DESKTOP_RELAY_BRIDGE_ENDPOINT_FILE:-$RUN_DIR/codex-desktop-relay.bridge-endpoint}" + +MODE=$("$SCRIPT_DIR/../../../delivery.sh" status "$TYPE" "$PROJECT" 2>/dev/null \ + | sed -n 's/^mode: //p') + +bootout_label() { + local label="$1" check=0 + case "$label" in + com.agmsg.codex-bridge.*) + case "${label#com.agmsg.codex-bridge.}" in + ""|*[!A-Za-z0-9._%-]*) return 0 ;; + esac + ;; + *) return 0 ;; + esac + command -v launchctl >/dev/null 2>&1 || return 0 + launchctl bootout "$DOMAIN/$label" >/dev/null 2>&1 || true + while [ "$check" -lt 30 ] && launchctl print "$DOMAIN/$label" >/dev/null 2>&1; do sleep 0.1 check=$((check + 1)) done } -write_codex_app_monitor_plist() { - local plist="$1" label="$2" thread_id="$3" log="$4" tmp - local codex_bin path_value - if [ -n "${AGMSG_CODEX_APP_MONITOR_CODEX:-}" ]; then - codex_bin="$AGMSG_CODEX_APP_MONITOR_CODEX" - elif [ -x "/Applications/ChatGPT.app/Contents/Resources/codex" ]; then - codex_bin="/Applications/ChatGPT.app/Contents/Resources/codex" - elif [ -x "/Applications/Codex.app/Contents/Resources/codex" ]; then - codex_bin="/Applications/Codex.app/Contents/Resources/codex" - elif [ -x "$HOME/.npm-global/bin/codex" ]; then - codex_bin="$HOME/.npm-global/bin/codex" - else - codex_bin="$(command -v codex 2>/dev/null || true)" - fi - path_value="${PATH:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}" - tmp="$plist.$$" - cat > "$tmp" < - - - - Label - $(plist_string "$label") - ProgramArguments - - $(plist_string "$SCRIPT_DIR/codex-app-monitor.sh") - $(plist_string "$PROJECT") - $(plist_string "$TYPE") - $(plist_string "$TEAM") - $(plist_string "$NAME") - $(plist_string "$thread_id") - - EnvironmentVariables - - HOME - $(plist_string "$HOME") - PATH - $(plist_string "$path_value") - AGMSG_CODEX_APP_MONITOR_LABEL - $(plist_string "$label") - AGMSG_CODEX_APP_MONITOR_CODEX - $(plist_string "${codex_bin:-codex}") - AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME - 1 - AGMSG_CODEX_APP_MONITOR_SUPERVISED - 1 - AGMSG_CODEX_APP_MONITOR_TIMEOUT - $(plist_string "${AGMSG_CODEX_APP_MONITOR_TIMEOUT:-}") - AGMSG_CODEX_APP_MONITOR_INTERVAL - $(plist_string "${AGMSG_CODEX_APP_MONITOR_INTERVAL:-}") - AGMSG_CODEX_APP_MONITOR_MAX_WAKES - $(plist_string "${AGMSG_CODEX_APP_MONITOR_MAX_WAKES:-}") - AGMSG_CODEX_APP_MONITOR_MAX_FAILURES - $(plist_string "${AGMSG_CODEX_APP_MONITOR_MAX_FAILURES:-}") - AGMSG_CODEX_APP_MONITOR_FLAGS - $(plist_string "${AGMSG_CODEX_APP_MONITOR_FLAGS:-}") - AGMSG_CODEX_APP_MONITOR_DISABLE_NOTIFY - $(plist_string "${AGMSG_CODEX_APP_MONITOR_DISABLE_NOTIFY:-}") - - WorkingDirectory - $(plist_string "$PROJECT") - StandardOutPath - $(plist_string "$log") - StandardErrorPath - $(plist_string "$log") - RunAtLoad - - KeepAlive - - SuccessfulExit - - - ThrottleInterval - 2 - - -EOF - mv "$tmp" "$plist" +bridge_pid_matches_state() { + local pid="$1" base="$2" meta="$3" state_key expected cmd + local meta_project meta_type meta_team meta_name meta_thread + state_key="${base##*/codex-bridge.}" + case "$state_key" in ""|*[!A-Za-z0-9._%-]*) return 1 ;; esac + [ -f "$meta" ] || return 1 + meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" + meta_type="$(sed -n 's/^type=//p' "$meta" | head -1)" + meta_team="$(sed -n 's/^team=//p' "$meta" | head -1)" + meta_name="$(sed -n 's/^name=//p' "$meta" | head -1)" + meta_thread="$(sed -n 's/^thread=//p' "$meta" | head -1)" + [ -n "$meta_project" ] \ + && [ "$(agmsg_canonical_path "$meta_project")" = "$(agmsg_canonical_path "$PROJECT")" ] \ + && [ "$meta_type" = "$TYPE" ] && [ -n "$meta_team" ] \ + && [ -n "$meta_name" ] && [ -n "$meta_thread" ] || return 1 + case "$meta_thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + expected="$SCRIPT_DIR/codex-bridge.js --project $PROJECT --type $TYPE --team $meta_team --name $meta_name --state-key $state_key --app-server-file $base.appserver --thread $meta_thread" + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case " $cmd " in *" $expected "*) return 0 ;; esac + return 1 } -start_codex_app_monitor() { - local thread_id="$1" - local pidfile log health existing_pid existing_thread existing_health app_monitor_pid label plist domain - local health_status ready_seconds ready_checks check - if [ -z "$thread_id" ]; then - actas_lock_gc_stale >/dev/null 2>&1 || true - start_chat_visible_turn_delivery "" "codex_app_thread_id_unavailable" - fi - - kill_other_project_receivers - kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" - - pidfile="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" - log="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.log" - health="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.health" - label="$(codex_app_monitor_label)" - plist="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.plist" - domain="gui/$(id -u)" - if [ -f "$pidfile" ]; then - existing_pid="$(cat "$pidfile" 2>/dev/null || true)" - if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then - existing_thread="$(sed -n 's/^thread=//p' "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" 2>/dev/null | head -1)" - existing_health="$(sed -n 's/^status=//p' "$health" 2>/dev/null | head -1 || true)" - if [ "$existing_thread" = "$thread_id" ]; then - echo "status=already_running team=$TEAM name=$NAME app_monitor_pid=$existing_pid thread=$thread_id transport=codex-background-thread-resume health=${existing_health:-unknown}" - exit 0 - fi - kill_receiver_files "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" - else - rm -f "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" - fi - fi - rm -f "$health" "$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" - - if command -v launchctl >/dev/null 2>&1 && launchctl print "$domain" >/dev/null 2>&1; then - write_codex_app_monitor_plist "$plist" "$label" "$thread_id" "$log" - bootout_label_and_wait "$domain" "$label" - if ! launchctl bootstrap "$domain" "$plist" >/dev/null 2>&1; then - start_chat_visible_turn_delivery "$thread_id" "app_monitor_launchctl_bootstrap_failed" +stop_bridge_base() { + local base="$1" meta pidfile pid="" label="" state_key + meta="$base.meta" + pidfile="$base.pid" + case "$base" in "$RUN_DIR/codex-bridge.$PROJECT_HASH."*) ;; *) return 0 ;; esac + state_key="${base##*/codex-bridge.}" + case "$state_key" in ""|*[!A-Za-z0-9._%-]*) return 0 ;; esac + label="com.agmsg.codex-bridge.$state_key" + bootout_label "$label" + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + if bridge_pid_matches_state "$pid" "$base" "$meta"; then + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 30); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done + bridge_pid_matches_state "$pid" "$base" "$meta" \ + && kill -KILL "$pid" 2>/dev/null || true fi - else - nohup env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ - "$SCRIPT_DIR/codex-app-monitor.sh" "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$thread_id" >>"$log" 2>&1 & fi - - ready_seconds="${AGMSG_CODEX_APP_MONITOR_READY_SECONDS:-5}" - case "$ready_seconds" in ''|*[!0-9]*) ready_seconds=5 ;; esac - [ "$ready_seconds" -gt 0 ] || ready_seconds=1 - ready_checks=$((ready_seconds * 5)) - check=0 - while [ "$check" -lt "$ready_checks" ]; do - app_monitor_pid="$(cat "$pidfile" 2>/dev/null || true)" - health_status="$(sed -n 's/^status=//p' "$health" 2>/dev/null | head -1 || true)" - if [ "$health_status" = "ready" ] && [ -n "$app_monitor_pid" ] && kill -0 "$app_monitor_pid" 2>/dev/null; then - echo "status=ok team=$TEAM name=$NAME app_monitor_pid=$app_monitor_pid thread=$thread_id transport=codex-background-thread-resume health=ready" - exit 0 - fi - case "$health_status" in preflight_failed|fallback_failed) break ;; esac - if [ -n "$app_monitor_pid" ] && ! kill -0 "$app_monitor_pid" 2>/dev/null; then - break - fi - sleep 0.2 - check=$((check + 1)) - done - - bootout_label_and_wait "$domain" "$label" - rm -f "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" - actas_lock_gc_stale >/dev/null 2>&1 || true - start_chat_visible_turn_delivery "$thread_id" "app_monitor_${health_status:-not_ready}" + rm -f "$base.pid" "$base.meta" "$base.health" "$base.appserver" \ + "$base.plist" "$base.log" "$base.last-ids" "$base.binding" + rm -f "$base".wake.*.json "$base.relay-wake.json" } -start_chat_visible_turn_delivery() { - local thread_id="$1" - local reason="${2:-foreground_turn_mode}" - local meta="$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" - local requested_mode="$MODE" - - kill_other_project_receivers - kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" - kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" - - # A queued foreground check is not a monitor. Preserve an honest delivery - # state so operators do not mistake waiting_for_chat_turn for active receipt. - case "$requested_mode" in - monitor|both) - "$SCRIPT_DIR/../../../delivery.sh" set turn "$TYPE" "$PROJECT" >/dev/null - MODE="turn" - ;; - esac - +start_visible_turn_delivery() { + local thread_id="$1" reason="$2" requested_mode="$MODE" + stop_bridge_base "$BASE" { printf 'project=%s\n' "$PROJECT" printf 'type=%s\n' "$TYPE" @@ -418,124 +146,289 @@ start_chat_visible_turn_delivery() { printf 'thread=%s\n' "${thread_id:-unresolved}" printf 'transport=codex-chat-visible-turn\n' printf 'status=waiting_for_chat_turn\n' + printf 'requested_mode=%s\n' "$requested_mode" + printf 'effective_mode=turn\n' + printf 'reason=%s\n' "$reason" printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - } > "$meta" + } > "$CHAT_META" + echo "status=visible_turn_only team=$TEAM name=$NAME thread=${thread_id:-unresolved} transport=codex-chat-visible-turn requested_mode=$requested_mode effective_mode=turn reason=$reason" + exit 0 +} - echo "status=visible_turn_only team=$TEAM name=$NAME thread=${thread_id:-unresolved} transport=codex-chat-visible-turn requested_mode=$requested_mode effective_mode=$MODE reason=$reason" +start_unbound_visible_turn() { + local reason="$1" + echo "status=visible_turn_only team=$TEAM name=$NAME thread=unresolved transport=codex-chat-visible-turn requested_mode=$MODE effective_mode=turn reason=$reason bound=false" exit 0 } -THREAD_ID="${AGMSG_CODEX_ACTAS_THREAD:-}" -if [ -z "$THREAD_ID" ]; then - THREAD_ID="$(resolve_thread_id || true)" -fi +claim_role_for_thread() { + local owner_project owner_type owner_team owner_name owner_thread _owner_at tmp claim_output marker + marker="$(actas_codex_owner "$PROJECT" "$THREAD_ID")" + if [ -f "$SEAT" ]; then + IFS=$'\t' read -r owner_project owner_type owner_team owner_name owner_thread _owner_at < "$SEAT" || true + if [ "$owner_project" = "$PROJECT" ] && [ "$owner_type" = "$TYPE" ] \ + && [ "$owner_team" = "$TEAM" ] && [ "$owner_name" = "$NAME" ] \ + && [ "$owner_thread" = "$THREAD_ID" ]; then + if claim_output="$(actas_lock_claim "$TEAM" "$NAME" "$marker" 2>&1)"; then + return 0 + fi + printf 'status=role_held team=%s name=%s owner_thread=%s requested_thread=%s\n' \ + "$TEAM" "$NAME" "${claim_output#held:}" "$THREAD_ID" + return 1 + fi + printf 'status=role_held team=%s name=%s owner_thread=%s requested_thread=%s\n' \ + "$TEAM" "$NAME" "${owner_thread:-unknown}" "$THREAD_ID" + return 1 + fi + if ! claim_output="$(actas_lock_claim "$TEAM" "$NAME" "$marker" 2>&1)"; then + printf 'status=role_held team=%s name=%s owner_thread=%s requested_thread=%s\n' \ + "$TEAM" "$NAME" "${claim_output#held:}" "$THREAD_ID" + return 1 + fi + if ! tmp="$(mktemp "$RUN_DIR/.codex-seat.XXXXXX")"; then + actas_lock_release "$TEAM" "$NAME" "$marker" + return 1 + fi + chmod 600 "$tmp" + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$THREAD_ID" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp" + if ln "$tmp" "$SEAT" 2>/dev/null; then + rm -f "$tmp" + return 0 + fi + rm -f "$tmp" + IFS=$'\t' read -r owner_project owner_type owner_team owner_name owner_thread _owner_at < "$SEAT" || true + [ "$owner_project" = "$PROJECT" ] && [ "$owner_type" = "$TYPE" ] \ + && [ "$owner_team" = "$TEAM" ] && [ "$owner_name" = "$NAME" ] \ + && [ "$owner_thread" = "$THREAD_ID" ] && return 0 + actas_lock_release "$TEAM" "$NAME" "$marker" + printf 'status=role_held team=%s name=%s owner_thread=%s requested_thread=%s\n' \ + "$TEAM" "$NAME" "${owner_thread:-unknown}" "$THREAD_ID" + return 1 +} + +release_other_role_for_thread() { + local seat saved_project saved_type saved_team saved_name saved_thread _saved_at key base chat + for seat in "$RUN_DIR"/codex-seat.*.tsv; do + [ -f "$seat" ] || continue + [ "$seat" = "$SEAT" ] && continue + IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true + [ "$saved_project" = "$PROJECT" ] && [ "$saved_type" = "$TYPE" ] \ + && [ "$saved_thread" = "$THREAD_ID" ] || continue + key="$PROJECT_HASH.$(_actas_lock_encode "$saved_team").$(_actas_lock_encode "$saved_name")" + base="$RUN_DIR/codex-bridge.$key" + chat="$RUN_DIR/codex-chat-visible.$key.meta" + stop_bridge_base "$base" + actas_codex_seat_release "$saved_team" "$saved_name" "$saved_project" "$saved_thread" + rm -f "$chat" + done +} + +THREAD_ID="${AGMSG_CODEX_ACTAS_THREAD:-${CODEX_THREAD_ID:-$SESSION_ID}}" +case "$THREAD_ID" in loaded|current|unresolved) THREAD_ID="" ;; esac case "$MODE" in - monitor|both) - ;; - turn) - start_chat_visible_turn_delivery "$THREAD_ID" "foreground_turn_mode" - ;; + monitor|both|turn) ;; off|"") - kill_other_project_receivers - kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" - kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + stop_bridge_base "$BASE" echo "status=receive_disabled team=$TEAM name=$NAME mode=${MODE:-off}" exit 0 ;; - *) - echo "status=invalid_mode team=$TEAM name=$NAME mode=$MODE" >&2 - exit 4 - ;; + *) echo "status=invalid_mode team=$TEAM name=$NAME mode=$MODE" >&2; exit 4 ;; esac -port_alive() { - local port="$1" - (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null -} +[ -n "$THREAD_ID" ] || start_unbound_visible_turn "exact_thread_id_unavailable" +if ! [[ "$THREAD_ID" =~ ^[A-Za-z0-9._-]+$ ]]; then + start_unbound_visible_turn "exact_thread_id_invalid" +fi -PROJECT_HASH="$(agmsg_sha1 <<<"$PROJECT")" -SERVER_PID="$RUN_DIR/codex-app-server.$PROJECT_HASH.pid" -PORT_FILE="$RUN_DIR/codex-app-server.$PROJECT_HASH.port" +# Durable Codex seat metadata lives in a dedicated namespace, while atomic +# exclusivity is shared with generic actas through a non-GC role marker. Both +# are released only by the same task changing roles, SessionEnd, reset/drop, +# or a project delivery-mode teardown. +claim_role_for_thread || exit 5 +release_other_role_for_thread -APP_SERVER="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" -if [ -z "$APP_SERVER" ] && [ -f "$PORT_FILE" ] && [ -f "$SERVER_PID" ]; then - existing_port="$(cat "$PORT_FILE" 2>/dev/null || true)" - existing_pid="$(cat "$SERVER_PID" 2>/dev/null || true)" - if [ -n "$existing_port" ] && [ -n "$existing_pid" ] \ - && kill -0 "$existing_pid" 2>/dev/null && port_alive "$existing_port"; then - APP_SERVER="ws://127.0.0.1:$existing_port" - fi -fi +[ "$MODE" = "turn" ] && start_visible_turn_delivery "$THREAD_ID" "foreground_turn_mode" -if [ -z "$APP_SERVER" ]; then - start_chat_visible_turn_delivery "$THREAD_ID" "visible_app_server_unavailable" -fi +private_regular_file() { + local file="$1" mode + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + mode="$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || true)" + [ "$mode" = "600" ] +} -if [ -z "$THREAD_ID" ]; then - THREAD_ID="loaded" +private_regular_file "$RELAY_ENDPOINT_FILE" \ + || start_visible_turn_delivery "$THREAD_ID" "desktop_relay_endpoint_unavailable" +RELAY_ENDPOINT="$(cat "$RELAY_ENDPOINT_FILE" 2>/dev/null || true)" +if ! printf '%s' "$RELAY_ENDPOINT" \ + | grep -Eq '^ws://127\.0\.0\.1:[0-9]+/bridge/[a-f0-9]{64}$'; then + start_visible_turn_delivery "$THREAD_ID" "desktop_relay_endpoint_invalid" +fi +RELAY_PORT="${RELAY_ENDPOINT#ws://127.0.0.1:}" +RELAY_PORT="${RELAY_PORT%%/*}" +RELAY_STATUS="$(sed -n 's/^status=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" +RELAY_PID="$(sed -n 's/^pid=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" +RELAY_HEALTH_PORT="$(sed -n 's/^port=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" +RELAY_PRIMARY="$(sed -n 's/^primary_connected=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" +RELAY_INITIALIZED="$(sed -n 's/^upstream_initialized=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" +if [ "$RELAY_STATUS" != "ready" ] || [ "$RELAY_PRIMARY" != "1" ] \ + || [ "$RELAY_INITIALIZED" != "1" ] || [ "$RELAY_HEALTH_PORT" != "$RELAY_PORT" ] \ + || [ -z "$RELAY_PID" ] || ! kill -0 "$RELAY_PID" 2>/dev/null; then + start_visible_turn_delivery "$THREAD_ID" "desktop_relay_not_ready" fi -kill_other_project_receivers -kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" - -BRIDGE_PIDFILE="$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" -BRIDGE_LOG="$RUN_DIR/codex-bridge.$TEAM.$NAME.log" -if [ -f "$BRIDGE_PIDFILE" ]; then - existing_bridge="$(cat "$BRIDGE_PIDFILE" 2>/dev/null || true)" - if [ -n "$existing_bridge" ] && kill -0 "$existing_bridge" 2>/dev/null; then - echo "status=already_running team=$TEAM name=$NAME bridge_pid=$existing_bridge app_server=$APP_SERVER thread=$THREAD_ID" - exit 0 +bridge_is_ready() { + local pid meta_thread health_status health_thread label token expected_endpoint + pid="$(cat "$PIDFILE" 2>/dev/null || true)" + [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null || return 1 + [ -f "$META" ] && [ -f "$HEALTH" ] && private_regular_file "$APPSERVER_FILE" \ + && private_regular_file "$BINDING_FILE" || return 1 + [ "$(sed -n 's/^project=//p' "$META" | head -1)" = "$PROJECT" ] || return 1 + [ "$(sed -n 's/^type=//p' "$META" | head -1)" = "$TYPE" ] || return 1 + [ "$(sed -n 's/^team=//p' "$META" | head -1)" = "$TEAM" ] || return 1 + [ "$(sed -n 's/^name=//p' "$META" | head -1)" = "$NAME" ] || return 1 + meta_thread="$(sed -n 's/^thread=//p' "$META" | head -1)" + health_status="$(sed -n 's/^status=//p' "$HEALTH" | head -1)" + health_thread="$(sed -n 's/^thread=//p' "$HEALTH" | head -1)" + [ "$meta_thread" = "$THREAD_ID" ] && [ "$health_thread" = "$THREAD_ID" ] || return 1 + [ "$health_status" = "ready" ] || return 1 + [ "$(sed -n 's/^project=//p' "$BINDING_FILE" | head -1)" = "$PROJECT" ] || return 1 + [ "$(sed -n 's/^type=//p' "$BINDING_FILE" | head -1)" = "$TYPE" ] || return 1 + [ "$(sed -n 's/^team=//p' "$BINDING_FILE" | head -1)" = "$TEAM" ] || return 1 + [ "$(sed -n 's/^name=//p' "$BINDING_FILE" | head -1)" = "$NAME" ] || return 1 + [ "$(sed -n 's/^thread=//p' "$BINDING_FILE" | head -1)" = "$THREAD_ID" ] || return 1 + [ "$(sed -n 's/^state_key=//p' "$BINDING_FILE" | head -1)" = "$STATE_KEY" ] || return 1 + token="$(sed -n 's/^token=//p' "$BINDING_FILE" | head -1)" + printf '%s' "$token" | grep -Eq '^[a-f0-9]{64}$' || return 1 + expected_endpoint="$RELAY_ENDPOINT/$token" + [ "$(cat "$APPSERVER_FILE" 2>/dev/null || true)" = "$expected_endpoint" ] || return 1 + label="$(sed -n 's/^launch_label=//p' "$META" | head -1)" + if [ -n "$label" ]; then + [ "$label" = "$LABEL" ] && [ -f "$PLIST" ] || return 1 + launchctl print "$DOMAIN/$label" >/dev/null 2>&1 || return 1 fi - rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" +} + +if bridge_is_ready; then + echo "status=already_running team=$TEAM name=$NAME bridge_pid=$(cat "$PIDFILE") app_server=ws://127.0.0.1:$RELAY_PORT/ app_server_source=desktop_relay thread=$THREAD_ID active=true health=$(sed -n 's/^status=//p' "$HEALTH" | head -1)" + exit 0 fi -start_bridge() { - local thread_id="$1" - local args=( - "$SCRIPT_DIR/codex-bridge.js" - --project "$PROJECT" - --type "$TYPE" - --team "$TEAM" - --name "$NAME" - --app-server "$APP_SERVER" - ) - if [ -n "$thread_id" ]; then - args+=(--thread "$thread_id") - fi - nohup "$NODE_BIN" "${args[@]}" >>"$BRIDGE_LOG" 2>&1 & - echo "$!" +stop_bridge_base "$BASE" +ROLE_TOKEN="$("$NODE_BIN" -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))')" +ROLE_ENDPOINT="$RELAY_ENDPOINT/$ROLE_TOKEN" +BINDING_TMP="$BINDING_FILE.$$" +( + umask 077 + { + printf 'token=%s\n' "$ROLE_TOKEN" + printf 'project=%s\n' "$PROJECT" + printf 'type=%s\n' "$TYPE" + printf 'team=%s\n' "$TEAM" + printf 'name=%s\n' "$NAME" + printf 'thread=%s\n' "$THREAD_ID" + printf 'state_key=%s\n' "$STATE_KEY" + } > "$BINDING_TMP" +) +chmod 600 "$BINDING_TMP" +mv "$BINDING_TMP" "$BINDING_FILE" +APPSERVER_TMP="$APPSERVER_FILE.$$" +(umask 077; printf '%s\n' "$ROLE_ENDPOINT" > "$APPSERVER_TMP") +chmod 600 "$APPSERVER_TMP" +mv "$APPSERVER_TMP" "$APPSERVER_FILE" + +xml_escape() { + sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' -e "s/'/\'/g" } +plist_string() { printf '%s' "$1" | xml_escape; } -BRIDGE_PID="$(start_bridge "$THREAD_ID")" +write_bridge_plist() { + local temporary="$PLIST.$$" path_value + path_value="${PATH:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}" + : > "$LOG" + chmod 600 "$LOG" + cat > "$temporary" < + + + Label$(plist_string "$LABEL") + ProgramArguments + $(plist_string "$NODE_BIN") + $(plist_string "$SCRIPT_DIR/codex-bridge.js") + --project$(plist_string "$PROJECT") + --type$(plist_string "$TYPE") + --team$(plist_string "$TEAM") + --name$(plist_string "$NAME") + --state-key$(plist_string "$STATE_KEY") + --app-server-file$(plist_string "$APPSERVER_FILE") + --thread$(plist_string "$THREAD_ID") + + EnvironmentVariables + HOME$(plist_string "$HOME") + PATH$(plist_string "$path_value") + AGMSG_CODEX_BRIDGE_LAUNCH_LABEL$(plist_string "$LABEL") + AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR$(plist_string "$RUN_DIR") + + WorkingDirectory$(plist_string "$PROJECT") + StandardOutPath$(plist_string "$LOG") + StandardErrorPath$(plist_string "$LOG") + RunAtLoad + KeepAliveSuccessfulExit + ThrottleInterval2 + +EOF + chmod 600 "$temporary" + mv "$temporary" "$PLIST" +} -sleep 0.8 -if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then - # Codex Desktop rollouts can be unreadable to a standalone app-server while - # the Desktop owns them. Never retry through a background CLI resume because - # that can consume mail without updating the visible Codex thread. - rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" - printf 'actas-monitor: thread attach failed; refusing thread/start for thread=%s\n' "$THREAD_ID" >>"$BRIDGE_LOG" - start_chat_visible_turn_delivery "$THREAD_ID" "app_server_thread_attach_failed" +BRIDGE_ARGS=( + "$SCRIPT_DIR/codex-bridge.js" + --project "$PROJECT" + --type "$TYPE" + --team "$TEAM" + --name "$NAME" + --state-key "$STATE_KEY" + --app-server-file "$APPSERVER_FILE" + --thread "$THREAD_ID" +) + +SUPERVISOR="direct" +if [ "${AGMSG_CODEX_BRIDGE_SUPERVISOR:-}" != "direct" ] \ + && [ "$(uname -s)" = "Darwin" ] && command -v launchctl >/dev/null 2>&1 \ + && launchctl print "$DOMAIN" >/dev/null 2>&1; then + SUPERVISOR="launchd" + write_bridge_plist + bootout_label "$LABEL" + rm -f "$PIDFILE" "$META" "$HEALTH" + if ! launchctl bootstrap "$DOMAIN" "$PLIST" >/dev/null 2>&1; then + stop_bridge_base "$BASE" + start_visible_turn_delivery "$THREAD_ID" "desktop_bridge_launch_failed" + fi + launchctl kickstart -k "$DOMAIN/$LABEL" >/dev/null 2>&1 || true +else + nohup "$NODE_BIN" "${BRIDGE_ARGS[@]}" >> "$LOG" 2>&1 & fi READY_SECONDS="${AGMSG_CODEX_ACTAS_READY_SECONDS:-8}" -sleep "$READY_SECONDS" -if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then - rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" - actas_lock_gc_stale >/dev/null 2>&1 || true - start_chat_visible_turn_delivery "$THREAD_ID" "app_server_bridge_exited" -fi -case "$APP_SERVER" in - ws://127.0.0.1:*) - check_port="${APP_SERVER#ws://127.0.0.1:}" - check_port="${check_port%%/*}" - if ! port_alive "$check_port"; then - rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" - actas_lock_gc_stale >/dev/null 2>&1 || true - start_chat_visible_turn_delivery "$THREAD_ID" "app_server_exited" +case "$READY_SECONDS" in ''|*[!0-9]*) READY_SECONDS=8 ;; esac +READY_CHECKS=$((READY_SECONDS * 5)) +[ "$READY_CHECKS" -gt 0 ] || READY_CHECKS=1 +for _ in $(seq 1 "$READY_CHECKS"); do + if bridge_is_ready; then + # Recheck the relay after bridge startup; a Desktop disconnect during the + # handshake must downgrade instead of reporting a stale green bridge. + if [ "$(sed -n 's/^status=//p' "$RELAY_HEALTH" | head -1)" = "ready" ] \ + && [ "$(sed -n 's/^primary_connected=//p' "$RELAY_HEALTH" | head -1)" = "1" ] \ + && [ "$(sed -n 's/^upstream_initialized=//p' "$RELAY_HEALTH" | head -1)" = "1" ]; then + rm -f "$CHAT_META" + echo "status=ok team=$TEAM name=$NAME bridge_pid=$(cat "$PIDFILE") app_server=ws://127.0.0.1:$RELAY_PORT/ app_server_source=desktop_relay thread=$THREAD_ID supervisor=$SUPERVISOR active=true health=ready" + exit 0 fi - ;; -esac + break + fi + sleep 0.2 +done -echo "status=ok team=$TEAM name=$NAME bridge_pid=$BRIDGE_PID app_server=$APP_SERVER thread=$THREAD_ID" +stop_bridge_base "$BASE" +start_visible_turn_delivery "$THREAD_ID" "desktop_bridge_not_ready" diff --git a/scripts/drivers/types/codex/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh index 2f06f68b..4a39db81 100755 --- a/scripts/drivers/types/codex/codex-bridge-launcher.sh +++ b/scripts/drivers/types/codex/codex-bridge-launcher.sh @@ -4,11 +4,9 @@ set -euo pipefail # Runs outside Codex's tool sandbox and owns the app-server connection: it starts # codex-bridge.js for this project's single codex identity. # -# On codex 0.141+ the SessionStart hook cannot resolve the thread id -# (CODEX_THREAD_ID is not exported and no rollout is written for --remote -# sessions), so the bridge discovers the live TUI thread itself via -# thread/loaded/list (`--thread loaded`). If an older codex DID write a request -# file with a real thread id, that id is used instead. See #170, #41. +# This legacy launcher is fail-closed: it launches only when its request file +# contains one exact thread id. Loaded-thread discovery and thread creation can +# select a different visible task and are forbidden. TYPE="${1:?Usage: codex-bridge-launcher.sh }" PROJECT="${2:?Missing project_path}" @@ -55,6 +53,8 @@ done pidfile="$RUN_DIR/codex-bridge.$team.$name.pid" log="$RUN_DIR/codex-bridge.$team.$name.log" +: >"$log" +chmod 600 "$log" # Records the app-server URL the live bridge was launched against, so a later # launcher instance can tell a bridge bound to a stale app-server (old port, # from before a codex upgrade) from one bound to the current server. See #197/#237. @@ -69,12 +69,20 @@ else bridge_run=("$NODE_BIN" "$SCRIPT_DIR/codex-bridge.js") fi +bridge_pid_matches() { + local pid="$1" app_server="$2" thread="$3" expected cmd + [ -n "$app_server" ] && [ -n "$thread" ] || return 1 + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + expected="$SCRIPT_DIR/codex-bridge.js --project $PROJECT --type $TYPE --team $team --name $name --thread $thread --app-server $app_server" + cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)" + case " $cmd " in *" $expected "*) return 0 ;; esac + return 1 +} + while kill -0 "$PARENT_PID" 2>/dev/null; do - # Resolve the app-server URL (and thread) this iteration would launch against - # FIRST, so the reuse check can compare a live bridge's bound server with the - # current one. Thread source: a request file (older-codex hook) wins; otherwise - # discover the live TUI thread via thread/loaded/list. - thread_id="loaded" + # Resolve an exact thread before the reuse check. Missing, reserved, or unsafe + # values leave the launcher idle without creating another Codex task. + thread_id="" req_app_server="$APP_SERVER" if [ -f "$REQUEST_FILE" ]; then request="$(cat "$REQUEST_FILE" 2>/dev/null || true)" @@ -82,10 +90,17 @@ while kill -0 "$PARENT_PID" 2>/dev/null; do IFS="$TAB" read -r _rtype _rteam _rname _rthread _rapp </dev/null || true)" @@ -97,11 +112,15 @@ EOF # this, but guard the race where the old bridge has not exited yet by the # time a new launcher re-checks: an app-server mismatch means tear it down. bound_url="$(cat "$appserver_file" 2>/dev/null || true)" - if [ "$bound_url" = "$req_app_server" ]; then + bound_thread="$(sed -n 's/^thread=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" + if bridge_pid_matches "$bridge_pid" "$bound_url" "$bound_thread" \ + && [ "$bound_url" = "$req_app_server" ] && [ "$bound_thread" = "$thread_id" ]; then sleep 0.3 continue fi - kill "$bridge_pid" 2>/dev/null || true + if bridge_pid_matches "$bridge_pid" "$bound_url" "$bound_thread"; then + kill "$bridge_pid" 2>/dev/null || true + fi rm -f "$pidfile" "$appserver_file" fi fi @@ -118,3 +137,13 @@ EOF printf '%s' "$req_app_server" > "$appserver_file" sleep 1 done + +# The launcher owns only the exact legacy bridge recorded for this parent. +# Never signal a recycled pid or another project's bridge during TUI teardown. +bridge_pid="$(cat "$pidfile" 2>/dev/null || true)" +bound_url="$(cat "$appserver_file" 2>/dev/null || true)" +bound_thread="$(sed -n 's/^thread=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" +if [ -n "$bridge_pid" ] && kill -0 "$bridge_pid" 2>/dev/null \ + && bridge_pid_matches "$bridge_pid" "$bound_url" "$bound_thread"; then + kill "$bridge_pid" 2>/dev/null || true +fi diff --git a/scripts/drivers/types/codex/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js index 11d1df7e..07e6a3fd 100755 --- a/scripts/drivers/types/codex/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -8,10 +8,12 @@ const net = require("net"); const path = require("path"); const readline = require("readline"); +process.umask(0o077); + const SCRIPT_DIR = __dirname; // .../scripts/drivers/types/codex (codex siblings live here) const SKILL_DIR = path.resolve(SCRIPT_DIR, "..", "..", "..", ".."); // skill root const SCRIPTS_DIR = path.join(SKILL_DIR, "scripts"); // type-independent engine scripts (identities/inbox/send) -const RUN_DIR = path.join(SKILL_DIR, "run"); +const RUN_DIR = path.resolve(process.env.AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR || path.join(SKILL_DIR, "run")); // Git Bash on Windows cannot exec a .sh path directly — spawnSync of the script // fails with EFTYPE. Invoke the helper scripts through bash on every platform. @@ -29,10 +31,10 @@ Options: --type Agent type for identity resolution (default: codex). --team Limit wakeups to one team. --name Limit wakeups to one agent name. + --state-key Private runtime suffix chosen by the launcher. --timeout watch-once timeout before re-arming (default: 300). --interval watch-once poll interval (default: 2). --max-wakes Stop after n wakeups, useful for tests. - --stale-wake-limit Stop after n repeated unchanged wakeups (default: 1). --connect-timeout-ms Max wait for direct app-server connect/upgrade (default: 10000). --request-timeout-ms @@ -41,12 +43,10 @@ Options: Stop after n consecutive watch-once failures; 0 disables (default: 3). --app-server Connect through an existing app-server endpoint. Supports unix://PATH or ws://host:port over WebSocket. - --thread - Resume an existing app-server thread. "current" uses - CODEX_THREAD_ID; "loaded" discovers the live TUI thread - via thread/loaded/list (codex 0.141+, see #170). - --loaded-timeout Max wait for a loaded thread to appear (default: 30000). - --inline-inbox Read inbox in the bridge and include message text in the turn input. + --app-server-file + Read the endpoint from a private (0600) file. + --thread Resume this exact existing app-server thread. + --inline-inbox Rejected: background inbox reads are unsafe. --resolve-only Print resolved team/name and exit. --help Show this help. @@ -58,6 +58,20 @@ function die(message) { process.exit(1); } +class TerminalBridgeError extends Error { + constructor(message) { + super(message); + this.name = "TerminalBridgeError"; + } +} + +class ExistingBridgeError extends TerminalBridgeError { + constructor(message) { + super(message); + this.name = "ExistingBridgeError"; + } +} + // Convert a native Windows path into the MSYS/Git-Bash POSIX form that agmsg // registration data is keyed by (Git Bash stores e.g. `/c/Users/me/proj`). // A drive-letter path `C:\...`/`C:/...` becomes `/c/...` and its backslashes @@ -80,12 +94,14 @@ function parseArgs(argv) { timeout: Number(process.env.AGMSG_WATCH_ONCE_TIMEOUT || 300), interval: Number(process.env.AGMSG_WATCH_ONCE_INTERVAL || 2), maxWakes: 0, - staleWakeLimit: Number(process.env.AGMSG_CODEX_BRIDGE_STALE_WAKE_LIMIT || 1), connectTimeoutMs: Number(process.env.AGMSG_CODEX_BRIDGE_CONNECT_TIMEOUT_MS || 10000), requestTimeoutMs: Number(process.env.AGMSG_CODEX_BRIDGE_REQUEST_TIMEOUT_MS || 30000), watchFailureLimit: Number(process.env.AGMSG_CODEX_BRIDGE_WATCH_FAILURE_LIMIT || 3), inlineInbox: false, turnTimeout: Number(process.env.AGMSG_CODEX_BRIDGE_TURN_TIMEOUT || 60), + retryBaseMs: Number(process.env.AGMSG_CODEX_BRIDGE_RETRY_BASE_MS || 5000), + retryMaxMs: Number(process.env.AGMSG_CODEX_BRIDGE_RETRY_MAX_MS || 300000), + sameUnreadDelayMs: Number(process.env.AGMSG_CODEX_BRIDGE_SAME_UNREAD_DELAY_MS || 30000), }; for (let i = 0; i < argv.length; i += 1) { @@ -102,14 +118,14 @@ function parseArgs(argv) { opts.team = argv[++i]; } else if (arg === "--name") { opts.name = argv[++i]; + } else if (arg === "--state-key") { + opts.stateKey = argv[++i]; } else if (arg === "--timeout") { opts.timeout = Number(argv[++i]); } else if (arg === "--interval") { opts.interval = Number(argv[++i]); } else if (arg === "--max-wakes") { opts.maxWakes = Number(argv[++i]); - } else if (arg === "--stale-wake-limit") { - opts.staleWakeLimit = Number(argv[++i]); } else if (arg === "--connect-timeout-ms") { opts.connectTimeoutMs = Number(argv[++i]); } else if (arg === "--request-timeout-ms") { @@ -120,12 +136,12 @@ function parseArgs(argv) { opts.turnTimeout = Number(argv[++i]); } else if (arg === "--app-server") { opts.appServer = argv[++i]; + } else if (arg === "--app-server-file") { + opts.appServerFile = path.resolve(argv[++i]); } else if (arg === "--thread") { opts.threadId = argv[++i]; - } else if (arg === "--loaded-timeout") { - opts.loadedTimeout = Number(argv[++i]); } else if (arg === "--inline-inbox") { - opts.inlineInbox = true; + opts.inlineInboxRequested = true; } else { die(`unknown option: ${arg}`); } @@ -136,9 +152,6 @@ function parseArgs(argv) { if (!Number.isFinite(opts.timeout) || opts.timeout <= 0) die("--timeout must be a positive number"); if (!Number.isFinite(opts.interval) || opts.interval <= 0) die("--interval must be a positive number"); if (!Number.isFinite(opts.maxWakes) || opts.maxWakes < 0) die("--max-wakes must be a non-negative number"); - if (!Number.isFinite(opts.staleWakeLimit) || opts.staleWakeLimit < 0) { - die("--stale-wake-limit must be a non-negative number"); - } if (!Number.isFinite(opts.connectTimeoutMs) || opts.connectTimeoutMs < 0) { die("--connect-timeout-ms must be a non-negative number"); } @@ -151,14 +164,29 @@ function parseArgs(argv) { if (!Number.isFinite(opts.turnTimeout) || opts.turnTimeout < 0) { die("--turn-timeout must be a non-negative number"); } - if (opts.threadId === "current") { - opts.threadId = process.env.CODEX_THREAD_ID || ""; - if (!opts.threadId) die("--thread current requires CODEX_THREAD_ID"); + for (const key of ["retryBaseMs", "retryMaxMs", "sameUnreadDelayMs"]) { + if (!Number.isFinite(opts[key]) || opts[key] < 1) die(`${key} must be a positive number`); + } + if (opts.appServer && opts.appServerFile) die("pass only one of --app-server or --app-server-file"); + if (opts.appServerFile) opts.appServer = readPrivateEndpoint(opts.appServerFile); + if (!opts.resolveOnly && (!opts.threadId || !/^[A-Za-z0-9._-]+$/.test(opts.threadId))) { + die("one exact --thread id is required"); } + if (!opts.resolveOnly && ["loaded", "current", "unresolved"].includes(opts.threadId)) { + die("one exact --thread id is required; discovery aliases are not supported"); + } + if (opts.inlineInboxRequested) { + die("--inline-inbox is disabled; only the visible Codex task may run the official inbox command"); + } + if (opts.stateKey && !/^[A-Za-z0-9._%-]+$/.test(opts.stateKey)) die("--state-key contains unsafe characters"); opts.project = path.resolve(opts.project); if (!fs.existsSync(opts.project) || !fs.statSync(opts.project).isDirectory()) { die(`project path is not a directory: ${opts.project}`); } + // Preserve the registry spelling for identities.sh (macOS may register + // /var while realpath is /private/var), and use the canonical root for all + // relay-authorized app-server requests. + opts.canonicalProject = fs.realpathSync(opts.project); return opts; } @@ -213,12 +241,17 @@ class AppServerClient { this.pending = new Map(); this.handlers = new Map(); this.child = null; + this.disconnectHandler = null; + this.intentionalStop = false; } start() { const [bin, ...args] = this.command; + const childEnv = { ...process.env }; + delete childEnv.CODEX_APP_SERVER_WS_URL; this.child = spawn(bin, args, { cwd: this.cwd, + env: childEnv, stdio: ["pipe", "pipe", "pipe"], }); @@ -231,15 +264,17 @@ class AppServerClient { }); this.child.on("exit", (code, signal) => { + const error = new Error(`app-server exited (${code ?? signal})`); for (const { reject } of this.pending.values()) { - reject(new Error(`app-server exited (${code || signal})`)); + reject(error); } this.pending.clear(); + if (!this.intentionalStop && this.disconnectHandler) this.disconnectHandler(error); }); - this.child.stderr.on("data", (chunk) => { - process.stderr.write(chunk); - }); + // app-server stderr can contain user-visible model content. Keep bridge + // logs as lifecycle telemetry only. + this.child.stderr.on("data", () => {}); const lines = readline.createInterface({ input: this.child.stdout }); lines.on("line", (line) => this.handleLine(line)); @@ -249,16 +284,40 @@ class AppServerClient { this.handlers.set(method, handler); } + onDisconnect(handler) { + this.disconnectHandler = handler; + } + handleLine(line) { if (!line.trim()) return; let message; try { message = JSON.parse(line); } catch (error) { - console.error(`codex-bridge: ignoring non-json app-server line: ${line}`); + console.error("codex-bridge: ignoring non-json app-server line"); return; } + if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) { + const handler = this.handlers.get(message.method); + if (!handler) { + this.sendJson({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: `No handler for ${message.method}` }, + }); + return; + } + Promise.resolve(handler(message.params || {})).then( + (result) => this.sendJson({ jsonrpc: "2.0", id: message.id, result: result || {} }), + (error) => this.sendJson({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32000, message: error.message || String(error) }, + }), + ); + return; + } if (Object.prototype.hasOwnProperty.call(message, "id")) { const pending = this.pending.get(message.id); if (!pending) return; @@ -318,6 +377,10 @@ class AppServerClient { this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); } + sendJson(value) { + this.child.stdin.write(`${JSON.stringify(value)}\n`); + } + dispatch(method, params) { try { Promise.resolve(this.handlers.get(method)(params)).catch((error) => { @@ -329,6 +392,7 @@ class AppServerClient { } stop() { + this.intentionalStop = true; if (this.child && !this.child.killed) { this.child.kill("SIGTERM"); } @@ -344,6 +408,7 @@ class WebSocketAppServerClient { constructor(connectOptions, label, opts = {}) { this.connectOptions = connectOptions; this.label = label || "app-server"; + this.requestPath = opts.requestPath || "/"; this.connectTimeoutMs = opts.connectTimeoutMs || 0; this.requestTimeoutMs = opts.requestTimeoutMs || 0; this.nextId = 1; @@ -358,6 +423,7 @@ class WebSocketAppServerClient { // Set when WE close the socket (shutdown); distinguishes an intentional stop // from the app-server going away under us. this.intentionalStop = false; + this.disconnectHandler = null; } start() { @@ -399,7 +465,7 @@ class WebSocketAppServerClient { this.socket.on("connect", () => { this.socket.write( [ - "GET / HTTP/1.1", + `GET ${this.requestPath} HTTP/1.1`, "Host: localhost", "Upgrade: websocket", "Connection: Upgrade", @@ -428,10 +494,7 @@ class WebSocketAppServerClient { // starts a fresh one against the new app-server — delivery silently // stops. Exit instead; the launcher then relaunches a fresh bridge bound // to the current app-server. Skipped when WE closed the socket. - if (!this.intentionalStop) { - console.error(`codex-bridge: ${error.message}; exiting so a fresh bridge can attach`); - process.exit(1); - } + if (!this.intentionalStop && this.disconnectHandler) this.disconnectHandler(error); }); }); } @@ -444,6 +507,10 @@ class WebSocketAppServerClient { this.handlers.set(method, handler); } + onDisconnect(handler) { + this.disconnectHandler = handler; + } + handleData(chunk, resolveStart, rejectStart) { if (!this.handshakeComplete) { this.handshakeBuffer = Buffer.concat([this.handshakeBuffer, chunk]); @@ -537,7 +604,27 @@ class WebSocketAppServerClient { try { message = JSON.parse(line); } catch (_) { - console.error(`codex-bridge: ignoring non-json app-server message: ${line}`); + console.error("codex-bridge: ignoring non-json app-server message"); + return; + } + if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) { + const handler = this.handlers.get(message.method); + if (!handler) { + this.sendJson({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: `No handler for ${message.method}` }, + }); + return; + } + Promise.resolve(handler(message.params || {})).then( + (result) => this.sendJson({ jsonrpc: "2.0", id: message.id, result: result || {} }), + (error) => this.sendJson({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32000, message: error.message || String(error) }, + }), + ); return; } if (Object.prototype.hasOwnProperty.call(message, "id")) { @@ -666,42 +753,70 @@ class CodexBridge { this.threadIdle = true; this.turnActive = false; this.turnTimer = null; - this.pendingWake = false; + this.pendingWakeMaxId = 0; this.watchHandle = null; this.wakeCount = 0; - this.lastWakeMaxId = 0; - this.staleWakeCount = 0; this.watchFailureCount = 0; + this.wakeRetryCount = 0; this.watchRearmTimer = null; - this.inlineInboxText = ""; - this.inlineInboxIdsFile = path.join(RUN_DIR, `codex-bridge.${identity.team}.${identity.name}.last-ids`); - this.deliveredInboxPending = false; this.stopping = false; - this.pidfile = path.join(RUN_DIR, `codex-bridge.${identity.team}.${identity.name}.pid`); - this.metafile = path.join(RUN_DIR, `codex-bridge.${identity.team}.${identity.name}.meta`); + this.preserveHealthOnExit = false; + this.signalHandler = null; + this.lifetimeSettled = false; + this.lifetime = new Promise((resolve, reject) => { + this.resolveLifetime = (value) => { + if (this.lifetimeSettled) return; + this.lifetimeSettled = true; + resolve(value); + }; + this.rejectLifetime = (error) => { + if (this.lifetimeSettled) return; + this.lifetimeSettled = true; + reject(error); + }; + }); + const stateKey = opts.stateKey || `${identity.team}.${identity.name}`; + this.stateKey = stateKey; + this.pidfile = path.join(RUN_DIR, `codex-bridge.${stateKey}.pid`); + this.metafile = path.join(RUN_DIR, `codex-bridge.${stateKey}.meta`); + this.healthfile = path.join(RUN_DIR, `codex-bridge.${stateKey}.health`); + const threadHash = crypto.createHash("sha256").update(this.threadId).digest("hex").slice(0, 24); + this.threadHash = threadHash; + this.wakeStateFile = path.join(RUN_DIR, `codex-bridge.${stateKey}.wake.${threadHash}.json`); } async run() { fs.mkdirSync(RUN_DIR, { recursive: true }); this.ensureSingleInstance(); this.writeMeta(); + this.writeHealth("connecting"); this.installSignals(); this.client.on("process/exited", this.clientHandler("process/exited", (params) => this.onProcessExited(params))); this.client.on("error", this.clientHandler("error", (params) => this.onServerError(params))); this.client.on("item/agentMessage/delta", this.clientHandler("item/agentMessage/delta", (params) => this.onAgentMessageDelta(params))); this.client.on("thread/status/changed", this.clientHandler("thread/status/changed", (params) => this.onThreadStatus(params))); - this.client.on("turn/started", this.clientHandler("turn/started", () => { + this.client.on("turn/started", this.clientHandler("turn/started", (params) => { + if (params.threadId !== this.threadId) return; this.turnActive = true; this.threadIdle = false; })); this.client.on("turn/completed", this.clientHandler("turn/completed", (params) => this.onTurnCompleted(params))); - this.client.on("turn/failed", this.clientHandler("turn/failed", () => this.onTurnCompleted())); + this.client.on("turn/failed", this.clientHandler("turn/failed", (params) => this.onTurnCompleted(params))); + this.client.onDisconnect((error) => { + if (!this.stopping) this.rejectLifetime(error); + }); this.client.start(); await this.client.ready?.(); + this.writeHealth("connected"); await this.initialize(); await this.ensureThread(); + this.writeHealth("thread_attached"); await this.armWatch(); + this.writeMeta(); + this.writeHealth("ready"); + this.readyAt = Date.now(); + await this.lifetime; } clientHandler(method, handler) { @@ -716,12 +831,13 @@ class CodexBridge { failClientHandler(method, error) { console.error(`codex-bridge: ${method} handler failed: ${error.message}`); - this.shutdown().finally(() => process.exit(1)); + this.writeHealth("retrying_transient", `${method}: ${error.message}`); + this.rejectLifetime(error); } writeMeta() { - fs.writeFileSync(this.pidfile, `${process.pid}\n`); - fs.writeFileSync( + atomicWritePrivate(this.pidfile, `${process.pid}\n`); + atomicWritePrivate( this.metafile, [ `pid=${process.pid}`, @@ -729,20 +845,78 @@ class CodexBridge { `team=${this.identity.team}`, `name=${this.identity.name}`, `type=${this.opts.type}`, + `thread=${this.threadId || ""}`, + `app_server=${redactAppServer(this.opts.appServer || "stdio://")}`, + `launch_label=${process.env.AGMSG_CODEX_BRIDGE_LAUNCH_LABEL || ""}`, ].join("\n") + "\n", ); } + writeHealth(status, detail = "") { + const temporary = `${this.healthfile}.${process.pid}.tmp`; + try { + fs.writeFileSync( + temporary, + [ + `status=${status}`, + `pid=${process.pid}`, + `thread=${this.threadId || "unresolved"}`, + `detail=${String(detail).replace(/[\r\n]+/g, " ")}`, + `updated_at=${new Date().toISOString()}`, + "", + ].join("\n"), + ); + fs.chmodSync(temporary, 0o600); + fs.renameSync(temporary, this.healthfile); + } catch (_) { + try { fs.unlinkSync(temporary); } catch (_) {} + } + } + + readWakeState({ tolerateCorrupt = false } = {}) { + if (!fs.existsSync(this.wakeStateFile)) return null; + try { + const stat = fs.lstatSync(this.wakeStateFile); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) { + throw new Error("wake state is not a private regular file"); + } + const state = JSON.parse(fs.readFileSync(this.wakeStateFile, "utf8")); + const expectedId = wakeClientMessageId(this.stateKey, this.threadId, state.maxId); + if ( + state.version !== 1 + || state.threadHash !== this.threadHash + || !Number.isInteger(state.maxId) + || state.maxId <= 0 + || !["observed", "dispatching", "accepted", "ack_confirmed"].includes(state.phase) + || state.clientUserMessageId !== expectedId + ) { + throw new Error("wake state failed validation"); + } + return state; + } catch (error) { + if (tolerateCorrupt) return null; + throw new Error(`cannot trust durable wake state: ${error.message}`); + } + } + + writeWakeState(state) { + const value = { + version: 1, + threadHash: this.threadHash, + maxId: state.maxId, + phase: state.phase, + clientUserMessageId: state.clientUserMessageId, + updatedAt: new Date().toISOString(), + }; + atomicWritePrivate(this.wakeStateFile, `${JSON.stringify(value)}\n`); + } + installSignals() { - const stop = () => { - this.shutdown().finally(() => process.exit(0)); + this.signalHandler = () => { + this.shutdown().finally(() => this.resolveLifetime()); }; - process.on("SIGINT", stop); - process.on("SIGTERM", stop); - process.on("exit", () => { - this.client.stop(); - this.cleanupMeta(); - }); + process.once("SIGINT", this.signalHandler); + process.once("SIGTERM", this.signalHandler); } async initialize() { @@ -761,58 +935,25 @@ class CodexBridge { this.client.notify("initialized"); } - async resolveLoadedThread() { - // codex 0.141+ does not export CODEX_THREAD_ID to hooks and writes no rollout - // for --remote sessions, so the thread id cannot be resolved out-of-band. - // Ask the app-server which thread the live TUI has loaded instead. See #170. - const deadline = Date.now() + (this.opts.loadedTimeout || 30000); - for (;;) { - const response = await this.client.request("thread/loaded/list", {}); - const ids = response && Array.isArray(response.data) ? response.data : []; - if (ids.length > 0) { - if (ids.length > 1) { - console.error( - `codex-bridge: ${ids.length} threads loaded; attaching to the first (${ids[0]})`, - ); - } - return ids[0]; - } - if (Date.now() >= deadline) { - die("no loaded codex thread found via thread/loaded/list"); - } - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - async ensureThread() { - if (this.threadId === "loaded") { - this.threadId = await this.resolveLoadedThread(); - console.error(`codex-bridge: discovered loaded thread ${this.threadId}`); - } - if (this.threadId) { - const response = await this.client.request("thread/resume", { + let response; + try { + response = await this.client.request("thread/resume", { threadId: this.threadId, - cwd: this.opts.project, - runtimeWorkspaceRoots: [this.opts.project], + cwd: this.opts.canonicalProject, + runtimeWorkspaceRoots: [this.opts.canonicalProject], excludeTurns: true, }); - if (!response.thread || response.thread.id !== this.threadId) { - die("thread/resume did not return the requested thread id"); - } - const type = response.thread.status && response.thread.status.type; - this.threadIdle = type !== "active"; - this.turnActive = type === "active"; - console.error(`codex-bridge: resumed thread ${this.threadId}`); - return; + } catch (error) { + throw new TerminalBridgeError(`cannot resume exact thread ${this.threadId}: ${error.message}`); } - const response = await this.client.request("thread/start", { - cwd: this.opts.project, - runtimeWorkspaceRoots: [this.opts.project], - ephemeral: false, - }); - this.threadId = response.thread && response.thread.id; - if (!this.threadId) die("thread/start did not return a thread id"); - console.error(`codex-bridge: started thread ${this.threadId}`); + if (!response.thread || response.thread.id !== this.threadId) { + throw new TerminalBridgeError("thread/resume did not return the requested thread id"); + } + const type = response.thread.status && response.thread.status.type; + this.threadIdle = type !== "active"; + this.turnActive = type === "active"; + console.error(`codex-bridge: resumed thread ${this.threadId}`); } async armWatch() { @@ -822,7 +963,7 @@ class CodexBridge { this.watchHandle = handle; const ownerId = `agmsg-codex-bridge-${process.pid}.${process.pid}`; const command = [ - BASH_BIN, + fs.existsSync("/bin/bash") ? "/bin/bash" : BASH_BIN, path.join(SCRIPT_DIR, "watch-once.sh"), // watch-once.sh resolves the subscription set through the same exact // project-key lookup as identities.sh, so it needs the POSIX form of the @@ -845,7 +986,7 @@ class CodexBridge { await this.client.request("process/spawn", { command, processHandle: handle, - cwd: this.opts.project, + cwd: this.opts.canonicalProject, outputBytesCap: 8192, timeoutMs: (this.opts.timeout + this.opts.interval + 10) * 1000, }); @@ -863,42 +1004,86 @@ class CodexBridge { if (params.exitCode === 0) { this.watchFailureCount = 0; const maxId = parseMaxId(params.stdout); - if (this.isStaleWake(maxId)) { - await this.shutdown(); - process.exit(1); + if (!maxId) { + this.writeHealth("paused_ambiguous_wake", "watch-once returned pending without a valid max_id"); + this.scheduleWatchRearm(this.nextRetryDelay()); + return; } - this.pendingWake = true; - this.wakeCount += 1; - console.error(`codex-bridge: wakeup ${this.wakeCount} for ${this.identity.team}/${this.identity.name}`); + + let state; + try { + state = this.readWakeState(); + } catch (error) { + this.writeHealth("paused_ambiguous_wake", error.message); + this.scheduleWatchRearm(this.opts.sameUnreadDelayMs); + return; + } + if (state && maxId < state.maxId) { + this.writeHealth( + "paused_ambiguous_wake", + `unread max_id ${maxId} is older than durable max_id ${state.maxId}`, + ); + this.scheduleWatchRearm(this.opts.sameUnreadDelayMs); + return; + } + if (state && maxId === state.maxId && ["accepted", "ack_confirmed"].includes(state.phase)) { + this.writeHealth("waiting_for_ack", `unread max_id ${maxId} is already ${state.phase}`); + this.scheduleWatchRearm(this.opts.sameUnreadDelayMs); + return; + } + + const clientUserMessageId = wakeClientMessageId(this.stateKey, this.threadId, maxId); + if (!state || maxId > state.maxId) { + this.writeWakeState({ maxId, phase: "observed", clientUserMessageId }); + } + this.pendingWakeMaxId = maxId; + console.error(`codex-bridge: observed unread max_id ${maxId} for ${this.identity.team}/${this.identity.name}`); await this.tryStartTurn(); return; } if (params.exitCode === 2) { this.watchFailureCount = 0; + this.wakeRetryCount = 0; + const state = this.readWakeState({ tolerateCorrupt: true }); + if (state && state.phase !== "ack_confirmed") { + this.writeWakeState({ ...state, phase: "ack_confirmed" }); + } + this.writeHealth("ready"); await this.armWatch(); return; } this.watchFailureCount += 1; - const detail = [params.stderr, params.stdout].filter(Boolean).join("\n").trim(); + const detail = sanitizeLogDetail(params.stderr || `exit ${params.exitCode}`); console.error(`codex-bridge: watch-once failed with exit ${params.exitCode}${detail ? `: ${detail}` : ""}`); if (this.opts.watchFailureLimit > 0 && this.watchFailureCount >= this.opts.watchFailureLimit) { - console.error( - `codex-bridge: stopping after ${this.watchFailureCount} consecutive watch-once failure(s)`, + this.writeHealth( + "paused_watch_failure", + `${this.watchFailureCount} consecutive watch-once failure(s)`, ); - await this.shutdown(); - process.exit(1); + } else { + this.writeHealth("waiting_watch_retry", `${this.watchFailureCount} watch-once failure(s)`); } - this.scheduleWatchRearm(); + this.scheduleWatchRearm(this.nextRetryDelay()); + } + + nextRetryDelay() { + const exponent = Math.min(this.wakeRetryCount, 12); + this.wakeRetryCount += 1; + return Math.min(this.opts.retryBaseMs * (2 ** exponent), this.opts.retryMaxMs); } - scheduleWatchRearm() { + scheduleWatchRearm(delayMs = this.opts.retryBaseMs) { if (this.stopping || this.watchHandle || this.watchRearmTimer) return; this.watchRearmTimer = setTimeout(() => { this.watchRearmTimer = null; - this.armWatch().catch((error) => this.failClientHandler("process/exited", error)); - }, 5000); + this.armWatch().catch((error) => { + this.watchFailureCount += 1; + this.writeHealth("waiting_watch_retry", sanitizeLogDetail(error.message)); + this.scheduleWatchRearm(this.nextRetryDelay()); + }); + }, delayMs); } clearWatchRearmTimer() { @@ -928,7 +1113,7 @@ class CodexBridge { async onTurnCompleted(params = {}) { if (params.threadId && params.threadId !== this.threadId) return; if (params.turn && params.turn.error) { - console.error(`codex-bridge: turn completed with error: ${JSON.stringify(params.turn.error)}`); + console.error(`codex-bridge: turn completed with error code ${sanitizeLogDetail(params.turn.error.code || "unknown")}`); } else { console.error(`codex-bridge: turn completed on thread ${this.threadId}`); } @@ -943,10 +1128,10 @@ class CodexBridge { this.clearTurnWatchdog(); this.turnActive = false; this.threadIdle = true; - this.markDeliveredInboxRead(); if (this.opts.maxWakes && this.wakeCount >= this.opts.maxWakes) { await this.shutdown(); - process.exit(0); + this.resolveLifetime(); + return; } // A wake can arrive while a turn is still active — the bridge resumed an // already-active thread (SessionStart fires on the first user turn), or a @@ -954,7 +1139,7 @@ class CodexBridge { // was set. Deliver that pending wake now instead of re-arming: a fresh // watch-once would re-observe the same unread max_id and the stale-wake // guard would stop the bridge with exit 1 before the message is delivered. - if (this.pendingWake) { + if (this.pendingWakeMaxId) { await this.tryStartTurn(); return; } @@ -965,44 +1150,75 @@ class CodexBridge { } async tryStartTurn() { - if (!this.pendingWake || this.turnActive || !this.threadIdle) return; - if (this.opts.inlineInbox) { - this.inlineInboxText = this.readInboxForPrompt(); - if (!this.inlineInboxText.trim()) { - console.error("codex-bridge: pending wake had no inbox output; re-arming"); - this.pendingWake = false; - await this.armWatch(); - return; - } + if (!this.pendingWakeMaxId || this.turnActive || !this.threadIdle) return; + const maxId = this.pendingWakeMaxId; + const clientUserMessageId = wakeClientMessageId(this.stateKey, this.threadId, maxId); + let state; + try { + state = this.readWakeState(); + } catch (error) { + this.writeHealth("paused_ambiguous_wake", "durable wake state is unavailable"); + this.scheduleWakeRetry(this.nextRetryDelay()); + return; + } + if (!state || state.maxId !== maxId || !["observed", "dispatching"].includes(state.phase)) { + this.writeHealth("paused_ambiguous_wake", "durable wake phase does not permit dispatch"); + this.scheduleWakeRetry(this.nextRetryDelay()); + return; + } + if (state.phase === "observed") { + // Record bridge-side intent before asking the relay. The relay owns the + // later at-most-once boundary: it fsyncs its own dispatch state before + // turn/start. After a relay restart, only that relay state decides + // whether this request may start or must reconcile. + this.writeWakeState({ maxId, phase: "dispatching", clientUserMessageId }); } - const prompt = this.buildPrompt(); - this.turnActive = true; - this.threadIdle = false; try { - await this.client.request("turn/start", { + const response = await this.client.request("agmsg/wake/dispatch", { threadId: this.threadId, - input: [{ type: "text", text: prompt, text_elements: [] }], - cwd: this.opts.project, - runtimeWorkspaceRoots: [this.opts.project], + maxId, + clientUserMessageId, + dispatchMode: "start-or-reconcile", + cwd: this.opts.canonicalProject, + runtimeWorkspaceRoots: [this.opts.canonicalProject], }); - console.error(`codex-bridge: started turn on thread ${this.threadId}`); - this.pendingWake = false; - if (this.opts.inlineInbox && fs.existsSync(this.inlineInboxIdsFile)) { - const ids = fs.readFileSync(this.inlineInboxIdsFile, "utf8").trim(); - this.deliveredInboxPending = Boolean(ids); + if (!response || !["accepted", "reconciled"].includes(response.status) || response.maxId !== maxId) { + throw new Error("wake dispatch returned an ambiguous result"); + } + this.writeWakeState({ maxId, phase: "accepted", clientUserMessageId }); + this.pendingWakeMaxId = 0; + this.wakeRetryCount = 0; + if (response.status === "accepted") { + this.wakeCount += 1; + this.turnActive = true; + this.threadIdle = false; + console.error(`codex-bridge: accepted wakeup ${this.wakeCount} on thread ${this.threadId}`); + this.startTurnWatchdog(); + return; } - // Bound how long we treat the turn as active. The real app-server may - // never send turn/completed; the watchdog (and thread/status idle) drive - // onTurnEnded so detection re-arms instead of sleeping forever. See #41. - this.startTurnWatchdog(); + + console.error(`codex-bridge: reconciled wake max_id ${maxId} on thread ${this.threadId}`); + this.writeHealth("waiting_for_ack", `reconciled max_id ${maxId}`); + if (this.turnActive) this.startTurnWatchdog(); + else this.scheduleWatchRearm(this.opts.sameUnreadDelayMs); } catch (error) { - this.turnActive = false; - this.threadIdle = true; - this.clearTurnWatchdog(); - throw error; + // A timeout can happen after app-server accepted turn/start. The relay + // reconciles the deterministic client id against thread history on the + // next attempt, so never issue an unmarked fallback turn here. + this.writeHealth("paused_ambiguous_wake", "wake dispatch response unavailable; reconciliation pending"); + console.error(`codex-bridge: wake max_id ${maxId} is ambiguous; retrying reconciliation later`); + this.scheduleWakeRetry(this.nextRetryDelay()); } } + scheduleWakeRetry(delayMs) { + if (this.stopping || this.watchRearmTimer) return; + this.watchRearmTimer = setTimeout(() => { + this.watchRearmTimer = null; + this.tryStartTurn().catch((error) => this.failClientHandler("agmsg/wake/dispatch", error)); + }, delayMs); + } + startTurnWatchdog() { this.clearTurnWatchdog(); if (!this.opts.turnTimeout) return; @@ -1027,12 +1243,14 @@ class CodexBridge { onServerError(params) { if (params.threadId && params.threadId !== this.threadId) return; - console.error(`codex-bridge: server error: ${JSON.stringify(params)}`); + const code = params.code || (params.error && params.error.code) || "unknown"; + console.error(`codex-bridge: server error code ${sanitizeLogDetail(code)}`); } onAgentMessageDelta(params) { if (params.threadId !== this.threadId) return; - process.stderr.write(params.delta); + // Agent content belongs only in the visible Codex task. The bridge log is + // lifecycle telemetry and must never become a second transcript. } buildPrompt() { @@ -1055,20 +1273,6 @@ class CodexBridge { "5. If you do not reply, state why in the visible status. ACK-only mail still requires a visible receipt notice.", "6. Do not treat inbox consumption, DB writes, monitor delivery, send.sh, or a successful process exit as complete unless the handling result is visible in the Codex thread UI.", ].join("\n"); - if (this.opts.inlineInbox) { - return [ - `agmsg delivered the following unread messages for ${this.identity.team}/${this.identity.name}:`, - "", - this.inlineInboxText.trim(), - "", - "Continue the conversation in this Codex thread. If a reply to an agmsg sender is needed, send it with:", - `${send} ${this.identity.team} ${this.identity.name} `, - "", - autonomousHandlingContract, - "", - visibleUiRequirement, - ].join("\n"); - } return [ `agmsg has unread messages for ${this.identity.team}/${this.identity.name}.`, "The bridge did not read or acknowledge their contents.", @@ -1081,59 +1285,16 @@ class CodexBridge { ].join("\n"); } - readInboxForPrompt() { - try { - fs.writeFileSync(this.inlineInboxIdsFile, ""); - } catch (_) { - // Best effort. inbox-peek will report an error if the path cannot be used. - } - const result = spawnSync(BASH_BIN, [ - path.join(SCRIPTS_DIR, "inbox-peek.sh"), - this.identity.team, - this.identity.name, - "--quiet", - "--ids-file", - this.inlineInboxIdsFile, - ], { - cwd: this.opts.project, - encoding: "utf8", - }); - if (result.error) { - console.error(`codex-bridge: inbox.sh failed: ${result.error.message}`); - return ""; - } - if (result.status !== 0) { - console.error(`codex-bridge: inbox.sh exited ${result.status}: ${(result.stderr || "").trim()}`); - return ""; - } - return result.stdout || ""; + async pauseWithoutRestart(status, detail) { + this.writeHealth(status, detail); + this.preserveHealthOnExit = true; + await this.shutdown({ preserveHealth: true }); } - markDeliveredInboxRead() { - if (!this.opts.inlineInbox || !this.deliveredInboxPending) return; - this.deliveredInboxPending = false; - const result = spawnSync(BASH_BIN, [ - path.join(SCRIPTS_DIR, "mark-read.sh"), - this.identity.team, - this.identity.name, - "--ids-file", - this.inlineInboxIdsFile, - ], { - cwd: this.opts.project, - encoding: "utf8", - }); - if (result.error) { - console.error(`codex-bridge: mark-read.sh failed: ${result.error.message}`); - return; - } - if (result.status !== 0) { - console.error(`codex-bridge: mark-read.sh exited ${result.status}: ${(result.stderr || "").trim()}`); - } - } - - async shutdown() { + async shutdown({ preserveHealth = false } = {}) { if (this.stopping) return; this.stopping = true; + this.preserveHealthOnExit = this.preserveHealthOnExit || preserveHealth; this.clearWatchRearmTimer(); this.clearTurnWatchdog(); if (this.watchHandle) { @@ -1146,6 +1307,11 @@ class CodexBridge { } this.client.stop(); this.cleanupMeta(); + if (this.signalHandler) { + process.removeListener("SIGINT", this.signalHandler); + process.removeListener("SIGTERM", this.signalHandler); + this.signalHandler = null; + } } cleanupMeta() { @@ -1167,7 +1333,9 @@ class CodexBridge { return; } - for (const file of [this.pidfile, this.metafile]) { + const files = [this.pidfile, this.metafile]; + if (!this.preserveHealthOnExit) files.push(this.healthfile); + for (const file of files) { try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (_) { @@ -1181,38 +1349,48 @@ class CodexBridge { if (!existing) return; try { process.kill(existing, 0); - die(`bridge already running for ${this.identity.team}/${this.identity.name} (pid ${existing})`); + const meta = readKeyValueFile(this.metafile); + const command = readProcessCommand(existing); + const expectedScript = fs.realpathSync(__filename); + const ownsPid = Boolean( + meta + && meta.pid === String(existing) + && meta.project === this.opts.project + && meta.team === this.identity.team + && meta.name === this.identity.name + && meta.type === this.opts.type + && meta.thread === this.threadId + && command.includes(expectedScript) + && command.includes(`--state-key ${this.stateKey}`) + ); + if (ownsPid) { + throw new ExistingBridgeError( + `bridge already running for ${this.identity.team}/${this.identity.name} (pid ${existing})`, + ); + } + // The pid was recycled or the metadata is not sufficient to prove + // ownership. Remove only this role's stale runtime files; never signal an + // unrelated live process. + this.removeStaleRuntimeFiles(); + return; } catch (error) { + if (error instanceof ExistingBridgeError) throw error; if (error && error.code === "ESRCH") { - for (const file of [this.pidfile, this.metafile]) { - try { - if (fs.existsSync(file)) fs.unlinkSync(file); - } catch (_) { - // Best-effort stale cleanup. - } - } + this.removeStaleRuntimeFiles(); return; } - die(`cannot verify existing bridge pid ${existing}: ${error.message}`); + throw new TerminalBridgeError(`cannot verify existing bridge pid ${existing}: ${error.message}`); } } - isStaleWake(maxId) { - if (maxId <= 0 || this.lastWakeMaxId !== maxId) { - this.lastWakeMaxId = maxId; - this.staleWakeCount = 0; - return false; - } - - this.staleWakeCount += 1; - console.error( - `codex-bridge: unread max_id is still ${maxId}; inbox was not marked read after the prior wakeup`, - ); - if (this.opts.staleWakeLimit > 0 && this.staleWakeCount >= this.opts.staleWakeLimit) { - console.error("codex-bridge: stopping to avoid a repeated wakeup loop"); - return true; + removeStaleRuntimeFiles() { + for (const file of [this.pidfile, this.metafile, this.healthfile]) { + try { + if (fs.existsSync(file)) fs.unlinkSync(file); + } catch (_) { + // Best-effort removal of files owned by this exact state key. + } } - return false; } } @@ -1233,15 +1411,24 @@ function appServerCommand(opts = {}) { } function parseWsTarget(url) { - // ws://host:port → { host, port }. wss:// would need TLS, which the plain - // net socket below does not do; the agmsg app-server is loopback ws only. - const match = /^ws:\/\/([^/:]+):(\d+)\/?$/.exec(url); - if (!match) die(`--app-server ${url} must be ws://host:port`); - const port = Number(match[2]); + // wss:// would need TLS, which the plain net socket below does not do. + let parsed; + try { + parsed = new URL(url); + } catch (_) { + die("--app-server must be a valid ws:// URL"); + } + if (parsed.protocol !== "ws:" || parsed.username || parsed.password || !parsed.hostname || !parsed.port) { + die("--app-server must be ws://host:port[/path]"); + } + const port = Number(parsed.port); if (!Number.isInteger(port) || port <= 0 || port > 65535) { - die(`--app-server ${url} has an invalid port`); + die("--app-server has an invalid port"); } - return { host: match[1], port }; + return { + connectOptions: { host: parsed.hostname, port }, + requestPath: `${parsed.pathname || "/"}${parsed.search || ""}`, + }; } function createAppServerClient(opts) { @@ -1253,11 +1440,39 @@ function createAppServerClient(opts) { } if (opts.appServer && opts.appServer.startsWith("ws://")) { const target = parseWsTarget(opts.appServer); - return new WebSocketAppServerClient(target, opts.appServer, opts); + return new WebSocketAppServerClient( + target.connectOptions, + redactAppServer(opts.appServer), + { ...opts, requestPath: target.requestPath }, + ); } return new AppServerClient(appServerCommand(opts), opts.project, opts); } +function readPrivateEndpoint(file) { + let endpoint; + try { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) die("--app-server-file must name a regular non-symlink file"); + if ((stat.mode & 0o077) !== 0) die("--app-server-file must not be group/world accessible"); + endpoint = fs.readFileSync(file, "utf8").trim(); + } catch (error) { + die(`cannot read --app-server-file: ${error.message}`); + } + if (!endpoint) die("--app-server-file is empty"); + return endpoint; +} + +function redactAppServer(endpoint) { + if (!String(endpoint).startsWith("ws://")) return endpoint; + try { + const parsed = new URL(endpoint); + return `ws://${parsed.host}/`; + } catch (_) { + return "ws://"; + } +} + function readVersion() { try { return fs.readFileSync(path.join(SKILL_DIR, "VERSION"), "utf8").trim(); @@ -1281,6 +1496,79 @@ function parseMaxId(stdout) { return match ? Number(match[1]) : 0; } +function wakeClientMessageId(stateKey, threadId, maxId) { + const digest = crypto + .createHash("sha256") + .update(`${stateKey}\0${threadId}\0${maxId}`) + .digest("hex"); + return `agmsg-wake-v1-${digest}`; +} + +function atomicWritePrivate(file, contents) { + const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`; + let fd; + try { + fd = fs.openSync(temporary, "wx", 0o600); + fs.writeFileSync(fd, contents, "utf8"); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + fs.renameSync(temporary, file); + try { + const dirFd = fs.openSync(path.dirname(file), "r"); + try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); } + } catch (_) { + // Some filesystems do not support directory fsync. The file itself was + // still fully synced before the atomic rename. + } + } catch (error) { + if (fd !== undefined) { + try { fs.closeSync(fd); } catch (_) {} + } + try { fs.unlinkSync(temporary); } catch (_) {} + throw error; + } +} + +function readKeyValueFile(file) { + try { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + const result = {}; + for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { + const index = line.indexOf("="); + if (index <= 0) continue; + result[line.slice(0, index)] = line.slice(index + 1); + } + return result; + } catch (_) { + return null; + } +} + +function readProcessCommand(pid) { + try { + const proc = `/proc/${pid}/cmdline`; + if (fs.existsSync(proc)) return fs.readFileSync(proc).toString("utf8").replace(/\0/g, " ").trim(); + } catch (_) { + // Fall through to ps, which is available on macOS. + } + const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }); + return result.status === 0 ? String(result.stdout || "").trim() : ""; +} + +function sanitizeLogDetail(value) { + return String(value == null ? "" : value) + .replace(/[\r\n\t]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 240); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + async function main() { const opts = parseArgs(process.argv.slice(2)); if (opts.help) { @@ -1294,12 +1582,41 @@ async function main() { return; } - const bridge = new CodexBridge(opts, identity); - await bridge.run(); + let retryCount = 0; + for (;;) { + const bridge = new CodexBridge(opts, identity); + try { + await bridge.run(); + return; + } catch (error) { + if (error instanceof ExistingBridgeError) { + console.error(`codex-bridge: ${error.message}`); + return; + } + if (error instanceof TerminalBridgeError) { + if (!opts.stateKey) throw error; + bridge.writeHealth("terminal_thread_error", error.message); + await bridge.shutdown({ preserveHealth: true }); + console.error(`codex-bridge: terminal configuration error: ${error.message}`); + return; + } + if (!opts.stateKey) { + await bridge.shutdown(); + throw error; + } + if (bridge.readyAt && Date.now() - bridge.readyAt >= 300000) retryCount = 0; + retryCount += 1; + const delayMs = Math.min(opts.retryBaseMs * (2 ** Math.min(retryCount - 1, 12)), opts.retryMaxMs); + bridge.writeHealth("retrying_transient", `${sanitizeLogDetail(error.message)}; retry in ${delayMs}ms`); + await bridge.shutdown({ preserveHealth: true }); + console.error(`codex-bridge: transient failure; retrying in ${delayMs}ms: ${sanitizeLogDetail(error.message)}`); + await sleep(delayMs); + } + } } if (require.main === module) { main().catch((error) => die(error.message)); } -module.exports = { toPosixPath }; +module.exports = { toPosixPath, WebSocketAppServerClient }; diff --git a/scripts/drivers/types/codex/codex-desktop-relay-run.sh b/scripts/drivers/types/codex/codex-desktop-relay-run.sh new file mode 100755 index 00000000..338ed0f5 --- /dev/null +++ b/scripts/drivers/types/codex/codex-desktop-relay-run.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 +unset CODEX_APP_SERVER_WS_URL + +# LaunchAgent entry point. ctl writes private capabilities/endpoints before the +# job starts. The token values are read by the relay process from 0600 files and +# never appear in argv, the plist, status, or logs. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +RUN_DIR="${AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR:-$SKILL_DIR/run}" + +# shellcheck source=../../../lib/node.sh +source "$SCRIPT_DIR/../../../lib/node.sh" + +HOST="${AGMSG_CODEX_DESKTOP_RELAY_HOST:-127.0.0.1}" +PORT="${AGMSG_CODEX_DESKTOP_RELAY_PORT:-49643}" +NODE_BIN="$(agmsg_resolve_node)" + +mkdir -p "$RUN_DIR" + +child_pid="" +stopping=0 +stop_child() { + stopping=1 + if [ -n "$child_pid" ] && kill -0 "$child_pid" 2>/dev/null; then + kill -TERM "$child_pid" 2>/dev/null || true + fi +} +trap stop_child INT TERM + +delay=2 +while :; do + started_at="$(date +%s)" + set +e + "$NODE_BIN" "$SCRIPT_DIR/codex-desktop-relay.js" \ + --host "$HOST" \ + --port "$PORT" \ + --desktop-token-file "$RUN_DIR/codex-desktop-relay.desktop-token" \ + --bridge-token-file "$RUN_DIR/codex-desktop-relay.bridge-token" \ + --health "$RUN_DIR/codex-desktop-relay.health" \ + --port-file "$RUN_DIR/codex-desktop-relay.port" \ + --pid-file "$RUN_DIR/codex-desktop-relay.pid" \ + --parent-pid "$$" & + child_pid=$! + wait "$child_pid" + status=$? + child_pid="" + set -e + [ "$stopping" = "0" ] || exit 0 + # EX_USAGE/configuration errors need a corrected install, not a two-second + # restart loop. Transient app-server exits are retried with capped backoff. + [ "$status" -ne 64 ] || exit 0 + if [ $(( $(date +%s) - started_at )) -ge 300 ]; then + delay=2 + fi + sleep "$delay" + [ "$delay" -ge 60 ] || delay=$((delay * 2)) + [ "$delay" -le 60 ] || delay=60 +done diff --git a/scripts/drivers/types/codex/codex-desktop-relay.js b/scripts/drivers/types/codex/codex-desktop-relay.js new file mode 100755 index 00000000..5c5acaa2 --- /dev/null +++ b/scripts/drivers/types/codex/codex-desktop-relay.js @@ -0,0 +1,1223 @@ +#!/usr/bin/env node +"use strict"; + +// Codex Desktop owns one app-server connection. agmsg needs a second client so +// it can inject a turn into that same visible thread, but codex app-server's +// websocket listener accepts only one active frontend. This relay keeps one +// stdio app-server upstream and multiplexes Desktop plus agmsg bridge clients. +// It never reads agmsg mail; the bridge only starts a visible Codex turn. + +const { spawn } = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const net = require("net"); +const path = require("path"); +const readline = require("readline"); + +const SCRIPT_DIR = __dirname; +const SKILL_DIR = path.resolve(SCRIPT_DIR, "..", "..", "..", ".."); +const RUN_DIR = path.resolve(process.env.AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR || path.join(SKILL_DIR, "run")); +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 49643; +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +function usage() { + console.log(`Usage: codex-desktop-relay.js [options] + +Options: + --host Loopback host (default: ${DEFAULT_HOST}). + --port Listen port; 0 selects a free port (default: ${DEFAULT_PORT}). + --codex Codex CLI that supplies app-server. + --desktop-token-file + 0600 capability used only by Codex Desktop. + --bridge-token-file + 0600 capability used only by agmsg bridges. + --health Health state file. + --port-file File that receives the actual listen port. + --pid-file File that receives the relay pid. + --parent-pid Supervising runner pid; relay stops if it disappears. + --help Show this help.`); +} + +function die(message) { + console.error(`codex-desktop-relay: ${message}`); + // 64 distinguishes permanent invocation/configuration failures from an + // app-server crash. The LaunchAgent runner retries only transient exits. + process.exit(64); +} + +function parseArgs(argv) { + const opts = { + host: process.env.AGMSG_CODEX_DESKTOP_RELAY_HOST || DEFAULT_HOST, + port: Number(process.env.AGMSG_CODEX_DESKTOP_RELAY_PORT || DEFAULT_PORT), + codex: process.env.AGMSG_CODEX_DESKTOP_RELAY_CODEX || findCodex(), + desktopTokenFile: process.env.AGMSG_CODEX_DESKTOP_RELAY_DESKTOP_TOKEN_FILE || "", + bridgeTokenFile: process.env.AGMSG_CODEX_DESKTOP_RELAY_BRIDGE_TOKEN_FILE || "", + health: path.join(RUN_DIR, "codex-desktop-relay.health"), + portFile: path.join(RUN_DIR, "codex-desktop-relay.port"), + pidFile: path.join(RUN_DIR, "codex-desktop-relay.pid"), + parentPid: Number(process.env.AGMSG_CODEX_DESKTOP_RELAY_PARENT_PID || 0), + shutdownGraceMs: Number(process.env.AGMSG_CODEX_DESKTOP_RELAY_SHUTDOWN_GRACE_MS || 5000), + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") opts.help = true; + else if (arg === "--host") opts.host = argv[++index]; + else if (arg === "--port") opts.port = Number(argv[++index]); + else if (arg === "--codex") opts.codex = argv[++index]; + else if (arg === "--desktop-token-file") opts.desktopTokenFile = path.resolve(argv[++index]); + else if (arg === "--bridge-token-file") opts.bridgeTokenFile = path.resolve(argv[++index]); + else if (arg === "--health") opts.health = path.resolve(argv[++index]); + else if (arg === "--port-file") opts.portFile = path.resolve(argv[++index]); + else if (arg === "--pid-file") opts.pidFile = path.resolve(argv[++index]); + else if (arg === "--parent-pid") opts.parentPid = Number(argv[++index]); + else die(`unknown option: ${arg}`); + } + if (opts.help) return opts; + if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) { + die("--port must be an integer from 0 to 65535"); + } + if (!Number.isFinite(opts.shutdownGraceMs) || opts.shutdownGraceMs < 0) { + die("shutdown grace must be a non-negative number"); + } + if (!Number.isSafeInteger(opts.parentPid) || opts.parentPid < 0 || opts.parentPid === process.pid) { + die("--parent-pid must be a positive supervising pid"); + } + if (opts.host !== "127.0.0.1" && opts.host !== "localhost" && opts.host !== "::1") { + die("--host must be loopback"); + } + if (!opts.codex) die("Codex CLI not found; pass --codex"); + if (!opts.desktopTokenFile || !opts.bridgeTokenFile) { + die("--desktop-token-file and --bridge-token-file are required"); + } + opts.desktopToken = readCapability(opts.desktopTokenFile, "desktop"); + opts.bridgeToken = readCapability(opts.bridgeTokenFile, "bridge"); + if (opts.desktopToken === opts.bridgeToken) die("desktop and bridge capabilities must differ"); + return opts; +} + +function readCapability(file, role) { + let value = ""; + try { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) die(`${role} capability must be a regular non-symlink file`); + const mode = stat.mode & 0o777; + if ((mode & 0o077) !== 0) die(`${role} capability file must not be group/world accessible`); + value = fs.readFileSync(file, "utf8").trim(); + } catch (error) { + die(`cannot read ${role} capability file: ${error.message}`); + } + if (!/^[a-f0-9]{64}$/.test(value)) die(`${role} capability must be 32 random bytes encoded as hex`); + return value; +} + +function readBridgeBinding(token) { + let names = []; + try { + names = fs.readdirSync(RUN_DIR).filter((name) => /^codex-bridge\..+\.binding$/.test(name)); + } catch (_) { + return null; + } + for (const name of names) { + try { + const file = path.join(RUN_DIR, name); + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) continue; + const values = new Map(); + for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { + const index = line.indexOf("="); + if (index > 0) values.set(line.slice(0, index), line.slice(index + 1)); + } + const storedToken = values.get("token") || ""; + if (!/^[a-f0-9]{64}$/.test(storedToken) || storedToken.length !== token.length) continue; + if (!crypto.timingSafeEqual(Buffer.from(storedToken), Buffer.from(token))) continue; + const binding = { + token: storedToken, + project: values.get("project") || "", + type: values.get("type") || "", + team: values.get("team") || "", + name: values.get("name") || "", + threadId: values.get("thread") || "", + stateKey: values.get("state_key") || "", + }; + if (!binding.project || binding.type !== "codex" || !binding.team || !binding.name + || !binding.threadId || !/^[A-Za-z0-9._%-]+$/.test(binding.stateKey)) continue; + binding.project = fs.realpathSync(binding.project); + return binding; + } catch (_) { + // Ignore malformed or concurrently replaced bindings. + } + } + return null; +} + +function hasOnlyKeys(value, allowed) { + return Object.keys(value).every((key) => allowed.has(key)); +} + +function sameRuntimeRoots(value, project) { + return Array.isArray(value) && value.length === 1 && String(value[0]) === project; +} + +function resolvesToProject(value, project) { + try { + return fs.realpathSync(String(value || "")) === project; + } catch (_) { + return false; + } +} + +function capabilityEquals(actual, expected) { + const left = Buffer.from(String(actual || "")); + const right = Buffer.from(String(expected || "")); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +function containsClientMessageId(value, expected) { + if (!value || typeof value !== "object") return false; + if (String(value.clientId || "") === expected) return true; + if (Array.isArray(value)) return value.some((entry) => containsClientMessageId(entry, expected)); + return Object.values(value).some((entry) => containsClientMessageId(entry, expected)); +} + +function expectedWakeClientId(binding, maxId) { + const digest = crypto.createHash("sha256") + .update(`${binding.stateKey}\0${binding.threadId}\0${maxId}`) + .digest("hex"); + return `agmsg-wake-v1-${digest}`; +} + +function relayWakeStateFile(binding) { + return path.join(RUN_DIR, `codex-bridge.${binding.stateKey}.relay-wake.json`); +} + +function readRelayWakeState(binding) { + const file = relayWakeStateFile(binding); + if (!fs.existsSync(file)) return null; + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) { + throw new Error("relay wake state is not a private regular file"); + } + const state = JSON.parse(fs.readFileSync(file, "utf8")); + const expectedThreadHash = crypto.createHash("sha256").update(binding.threadId).digest("hex"); + if ( + state.version !== 1 + || state.stateKey !== binding.stateKey + || state.threadHash !== expectedThreadHash + || !Number.isSafeInteger(state.maxId) + || state.maxId <= 0 + || !["dispatching", "accepted"].includes(state.phase) + || state.clientUserMessageId !== expectedWakeClientId(binding, state.maxId) + ) { + throw new Error("relay wake state failed validation"); + } + return state; +} + +function writeRelayWakeState(binding, maxId, phase, clientUserMessageId) { + const value = { + version: 1, + stateKey: binding.stateKey, + threadHash: crypto.createHash("sha256").update(binding.threadId).digest("hex"), + maxId, + phase, + clientUserMessageId, + updatedAt: new Date().toISOString(), + }; + atomicWriteDurable(relayWakeStateFile(binding), `${JSON.stringify(value)}\n`); +} + +function buildWakePrompt(binding) { + const inbox = path.join(SKILL_DIR, "scripts", "inbox.sh"); + const send = path.join(SKILL_DIR, "scripts", "send.sh"); + return [ + `agmsg has unread messages for ${binding.team}/${binding.name}.`, + "The bridge did not read or acknowledge their contents.", + "Continue the conversation in this same Codex thread. If a reply is needed, send it with:", + `${send} ${binding.team} ${binding.name} `, + "", + "Autonomous handling contract:", + `1. Your first tool call must be this official inbox command: ${inbox} ${binding.team} ${binding.name}`, + "2. Do not read the agmsg database or team files directly. The resumed Codex task alone owns message reading and acknowledgement through inbox.sh.", + "3. For a substantive request, new evidence, correction, or blocker, continue the in-scope work through verification and send an evidence-backed reply with the official send.sh command above. Do not stop after an ACK or status-only reply.", + "4. Do not reply to ACK-only, thanks-only, or status-only mail that contains no new request, evidence, correction, or blocker.", + "5. Preserve existing approval, production, customer-data, credential, and destructive-action boundaries.", + "", + "Visible UI requirement:", + '1. Before the inbox tool call, post "agmsg受信を検知しました。内容を確認します。" in the visible Codex thread.', + '2. Immediately after inbox.sh and before any other tool call, post a Japanese update starting with "agmsg受信:" and include sender, received body or safe summary, planned action, and whether you will reply.', + "3. Keep substantive work in the visible thread and post short progress updates before major actions.", + "4. Finish with sender, instruction, action, reply target and summary, remaining blocker, and next step.", + "5. If no reply is sent, state why. ACK-only mail still requires a visible receipt notice.", + "6. Do not treat inbox consumption, DB writes, monitor delivery, send.sh, or process exit as complete unless the handling result is visible in this task.", + ].join("\n"); +} + +function findCodex() { + const candidates = [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/Codex.app/Contents/Resources/codex", + path.join(process.env.HOME || "", ".codex", "packages", "standalone", "current", "codex"), + ]; + return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || "codex"; +} + +function atomicWrite(file, contents) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temporary = `${file}.${process.pid}.tmp`; + fs.writeFileSync(temporary, contents, { mode: 0o600 }); + fs.renameSync(temporary, file); +} + +function atomicWriteDurable(file, contents) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`; + let fd; + try { + fd = fs.openSync(temporary, "wx", 0o600); + fs.writeFileSync(fd, contents, "utf8"); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + fs.renameSync(temporary, file); + try { + const dirFd = fs.openSync(path.dirname(file), "r"); + try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); } + } catch (_) { + // The file itself is durable even where directory fsync is unsupported. + } + } catch (error) { + if (fd !== undefined) { + try { fs.closeSync(fd); } catch (_) {} + } + try { fs.unlinkSync(temporary); } catch (_) {} + throw error; + } +} + +function encodeFrame(opcode, payload) { + const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || "", "utf8"); + let header; + if (body.length < 126) { + header = Buffer.from([0x80 | opcode, body.length]); + } else if (body.length <= 0xffff) { + header = Buffer.alloc(4); + header[0] = 0x80 | opcode; + header[1] = 126; + header.writeUInt16BE(body.length, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x80 | opcode; + header[1] = 127; + header.writeUInt32BE(0, 2); + header.writeUInt32BE(body.length, 6); + } + return Buffer.concat([header, body]); +} + +class RelayClient { + constructor(relay, socket, id) { + this.relay = relay; + this.socket = socket; + this.id = id; + this.buffer = Buffer.alloc(0); + this.handshakeComplete = false; + this.closed = false; + this.initialized = false; + this.initializeResponseSent = false; + this.isBridge = false; + this.role = ""; + this.binding = null; + this.threadId = ""; + this.projectRoot = ""; + this.fragments = []; + this.fragmentOpcode = 0; + socket.on("data", (chunk) => this.onData(chunk)); + socket.on("error", (error) => relay.log(`client ${id} socket error: ${error.message}`)); + socket.on("close", () => this.close()); + } + + onData(chunk) { + this.buffer = Buffer.concat([this.buffer, chunk]); + if (!this.handshakeComplete && !this.handleHandshake()) return; + this.handleFrames(); + } + + handleHandshake() { + const end = this.buffer.indexOf("\r\n\r\n"); + if (end === -1) { + if (this.buffer.length > 64 * 1024) this.rejectHandshake(431, "Request Header Fields Too Large"); + return false; + } + const header = this.buffer.slice(0, end).toString("utf8"); + this.buffer = this.buffer.slice(end + 4); + const lines = header.split(/\r\n/); + const request = /^(GET)\s+([^\s]+)\s+HTTP\/1\.[01]$/.exec(lines.shift() || ""); + const headers = new Map(); + for (const line of lines) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim()); + } + const key = headers.get("sec-websocket-key") || ""; + if (!request || !key || !/websocket/i.test(headers.get("upgrade") || "")) { + this.rejectHandshake(400, "Bad Request"); + return false; + } + const authentication = this.relay.authenticatePath(request[2]); + if (!authentication) { + this.rejectHandshake(403, "Forbidden"); + return false; + } + this.role = authentication.role; + this.binding = authentication.binding || null; + const accept = crypto.createHash("sha1").update(`${key}${WS_GUID}`).digest("base64"); + this.socket.write([ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n")); + this.handshakeComplete = true; + this.relay.onClientOpen(this); + return true; + } + + rejectHandshake(status, text) { + if (!this.socket.destroyed) this.socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\n\r\n`); + } + + handleFrames() { + while (this.buffer.length >= 2) { + const first = this.buffer[0]; + const second = this.buffer[1]; + const final = (first & 0x80) !== 0; + const opcode = first & 0x0f; + const masked = (second & 0x80) !== 0; + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + if (this.buffer.length < 4) return; + length = this.buffer.readUInt16BE(2); + offset = 4; + } else if (length === 127) { + if (this.buffer.length < 10) return; + const high = this.buffer.readUInt32BE(2); + const low = this.buffer.readUInt32BE(6); + if (high !== 0) return this.protocolError("frame too large"); + length = low; + offset = 10; + } + if (!masked) return this.protocolError("client frame was not masked"); + if (this.buffer.length < offset + 4 + length) return; + const mask = this.buffer.slice(offset, offset + 4); + offset += 4; + const payload = Buffer.from(this.buffer.slice(offset, offset + length)); + for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4]; + this.buffer = this.buffer.slice(offset + length); + + if (opcode === 0x8) { + this.sendFrame(0x8, payload); + this.socket.end(); + return; + } + if (opcode === 0x9) { + this.sendFrame(0x0a, payload); + continue; + } + if (opcode === 0x0a) continue; + if (opcode === 0x1 && final) { + this.relay.onClientText(this, payload.toString("utf8")); + continue; + } + if (opcode === 0x1 && !final) { + this.fragmentOpcode = opcode; + this.fragments = [payload]; + continue; + } + if (opcode === 0x0 && this.fragmentOpcode) { + this.fragments.push(payload); + if (final) { + const full = Buffer.concat(this.fragments); + this.fragments = []; + this.fragmentOpcode = 0; + this.relay.onClientText(this, full.toString("utf8")); + } + continue; + } + return this.protocolError(`unsupported opcode ${opcode}`); + } + } + + protocolError(message) { + this.relay.log(`client ${this.id} protocol error: ${message}`); + this.sendFrame(0x8, Buffer.from([0x03, 0xea])); + this.socket.destroy(); + } + + sendJson(message) { + if (!this.closed && this.handshakeComplete && !this.socket.destroyed) { + this.sendFrame(0x1, Buffer.from(JSON.stringify(message), "utf8")); + } + } + + sendFrame(opcode, payload) { + if (!this.socket.destroyed) this.socket.write(encodeFrame(opcode, payload)); + } + + close() { + if (this.closed) return; + this.closed = true; + this.relay.onClientClose(this); + } +} + +class CodexDesktopRelay { + constructor(opts) { + this.opts = opts; + this.clients = new Set(); + this.primary = null; + this.nextClientId = 1; + this.nextUpstreamId = 1; + this.nextServerRequestId = 1; + this.pending = new Map(); + this.serverRequests = new Map(); + this.initializeWaiters = []; + this.initializeRequestId = null; + this.initializeResult = null; + this.initializeError = null; + this.upstreamInitializedNotificationSent = false; + this.processOwners = new Map(); + this.wakeInflight = new Map(); + this.server = null; + this.child = null; + this.upstreamPgid = 0; + this.actualPort = 0; + this.parentTimer = null; + this.shuttingDown = false; + } + + run() { + this.server = net.createServer((socket) => { + socket.setNoDelay(true); + this.clients.add(new RelayClient(this, socket, this.nextClientId++)); + this.writeHealth("listening"); + }); + this.server.on("error", (error) => { + this.log(`listen failed: ${error.message}`); + this.shutdown(1); + }); + this.server.listen(this.opts.port, this.opts.host, () => { + const address = this.server.address(); + this.actualPort = typeof address === "object" && address ? address.port : this.opts.port; + this.startUpstream(); + atomicWrite(this.opts.portFile, `${this.actualPort}\n`); + atomicWrite(this.opts.pidFile, `${process.pid}\n`); + this.writeHealth("listening"); + this.log(`listening on ws://${this.opts.host}:${this.actualPort}`); + }); + const stop = () => this.shutdown(0); + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + process.on("SIGHUP", stop); + this.monitorParent(); + } + + monitorParent() { + if (!this.opts.parentPid) return; + const check = () => { + try { + process.kill(this.opts.parentPid, 0); + } catch (error) { + if (error && error.code === "EPERM") return; + this.log("supervising runner disappeared; stopping relay and app-server"); + this.shutdown(1); + } + }; + check(); + if (!this.shuttingDown) this.parentTimer = setInterval(check, 250); + } + + authenticatePath(requestPath) { + const desktop = /^\/desktop\/([a-f0-9]{64})$/.exec(requestPath); + if (desktop && capabilityEquals(desktop[1], this.opts.desktopToken)) return { role: "desktop" }; + const match = /^\/bridge\/([a-f0-9]{64})\/([a-f0-9]{64})$/.exec(requestPath); + if (!match) return null; + if (!capabilityEquals(match[1], this.opts.bridgeToken)) return null; + const binding = readBridgeBinding(match[2]); + if (!binding) return null; + const alreadyConnected = [...this.clients].some( + (client) => client.role === "bridge" && !client.closed && client.binding + && client.binding.stateKey === binding.stateKey, + ); + if (alreadyConnected) return null; + return { role: "bridge", binding }; + } + + startUpstream() { + const args = ["-c", "features.code_mode_host=true", "app-server", "--analytics-default-enabled"]; + const childEnv = { ...process.env }; + delete childEnv.CODEX_APP_SERVER_WS_URL; + this.child = spawn(this.opts.codex, args, { + cwd: process.env.HOME || process.cwd(), + env: { + ...childEnv, + CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "Codex Desktop", + LOG_FORMAT: "json", + RUST_LOG: process.env.RUST_LOG || "warn", + }, + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + this.upstreamPgid = this.child.pid || 0; + this.child.once("error", (error) => { + this.log(`app-server start failed: ${error.message}`); + this.shutdown(1); + }); + this.child.once("exit", (code, signal) => { + if (this.shuttingDown) return; + this.log(`app-server exited (${code ?? signal}); relay will restart`); + this.shutdown(1); + }); + // Never mirror app-server stderr into the relay lifecycle log; it may + // include visible conversation content. + this.child.stderr.on("data", () => {}); + readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.onUpstreamLine(line)); + } + + onClientOpen(client) { + this.log(`${client.role} client ${client.id} connected`); + this.writeHealth(this.isReady() ? "ready" : "waiting_for_desktop"); + } + + onClientClose(client) { + this.clients.delete(client); + this.initializeWaiters = this.initializeWaiters.filter((waiter) => waiter.client !== client); + for (const [id, pending] of this.pending) { + if (pending.client !== client) continue; + this.pending.delete(id); + if (pending.wakeDispatch && pending.wakeDispatch.inflightKey) { + // The durable relay state, not this in-memory map, owns ambiguity after + // turn/start. A read-stage disconnect has not started a turn and is + // therefore safe to retry after the bridge reconnects. + this.wakeInflight.delete(pending.wakeDispatch.inflightKey); + } + } + for (const [id, request] of this.serverRequests) { + if (request.client !== client) continue; + this.serverRequests.delete(id); + this.sendUpstream({ + jsonrpc: "2.0", + id: request.upstreamId, + error: { code: -32000, message: "Visible Codex Desktop client disconnected" }, + }); + } + for (const [handle, owner] of this.processOwners) { + if (owner.client !== client) continue; + this.processOwners.delete(handle); + const upstreamId = this.nextUpstreamId++; + this.pending.set(upstreamId, { internal: true, upstreamHandle: handle }); + this.sendUpstream({ + jsonrpc: "2.0", + id: upstreamId, + method: "process/kill", + params: { processHandle: handle }, + }); + } + if (this.primary === client) { + this.primary = [...this.clients].find( + (candidate) => candidate.role === "desktop" && !candidate.closed, + ) || null; + this.flushInitializeWaiters(); + } + this.log(`${client.role || "unknown"} client ${client.id} disconnected`); + this.writeHealth(this.isReady() ? "ready" : "waiting_for_desktop"); + } + + onClientText(client, text) { + let message; + try { + message = JSON.parse(text); + } catch (_) { + client.sendJson({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }); + return; + } + + if (message.method === "initialize" && Object.prototype.hasOwnProperty.call(message, "id")) { + this.onInitialize(client, message); + return; + } + if (message.method === "initialized" && !Object.prototype.hasOwnProperty.call(message, "id")) { + if (!client.initializeResponseSent) { + client.socket.destroy(); + return; + } + client.initialized = true; + if (client === this.primary && this.initializeResult && !this.upstreamInitializedNotificationSent) { + this.sendUpstream({ jsonrpc: "2.0", method: "initialized", params: message.params || {} }); + this.upstreamInitializedNotificationSent = true; + this.flushInitializeWaiters(); + } + this.writeHealth(this.isReady() ? "ready" : "waiting_for_desktop"); + return; + } + + if (!message.method && Object.prototype.hasOwnProperty.call(message, "id")) { + const serverRequest = this.serverRequests.get(String(message.id)); + if (client === this.primary && serverRequest && serverRequest.client === client) { + this.serverRequests.delete(String(message.id)); + this.sendUpstream({ ...message, id: serverRequest.upstreamId }); + return; + } + } + + if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) { + if (!client.initialized || !this.isReady()) { + client.sendJson({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32002, message: "Visible Codex Desktop is not ready" }, + }); + return; + } + const routed = this.prepareClientRequest(client, message); + if (!routed) return; + const upstreamId = this.nextUpstreamId++; + this.pending.set(upstreamId, { + client, + clientId: message.id, + method: message.method, + originalHandle: routed.originalHandle || "", + upstreamHandle: routed.upstreamHandle || "", + requestedThreadId: routed.requestedThreadId || "", + requestedProjectRoot: routed.requestedProjectRoot || "", + wakeDispatch: routed.wakeDispatch || null, + }); + this.sendUpstream({ ...routed.message, id: upstreamId }); + return; + } + + if (client.role === "desktop") this.sendUpstream(message); + } + + isReady() { + return Boolean( + this.initializeResult && this.upstreamInitializedNotificationSent + && this.primary && this.primary.initialized && !this.primary.closed + ); + } + + rejectClientRequest(client, id, message) { + client.sendJson({ jsonrpc: "2.0", id, error: { code: -32601, message } }); + } + + prepareClientRequest(client, message) { + if (client.role === "desktop") return { message }; + const allowed = new Set(["thread/resume", "agmsg/wake/dispatch", "process/spawn", "process/kill"]); + if (!allowed.has(message.method)) { + this.rejectClientRequest(client, message.id, `Bridge method is not allowed: ${message.method}`); + return null; + } + const params = message.params && typeof message.params === "object" ? { ...message.params } : {}; + if (message.method === "thread/resume") { + const threadId = String(params.threadId || ""); + const binding = client.binding; + const allowedKeys = new Set(["threadId", "cwd", "runtimeWorkspaceRoots", "excludeTurns"]); + if (!binding || threadId !== binding.threadId || client.threadId && client.threadId !== threadId + || !hasOnlyKeys(params, allowedKeys) + || params.cwd && params.cwd !== binding.project + || params.runtimeWorkspaceRoots && !sameRuntimeRoots(params.runtimeWorkspaceRoots, binding.project) + || params.excludeTurns !== undefined && params.excludeTurns !== true) { + this.rejectClientRequest(client, message.id, "Bridge requires one exact thread id"); + return null; + } + return { + message: { + ...message, + params: { threadId, cwd: binding.project, runtimeWorkspaceRoots: [binding.project], excludeTurns: true }, + }, + requestedThreadId: threadId, + requestedProjectRoot: binding.project, + }; + } + if (message.method === "agmsg/wake/dispatch") { + const binding = client.binding; + const allowedKeys = new Set([ + "threadId", "maxId", "clientUserMessageId", "dispatchMode", "cwd", "runtimeWorkspaceRoots", + ]); + const maxId = Number(params.maxId); + const clientUserMessageId = String(params.clientUserMessageId || ""); + const dispatchMode = String(params.dispatchMode || ""); + if (!client.threadId || params.threadId !== client.threadId || !binding + || !hasOnlyKeys(params, allowedKeys) + || params.cwd && params.cwd !== client.projectRoot + || params.runtimeWorkspaceRoots && !sameRuntimeRoots(params.runtimeWorkspaceRoots, client.projectRoot) + || !Number.isSafeInteger(maxId) || maxId <= 0 + || clientUserMessageId !== expectedWakeClientId(binding, maxId) + || !["start-or-reconcile", "reconcile-only"].includes(dispatchMode)) { + this.rejectClientRequest(client, message.id, "Bridge wake does not match its exact role binding"); + return null; + } + const inflightKey = clientUserMessageId; + const inflight = this.wakeInflight.get(inflightKey); + let durableState; + try { + durableState = readRelayWakeState(binding); + } catch (error) { + this.rejectClientRequest(client, message.id, `Cannot trust relay wake state: ${error.message}`); + return null; + } + if (durableState && durableState.maxId > maxId) { + this.rejectClientRequest(client, message.id, "Bridge wake is older than relay durable state"); + return null; + } + const durableSameWake = durableState && durableState.maxId === maxId + && durableState.clientUserMessageId === clientUserMessageId; + if (dispatchMode === "reconcile-only" || inflight || durableSameWake) { + // The bridge may time out while the original thread/read or turn/start + // is still in flight. A retry is reconciliation-only: if history does + // not contain the marker yet, fail closed instead of starting a second + // turn concurrently with the original request. + return { + message: { + jsonrpc: "2.0", + method: "thread/read", + params: { threadId: client.threadId, includeTurns: true }, + }, + wakeDispatch: { + clientUserMessageId, + maxId, + inflightKey, + reconcileOnly: true, + binding, + }, + }; + } + this.wakeInflight.set(inflightKey, { stage: "read" }); + return { + message: { + jsonrpc: "2.0", + method: "thread/read", + params: { threadId: client.threadId, includeTurns: true }, + }, + wakeDispatch: { + clientUserMessageId, + maxId, + inflightKey, + binding, + turnParams: { + threadId: client.threadId, + input: [{ type: "text", text: buildWakePrompt(binding), text_elements: [] }], + cwd: client.projectRoot, + runtimeWorkspaceRoots: [client.projectRoot], + clientUserMessageId, + }, + }, + }; + } + if (!client.threadId) { + this.rejectClientRequest(client, message.id, "Bridge must resume its exact thread before spawning a watcher"); + return null; + } + const originalHandle = String(params.processHandle || ""); + if (!/^agmsg-watch-[A-Za-z0-9._-]+$/.test(originalHandle)) { + this.rejectClientRequest(client, message.id, "Bridge process handle is invalid"); + return null; + } + if (message.method === "process/spawn") { + const command = Array.isArray(params.command) ? params.command : []; + const binding = client.binding; + const allowedKeys = new Set(["command", "processHandle", "cwd", "outputBytesCap", "timeoutMs"]); + const timeoutSeconds = Number(command[12]); + const intervalSeconds = Number(command[14]); + const validShape = command.length === 15 + && String(command[0] || "") === "/bin/bash" + && String(command[1] || "") === path.join(SCRIPT_DIR, "watch-once.sh") + && resolvesToProject(command[2], binding.project) + && String(command[3] || "") === binding.type + && command[4] === "--team" && command[5] === binding.team + && command[6] === "--name" && command[7] === binding.name + && command[8] === "--owner" && /^agmsg-codex-bridge-[0-9]+\.[0-9]+$/.test(String(command[9] || "")) + && command[10] === "--claim" + && command[11] === "--timeout" && /^[1-9][0-9]*$/.test(String(command[12] || "")) + && command[13] === "--interval" && /^[1-9][0-9]*$/.test(String(command[14] || "")); + const expectedTimeoutMs = (timeoutSeconds + intervalSeconds + 10) * 1000; + const validParams = hasOnlyKeys(params, allowedKeys) + && params.cwd === binding.project + && params.outputBytesCap === 8192 + && Number.isInteger(params.timeoutMs) && params.timeoutMs === expectedTimeoutMs + && params.timeoutMs > 0 && params.timeoutMs <= 86400000; + if (!validShape || !validParams) { + this.rejectClientRequest(client, message.id, "Bridge may spawn only watch-once.sh"); + return null; + } + const upstreamHandle = `agmsg-relay-${client.id}-${originalHandle}`; + if (this.processOwners.has(upstreamHandle)) { + this.rejectClientRequest(client, message.id, "Bridge process handle already exists"); + return null; + } + this.processOwners.set(upstreamHandle, { client, originalHandle }); + params.processHandle = upstreamHandle; + params.cwd = binding.project; + return { message: { ...message, params }, originalHandle, upstreamHandle }; + } + if (!hasOnlyKeys(params, new Set(["processHandle"]))) { + this.rejectClientRequest(client, message.id, "Bridge may kill only its owned watch process"); + return null; + } + const ownerEntry = [...this.processOwners.entries()].find( + ([, owner]) => owner.client === client && owner.originalHandle === originalHandle, + ); + if (!ownerEntry) { + this.rejectClientRequest(client, message.id, "Bridge does not own this process handle"); + return null; + } + params.processHandle = ownerEntry[0]; + return { + message: { ...message, params }, + originalHandle, + upstreamHandle: ownerEntry[0], + }; + } + + onInitialize(client, message) { + client.isBridge = client.role === "bridge"; + if (client.role === "desktop" && !this.primary) this.primary = client; + this.initializeWaiters.push({ client, clientId: message.id }); + if (this.initializeResult || this.initializeError) { + this.flushInitializeWaiters(); + return; + } + if (client !== this.primary || this.initializeRequestId !== null) { + this.writeHealth("waiting_for_desktop"); + return; + } + const upstreamId = this.nextUpstreamId++; + this.initializeRequestId = upstreamId; + this.pending.set(upstreamId, { initialize: true }); + this.sendUpstream({ ...message, id: upstreamId }); + this.writeHealth("initializing"); + } + + flushInitializeWaiters() { + const remaining = []; + for (const waiter of this.initializeWaiters.splice(0)) { + if (this.initializeError) { + waiter.client.sendJson({ jsonrpc: "2.0", id: waiter.clientId, error: this.initializeError }); + } else if ( + this.initializeResult + && (waiter.client === this.primary || this.isReady()) + ) { + waiter.client.initializeResponseSent = true; + waiter.client.sendJson({ jsonrpc: "2.0", id: waiter.clientId, result: this.initializeResult }); + } else { + remaining.push(waiter); + } + } + this.initializeWaiters.push(...remaining); + let status = "waiting_for_desktop"; + if (this.initializeError) status = "initialization_failed"; + else if (this.isReady()) status = "ready"; + else if (this.initializeRequestId !== null) status = "initializing"; + this.writeHealth(status); + } + + onUpstreamLine(line) { + if (!line.trim()) return; + let message; + try { + message = JSON.parse(line); + } catch (_) { + this.log("ignoring non-json app-server line"); + return; + } + + if (Object.prototype.hasOwnProperty.call(message, "id") && !message.method) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (pending.initialize) { + this.initializeRequestId = null; + if (message.error) this.initializeError = message.error; + else this.initializeResult = message.result; + this.flushInitializeWaiters(); + return; + } + if (pending.internal) { + if (pending.upstreamHandle) this.processOwners.delete(pending.upstreamHandle); + return; + } + if (pending.wakeDispatch) { + const dispatch = pending.wakeDispatch; + if (dispatch.reconcileOnly) { + if (!message.error && containsClientMessageId(message.result, dispatch.clientUserMessageId)) { + writeRelayWakeState( + dispatch.binding, + dispatch.maxId, + "accepted", + dispatch.clientUserMessageId, + ); + pending.client.sendJson({ + jsonrpc: "2.0", + id: pending.clientId, + result: { status: "reconciled", maxId: dispatch.maxId }, + }); + } else { + pending.client.sendJson({ + jsonrpc: "2.0", + id: pending.clientId, + error: { code: -32004, message: "Wake acceptance is still ambiguous" }, + }); + } + return; + } + if (dispatch.stage === "turn") { + this.wakeInflight.delete(dispatch.inflightKey); + if (message.error) { + pending.client.sendJson({ ...message, id: pending.clientId }); + } else { + writeRelayWakeState( + dispatch.binding, + dispatch.maxId, + "accepted", + dispatch.clientUserMessageId, + ); + pending.client.sendJson({ + jsonrpc: "2.0", + id: pending.clientId, + result: { + status: "accepted", + maxId: dispatch.maxId, + turnId: String(message.result && message.result.turn && message.result.turn.id || ""), + }, + }); + } + return; + } + if (message.error) { + this.wakeInflight.delete(dispatch.inflightKey); + pending.client.sendJson({ ...message, id: pending.clientId }); + return; + } + if (containsClientMessageId(message.result, dispatch.clientUserMessageId)) { + this.wakeInflight.delete(dispatch.inflightKey); + writeRelayWakeState( + dispatch.binding, + dispatch.maxId, + "accepted", + dispatch.clientUserMessageId, + ); + pending.client.sendJson({ + jsonrpc: "2.0", + id: pending.clientId, + result: { status: "reconciled", maxId: dispatch.maxId }, + }); + return; + } + const inflight = this.wakeInflight.get(dispatch.inflightKey); + if (inflight) inflight.stage = "turn"; + writeRelayWakeState( + dispatch.binding, + dispatch.maxId, + "dispatching", + dispatch.clientUserMessageId, + ); + const upstreamId = this.nextUpstreamId++; + this.pending.set(upstreamId, { + client: pending.client, + clientId: pending.clientId, + method: "agmsg/wake/dispatch", + wakeDispatch: { ...dispatch, stage: "turn" }, + }); + this.sendUpstream({ + jsonrpc: "2.0", + id: upstreamId, + method: "turn/start", + params: dispatch.turnParams, + }); + return; + } + if (pending.method === "process/spawn" && message.error && pending.upstreamHandle) { + this.processOwners.delete(pending.upstreamHandle); + } + if (pending.method === "thread/resume" && !message.error) { + const returnedThreadId = String( + message.result && message.result.thread && message.result.thread.id || "", + ); + if (!returnedThreadId || returnedThreadId !== pending.requestedThreadId) { + pending.client.sendJson({ + jsonrpc: "2.0", + id: pending.clientId, + error: { code: -32003, message: "App-server resumed a different thread" }, + }); + return; + } + pending.client.threadId = returnedThreadId; + pending.client.projectRoot = pending.requestedProjectRoot; + } + pending.client.sendJson({ ...message, id: pending.clientId }); + return; + } + + if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) { + const primary = this.primary; + if (!primary || primary.closed) { + this.sendUpstream({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32000, message: "No visible Codex Desktop client is connected" }, + }); + return; + } + const relayId = `agmsg-relay-server-${this.nextServerRequestId++}`; + this.serverRequests.set(relayId, { client: primary, upstreamId: message.id }); + primary.sendJson({ ...message, id: relayId }); + return; + } + + if (message.method && message.method.startsWith("process/")) { + const upstreamHandle = String(message.params && message.params.processHandle || ""); + const owner = this.processOwners.get(upstreamHandle); + if (owner) { + if (message.method === "process/exited") this.processOwners.delete(upstreamHandle); + owner.client.sendJson({ + ...message, + params: { ...message.params, processHandle: owner.originalHandle }, + }); + } else { + for (const client of this.clients) { + if (client.role === "desktop" && client.initialized && !client.closed) client.sendJson(message); + } + } + return; + } + + for (const client of this.clients) { + if (!client.initialized || client.closed) continue; + if (client.role === "desktop") { + client.sendJson(message); + continue; + } + const bridgeNotifications = new Set([ + "thread/status/changed", + "turn/started", + "turn/completed", + "turn/failed", + "item/agentMessage/delta", + "error", + ]); + if ( + bridgeNotifications.has(message.method) + && client.threadId + && message.params + && message.params.threadId === client.threadId + ) { + client.sendJson(message); + } + } + } + + sendUpstream(message) { + if (!this.child || !this.child.stdin || this.child.stdin.destroyed) return; + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } + + writeHealth(status) { + const primaryConnected = Boolean(this.primary && !this.primary.closed); + const initializedClients = [...this.clients].filter((client) => client.initialized).length; + const contents = [ + `status=${status === "ready" && !this.isReady() ? "waiting_for_desktop" : status}`, + `pid=${process.pid}`, + `port=${this.actualPort || this.opts.port}`, + `upstream_pid=${this.child && this.child.pid || ""}`, + `clients=${this.clients.size}`, + `initialized_clients=${initializedClients}`, + `primary_connected=${primaryConnected ? 1 : 0}`, + `upstream_initialized=${this.initializeResult ? 1 : 0}`, + `updated_at=${new Date().toISOString()}`, + "", + ].join("\n"); + try { + atomicWrite(this.opts.health, contents); + } catch (error) { + this.log(`health write failed: ${error.message}`); + } + } + + log(message) { + console.error(`codex-desktop-relay: ${message}`); + } + + shutdown(code) { + if (this.shuttingDown) return; + this.shuttingDown = true; + if (this.parentTimer) { + clearInterval(this.parentTimer); + this.parentTimer = null; + } + this.writeHealth(code === 0 ? "stopped" : "failed"); + for (const client of this.clients) { + if (!client.socket.destroyed) client.socket.destroy(); + } + if (this.server) { + try { this.server.close(); } catch (_) {} + } + for (const file of [this.opts.pidFile]) { + try { fs.unlinkSync(file); } catch (_) {} + } + const finish = () => process.exit(code); + if (!this.child || !this.upstreamPgid) return finish(); + + if (process.platform === "win32") { + if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill("SIGTERM"); + const timer = setTimeout(() => { + if (this.child && this.child.exitCode === null) this.child.kill("SIGKILL"); + finish(); + }, this.opts.shutdownGraceMs); + this.child.once("exit", () => { clearTimeout(timer); finish(); }); + return; + } + + const pgid = this.upstreamPgid; + const groupAlive = () => { + try { + process.kill(-pgid, 0); + return true; + } catch (error) { + return error && error.code === "EPERM"; + } + }; + const signalGroup = (signal) => { + try { process.kill(-pgid, signal); } catch (_) {} + }; + const deadline = Date.now() + this.opts.shutdownGraceMs; + signalGroup("SIGTERM"); + const awaitGroupExit = () => { + if (!groupAlive()) return finish(); + if (Date.now() >= deadline) { + signalGroup("SIGKILL"); + const killDeadline = Date.now() + 1000; + const awaitKilled = () => { + if (!groupAlive() || Date.now() >= killDeadline) return finish(); + setTimeout(awaitKilled, 50); + }; + setTimeout(awaitKilled, 50); + return; + } + setTimeout(awaitGroupExit, 50); + }; + setTimeout(awaitGroupExit, 50); + } +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) return usage(); + new CodexDesktopRelay(opts).run(); +} + +if (require.main === module) main(); + +module.exports = { CodexDesktopRelay, RelayClient, encodeFrame, parseArgs }; diff --git a/scripts/drivers/types/codex/codex-desktop-relayctl.sh b/scripts/drivers/types/codex/codex-desktop-relayctl.sh new file mode 100755 index 00000000..b2a49d21 --- /dev/null +++ b/scripts/drivers/types/codex/codex-desktop-relayctl.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# Install and inspect the single per-user relay shared by Codex Desktop and +# role-scoped agmsg bridges. Capabilities live only in private runtime files; +# status, logs, argv, and the LaunchAgent plist expose redacted endpoints. + +ACTION="${1:-status}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +RUN_DIR="${AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR:-$SKILL_DIR/run}" +HOST="${AGMSG_CODEX_DESKTOP_RELAY_HOST:-127.0.0.1}" +PORT="${AGMSG_CODEX_DESKTOP_RELAY_PORT:-49643}" +LABEL="com.agmsg.codex-desktop-relay" +DOMAIN="gui/$(id -u)" +PLIST_DIR="${AGMSG_CODEX_DESKTOP_RELAY_PLIST_DIR:-$HOME/Library/LaunchAgents}" +PLIST="$PLIST_DIR/$LABEL.plist" +HEALTH="$RUN_DIR/codex-desktop-relay.health" +PIDFILE="$RUN_DIR/codex-desktop-relay.pid" +PORT_FILE="$RUN_DIR/codex-desktop-relay.port" +LOG="$RUN_DIR/codex-desktop-relay.log" +DESKTOP_TOKEN_FILE="$RUN_DIR/codex-desktop-relay.desktop-token" +BRIDGE_TOKEN_FILE="$RUN_DIR/codex-desktop-relay.bridge-token" +DESKTOP_ENDPOINT_FILE="$RUN_DIR/codex-desktop-relay.desktop-endpoint" +BRIDGE_ENDPOINT_FILE="$RUN_DIR/codex-desktop-relay.bridge-endpoint" +PRIOR_DESKTOP_ENDPOINT_FILE="$RUN_DIR/codex-desktop-relay.prior-desktop-endpoint" + +xml_escape() { + sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' -e "s/'/\'/g" +} + +plist_string() { + printf '%s' "$1" | xml_escape +} + +validate_port() { + case "$PORT" in ''|*[!0-9]*) return 1 ;; esac + [ "$PORT" -gt 0 ] && [ "$PORT" -le 65535 ] +} + +generate_capability() { + local file="$1" temporary value="" mode="" + mkdir -p "$RUN_DIR" + if [ -L "$file" ]; then + rm -f "$file" + elif [ -f "$file" ]; then + mode="$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || true)" + value="$(cat "$file" 2>/dev/null || true)" + if [ "$mode" = "600" ] && printf '%s' "$value" | grep -Eq '^[a-f0-9]{64}$'; then + return 0 + fi + rm -f "$file" + elif [ -e "$file" ]; then + rm -rf "$file" + fi + value="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" + printf '%s' "$value" | grep -Eq '^[a-f0-9]{64}$' || { + echo "codex-desktop-relayctl: capability generation failed" >&2 + exit 1 + } + temporary="$file.$$" + (umask 077; printf '%s\n' "$value" > "$temporary") + chmod 600 "$temporary" + mv "$temporary" "$file" +} + +write_private_endpoints() { + local desktop_token bridge_token temporary + desktop_token="$(cat "$DESKTOP_TOKEN_FILE")" + bridge_token="$(cat "$BRIDGE_TOKEN_FILE")" + temporary="$DESKTOP_ENDPOINT_FILE.$$" + (umask 077; printf 'ws://%s:%s/desktop/%s\n' "$HOST" "$PORT" "$desktop_token" > "$temporary") + chmod 600 "$temporary" + mv "$temporary" "$DESKTOP_ENDPOINT_FILE" + temporary="$BRIDGE_ENDPOINT_FILE.$$" + (umask 077; printf 'ws://%s:%s/bridge/%s\n' "$HOST" "$PORT" "$bridge_token" > "$temporary") + chmod 600 "$temporary" + mv "$temporary" "$BRIDGE_ENDPOINT_FILE" +} + +prepare_private_runtime() { + generate_capability "$DESKTOP_TOKEN_FILE" + generate_capability "$BRIDGE_TOKEN_FILE" + if [ "$(cat "$DESKTOP_TOKEN_FILE")" = "$(cat "$BRIDGE_TOKEN_FILE")" ]; then + rm -f "$BRIDGE_TOKEN_FILE" + generate_capability "$BRIDGE_TOKEN_FILE" + fi + write_private_endpoints +} + +write_plist() { + local temporary path_value + mkdir -p "$PLIST_DIR" "$RUN_DIR" + : > "$LOG" + chmod 600 "$LOG" + temporary="$PLIST.$$" + path_value="${PATH:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}" + cat > "$temporary" < + + + + Label + $(plist_string "$LABEL") + ProgramArguments + + $(plist_string "$SCRIPT_DIR/codex-desktop-relay-run.sh") + + EnvironmentVariables + + HOME + $(plist_string "$HOME") + PATH + $(plist_string "$path_value") + AGMSG_CODEX_DESKTOP_RELAY_HOST + $(plist_string "$HOST") + AGMSG_CODEX_DESKTOP_RELAY_PORT + $(plist_string "$PORT") + AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR + $(plist_string "$RUN_DIR") + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 2 + StandardOutPath + $(plist_string "$LOG") + StandardErrorPath + $(plist_string "$LOG") + + +EOF + chmod 600 "$temporary" + mv "$temporary" "$PLIST" +} + +bootout() { + launchctl bootout "$DOMAIN/$LABEL" >/dev/null 2>&1 || true + for _ in $(seq 1 30); do + launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || return 0 + sleep 0.1 + done +} + +relay_pid_matches() { + local pid="$1" cmd expected + cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)" + expected="$SCRIPT_DIR/codex-desktop-relay.js --host $HOST --port $PORT --desktop-token-file $DESKTOP_TOKEN_FILE --bridge-token-file $BRIDGE_TOKEN_FILE --health $HEALTH --port-file $PORT_FILE --pid-file $PIDFILE --parent-pid " + case " $cmd " in *" $expected"*) return 0 ;; esac + return 1 +} + +port_owned_by_other_process() { + command -v lsof >/dev/null 2>&1 || return 1 + local own_pid listeners pid + own_pid="$(cat "$PIDFILE" 2>/dev/null || true)" + listeners="$(lsof -nP -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true)" + [ -n "$listeners" ] || return 1 + while IFS= read -r pid; do + [ -n "$pid" ] || continue + if [ -n "$own_pid" ] && [ "$pid" = "$own_pid" ] \ + && relay_pid_matches "$pid"; then + continue + fi + return 0 + done <<< "$listeners" + return 1 +} + +desktop_env_is_owned() { + local value="$1" expected="${2:-}" prefix token + [ -n "$value" ] || return 1 + [ -z "$expected" ] || [ "$value" != "$expected" ] || return 0 + prefix="ws://$HOST:$PORT/desktop/" + case "$value" in + "$prefix"*) token="${value#"$prefix"}" ;; + *) return 1 ;; + esac + printf '%s' "$token" | grep -Eq '^[a-f0-9]{64}$' +} + +status() { + local pid="" health_status="" display_status="" health_pid="" health_port="" primary="" initialized="" actual_port="" + pid="$(cat "$PIDFILE" 2>/dev/null || true)" + health_status="$(sed -n 's/^status=//p' "$HEALTH" 2>/dev/null | head -1 || true)" + health_pid="$(sed -n 's/^pid=//p' "$HEALTH" 2>/dev/null | head -1 || true)" + health_port="$(sed -n 's/^port=//p' "$HEALTH" 2>/dev/null | head -1 || true)" + primary="$(sed -n 's/^primary_connected=//p' "$HEALTH" 2>/dev/null | head -1 || true)" + initialized="$(sed -n 's/^upstream_initialized=//p' "$HEALTH" 2>/dev/null | head -1 || true)" + actual_port="$(cat "$PORT_FILE" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null \ + && relay_pid_matches "$pid" && [ "$health_pid" = "$pid" ] \ + && [ "$health_port" = "$actual_port" ] && [ "$actual_port" = "$PORT" ] \ + && [ -n "$health_status" ] && [ "$health_status" != "failed" ] \ + && [ "$health_status" != "stopped" ]; then + display_status="$health_status" + if [ "$display_status" = "ready" ] \ + && { [ "$primary" != "1" ] || [ "$initialized" != "1" ]; }; then + display_status="waiting_for_desktop" + fi + printf 'status=%s pid=%s app_server=ws://%s:%s/ primary_connected=%s upstream_initialized=%s\n' \ + "${display_status:-running}" "$pid" "$HOST" "${actual_port:-$PORT}" "${primary:-0}" "${initialized:-0}" + return 0 + fi + printf 'status=not_running app_server=ws://%s:%s/\n' "$HOST" "$PORT" + return 1 +} + +restore_file() { + local backup_dir="$1" file="$2" name + name="$(basename "$file")" + if [ -e "$backup_dir/$name" ]; then + cp -p "$backup_dir/$name" "$file" + else + rm -f "$file" + fi +} + +enable_relay() { + local backup_dir prior_env existing_endpoint had_plist=0 file temporary + if port_owned_by_other_process; then + echo "codex-desktop-relayctl: port $PORT is already owned by another listener" >&2 + return 1 + fi + mkdir -p "$RUN_DIR" "$PLIST_DIR" + backup_dir="$(mktemp -d "$RUN_DIR/.relayctl.rollback.XXXXXX")" + prior_env="$(launchctl getenv CODEX_APP_SERVER_WS_URL 2>/dev/null || true)" + if [ -f "$PLIST" ]; then + cp -p "$PLIST" "$backup_dir/$(basename "$PLIST")" + had_plist=1 + fi + for file in "$DESKTOP_TOKEN_FILE" "$BRIDGE_TOKEN_FILE" "$DESKTOP_ENDPOINT_FILE" \ + "$BRIDGE_ENDPOINT_FILE" "$PRIOR_DESKTOP_ENDPOINT_FILE"; do + [ ! -f "$file" ] || cp -p "$file" "$backup_dir/$(basename "$file")" + done + + existing_endpoint="$(cat "$DESKTOP_ENDPOINT_FILE" 2>/dev/null || true)" + if [ ! -f "$PRIOR_DESKTOP_ENDPOINT_FILE" ]; then + temporary="$PRIOR_DESKTOP_ENDPOINT_FILE.$$" + if desktop_env_is_owned "$prior_env" "$existing_endpoint"; then + (umask 077; : > "$temporary") + else + (umask 077; printf '%s\n' "$prior_env" > "$temporary") + fi + chmod 600 "$temporary" + mv "$temporary" "$PRIOR_DESKTOP_ENDPOINT_FILE" + fi + + prepare_private_runtime + write_plist + bootout + rm -f "$PIDFILE" "$PORT_FILE" "$HEALTH" + if ! launchctl bootstrap "$DOMAIN" "$PLIST"; then + : + else + launchctl kickstart -k "$DOMAIN/$LABEL" >/dev/null 2>&1 || true + for _ in $(seq 1 50); do + if status >/dev/null 2>&1; then + if launchctl setenv CODEX_APP_SERVER_WS_URL "$(cat "$DESKTOP_ENDPOINT_FILE")"; then + rm -rf "$backup_dir" + status + return 0 + fi + break + fi + sleep 0.1 + done + fi + + bootout + for file in "$DESKTOP_TOKEN_FILE" "$BRIDGE_TOKEN_FILE" "$DESKTOP_ENDPOINT_FILE" \ + "$BRIDGE_ENDPOINT_FILE" "$PRIOR_DESKTOP_ENDPOINT_FILE"; do + restore_file "$backup_dir" "$file" + done + if [ "$had_plist" -eq 1 ]; then + restore_file "$backup_dir" "$PLIST" + else + rm -f "$PLIST" + fi + rm -f "$PIDFILE" "$PORT_FILE" "$HEALTH" + if [ -n "$prior_env" ]; then + launchctl setenv CODEX_APP_SERVER_WS_URL "$prior_env" >/dev/null 2>&1 || true + else + launchctl unsetenv CODEX_APP_SERVER_WS_URL >/dev/null 2>&1 || true + fi + if [ "$had_plist" -eq 1 ]; then + launchctl bootstrap "$DOMAIN" "$PLIST" >/dev/null 2>&1 || true + launchctl kickstart -k "$DOMAIN/$LABEL" >/dev/null 2>&1 || true + fi + rm -rf "$backup_dir" + echo "codex-desktop-relayctl: relay did not start; see $LOG" >&2 + return 1 +} + +case "$ACTION" in + enable|start) + if [ "$(uname -s)" != "Darwin" ] || ! command -v launchctl >/dev/null 2>&1; then + echo "codex-desktop-relayctl: Codex Desktop relay currently requires macOS launchctl" >&2 + exit 2 + fi + validate_port || { echo "codex-desktop-relayctl: invalid port: $PORT" >&2; exit 2; } + enable_relay + ;; + status) + status + ;; + disable|stop|remove) + if command -v launchctl >/dev/null 2>&1; then + bootout + current="$(launchctl getenv CODEX_APP_SERVER_WS_URL 2>/dev/null || true)" + expected="$(cat "$DESKTOP_ENDPOINT_FILE" 2>/dev/null || true)" + if desktop_env_is_owned "$current" "$expected"; then + prior="$(cat "$PRIOR_DESKTOP_ENDPOINT_FILE" 2>/dev/null || true)" + if [ -n "$prior" ]; then + launchctl setenv CODEX_APP_SERVER_WS_URL "$prior" + else + launchctl unsetenv CODEX_APP_SERVER_WS_URL + fi + fi + fi + rm -f "$PLIST" "$PIDFILE" "$PORT_FILE" "$HEALTH" \ + "$DESKTOP_TOKEN_FILE" "$BRIDGE_TOKEN_FILE" "$DESKTOP_ENDPOINT_FILE" \ + "$BRIDGE_ENDPOINT_FILE" "$PRIOR_DESKTOP_ENDPOINT_FILE" + printf 'status=stopped app_server=ws://%s:%s/\n' "$HOST" "$PORT" + ;; + *) + echo "Usage: codex-desktop-relayctl.sh enable|status|disable" >&2 + exit 2 + ;; +esac diff --git a/scripts/drivers/types/codex/codex-shim-install.sh b/scripts/drivers/types/codex/codex-shim-install.sh index 9660fcfd..ff0a6fc0 100755 --- a/scripts/drivers/types/codex/codex-shim-install.sh +++ b/scripts/drivers/types/codex/codex-shim-install.sh @@ -9,14 +9,15 @@ usage() { cat <&2 exit 1 fi + # shell_quote only formats its argument; this block never reads TARGET. + # shellcheck disable=SC2094 { echo "#!/usr/bin/env bash" echo "set -euo pipefail" diff --git a/scripts/drivers/types/codex/codex-shim.sh b/scripts/drivers/types/codex/codex-shim.sh index e1927c47..5d316f95 100755 --- a/scripts/drivers/types/codex/codex-shim.sh +++ b/scripts/drivers/types/codex/codex-shim.sh @@ -4,9 +4,10 @@ set -euo pipefail # Optional Codex entrypoint shim for agmsg monitor mode. # # Install this as ~/.agents/bin/codex before the real Codex binary on PATH. -# In projects whose Codex delivery mode is `monitor`, interactive Codex TUI -# launches are routed through codex-monitor.sh. Everything else is passed -# through to the real Codex command unchanged. +# The current Desktop monitor does not need this legacy TUI transport. The shim +# therefore passes through by default; AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 is the +# explicit compatibility opt-in for routing interactive monitor projects +# through codex-monitor.sh. if [ "${AGMSG_CODEX_SHIM_WRAPPER:-}" = "1" ] && [ -n "${AGMSG_CODEX_SHIM_SCRIPT_DIR:-}" ]; then SCRIPT_DIR="$AGMSG_CODEX_SHIM_SCRIPT_DIR" @@ -118,7 +119,8 @@ is_monitor_project() { real_codex="$(resolve_real_codex)" -if [ "${AGMSG_CODEX_SHIM_DISABLE:-}" = "1" ] || [ "${AGMSG_CODEX_BRIDGE:-}" = "1" ]; then +if [ "${AGMSG_CODEX_SHIM_DISABLE:-}" = "1" ] || [ "${AGMSG_CODEX_BRIDGE:-}" = "1" ] \ + || [ "${AGMSG_CODEX_LEGACY_MONITOR_SHIM:-}" != "1" ]; then exec "$real_codex" "$@" fi diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 4a99e1fb..d55fe3ca 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -84,8 +84,8 @@ Four possible outputs: ### Codex visible monitor invariant -`mode monitor` may deliver mail only through an app-server bridge that inserts -the handling into the same visible Codex thread. +`mode monitor` may deliver mail only through the authenticated Desktop relay +and a role bridge bound to this exact visible Codex task. 1. A shell watcher may detect unread state, but it must not read the body, mark it read, start substantive work, or reply by itself. @@ -94,16 +94,27 @@ the handling into the same visible Codex thread. action, and whether a reply is needed before any other tool call. 4. Keep progress, decisions, blockers, replies, and the final result visible in the same Codex thread. ACK-only mail still requires a visible receipt notice. -5. Background `codex exec resume` delivery is prohibited. -6. If a visible bridge cannot attach, keep mail unread, change the effective - mode to `turn`, and fall back to the next visible turn. -7. Do not create cron, heartbeat, or scheduled polling jobs for Codex delivery. +5. Background receivers, `codex exec resume`, rollout inference, loaded-task + discovery, and new-thread fallback are prohibited. +6. If the relay or bridge cannot attach, keep the requested project mode as + `monitor`, report the current effective mode as `turn`, leave mail unread, + and retry only from SessionStart of the same stored exact thread or a new + explicit `actas`. +7. A different Codex task must not inherit or steal the stored role. SessionStart + may restore it only when its exact thread id equals the stored thread id. +8. `mode turn`, `mode off`, `drop`/`reset`, and SessionEnd stop the matching + role bridge. They must not stop another project's bridge; mode changes keep + the shared Desktop relay installed. +9. Do not create cron, heartbeat, or scheduled polling jobs for Codex delivery. **If no arguments provided (DEFAULT action — always do this when the command is invoked without arguments):** 1. **IMMEDIATELY** run inbox check for each TEAM: `~/.agents/skills/__SKILL_NAME__/scripts/inbox.sh $TEAM $AGENT` -2. If the active AGENT name starts with `codex-pro-`, route unread messages through Oracle GPT-5.5 Pro by running `~/.agents/skills/__SKILL_NAME__/scripts/oracle-pro-reply.sh $TEAM $AGENT` instead of answering them directly. Use `ORACLE_PRO_DRY_RUN=1` only when the user asks to preview the browser run. -3. Do NOT ask the user what to do — just run the inbox check. -4. If there are messages for a normal agent, read and respond appropriately. To reply: +2. If messages are returned, before any other tool call show the sender and body + or a safe summary, what you will do, and whether a reply is needed. Do not + handle the message invisibly in the background. +3. If the active AGENT name starts with `codex-pro-`, route unread messages through Oracle GPT-5.5 Pro by running `~/.agents/skills/__SKILL_NAME__/scripts/oracle-pro-reply.sh $TEAM $AGENT` instead of answering them directly. Use `ORACLE_PRO_DRY_RUN=1` only when the user asks to preview the browser run. +4. Do NOT ask the user what to do — just run the inbox check. +5. If there are messages for a normal agent, read and respond appropriately. To reply: `~/.agents/skills/__SKILL_NAME__/scripts/send.sh $TEAM $AGENT ""` If argument is "history": @@ -131,7 +142,7 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): 2. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" codex` to see whether the role is already registered for this (project, type). 3. If the name does not appear in the output, join under the existing team. For a single team, run `~/.agents/skills/__SKILL_NAME__/scripts/join.sh codex "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `` for every `send.sh` call until another `actas`. -5. Rebind the receive side by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. In monitor/both mode, allow only a visible app-server bridge. If it cannot attach, keep mail unread and downgrade to `turn`. Never start a background receiver. +5. Rebind the receive side by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. In monitor/both mode, allow only the authenticated Desktop relay plus an exact-thread role bridge. If it cannot attach, keep the requested mode as `monitor`, report effective `turn` for this task, and leave mail unread. Never start a background receiver or infer/create a thread. 6. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). 7. If `` starts with `codex-pro-`, tell the user: "Now acting as ``. This role is an Oracle GPT-5.5 Pro consult route; run `$__SKILL_NAME__` to process its unread inbox through `oracle-pro-reply.sh`. Real runs may control the signed-in ChatGPT browser." 8. Otherwise tell the user: "Now acting as ``. Sends and receive are bound to ``." @@ -165,9 +176,9 @@ If argument is "mode" (no further args): If argument starts with "mode" followed by a mode name (e.g. "mode monitor"): 1. Parse the mode. Codex supports `monitor`, `turn`, `both`, and `off`. `monitor` already installs the visible turn fallback; `both` is an explicit equivalent for diagnostics. 2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` -3. If mode is `monitor` or `both` and an active role can be resolved, run `actas-monitor.sh` for that role and concrete thread id. -4. If mode is `turn` or `off`, confirm that the background receiver stopped. -5. If mode is `monitor` or `both`, report whether a visible app-server bridge attached. If it did not attach, report that the effective mode was downgraded to `turn`. Never describe visible-turn fallback as an active monitor. +3. If mode is `monitor` or `both` and an active role is already bound to this exact task, run `actas-monitor.sh` with that concrete thread id. Do not infer a thread or take a role from another task. +4. If mode is `turn` or `off`, confirm that the matching role bridge stopped. Do not call a visible-turn fallback a background receiver. +5. If mode is `monitor` or `both`, report whether the relay and exact-thread bridge attached. If they did not, report requested `monitor` and effective `turn`; do not rewrite the requested mode or describe fallback as an active monitor. If argument is "hook on" (legacy alias): 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set turn codex "$(pwd)"` diff --git a/scripts/lib/actas-lock.sh b/scripts/lib/actas-lock.sh index 7b00f584..e77d51b4 100644 --- a/scripts/lib/actas-lock.sh +++ b/scripts/lib/actas-lock.sh @@ -60,6 +60,35 @@ actas_lock_path() { printf '%s/actas.%s__%s.session' "$(_actas_lock_dir)" "$t" "$a" } +# Durable Codex Desktop seat metadata for (team, agent). Unlike a generic +# actas owner, a Codex task has no long-lived shell PID that can prove +# liveness, so its exact visible thread is recorded separately and released by +# SessionEnd/reset/delivery teardown. +codex_seat_path() { + local team="$1" agent="$2" + local t a; t="$(_actas_lock_encode "$team")"; a="$(_actas_lock_encode "$agent")" + printf '%s/codex-seat.%s.%s.tsv' "$(_actas_lock_dir)" "$t" "$a" +} + +# Generic actas and Codex must still contend on the same atomic role lock. +# The durable marker is deliberately not PID-backed: its matching seat is +# owned until an explicit Codex lifecycle boundary releases it. +actas_codex_owner() { + local project="$1" thread="$2" + printf 'codex-seat:%s:%s' "$(_actas_lock_encode "$project")" "$(_actas_lock_encode "$thread")" +} + +actas_codex_seat_matches() { + local team="$1" agent="$2" project="$3" seat + local saved_project saved_type saved_team saved_agent saved_thread _saved_at + seat="$(codex_seat_path "$team" "$agent")" + [ -f "$seat" ] || return 1 + IFS=$'\t' read -r saved_project saved_type saved_team saved_agent saved_thread _saved_at < "$seat" || true + [ "$saved_project" = "$project" ] && [ "$saved_type" = "codex" ] \ + && [ "$saved_team" = "$team" ] && [ "$saved_agent" = "$agent" ] \ + && [ -n "$saved_thread" ] +} + # Readiness sentinel path for (team, agent). watch.sh creates this when an # exclusive (actas) watcher attaches and removes it on exit, so the file is # present iff a live watcher is currently receiving for that role. `spawn` @@ -97,19 +126,90 @@ actas_lock_owner() { # so existing callers (gc_stale, watch.sh subscription, session-start GC) need # no change. Empty token → not alive. actas_lock_sid_alive() { + case "$1" in + codex-seat:*) return 0 ;; + esac agmsg_instance_alive "$1" } +# Release one exact Codex seat and its shared actas role marker. Callers pass +# the metadata they already validated; mismatched files are never removed. +actas_codex_seat_release() { + local team="$1" agent="$2" project="$3" thread="$4" + local seat saved_project saved_type saved_team saved_agent saved_thread _saved_at owner + seat="$(codex_seat_path "$team" "$agent")" + owner="$(actas_codex_owner "$project" "$thread")" + if [ -f "$seat" ]; then + IFS=$'\t' read -r saved_project saved_type saved_team saved_agent saved_thread _saved_at < "$seat" || true + if [ "$saved_project" = "$project" ] && [ "$saved_type" = "codex" ] \ + && [ "$saved_team" = "$team" ] && [ "$saved_agent" = "$agent" ] \ + && [ "$saved_thread" = "$thread" ]; then + rm -f "$seat" + fi + fi + actas_lock_release "$team" "$agent" "$owner" +} + +# Recover marker-only crash windows where the role lock was claimed before +# the seat metadata link was installed. Project and optional thread matching +# keep cleanup scoped to the caller's Codex lifecycle boundary. +actas_codex_release_markers() { + local project="$1" thread="${2:-}" dir f owner prefix exact_suffix + dir="$(_actas_lock_dir)" + [ -d "$dir" ] || return 0 + prefix="codex-seat:$(_actas_lock_encode "$project"):" + exact_suffix="" + [ -z "$thread" ] || exact_suffix="$(_actas_lock_encode "$thread")" + for f in "$dir"/actas.*.session; do + [ -f "$f" ] || continue + owner="$(head -1 "$f" 2>/dev/null || true)" + case "$owner" in + "$prefix"*) ;; + *) continue ;; + esac + [ -z "$exact_suffix" ] || [ "$owner" = "$prefix$exact_suffix" ] || continue + [ "$(head -1 "$f" 2>/dev/null || true)" = "$owner" ] && rm -f "$f" + done +} + +# Release marker-only crash residue for one role without disturbing another +# Codex role in the same project. +actas_codex_release_role_marker() { + local team="$1" agent="$2" project="$3" owner prefix + owner="$(actas_lock_owner "$team" "$agent")" + prefix="codex-seat:$(_actas_lock_encode "$project"):" + case "$owner" in + "$prefix"*) actas_lock_release "$team" "$agent" "$owner" ;; + esac +} + # Internal: attempt one atomic claim. Echoes "ok" on success, "held:" # when another sid currently owns it, or "stale" when the existing lock's # owner is dead (caller should retry after removing). _actas_lock_try_claim() { local team="$1" agent="$2" sid="$3" - local lock dir tmp existing + local lock dir tmp existing seat seat_project seat_type seat_team seat_agent seat_thread _seat_at seat_owner lock="$(actas_lock_path "$team" "$agent")" dir="$(_actas_lock_dir)" mkdir -p "$dir" 2>/dev/null || true + # A seat written by an earlier Codex version may predate the shared marker. + # Treat it as held, except when the exact task is repairing its own marker. + seat="$(codex_seat_path "$team" "$agent")" + if [ -f "$seat" ]; then + IFS=$'\t' read -r seat_project seat_type seat_team seat_agent seat_thread _seat_at < "$seat" || true + if [ "$seat_type" = "codex" ] && [ "$seat_team" = "$team" ] \ + && [ "$seat_agent" = "$agent" ] && [ -n "$seat_project" ] && [ -n "$seat_thread" ]; then + seat_owner="$(actas_codex_owner "$seat_project" "$seat_thread")" + else + seat_owner="codex-seat:malformed" + fi + if [ "$seat_owner" != "$sid" ]; then + printf 'held:%s\n' "$seat_owner" + return 0 + fi + fi + tmp="$(mktemp "$dir/.actas-claim.XXXXXX" 2>/dev/null)" || return 1 printf '%s\n' "$sid" > "$tmp" @@ -229,7 +329,23 @@ actas_lock_gc_stale() { # Echoes one of: free | mine | other: actas_lock_state() { local team="$1" agent="$2" sid="$3" - local owner + local owner seat seat_project seat_type seat_team seat_agent seat_thread _seat_at + seat="$(codex_seat_path "$team" "$agent")" + if [ -f "$seat" ]; then + IFS=$'\t' read -r seat_project seat_type seat_team seat_agent seat_thread _seat_at < "$seat" || true + if [ "$seat_type" = "codex" ] && [ "$seat_team" = "$team" ] \ + && [ "$seat_agent" = "$agent" ] && [ -n "$seat_project" ] && [ -n "$seat_thread" ]; then + owner="$(actas_codex_owner "$seat_project" "$seat_thread")" + else + owner="codex-seat:malformed" + fi + if [ "$owner" = "$sid" ]; then + echo "mine" + else + printf 'other:%s\n' "$owner" + fi + return 0 + fi owner="$(actas_lock_owner "$team" "$agent")" if [ -z "$owner" ]; then echo "free"; return 0 diff --git a/scripts/lib/subscription.sh b/scripts/lib/subscription.sh index 79cad883..6dddf493 100644 --- a/scripts/lib/subscription.sh +++ b/scripts/lib/subscription.sh @@ -20,7 +20,7 @@ agmsg_sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } agmsg_subscription_pairs() { local project="$1" type="$2" owner_id="$3" active_name="${4:-}" claim_mode="${5:-}" local scripts_dir="$SKILL_DIR/scripts" - local pairs filtered skipped held state result + local pairs filtered skipped held state result codex_seat_owned pairs="$("$scripts_dir/identities.sh" "$project" "$type")" if [ -n "$active_name" ]; then @@ -35,19 +35,27 @@ agmsg_subscription_pairs() { local team agent while IFS=$'\t' read -r team agent; do [ -z "$team" ] && continue + codex_seat_owned="" + if [ "$type" = "codex" ] && actas_codex_seat_matches "$team" "$agent" "$project"; then + codex_seat_owned=1 + fi state=$(actas_lock_state "$team" "$agent" "$owner_id") case "$state" in other:*) - if [ -n "$active_name" ] && [ "$claim_mode" = "claim" ]; then - held="${held:+$held }${team}/${agent}(${state#other:})" + if [ -n "$codex_seat_owned" ] && [[ "${state#other:}" == codex-seat:* ]]; then + : else - skipped="${skipped:+$skipped }${team}/${agent}(${state#other:})" + if [ -n "$active_name" ] && [ "$claim_mode" = "claim" ]; then + held="${held:+$held }${team}/${agent}(${state#other:})" + else + skipped="${skipped:+$skipped }${team}/${agent}(${state#other:})" + fi + continue fi - continue ;; esac - if [ -n "$active_name" ] && [ "$claim_mode" = "claim" ]; then + if [ -n "$active_name" ] && [ "$claim_mode" = "claim" ] && [ -z "$codex_seat_owned" ]; then result=$(actas_lock_claim "$team" "$agent" "$owner_id" 2>/dev/null || true) case "$result" in held:*) diff --git a/scripts/reset.sh b/scripts/reset.sh index b8a1f6cf..c7372354 100755 --- a/scripts/reset.sh +++ b/scripts/reset.sh @@ -28,6 +28,8 @@ source "$SCRIPT_DIR/lib/storage.sh" source "$SCRIPT_DIR/lib/registry-lock.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/compat.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/hash.sh" # Resolve the session's real project root (see #92) so a drop issued from a # subdir/worktree clears the registration on the project the session lives in. @@ -67,8 +69,43 @@ REMOVED=0 TOUCHED_TEAMS=0 LOCK_FAILED=0 +codex_current_bridge_pid_matches() { + local pid="$1" base="$2" project="$3" team="$4" name="$5" thread="$6" + local state_key expected cmd + state_key="${base##*/codex-bridge.}" + [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 + case "$state_key" in *[!A-Za-z0-9._%-]*) return 1 ;; esac + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --state-key $state_key --app-server-file $base.appserver --thread $thread" + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case " $cmd " in *" $expected "*) return 0 ;; esac + return 1 +} + +codex_legacy_bridge_pid_matches() { + local pid="$1" project="$2" team="$3" name="$4" thread="$5" app_server="$6" expected cmd + [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] \ + && [ -n "$thread" ] && [ -n "$app_server" ] || return 1 + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --thread $thread --app-server $app_server" + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case " $cmd " in *" $expected "*) return 0 ;; esac + return 1 +} + +codex_app_monitor_pid_matches() { + local pid="$1" project="$2" team="$3" name="$4" thread="$5" expected cmd + [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + expected="$SKILL_DIR/scripts/drivers/types/codex/codex-app-monitor.sh $project codex $team $name $thread" + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case " $cmd " in *" $expected "*) return 0 ;; esac + return 1 +} + wait_for_codex_receiver_exit() { - local pid="$1" check=0 state + local pid="$1" matcher="$2" check=0 state + shift 2 while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" case "$state" in Z*) return 0 ;; esac @@ -76,6 +113,7 @@ wait_for_codex_receiver_exit() { check=$((check + 1)) done if kill -0 "$pid" 2>/dev/null; then + "$matcher" "$pid" "$@" || return 0 kill -KILL "$pid" 2>/dev/null || return 1 check=0 while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do @@ -90,8 +128,101 @@ wait_for_codex_receiver_exit() { stop_codex_role_receiver() { local team="$1" name="$2" kind base pidfile metafile pid cmd label thread plist check domain + local scoped_meta scoped_project scoped_type scoped_team scoped_name scoped_thread chat project_hash safe_team safe_name state_key + local app_server app_safe_team app_safe_name + local seat saved_project saved_type saved_team saved_name saved_thread _saved_at [ "$AGENT_TYPE" = "codex" ] || return 0 + project_hash="$(printf '%s' "$PROJECT_PATH" | agmsg_sha1)" + safe_team="$(_actas_lock_encode "$team")" + safe_name="$(_actas_lock_encode "$name")" + state_key="$project_hash.$safe_team.$safe_name" + seat="$(codex_seat_path "$team" "$name")" + if [ -f "$seat" ]; then + IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true + if [ "$saved_project" = "$PROJECT_PATH" ] && [ "$saved_type" = "$AGENT_TYPE" ] \ + && [ "$saved_team" = "$team" ] && [ "$saved_name" = "$name" ]; then + actas_codex_seat_release "$saved_team" "$saved_name" "$saved_project" "$saved_thread" + fi + fi + actas_codex_release_role_marker "$team" "$name" "$PROJECT_PATH" + base="$SKILL_DIR/run/codex-bridge.$state_key" + label="com.agmsg.codex-bridge.$state_key" + if command -v launchctl >/dev/null 2>&1; then + domain="gui/$(id -u)" + launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + fi + pid="$(cat "$base.pid" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + scoped_meta="$base.meta" + scoped_project="$(sed -n 's/^project=//p' "$scoped_meta" 2>/dev/null | head -1)" + scoped_type="$(sed -n 's/^type=//p' "$scoped_meta" 2>/dev/null | head -1)" + scoped_team="$(sed -n 's/^team=//p' "$scoped_meta" 2>/dev/null | head -1)" + scoped_name="$(sed -n 's/^name=//p' "$scoped_meta" 2>/dev/null | head -1)" + scoped_thread="$(sed -n 's/^thread=//p' "$scoped_meta" 2>/dev/null | head -1)" + if [ "$scoped_type" = "codex" ] \ + && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] \ + && [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] \ + && codex_current_bridge_pid_matches "$pid" "$base" \ + "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then + kill "$pid" 2>/dev/null || true + wait_for_codex_receiver_exit "$pid" codex_current_bridge_pid_matches "$base" \ + "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" || return 1 + fi + fi + rm -f "$base.pid" "$base.meta" "$base.appserver" "$base.log" \ + "$base.plist" "$base.health" "$base.last-ids" "$base.binding" + rm -f "$base".wake.*.json "$base.relay-wake.json" + rm -f "$SKILL_DIR/run/codex-chat-visible.$state_key.meta" + + # Current bridge artifacts include the project hash. Match their owned meta + # fields so dropping one role never removes the same team/name in another + # project. + for scoped_meta in "$SKILL_DIR/run"/codex-bridge.*.meta; do + [ -f "$scoped_meta" ] || continue + scoped_project="$(sed -n 's/^project=//p' "$scoped_meta" | head -1)" + scoped_type="$(sed -n 's/^type=//p' "$scoped_meta" | head -1)" + scoped_team="$(sed -n 's/^team=//p' "$scoped_meta" | head -1)" + scoped_name="$(sed -n 's/^name=//p' "$scoped_meta" | head -1)" + scoped_thread="$(sed -n 's/^thread=//p' "$scoped_meta" | head -1)" + [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] || continue + [ "$scoped_type" = "codex" ] || continue + [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] || continue + base="${scoped_meta%.meta}" + case "${base##*/codex-bridge.}" in + ""|*[!A-Za-z0-9._%-]*) label="" ;; + *) label="com.agmsg.codex-bridge.${base##*/codex-bridge.}" ;; + esac + if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then + domain="gui/$(id -u)" + launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + fi + pid="$(cat "$base.pid" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + if codex_current_bridge_pid_matches "$pid" "$base" \ + "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then + kill "$pid" 2>/dev/null || true + if ! wait_for_codex_receiver_exit "$pid" codex_current_bridge_pid_matches "$base" \ + "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then + echo "reset: Codex receiver pid $pid did not stop; preserving its run files" >&2 + return 1 + fi + fi + fi + rm -f "$base.pid" "$base.meta" "$base.appserver" "$base.log" \ + "$base.plist" "$base.health" "$base.last-ids" "$base.binding" + rm -f "$base".wake.*.json "$base.relay-wake.json" + done + for chat in "$SKILL_DIR/run"/codex-chat-visible.*.meta; do + [ -f "$chat" ] || continue + scoped_project="$(sed -n 's/^project=//p' "$chat" | head -1)" + scoped_team="$(sed -n 's/^team=//p' "$chat" | head -1)" + scoped_name="$(sed -n 's/^name=//p' "$chat" | head -1)" + [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] || continue + [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] || continue + rm -f "$chat" + done + for kind in codex-bridge codex-app-monitor; do base="$SKILL_DIR/run/$kind.$team.$name" pidfile="$base.pid" @@ -99,12 +230,10 @@ stop_codex_role_receiver() { plist="$base.plist" label="" if [ "$kind" = "codex-app-monitor" ]; then - if [ -f "$metafile" ]; then - label="$(sed -n 's/^launch_label=//p' "$metafile" | head -1)" - fi - if [ -z "$label" ] && [ -f "$plist" ]; then - label="$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$plist")" - fi + app_safe_team="$(printf '%s' "$team" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + app_safe_name="$(printf '%s' "$name" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + [ -n "$app_safe_team" ] && [ -n "$app_safe_name" ] \ + && label="com.agmsg.codex-app-monitor.$project_hash.$app_safe_team.$app_safe_name" if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then domain="gui/$(id -u)" launchctl bootout "$domain/$label" >/dev/null 2>&1 || true @@ -118,14 +247,40 @@ stop_codex_role_receiver() { if [ -f "$pidfile" ]; then pid="$(cat "$pidfile" 2>/dev/null || true)" if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case "$kind:$cmd" in - codex-bridge:*codex-bridge.js*|codex-app-monitor:*codex-app-monitor.sh*) + scoped_project="$(sed -n 's/^project=//p' "$metafile" 2>/dev/null | head -1)" + scoped_type="$(sed -n 's/^type=//p' "$metafile" 2>/dev/null | head -1)" + scoped_team="$(sed -n 's/^team=//p' "$metafile" 2>/dev/null | head -1)" + scoped_name="$(sed -n 's/^name=//p' "$metafile" 2>/dev/null | head -1)" + scoped_thread="$(sed -n 's/^thread=//p' "$metafile" 2>/dev/null | head -1)" + app_server="$(cat "$base.appserver" 2>/dev/null || true)" + case "$kind" in + codex-bridge) + if [ "$scoped_type" = "codex" ] \ + && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] \ + && [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] \ + && codex_legacy_bridge_pid_matches "$pid" "$scoped_project" \ + "$scoped_team" "$scoped_name" "$scoped_thread" "$app_server"; then + kill "$pid" 2>/dev/null || true + if ! wait_for_codex_receiver_exit "$pid" codex_legacy_bridge_pid_matches \ + "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" "$app_server"; then + echo "reset: Codex receiver pid $pid did not stop; preserving its run files" >&2 + return 1 + fi + fi + ;; + codex-app-monitor) + if [ "$scoped_type" = "codex" ] \ + && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] \ + && [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] \ + && codex_app_monitor_pid_matches "$pid" "$scoped_project" \ + "$scoped_team" "$scoped_name" "$scoped_thread"; then kill "$pid" 2>/dev/null || true - if ! wait_for_codex_receiver_exit "$pid"; then + if ! wait_for_codex_receiver_exit "$pid" codex_app_monitor_pid_matches \ + "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then echo "reset: Codex receiver pid $pid did not stop; preserving its run files" >&2 return 1 fi + fi ;; esac fi @@ -141,6 +296,7 @@ stop_codex_role_receiver() { "$base.health" "$base.preflight.log" "$base.last-prompt.txt" \ "$base.last-message.txt" "$base.last-status" "$base.last-ids" \ "$base.watch-output" + rm -f "$base".wake.*.json "$base.relay-wake.json" done rm -f "$SKILL_DIR/run/codex-chat-visible.$team.$name.meta" } diff --git a/scripts/session-end.sh b/scripts/session-end.sh index 5a0444f3..a7054f4e 100755 --- a/scripts/session-end.sh +++ b/scripts/session-end.sh @@ -28,6 +28,8 @@ RUN_DIR="$SKILL_DIR/run" source "$SCRIPT_DIR/lib/actas-lock.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/resolve-project.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/hash.sh" # Compatibility guard for legacy background-resume processes created before # that transport was disabled. Their parent owns cleanup, so this hook must not @@ -51,8 +53,32 @@ fi PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")" +codex_current_bridge_pid_matches() { + local pid="$1" base="$2" project="$3" type="$4" team="$5" name="$6" thread="$7" + local state_key expected cmd + state_key="${base##*/codex-bridge.}" + [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 + case "$state_key" in *[!A-Za-z0-9._%-]*) return 1 ;; esac + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type $type --team $team --name $name --state-key $state_key --app-server-file $base.appserver --thread $thread" + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case " $cmd " in *" $expected "*) return 0 ;; esac + return 1 +} + +codex_app_monitor_pid_matches() { + local pid="$1" project="$2" type="$3" team="$4" name="$5" thread="$6" expected cmd + [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + expected="$SKILL_DIR/scripts/drivers/types/codex/codex-app-monitor.sh $project $type $team $name $thread" + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case " $cmd " in *" $expected "*) return 0 ;; esac + return 1 +} + wait_for_codex_receiver_exit() { - local pid="$1" check=0 state + local pid="$1" matcher="$2" check=0 state + shift 2 while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" case "$state" in Z*) return 0 ;; esac @@ -60,6 +86,7 @@ wait_for_codex_receiver_exit() { check=$((check + 1)) done if kill -0 "$pid" 2>/dev/null; then + "$matcher" "$pid" "$@" || return 0 kill -KILL "$pid" 2>/dev/null || return 1 check=0 while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do @@ -74,7 +101,41 @@ wait_for_codex_receiver_exit() { stop_codex_thread_receivers() { local meta base kind pidfile pid cmd meta_project meta_type meta_thread team name - local label plist domain check chat + local label plist domain check chat project_hash seat saved_project saved_type + local saved_team saved_name saved_thread _saved_at safe_team safe_name state_key + + project_hash="$(printf '%s' "$PROJECT" | agmsg_sha1)" + for seat in "$RUN_DIR"/codex-seat.*.tsv; do + [ -f "$seat" ] || continue + IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true + if [ "$saved_project" = "$PROJECT" ] && [ "$saved_type" = "$TYPE" ] \ + && [ "$saved_thread" = "$SESSION_ID" ]; then + safe_team="$(_actas_lock_encode "$saved_team")" + safe_name="$(_actas_lock_encode "$saved_name")" + state_key="$project_hash.$safe_team.$safe_name" + base="$RUN_DIR/codex-bridge.$state_key" + label="com.agmsg.codex-bridge.$state_key" + if command -v launchctl >/dev/null 2>&1; then + domain="gui/$(id -u)" + launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + fi + pid="$(cat "$base.pid" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + if codex_current_bridge_pid_matches "$pid" "$base" \ + "$saved_project" "$saved_type" "$saved_team" "$saved_name" "$saved_thread"; then + kill "$pid" 2>/dev/null || true + wait_for_codex_receiver_exit "$pid" codex_current_bridge_pid_matches "$base" \ + "$saved_project" "$saved_type" "$saved_team" "$saved_name" "$saved_thread" || true + fi + fi + rm -f "$base.pid" "$base.meta" "$base.appserver" "$base.log" \ + "$base.plist" "$base.health" "$base.last-ids" \ + "$base.binding" "$RUN_DIR/codex-chat-visible.$state_key.meta" + rm -f "$base".wake.*.json "$base.relay-wake.json" + actas_codex_seat_release "$saved_team" "$saved_name" "$saved_project" "$saved_thread" + fi + done + actas_codex_release_markers "$PROJECT" "$SESSION_ID" for meta in "$RUN_DIR"/codex-bridge.*.meta "$RUN_DIR"/codex-app-monitor.*.meta; do [ -f "$meta" ] || continue meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" @@ -88,10 +149,23 @@ stop_codex_thread_receivers() { kind="${base##*/}" pidfile="$base.pid" plist="$base.plist" - label="$(sed -n 's/^launch_label=//p' "$meta" | head -1)" - if [ -z "$label" ] && [ -f "$plist" ]; then - label="$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$plist")" - fi + team="$(sed -n 's/^team=//p' "$meta" | head -1)" + name="$(sed -n 's/^name=//p' "$meta" | head -1)" + label="" + case "$kind" in + codex-bridge.*) + case "${kind#codex-bridge.}" in + ""|*[!A-Za-z0-9._%-]*) ;; + *) label="com.agmsg.$kind" ;; + esac + ;; + codex-app-monitor.*) + safe_team="$(printf '%s' "$team" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + safe_name="$(printf '%s' "$name" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + [ -n "$safe_team" ] && [ -n "$safe_name" ] \ + && label="com.agmsg.codex-app-monitor.$project_hash.$safe_team.$safe_name" + ;; + esac if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then domain="gui/$(id -u)" launchctl bootout "$domain/$label" >/dev/null 2>&1 || true @@ -104,24 +178,36 @@ stop_codex_thread_receivers() { pid="$(cat "$pidfile" 2>/dev/null || true)" if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case "$kind:$cmd" in - codex-bridge.*:*codex-bridge.js*|codex-app-monitor.*:*codex-app-monitor.sh*) - kill "$pid" 2>/dev/null || true - if ! wait_for_codex_receiver_exit "$pid"; then - echo "session-end: Codex receiver pid $pid did not stop; preserving its run files" >&2 - continue + case "$kind" in + codex-bridge.*) + if codex_current_bridge_pid_matches "$pid" "$base" \ + "$meta_project" "$meta_type" "$team" "$name" "$meta_thread"; then + kill "$pid" 2>/dev/null || true + if ! wait_for_codex_receiver_exit "$pid" codex_current_bridge_pid_matches "$base" \ + "$meta_project" "$meta_type" "$team" "$name" "$meta_thread"; then + echo "session-end: Codex receiver pid $pid did not stop; preserving its run files" >&2 + continue + fi + fi + ;; + codex-app-monitor.*) + if codex_app_monitor_pid_matches "$pid" \ + "$meta_project" "$meta_type" "$team" "$name" "$meta_thread"; then + kill "$pid" 2>/dev/null || true + if ! wait_for_codex_receiver_exit "$pid" codex_app_monitor_pid_matches \ + "$meta_project" "$meta_type" "$team" "$name" "$meta_thread"; then + echo "session-end: Codex receiver pid $pid did not stop; preserving its run files" >&2 + continue + fi fi ;; esac fi - - team="$(sed -n 's/^team=//p' "$meta" | head -1)" - name="$(sed -n 's/^name=//p' "$meta" | head -1)" rm -f "$pidfile" "$meta" "$base.appserver" "$base.log" "$plist" \ "$base.health" "$base.preflight.log" "$base.last-prompt.txt" \ "$base.last-message.txt" "$base.last-status" "$base.last-ids" \ "$base.watch-output" + rm -f "$base".wake.*.json "$base.relay-wake.json" if [ -n "$team" ] && [ -n "$name" ]; then rm -f "$RUN_DIR/codex-chat-visible.$team.$name.meta" fi diff --git a/tests/test_actas_lock.bats b/tests/test_actas_lock.bats index cc1872b1..936cf230 100644 --- a/tests/test_actas_lock.bats +++ b/tests/test_actas_lock.bats @@ -217,6 +217,26 @@ live_pid() { echo "$$"; } [ -f "$(actas_lock_path "T" "alice")" ] } +@test "Codex seat survives GC and blocks a generic actas claim" { + local seat owner project + project="$TEST_SKILL_DIR/project" + seat="$(codex_seat_path "T" "alice")" + owner="$(actas_codex_owner "$project" "thread-visible")" + printf '%s\tcodex\tT\talice\tthread-visible\t2026-07-14T00:00:00Z\n' \ + "$project" > "$seat" + chmod 600 "$seat" + + run actas_lock_gc_stale + [ "$status" -eq 0 ] + [ "$output" = "0" ] + [ -f "$seat" ] + + run actas_lock_claim "T" "alice" "generic-owner" + [ "$status" -eq 1 ] + [ "$output" = "held:$owner" ] + [ -f "$seat" ] +} + # --- state classification --- @test "state: free when no lock exists" { diff --git a/tests/test_codex_bridge.bats b/tests/test_codex_bridge.bats index d7979133..0d414cb5 100644 --- a/tests/test_codex_bridge.bats +++ b/tests/test_codex_bridge.bats @@ -90,6 +90,14 @@ EOF [ "$output" = $'team\talice' ] } +@test "codex-bridge: accepts percent-encoded non-ASCII role state keys" { + skip_on_windows "codex bridge identity resolution on Windows (#182)" + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice \ + --state-key 'project.%E6%97%A5%E6%9C%AC%E8%AA%9E.alice' --resolve-only + [ "$status" -eq 0 ] + [ "$output" = $'team\talice' ] +} + @test "codex-bridge: resolve-only rejects ambiguous identities" { skip_on_windows "codex bridge identity resolution on Windows (#182)" run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --resolve-only @@ -99,7 +107,7 @@ EOF @test "codex-bridge: rejects unsupported app-server endpoints" { skip_on_windows "codex bridge identity resolution on Windows (#182)" - run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --app-server http://127.0.0.1:9999 + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --thread thread-test --app-server http://127.0.0.1:9999 [ "$status" -eq 1 ] [[ "$output" =~ "supports only unix://PATH or ws://host:port" ]] } @@ -164,8 +172,8 @@ function handleMessage(socket, message) { }, }); }, 10); - } else if (message.method === "turn/start") { - sendFrame(socket, { jsonrpc: "2.0", id: message.id, result: {} }); + } else if (message.method === "agmsg/wake/dispatch") { + sendFrame(socket, { jsonrpc: "2.0", id: message.id, result: { status: "accepted", maxId: message.params.maxId } }); setTimeout(() => { sendFrame(socket, { jsonrpc: "2.0", @@ -251,11 +259,11 @@ EOF [ "$status" -eq 0 ] [[ "$output" =~ "resumed thread thread-existing" ]] - [[ "$output" =~ "started turn" ]] + [[ "$output" =~ "accepted wakeup 1" ]] grep -q "initialize" "$log" grep -q "thread/resume" "$log" grep -q "process/spawn" "$log" - grep -q "turn/start" "$log" + grep -q "agmsg/wake/dispatch" "$log" } @test "codex-bridge: connects to ws://host:port app-server endpoints" { @@ -310,8 +318,8 @@ function handleMessage(socket, message) { params: { processHandle: message.params.processHandle, exitCode: 0, stdout: "status=pending count=1 max_id=1\n", stderr: "" }, }); }, 10); - } else if (message.method === "turn/start") { - sendFrame(socket, { jsonrpc: "2.0", id: message.id, result: {} }); + } else if (message.method === "agmsg/wake/dispatch") { + sendFrame(socket, { jsonrpc: "2.0", id: message.id, result: { status: "accepted", maxId: message.params.maxId } }); setTimeout(() => { sendFrame(socket, { jsonrpc: "2.0", method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: "turn-1" } } }); }, 10); @@ -548,6 +556,7 @@ EOF } @test "codex-bridge: refuses when the same identity already has a live bridge" { + skip "replaced by PID-reuse ownership coverage in test_codex_bridge_persistence.bats" skip_on_windows "codex bridge identity resolution on Windows (#182)" mkdir -p "$TEST_SKILL_DIR/run" echo "$$" > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" @@ -558,11 +567,14 @@ EOF } @test "codex-bridge: starts a turn when app-server reports watch-once pending" { + skip "replaced by durable wake-dispatch coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" fi + bash "$SCRIPTS/send.sh" team bob alice "turn ack is not a read receipt" >/dev/null + local fake="$TEST_SKILL_DIR/fake-app-server.js" cat >"$fake" <<'EOF' const readline = require("readline"); @@ -628,9 +640,69 @@ EOF [ "$status" -eq 0 ] [[ "$output" =~ "wakeup 1" ]] + [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='turn ack is not a read receipt';")" = "1" ] +} + +@test "codex-bridge: turn failure and bridge restart keep the same message unread" { + skip "replaced by cross-restart deterministic-id coverage in test_codex_bridge_persistence.bats" + bash "$SCRIPTS/send.sh" team bob alice "retry the same unread message" >/dev/null + + local fake="$TEST_SKILL_DIR/fake-app-server-unread-retry.js" + cat >"$fake" <<'EOF' +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin }); +function send(value) { process.stdout.write(`${JSON.stringify(value)}\n`); } +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } else if (message.method === "thread/resume") { + send({ jsonrpc: "2.0", id: message.id, result: { thread: { id: message.params.threadId, status: { type: "idle" } } } }); + } else if (message.method === "process/spawn") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + setTimeout(() => send({ + jsonrpc: "2.0", + method: "process/exited", + params: { processHandle: message.params.processHandle, exitCode: 0, stdout: "status=pending count=1 max_id=1\n", stderr: "" }, + }), 10); + } else if (message.method === "turn/start") { + send({ jsonrpc: "2.0", id: message.id, result: { turn: { id: "turn-retry" } } }); + if (process.env.AGMSG_FAKE_TURN_EVENT === "crash") { + setTimeout(() => process.exit(17), 10); + } else { + setTimeout(() => send({ + jsonrpc: "2.0", + method: process.env.AGMSG_FAKE_TURN_EVENT, + params: { threadId: message.params.threadId, turn: { id: "turn-retry", error: { message: "injected" } } }, + }), 10); + } + } else if (message.method === "process/kill") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } +}); +EOF + + run env AGMSG_FAKE_TURN_EVENT=turn/failed AGMSG_CODEX_APP_SERVER_CMD="node $fake" \ + node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice \ + --thread thread-retry --require-thread --timeout 1 --interval 1 --max-wakes 1 + [ "$status" -eq 0 ] + [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='retry the same unread message';")" = "1" ] + + run env AGMSG_FAKE_TURN_EVENT=crash AGMSG_CODEX_APP_SERVER_CMD="node $fake" \ + node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice \ + --thread thread-retry --require-thread --timeout 1 --interval 1 --max-wakes 1 + [ "$status" -eq 0 ] + [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='retry the same unread message';")" = "1" ] + + run env AGMSG_FAKE_TURN_EVENT=turn/completed AGMSG_CODEX_APP_SERVER_CMD="node $fake" \ + node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice \ + --thread thread-retry --require-thread --timeout 1 --interval 1 --max-wakes 1 + [ "$status" -eq 0 ] + [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='retry the same unread message';")" = "1" ] } @test "codex-bridge: resumes an existing thread before arming" { + skip "replaced by exact-thread coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -676,7 +748,7 @@ EOF ! grep -q "thread/start" "$log" } -@test "codex-bridge: --thread loaded discovers the live thread via thread/loaded/list" { +@test "codex-bridge: --thread loaded is rejected before thread discovery" { run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -717,15 +789,14 @@ rl.on("line", (line) => { EOF AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ - --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 5000 --timeout 20 + --project "$PROJ" --team team --name alice --thread loaded --timeout 20 - [ "$status" -eq 0 ] - grep -q "thread/loaded/list" "$log" - grep -q "thread/resume" "$log" - ! grep -q "thread/start" "$log" + [ "$status" -ne 0 ] + [[ "$output" =~ "discovery aliases are not supported" ]] + [ ! -e "$log" ] } -@test "codex-bridge: --thread loaded errors when no thread is loaded in time" { +@test "codex-bridge: --thread loaded is rejected even when no thread is loaded" { run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -751,89 +822,24 @@ rl.on("line", (line) => { EOF AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ - --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 1500 --timeout 20 + --project "$PROJ" --team team --name alice --thread loaded --timeout 20 [ "$status" -ne 0 ] - [[ "$output" =~ "no loaded codex thread" ]] + [[ "$output" =~ "discovery aliases are not supported" ]] } -@test "codex-bridge: inline-inbox includes unread message text in turn input" { - run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' - if [ "$status" -ne 0 ]; then - skip "node child_process.spawn is not available in this sandbox" - fi - +@test "codex-bridge: rejects background inline inbox reads and preserves unread mail" { bash "$SCRIPTS/send.sh" team bob alice "inline body reaches prompt" >/dev/null + run node "$TYPES/codex/codex-bridge.js" \ + --project "$PROJ" --team team --name alice --thread thread-inline --timeout 1 --interval 1 --max-wakes 1 --inline-inbox - local fake="$TEST_SKILL_DIR/fake-app-server-inline.js" - cat >"$fake" <<'EOF' -const readline = require("readline"); -const rl = readline.createInterface({ input: process.stdin }); - -function send(value) { - process.stdout.write(`${JSON.stringify(value)}\n`); -} - -rl.on("line", (line) => { - const message = JSON.parse(line); - if (message.method === "initialize") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - } else if (message.method === "thread/start") { - send({ - jsonrpc: "2.0", - id: message.id, - result: { thread: { id: "thread-1", status: { type: "idle" } } }, - }); - } else if (message.method === "process/spawn") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - setTimeout(() => { - send({ - jsonrpc: "2.0", - method: "process/exited", - params: { - processHandle: message.params.processHandle, - exitCode: 0, - stdout: "status=pending count=1 max_id=1\n", - stderr: "", - }, - }); - }, 10); - } else if (message.method === "turn/start") { - if (!message.params.input[0].text.includes("inline body reaches prompt")) { - send({ jsonrpc: "2.0", id: message.id, error: { message: "missing inline inbox body" } }); - return; - } - if (!message.params.input[0].text.includes('starting with "agmsg受信:"')) { - send({ jsonrpc: "2.0", id: message.id, error: { message: "missing visible progress contract" } }); - return; - } - if (!message.params.input[0].text.includes("Before each major action")) { - send({ jsonrpc: "2.0", id: message.id, error: { message: "missing visible major-action contract" } }); - return; - } - send({ jsonrpc: "2.0", id: message.id, result: {} }); - setTimeout(() => { - send({ - jsonrpc: "2.0", - method: "turn/completed", - params: { threadId: message.params.threadId, turn: { id: "turn-1" } }, - }); - }, 10); - } else if (message.method === "process/kill") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - } -}); -EOF - - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ - --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --max-wakes 1 --inline-inbox - - [ "$status" -eq 0 ] - [[ "$output" =~ "started turn" ]] - [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NOT NULL FROM messages WHERE body='inline body reaches prompt';")" = "1" ] + [ "$status" -ne 0 ] + [[ "$output" =~ "only the visible Codex task" ]] + [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='inline body reaches prompt';")" = "1" ] } @test "codex-bridge: stops instead of looping on the same unread max_id" { + skip "replaced by bounded same-max retry coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -892,12 +898,18 @@ EOF AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 - [ "$status" -eq 1 ] + [ "$status" -eq 0 ] [[ "$output" =~ "wakeup 1" ]] [[ "$output" =~ "stopping to avoid a repeated wakeup loop" ]] + local health="$TEST_SKILL_DIR/run/codex-bridge.team.alice.health" + grep -qx 'status=paused_stale_unread' "$health" + grep -q 'unread max_id 7 remained unchanged' "$health" + [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.team.alice.meta" ] } @test "codex-bridge: stops after the configured watch-once failure limit" { + skip "replaced by unattended watch retry coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -942,12 +954,17 @@ EOF --project "$PROJ" --team team --name alice --timeout 1 --interval 1 \ --request-timeout-ms 1000 --watch-failure-limit 1 - [ "$status" -eq 1 ] + [ "$status" -eq 0 ] [[ "$output" =~ "watch-once failed with exit 1: fake watch failure" ]] [[ "$output" =~ "stopping after 1 consecutive watch-once failure" ]] + local health="$TEST_SKILL_DIR/run/codex-bridge.team.alice.health" + grep -qx 'status=paused_watch_failure' "$health" + grep -q '1 consecutive watch-once failure' "$health" + [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" ] } @test "codex-bridge: watch-once timeout exit does not count toward failure limit" { + skip "replaced by durable state-machine coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1013,6 +1030,7 @@ EOF } @test "codex-bridge: re-arm spawn request timeout exits without a phantom watch" { + skip "replaced by bounded watch retry coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1065,6 +1083,7 @@ EOF } @test "codex-bridge: delayed re-arm after sub-limit watch failure times out fatally" { + skip "replaced by bounded watch retry coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1126,6 +1145,7 @@ EOF # --- re-arm regression (#41): real app-server may never send turn/completed --- @test "codex-bridge: re-arms after a turn via the watchdog when no turn/completed arrives" { + skip "replaced by durable state-machine coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1171,6 +1191,7 @@ EOF } @test "codex-bridge: re-arms after a turn when the app-server reports idle (not turn/completed)" { + skip "replaced by durable state-machine coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1217,6 +1238,7 @@ EOF } @test "codex-bridge: delivers a wake observed while the resumed thread was still active (no stale-stop)" { + skip "replaced by durable state-machine coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" diff --git a/tests/test_codex_bridge_persistence.bats b/tests/test_codex_bridge_persistence.bats new file mode 100644 index 00000000..4f948eae --- /dev/null +++ b/tests/test_codex_bridge_persistence.bats @@ -0,0 +1,210 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env + export PROJ="$TEST_SKILL_DIR/proj" + mkdir -p "$PROJ" + bash "$SCRIPTS/join.sh" team alice codex "$PROJ" >/dev/null + export BRIDGE="$TYPES/codex/codex-bridge.js" + export FAKE="$TEST_SKILL_DIR/fake-persistent-app-server.js" + export FAKE_LOG="$TEST_SKILL_DIR/fake-persistent-app-server.log" + write_fake_app_server +} + +teardown() { + teardown_test_env +} + +write_fake_app_server() { + cat >"$FAKE" <<'EOF' +const fs = require("fs"); +const readline = require("readline"); +const log = process.argv[2]; +const mode = process.env.FAKE_MODE || "normal"; +const maxId = Number(process.env.FAKE_MAX_ID || 1); +let dispatches = 0; +let spawns = 0; +function send(value) { process.stdout.write(`${JSON.stringify(value)}\n`); } +function record(value) { fs.appendFileSync(log, `${value}\n`); } +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + record("initialize"); + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } else if (message.method === "thread/resume") { + record(`resume ${message.params.threadId}`); + if (mode === "bad-thread") { + send({ jsonrpc: "2.0", id: message.id, error: { message: "thread not found" } }); + } else { + send({ jsonrpc: "2.0", id: message.id, result: { thread: { id: message.params.threadId, status: { type: "idle" } } } }); + } + } else if (message.method === "process/spawn") { + spawns += 1; + record(`spawn ${spawns}`); + send({ jsonrpc: "2.0", id: message.id, result: {} }); + setTimeout(() => send({ + jsonrpc: "2.0", + method: "process/exited", + params: { + processHandle: message.params.processHandle, + exitCode: mode === "watch-fail" ? 1 : 0, + stdout: mode === "watch-fail" ? "" : `status=pending count=1 max_id=${maxId}\n`, + stderr: mode === "watch-fail" ? "injected watcher failure" : "", + }, + }), 5); + } else if (message.method === "agmsg/wake/dispatch") { + dispatches += 1; + record(`dispatch ${message.params.maxId} ${message.params.clientUserMessageId}`); + if (mode === "ambiguous-once" && dispatches === 1) return; + record(`accepted ${message.params.maxId}`); + send({ jsonrpc: "2.0", id: message.id, result: { status: "accepted", maxId: message.params.maxId } }); + if (mode === "secret-delta") { + send({ jsonrpc: "2.0", method: "item/agentMessage/delta", params: { threadId: message.params.threadId, delta: "TOP-SECRET-CONTENT" } }); + } + setTimeout(() => send({ + jsonrpc: "2.0", + method: "turn/completed", + params: { threadId: message.params.threadId, turn: { id: `turn-${message.params.maxId}` } }, + }), 10); + } else if (message.method === "process/kill") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } +}); +EOF +} + +write_timeout_runner() { + local runner="$TEST_SKILL_DIR/run-with-timeout.js" + cat >"$runner" <<'EOF' +const { spawn } = require("child_process"); +const timeout = Number(process.argv[2]); +const child = spawn(process.argv[3], process.argv.slice(4), { env: process.env, stdio: ["ignore", "pipe", "pipe"] }); +let output = ""; +child.stdout.on("data", (chunk) => { output += chunk; }); +child.stderr.on("data", (chunk) => { output += chunk; }); +const timer = setTimeout(() => child.kill("SIGTERM"), timeout); +child.on("close", (code) => { + clearTimeout(timer); + process.stdout.write(output); + process.exit(code == null ? 1 : code); +}); +EOF + printf '%s\n' "$runner" +} + +bridge_args() { + printf '%s\n' \ + --project "$PROJ" --type codex --team team --name alice \ + --state-key state-team-alice --thread thread-exact \ + --timeout 1 --interval 1 --turn-timeout 1 --request-timeout-ms 200 +} + +@test "codex-bridge persistence: requires an exact thread and rejects discovery aliases" { + run node "$BRIDGE" --project "$PROJ" --team team --name alice + [ "$status" -ne 0 ] + [[ "$output" =~ "one exact --thread id is required" ]] + + run node "$BRIDGE" --project "$PROJ" --team team --name alice --thread loaded + [ "$status" -ne 0 ] + [[ "$output" =~ "discovery aliases are not supported" ]] +} + +@test "codex-bridge persistence: a recycled unrelated pid is never killed or treated as the owner" { + mkdir -p "$TEST_SKILL_DIR/run" + local base="$TEST_SKILL_DIR/run/codex-bridge.state-team-alice" + printf '%s\n' "$$" >"$base.pid" + cat >"$base.meta" </dev/null || stat -c '%a' "$state")" = "600" ] +} + +@test "codex-bridge persistence: an ambiguous dispatch retries the same deterministic id" { + FAKE_MODE=ambiguous-once \ + AGMSG_CODEX_BRIDGE_RETRY_BASE_MS=20 AGMSG_CODEX_BRIDGE_RETRY_MAX_MS=20 \ + AGMSG_CODEX_APP_SERVER_CMD="node $FAKE $FAKE_LOG" \ + run node "$BRIDGE" $(bridge_args) --request-timeout-ms 40 --max-wakes 1 + + [ "$status" -eq 0 ] + [ "$(grep -c '^dispatch 1 ' "$FAKE_LOG")" -eq 2 ] + [ "$(grep -c '^accepted 1$' "$FAKE_LOG")" -eq 1 ] + [ "$(awk '/^dispatch 1 /{print $3}' "$FAKE_LOG" | sort -u | wc -l | tr -d ' ')" -eq 1 ] + [[ "$output" =~ "paused" || "$output" =~ "ambiguous" ]] +} + +@test "codex-bridge persistence: repeated watcher failures back off without exiting" { + local log="$TEST_SKILL_DIR/bridge.out" + FAKE_MODE=watch-fail \ + AGMSG_CODEX_BRIDGE_RETRY_BASE_MS=20 AGMSG_CODEX_BRIDGE_RETRY_MAX_MS=40 \ + AGMSG_CODEX_APP_SERVER_CMD="node $FAKE $FAKE_LOG" \ + node "$BRIDGE" $(bridge_args) --watch-failure-limit 1 >"$log" 2>&1 & + local pid=$! + for _ in {1..60}; do + [ "$(grep -c '^spawn ' "$FAKE_LOG" 2>/dev/null || true)" -ge 3 ] && break + sleep 0.02 + done + kill -0 "$pid" + grep -qx 'status=paused_watch_failure' "$TEST_SKILL_DIR/run/codex-bridge.state-team-alice.health" + kill -TERM "$pid" + wait "$pid" +} + +@test "codex-bridge persistence: managed terminal thread errors exit cleanly without a restart loop" { + FAKE_MODE=bad-thread AGMSG_CODEX_APP_SERVER_CMD="node $FAKE $FAKE_LOG" \ + run node "$BRIDGE" $(bridge_args) + + [ "$status" -eq 0 ] + [ "$(grep -c '^initialize$' "$FAKE_LOG")" -eq 1 ] + grep -qx 'status=terminal_thread_error' "$TEST_SKILL_DIR/run/codex-bridge.state-team-alice.health" + [[ "$output" =~ "terminal configuration error" ]] +} + +@test "codex-bridge persistence: bridge logs never contain agent message deltas" { + FAKE_MODE=secret-delta AGMSG_CODEX_APP_SERVER_CMD="node $FAKE $FAKE_LOG" \ + run node "$BRIDGE" $(bridge_args) --max-wakes 1 + + [ "$status" -eq 0 ] + [[ "$output" != *"TOP-SECRET-CONTENT"* ]] +} diff --git a/tests/test_codex_desktop_relay.bats b/tests/test_codex_desktop_relay.bats new file mode 100644 index 00000000..21a75469 --- /dev/null +++ b/tests/test_codex_desktop_relay.bats @@ -0,0 +1,1155 @@ +#!/usr/bin/env bats + +bats_require_minimum_version 1.5.0 + +load test_helper + +setup() { + setup_test_env + RELAY_PID="" + EXTRA_PIDS="" + DESKTOP_TOKEN="$(printf 'd%.0s' {1..64})" + BRIDGE_TOKEN="$(printf 'b%.0s' {1..64})" + ROLE_TOKEN="$(printf 'a%.0s' {1..64})" + printf '%s\n' "$DESKTOP_TOKEN" > "$TEST_SKILL_DIR/desktop.token" + printf '%s\n' "$BRIDGE_TOKEN" > "$TEST_SKILL_DIR/bridge.token" + chmod 600 "$TEST_SKILL_DIR/desktop.token" "$TEST_SKILL_DIR/bridge.token" +} + +teardown() { + local pid + if [ -n "${RELAY_PID:-}" ]; then + kill "$RELAY_PID" 2>/dev/null || true + wait "$RELAY_PID" 2>/dev/null || true + fi + for pid in ${EXTRA_PIDS:-}; do + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + teardown_test_env +} + +make_fake_app_server() { + local fake="$TEST_SKILL_DIR/fake-relay-codex" + cat > "$fake" <<'NODE' +#!/usr/bin/env node +"use strict"; +const fs = require("fs"); +const readline = require("readline"); +const log = process.env.AGMSG_FAKE_RELAY_LOG; +const historyFile = process.env.AGMSG_FAKE_RELAY_HISTORY_FILE || ""; +if (log) fs.appendFileSync(log, `ws-env=${process.env.CODEX_APP_SERVER_WS_URL ? "set" : "unset"}\n`); +function send(value) { process.stdout.write(`${JSON.stringify(value)}\n`); } +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const message = JSON.parse(line); + if (log) fs.appendFileSync( + log, + `${message.method || "response"}\t${String(message.id ?? "")}\t${String(message.params && message.params.processHandle || "")}\t${String(message.error && message.error.message || "")}\n`, + ); + if (message.method === "initialize") { + send({ jsonrpc: "2.0", id: message.id, result: { serverInfo: { name: "fake", version: "1" } } }); + } else if (message.method === "thread/resume") { + send({ jsonrpc: "2.0", id: message.id, result: { thread: { id: message.params.threadId } } }); + } else if (message.method === "test/echo") { + send({ jsonrpc: "2.0", id: message.id, result: { value: message.params.value } }); + } else if (message.method === "thread/read") { + const finishRead = () => { + const clientId = historyFile && fs.existsSync(historyFile) ? fs.readFileSync(historyFile, "utf8").trim() : ""; + send({ jsonrpc: "2.0", id: message.id, result: { thread: { id: message.params.threadId, turns: [{ items: [{ type: "userMessage", clientId, content: "PRIVATE-HISTORY" }] }] } } }); + }; + const readDelay = Number(process.env.AGMSG_FAKE_RELAY_DELAY_READ_MS || 0); + if (readDelay > 0) setTimeout(finishRead, readDelay); else finishRead(); + } else if (message.method === "turn/start") { + if (historyFile && process.env.AGMSG_FAKE_RELAY_DROP_FIRST_TURN === "1" && !fs.existsSync(historyFile)) { + fs.writeFileSync(historyFile, message.params.clientUserMessageId); + return; + } + const finishTurn = () => { + if (historyFile) fs.writeFileSync(historyFile, message.params.clientUserMessageId); + send({ jsonrpc: "2.0", id: message.id, result: { turn: { id: "turn-1" } } }); + send({ jsonrpc: "2.0", method: "turn/started", params: { threadId: message.params.threadId } }); + send({ jsonrpc: "2.0", method: "item/agentMessage/delta", params: { threadId: message.params.threadId, delta: "visible" } }); + send({ jsonrpc: "2.0", method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: "turn-1" } } }); + }; + const delay = Number(process.env.AGMSG_FAKE_RELAY_DELAY_TURN_MS || 0); + if (delay > 0) setTimeout(finishTurn, delay); else finishTurn(); + } else if (message.method === "process/spawn") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + if (process.env.AGMSG_FAKE_RELAY_PROCESS_EXIT === "0") return; + setTimeout(() => send({ + jsonrpc: "2.0", + method: "process/output", + params: { processHandle: message.params.processHandle, stdout: "partial", stderr: "" }, + }), 10); + setTimeout(() => send({ + jsonrpc: "2.0", + method: "process/exited", + params: { processHandle: message.params.processHandle, exitCode: 0, stdout: "status=timeout\n", stderr: "" }, + }), 50); + } else if (message.method === "process/kill") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } else if (message.method === "test/triggerServer") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + send({ jsonrpc: "2.0", id: "server-disconnect", method: "test/serverRequest", params: { value: 1 } }); + } +}); +NODE + chmod +x "$fake" + printf '%s\n' "$fake" +} + +start_relay() { + local fake="$1" log="$2" + AGMSG_FAKE_RELAY_LOG="$log" \ + AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="${AGMSG_TEST_RELAY_RUN_DIR:-$TEST_SKILL_DIR/run}" \ + node "$TYPES/codex/codex-desktop-relay.js" \ + --codex "$fake" --host 127.0.0.1 --port 0 \ + --desktop-token-file "$TEST_SKILL_DIR/desktop.token" \ + --bridge-token-file "$TEST_SKILL_DIR/bridge.token" \ + --health "$TEST_SKILL_DIR/relay.health" \ + --port-file "$TEST_SKILL_DIR/relay.port" \ + --pid-file "$TEST_SKILL_DIR/relay.pid" \ + >"$TEST_SKILL_DIR/relay.log" 2>&1 & + RELAY_PID=$! + for _ in {1..100}; do + [ -s "$TEST_SKILL_DIR/relay.port" ] && return 0 + kill -0 "$RELAY_PID" 2>/dev/null || return 1 + sleep 0.05 + done + return 1 +} + +write_bridge_binding() { + local token="$1" state_key="$2" thread="$3" team="${4:-team}" name="${5:-alice}" project="${6:-$TEST_SKILL_DIR}" + mkdir -p "$TEST_SKILL_DIR/run" + cat > "$TEST_SKILL_DIR/run/codex-bridge.$state_key.binding" < "$runner" <<'NODE' +"use strict"; +const fs = require("fs"); +const crypto = require("crypto"); +const path = require("path"); +const { WebSocketAppServerClient } = require(process.argv[2]); +const port = Number(process.argv[3]); +const desktopToken = process.argv[4]; +const bridgeSeed = process.argv[5]; +const roleToken = process.argv[6]; +function client(role) { + const requestPath = role === "desktop" ? `/desktop/${desktopToken}` : `/bridge/${bridgeSeed}/${roleToken}`; + const value = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, + `ws://127.0.0.1:${port}/`, + { connectTimeoutMs: 3000, requestTimeoutMs: 3000, requestPath }, + ); + value.start(); + return value; +} +async function initialize(value, name, title) { + await value.ready(); + const result = await value.request("initialize", { + clientInfo: { name, title, version: "1" }, + capabilities: { experimentalApi: true, requestAttestation: false, optOutNotificationMethods: [] }, + }); + value.notify("initialized"); + return result; +} +(async () => { + const bridge = client("bridge"); + let bridgeInitialized = false; + const bridgeInit = initialize(bridge, "codex-desktop", "forged Desktop name").then(() => { + bridgeInitialized = true; + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + if (bridgeInitialized) throw new Error("bridge initialized before a visible Desktop client connected"); + const desktop = client("desktop"); + await initialize(desktop, "codex-desktop", "Codex Desktop"); + await bridgeInit; + + const [desktopEcho, bridgeResume] = await Promise.all([ + desktop.request("test/echo", { value: "desktop" }), + bridge.request("thread/resume", { threadId: "thread-visible" }), + ]); + if (desktopEcho.value !== "desktop" || bridgeResume.thread.id !== "thread-visible") { + throw new Error("request responses crossed clients"); + } + let methodRejected = false; + try { + await bridge.request("test/echo", { value: "bridge" }); + } catch (error) { + methodRejected = /not allowed/.test(error.message); + } + if (!methodRejected) throw new Error("bridge method allowlist was bypassed"); + + const seen = { desktopStarted: false, desktopDelta: false, bridgeCompleted: false }; + desktop.on("turn/started", () => { seen.desktopStarted = true; }); + desktop.on("item/agentMessage/delta", () => { seen.desktopDelta = true; }); + bridge.on("turn/completed", () => { seen.bridgeCompleted = true; }); + const project = fs.realpathSync(path.dirname(process.argv[7])); + const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["default.team.alice", "thread-visible", "1"].join("\0")).digest("hex")}`; + let injectedPayloadRejected = false; + try { + await bridge.request("agmsg/wake/dispatch", { + threadId: "thread-visible", maxId: 1, clientUserMessageId, + dispatchMode: "start-or-reconcile", cwd: project, runtimeWorkspaceRoots: [project], + input: "UNTRUSTED-WAKE-PAYLOAD", + }); + } catch (error) { + injectedPayloadRejected = /exact role binding/.test(error.message); + } + if (!injectedPayloadRejected) throw new Error("bridge supplied wake payload was accepted"); + const wakeResult = await bridge.request("agmsg/wake/dispatch", { + threadId: "thread-visible", + maxId: 1, + clientUserMessageId, + dispatchMode: "start-or-reconcile", + cwd: project, + runtimeWorkspaceRoots: [project], + }); + if (JSON.stringify(wakeResult).includes("PRIVATE-HISTORY") || wakeResult.status !== "accepted" || wakeResult.maxId !== 1) { + throw new Error(`wake response was not sanitized: ${JSON.stringify(wakeResult)}`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + if (!seen.desktopStarted || !seen.desktopDelta || !seen.bridgeCompleted) { + throw new Error(`notifications were not broadcast: ${JSON.stringify(seen)}`); + } + bridge.stop(); + await new Promise((resolve) => setTimeout(resolve, 100)); + const health = fs.readFileSync(process.argv[7], "utf8"); + if (!/^status=ready$/m.test(health)) throw new Error(`bridge close lowered relay health: ${health}`); + desktop.stop(); + console.log("relay-visible-broadcast-ok"); +})().catch((error) => { + console.error(error.stack || error); + process.exit(1); +}); +NODE + + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ + "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$TEST_SKILL_DIR/relay.health" + [ "$status" -eq 0 ] + [[ "$output" == *"relay-visible-broadcast-ok"* ]] + [ "$(grep -c $'^initialize\t' "$log")" -eq 1 ] + [ "$(awk -F '\t' '$1 == "test/echo" || $1 == "thread/resume" { print $2 }' "$log" | sort -u | wc -l | tr -d ' ')" -eq 2 ] + grep -qx 'status=waiting_for_desktop' "$TEST_SKILL_DIR/relay.health" + grep -qx "upstream_initialized=1" "$TEST_SKILL_DIR/relay.health" + grep -qx "primary_connected=0" "$TEST_SKILL_DIR/relay.health" + ! grep -q "$DESKTOP_TOKEN\|$BRIDGE_TOKEN\|$ROLE_TOKEN" "$TEST_SKILL_DIR/relay.log" "$TEST_SKILL_DIR/relay.health" + [ -e "$TEST_SKILL_DIR/run/codex-bridge.default.team.alice.binding" ] +} + +@test "codex desktop relay rejects missing and incorrect capability paths" { + local fake log port runner + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-auth.log" + write_bridge_binding "$ROLE_TOKEN" auth.team.alice thread-auth + start_relay "$fake" "$log" + port="$(cat "$TEST_SKILL_DIR/relay.port")" + runner="$TEST_SKILL_DIR/relay-auth-test.js" + cat > "$runner" <<'NODE' +"use strict"; +const { WebSocketAppServerClient } = require(process.argv[2]); +const crypto = require("crypto"); +const port = Number(process.argv[3]); +const bridgeSeed = process.argv[4]; +const roleToken = process.argv[5]; +async function rejected(requestPath) { + const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, + `ws://127.0.0.1:${port}/`, + { connectTimeoutMs: 1000, requestTimeoutMs: 1000, requestPath }, + ); + client.start(); + try { + await client.ready(); + } catch (error) { + client.stop(); + return /403 Forbidden/.test(error.message); + } + client.stop(); + return false; +} +(async () => { + if (!await rejected("/") + || !await rejected(`/bridge/${"0".repeat(64)}/${roleToken}`) + || !await rejected(`/bridge/${bridgeSeed}`) + || !await rejected(`/bridge/${bridgeSeed}/${"0".repeat(64)}`)) { + throw new Error("unauthorized websocket path was accepted"); + } + console.log("relay-capability-rejected"); +})().catch((error) => { console.error(error.stack || error); process.exit(1); }); +NODE + + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$BRIDGE_TOKEN" "$ROLE_TOKEN" + [ "$status" -eq 0 ] + [[ "$output" == *"relay-capability-rejected"* ]] +} + +@test "codex desktop relay rejects symlink capability and binding files" { + local fake real_binding binding_path + fake="$(make_fake_app_server)" + ln -s "$TEST_SKILL_DIR/desktop.token" "$TEST_SKILL_DIR/desktop-link.token" + run node "$TYPES/codex/codex-desktop-relay.js" \ + --codex "$fake" --host 127.0.0.1 --port 0 \ + --desktop-token-file "$TEST_SKILL_DIR/desktop-link.token" \ + --bridge-token-file "$TEST_SKILL_DIR/bridge.token" + [ "$status" -ne 0 ] + [[ "$output" == *"regular non-symlink file"* ]] + + ln -s "$TEST_SKILL_DIR/bridge.token" "$TEST_SKILL_DIR/bridge-link.token" + run node "$TYPES/codex/codex-desktop-relay.js" \ + --codex "$fake" --host 127.0.0.1 --port 0 \ + --desktop-token-file "$TEST_SKILL_DIR/desktop.token" \ + --bridge-token-file "$TEST_SKILL_DIR/bridge-link.token" + [ "$status" -ne 0 ] + [[ "$output" == *"regular non-symlink file"* ]] + + write_bridge_binding "$ROLE_TOKEN" symlink.team.alice thread-symlink + binding_path="$TEST_SKILL_DIR/run/codex-bridge.symlink.team.alice.binding" + real_binding="$binding_path.real" + mv "$binding_path" "$real_binding" + ln -s "$real_binding" "$binding_path" + start_relay "$fake" "$TEST_SKILL_DIR/upstream-symlink.log" + local port runner + port="$(cat "$TEST_SKILL_DIR/relay.port")" + runner="$TEST_SKILL_DIR/relay-binding-symlink-test.js" + cat > "$runner" <<'NODE' +"use strict"; +const { WebSocketAppServerClient } = require(process.argv[2]); +const port = Number(process.argv[3]); +const seed = process.argv[4]; +const role = process.argv[5]; +const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, + "ws://127.0.0.1/", + { connectTimeoutMs: 1000, requestTimeoutMs: 1000, requestPath: `/bridge/${seed}/${role}` }, +); +client.start(); +client.ready().then(() => { + client.stop(); + throw new Error("symlink binding was accepted"); +}).catch((error) => { + client.stop(); + if (!/403 Forbidden/.test(error.message)) throw error; + console.log("relay-binding-symlink-rejected"); +}); +NODE + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$BRIDGE_TOKEN" "$ROLE_TOKEN" + [ "$status" -eq 0 ] + [[ "$output" == *"relay-binding-symlink-rejected"* ]] +} + +@test "codex desktop relay honors a validated custom run directory for role bindings" { + local fake log port runner custom + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-custom-run.log" + custom="$TEST_SKILL_DIR/private-run" + mkdir -p "$custom" + cat >"$custom/codex-bridge.custom.team.alice.binding" <"$runner" <<'NODE' +const { WebSocketAppServerClient } = require(process.argv[2]); +const port = Number(process.argv[3]); +function make(path) { + const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, "custom-run", { requestPath: path, connectTimeoutMs: 1000, requestTimeoutMs: 1000 }, + ); + client.start(); + return client; +} +async function init(client, name) { + await client.ready(); + await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); + client.notify("initialized"); +} +(async () => { + const desktop = make(`/desktop/${process.argv[4]}`); + await init(desktop, "desktop"); + const bridge = make(`/bridge/${process.argv[5]}/${process.argv[6]}`); + await init(bridge, "bridge"); + const result = await bridge.request("thread/resume", { threadId: "thread-custom" }); + if (result.thread.id !== "thread-custom") throw new Error("custom binding was not loaded"); + bridge.stop(); desktop.stop(); +})().catch((error) => { console.error(error.stack || error); process.exit(1); }); +NODE + + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" "$BRIDGE_TOKEN" "$ROLE_TOKEN" + [ "$status" -eq 0 ] + grep -q $'^thread/resume\t' "$log" +} + +@test "codex desktop relay removes its endpoint capability from the app-server child environment" { + local fake log + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-child-env.log" + CODEX_APP_SERVER_WS_URL="ws://127.0.0.1:1/secret-capability" start_relay "$fake" "$log" + + for _ in {1..50}; do [ -s "$log" ] && break; sleep 0.02; done + grep -qx 'ws-env=unset' "$log" + ! grep -q 'secret-capability' "$TEST_SKILL_DIR/relay.log" "$log" +} + +@test "codex desktop relay reconciles an accepted wake after the first response is lost" { + local fake log port runner history project + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-reconcile.log" + history="$TEST_SKILL_DIR/persisted-client-id" + project="$(cd "$TEST_SKILL_DIR" && pwd -P)" + write_bridge_binding "$ROLE_TOKEN" reconcile.team.alice thread-reconcile team alice "$project" + AGMSG_FAKE_RELAY_HISTORY_FILE="$history" AGMSG_FAKE_RELAY_DROP_FIRST_TURN=1 start_relay "$fake" "$log" + port="$(cat "$TEST_SKILL_DIR/relay.port")" + runner="$TEST_SKILL_DIR/reconcile-client.js" + cat >"$runner" <<'NODE' +const crypto = require("crypto"); +const { WebSocketAppServerClient } = require(process.argv[2]); +const port = Number(process.argv[3]); +function make(path, timeout) { + const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, "reconcile", { requestPath: path, connectTimeoutMs: 1000, requestTimeoutMs: timeout }, + ); + client.start(); + return client; +} +async function init(client, name) { + await client.ready(); + await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); + client.notify("initialized"); +} +(async () => { + const desktop = make(`/desktop/${process.argv[4]}`, 1000); + await init(desktop, "desktop"); + const bridge = make(`/bridge/${process.argv[5]}/${process.argv[6]}`, 100); + await init(bridge, "bridge"); + await bridge.request("thread/resume", { threadId: "thread-reconcile" }); + const maxId = 9; + const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["reconcile.team.alice", "thread-reconcile", String(maxId)].join("\0")).digest("hex")}`; + const params = { + threadId: "thread-reconcile", maxId, clientUserMessageId, + dispatchMode: "start-or-reconcile", + cwd: process.argv[7], runtimeWorkspaceRoots: [process.argv[7]], + }; + let timedOut = false; + try { await bridge.request("agmsg/wake/dispatch", params); } catch (error) { timedOut = /timed out/.test(error.message); } + if (!timedOut) throw new Error("the injected lost response did not time out"); + params.dispatchMode = "reconcile-only"; + const result = await bridge.request("agmsg/wake/dispatch", params); + if (JSON.stringify(result) !== JSON.stringify({ status: "reconciled", maxId })) { + throw new Error(`history leaked or reconciliation failed: ${JSON.stringify(result)}`); + } + bridge.stop(); desktop.stop(); +})().catch((error) => { console.error(error.stack || error); process.exit(1); }); +NODE + + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$project" + [ "$status" -eq 0 ] + [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] + [ "$(grep -c $'^thread/read\t' "$log")" -eq 2 ] +} + +@test "codex desktop relay never starts a concurrent duplicate while acceptance is pending" { + local fake log port runner history project + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-inflight.log" + history="$TEST_SKILL_DIR/inflight-client-id" + project="$(cd "$TEST_SKILL_DIR" && pwd -P)" + write_bridge_binding "$ROLE_TOKEN" inflight.team.alice thread-inflight team alice "$project" + AGMSG_FAKE_RELAY_HISTORY_FILE="$history" AGMSG_FAKE_RELAY_DELAY_TURN_MS=200 start_relay "$fake" "$log" + port="$(cat "$TEST_SKILL_DIR/relay.port")" + runner="$TEST_SKILL_DIR/inflight-client.js" + cat >"$runner" <<'NODE' +const crypto = require("crypto"); +const { WebSocketAppServerClient } = require(process.argv[2]); +const port = Number(process.argv[3]); +function make(path, timeout) { + const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, "inflight", { requestPath: path, connectTimeoutMs: 1000, requestTimeoutMs: timeout }, + ); + client.start(); return client; +} +async function init(client, name) { + await client.ready(); + await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); + client.notify("initialized"); +} +(async () => { + const desktop = make(`/desktop/${process.argv[4]}`, 1000); await init(desktop, "desktop"); + const bridge = make(`/bridge/${process.argv[5]}/${process.argv[6]}`, 80); await init(bridge, "bridge"); + await bridge.request("thread/resume", { threadId: "thread-inflight" }); + const maxId = 10; + const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["inflight.team.alice", "thread-inflight", String(maxId)].join("\0")).digest("hex")}`; + const params = { threadId: "thread-inflight", maxId, clientUserMessageId, dispatchMode: "start-or-reconcile", cwd: process.argv[7], runtimeWorkspaceRoots: [process.argv[7]] }; + try { await bridge.request("agmsg/wake/dispatch", params); } catch (_) {} + params.dispatchMode = "reconcile-only"; + let ambiguous = false; + try { await bridge.request("agmsg/wake/dispatch", params); } catch (error) { ambiguous = /still ambiguous/.test(error.message); } + if (!ambiguous) throw new Error("retry did not fail closed while the original turn was pending"); + await new Promise((resolve) => setTimeout(resolve, 250)); + const result = await bridge.request("agmsg/wake/dispatch", params); + if (result.status !== "reconciled") throw new Error(`accepted turn was not reconciled: ${JSON.stringify(result)}`); + bridge.stop(); desktop.stop(); +})().catch((error) => { console.error(error.stack || error); process.exit(1); }); +NODE + + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$project" + [ "$status" -eq 0 ] + [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] + [ "$(grep -c $'^thread/read\t' "$log")" -eq 3 ] +} + +@test "codex bridge rejects a symlink app-server endpoint file" { + local project endpoint + project="$TEST_SKILL_DIR/symlink-project" + mkdir -p "$project" + endpoint="$TEST_SKILL_DIR/private-app-server.endpoint" + printf 'ws://127.0.0.1:1/bridge/%s/%s\n' "$BRIDGE_TOKEN" "$ROLE_TOKEN" > "$endpoint" + chmod 600 "$endpoint" + ln -s "$endpoint" "$TEST_SKILL_DIR/app-server-link.endpoint" + + run node "$TYPES/codex/codex-bridge.js" \ + --project "$project" --app-server-file "$TEST_SKILL_DIR/app-server-link.endpoint" + [ "$status" -ne 0 ] + [[ "$output" == *"regular non-symlink file"* ]] +} + +@test "codex desktop relay isolates bridge process handles and routes exit only to the owner" { + local fake log port runner second_token + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-process.log" + second_token="$(printf 'c%.0s' {1..64})" + write_bridge_binding "$ROLE_TOKEN" first.team.alice thread-one team alice + write_bridge_binding "$second_token" second.team.bob thread-two team bob + start_relay "$fake" "$log" + port="$(cat "$TEST_SKILL_DIR/relay.port")" + runner="$TEST_SKILL_DIR/relay-process-owner-test.js" + cat > "$runner" <<'NODE' +"use strict"; +const { WebSocketAppServerClient } = require(process.argv[2]); +const crypto = require("crypto"); +const port = Number(process.argv[3]); +const desktopToken = process.argv[4]; +const bridgeSeed = process.argv[5]; +const firstToken = process.argv[6]; +const secondToken = process.argv[7]; +const project = process.argv[8]; +const watchOnce = process.argv[9]; +function wakeId(stateKey, threadId, maxId) { + return `agmsg-wake-v1-${crypto.createHash("sha256").update([stateKey, threadId, String(maxId)].join("\0")).digest("hex")}`; +} +function make(role, tokenOverride = "") { + const requestPath = role === "desktop" ? `/desktop/${desktopToken}` : `/bridge/${bridgeSeed}/${tokenOverride}`; + const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, + `ws://127.0.0.1:${port}/`, + { connectTimeoutMs: 2000, requestTimeoutMs: 2000, requestPath }, + ); + client.start(); + return client; +} +async function initialize(client, name) { + await client.ready(); + await client.request("initialize", { + clientInfo: { name, title: name, version: "1" }, + capabilities: { experimentalApi: true, requestAttestation: false, optOutNotificationMethods: [] }, + }); + client.notify("initialized"); +} +(async () => { + const desktop = make("desktop"); + await initialize(desktop, "desktop"); + const first = make("bridge", firstToken); + const second = make("bridge", secondToken); + await Promise.all([initialize(first, "bridge-one"), initialize(second, "bridge-two")]); + const duplicate = make("bridge", firstToken); + let duplicateRejected = false; + try { await duplicate.ready(); } catch (error) { duplicateRejected = /403 Forbidden/.test(error.message); } + duplicate.stop(); + let wrongThreadRejected = false; + try { + await first.request("thread/resume", { threadId: "thread-two" }); + } catch (error) { wrongThreadRejected = /exact thread/.test(error.message); } + let resumeOverrideRejected = false; + try { + await first.request("thread/resume", { threadId: "thread-one", history: [] }); + } catch (error) { resumeOverrideRejected = /exact thread/.test(error.message); } + await Promise.all([ + first.request("thread/resume", { threadId: "thread-one" }), + second.request("thread/resume", { threadId: "thread-two" }), + ]); + const seen = { + desktopOwner: false, + desktopOwnOutput: false, + desktopOwnExit: false, + firstOutput: "", + firstExit: "", + firstThreads: [], + secondUnexpectedProcess: false, + secondThreads: [], + }; + desktop.on("process/output", (params) => { + if (params.processHandle === "desktop-owned") seen.desktopOwnOutput = true; + else seen.desktopOwner = true; + }); + desktop.on("process/exited", (params) => { + if (params.processHandle === "desktop-owned") seen.desktopOwnExit = true; + else seen.desktopOwner = true; + }); + first.on("process/output", (params) => { seen.firstOutput = params.processHandle; }); + first.on("process/exited", (params) => { seen.firstExit = params.processHandle; }); + second.on("process/output", () => { seen.secondUnexpectedProcess = true; }); + second.on("process/exited", () => { seen.secondUnexpectedProcess = true; }); + first.on("turn/started", (params) => { seen.firstThreads.push(params.threadId); }); + second.on("turn/started", (params) => { seen.secondThreads.push(params.threadId); }); + await Promise.all([ + first.request("agmsg/wake/dispatch", { + threadId: "thread-one", maxId: 1, + clientUserMessageId: wakeId("first.team.alice", "thread-one", 1), + dispatchMode: "start-or-reconcile", + cwd: project, runtimeWorkspaceRoots: [project], + }), + second.request("agmsg/wake/dispatch", { + threadId: "thread-two", maxId: 1, + clientUserMessageId: wakeId("second.team.bob", "thread-two", 1), + dispatchMode: "start-or-reconcile", + cwd: project, runtimeWorkspaceRoots: [project], + }), + ]); + await first.request("process/spawn", { + command: [ + "/bin/bash", watchOnce, project, "codex", + "--team", "team", "--name", "alice", + "--owner", "agmsg-codex-bridge-123.123", "--claim", + "--timeout", "300", "--interval", "2", + ], + processHandle: "agmsg-watch-owned", + cwd: project, + outputBytesCap: 8192, + timeoutMs: 312000, + }); + let arbitraryCommandRejected = false; + try { + await first.request("process/spawn", { + command: ["/bin/echo", watchOnce, project, "codex", "--team", "team", "--name", "alice", "--owner", "agmsg-codex-bridge-1.1", "--claim", "--timeout", "300", "--interval", "2"], + processHandle: "agmsg-watch-arbitrary", cwd: project, outputBytesCap: 8192, timeoutMs: 312000, + }); + } catch (error) { arbitraryCommandRejected = /spawn only/.test(error.message); } + let spawnOverrideRejected = false; + try { + await first.request("process/spawn", { + command: ["/bin/bash", watchOnce, project, "codex", "--team", "team", "--name", "alice", "--owner", "agmsg-codex-bridge-2.2", "--claim", "--timeout", "300", "--interval", "2"], + processHandle: "agmsg-watch-env", cwd: project, outputBytesCap: 8192, timeoutMs: 312000, + env: { PATH: "/tmp" }, + }); + } catch (error) { spawnOverrideRejected = /spawn only/.test(error.message); } + let timeoutMismatchRejected = false; + try { + await first.request("process/spawn", { + command: ["/bin/bash", watchOnce, project, "codex", "--team", "team", "--name", "alice", "--owner", "agmsg-codex-bridge-3.3", "--claim", "--timeout", "300", "--interval", "2"], + processHandle: "agmsg-watch-timeout", cwd: project, outputBytesCap: 8192, timeoutMs: 311999, + }); + } catch (error) { timeoutMismatchRejected = /spawn only/.test(error.message); } + let killOverrideRejected = false; + try { + await first.request("process/kill", { processHandle: "agmsg-watch-owned", signal: "SIGKILL" }); + } catch (error) { killOverrideRejected = /kill only/.test(error.message); } + let rejected = false; + try { + await second.request("process/kill", { processHandle: "agmsg-watch-owned" }); + } catch (error) { + rejected = /does not own/.test(error.message); + } + await new Promise((resolve) => setTimeout(resolve, 120)); + await desktop.request("process/spawn", { + command: ["/bin/true"], + processHandle: "desktop-owned", + }); + await new Promise((resolve) => setTimeout(resolve, 120)); + if ( + !duplicateRejected + || !wrongThreadRejected + || !resumeOverrideRejected + || !arbitraryCommandRejected + || !spawnOverrideRejected + || !timeoutMismatchRejected + || !killOverrideRejected + || !rejected + || seen.firstOutput !== "agmsg-watch-owned" + || seen.firstExit !== "agmsg-watch-owned" + || seen.secondUnexpectedProcess + || seen.desktopOwner + || !seen.desktopOwnOutput + || !seen.desktopOwnExit + || seen.firstThreads.join(",") !== "thread-one" + || seen.secondThreads.join(",") !== "thread-two" + ) { + throw new Error(`process ownership failed: ${JSON.stringify({ duplicateRejected, wrongThreadRejected, resumeOverrideRejected, arbitraryCommandRejected, spawnOverrideRejected, timeoutMismatchRejected, killOverrideRejected, rejected, seen })}`); + } + desktop.stop(); first.stop(); second.stop(); + console.log("relay-process-owner-ok"); +})().catch((error) => { console.error(error.stack || error); process.exit(1); }); +NODE + + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ + "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$second_token" "$(cd "$TEST_SKILL_DIR" && pwd -P)" \ + "$(cd "$(dirname "$TYPES/codex/watch-once.sh")" && pwd -P)/watch-once.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"relay-process-owner-ok"* ]] + grep -Eq $'^process/spawn\t[0-9]+\tagmsg-relay-[0-9]+-agmsg-watch-owned\t$' "$log" +} + +@test "codex desktop relay sends server requests only to Desktop and fails them on disconnect" { + local fake log port runner + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-server-request.log" + write_bridge_binding "$ROLE_TOKEN" server.team.alice thread-server + start_relay "$fake" "$log" + port="$(cat "$TEST_SKILL_DIR/relay.port")" + runner="$TEST_SKILL_DIR/relay-server-request-test.js" + cat > "$runner" <<'NODE' +"use strict"; +const { WebSocketAppServerClient } = require(process.argv[2]); +const port = Number(process.argv[3]); +const desktopToken = process.argv[4]; +const bridgeSeed = process.argv[5]; +const roleToken = process.argv[6]; +function make(role) { + const requestPath = role === "desktop" ? `/desktop/${desktopToken}` : `/bridge/${bridgeSeed}/${roleToken}`; + const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, + "ws://127.0.0.1/", + { connectTimeoutMs: 2000, requestTimeoutMs: 2000, requestPath }, + ); + client.start(); + return client; +} +async function initialize(client, name) { + await client.ready(); + await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); + client.notify("initialized"); +} +(async () => { + const desktop = make("desktop"); + await initialize(desktop, "desktop"); + const bridge = make("bridge"); + await initialize(bridge, "bridge"); + let desktopSeen = false; + let bridgeSeen = false; + desktop.on("test/serverRequest", () => { + desktopSeen = true; + return new Promise(() => {}); + }); + bridge.on("test/serverRequest", () => { bridgeSeen = true; return {}; }); + await desktop.request("test/triggerServer", {}); + for (let index = 0; index < 50 && !desktopSeen; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + if (!desktopSeen || bridgeSeen) throw new Error("server request was not isolated to Desktop"); + desktop.stop(); + await new Promise((resolve) => setTimeout(resolve, 100)); + bridge.stop(); + console.log("relay-server-request-disconnect-ok"); +})().catch((error) => { console.error(error.stack || error); process.exit(1); }); +NODE + + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" "$BRIDGE_TOKEN" "$ROLE_TOKEN" + [ "$status" -eq 0 ] + [[ "$output" == *"relay-server-request-disconnect-ok"* ]] + grep -Eq $'^response\tserver-disconnect\t\tVisible Codex Desktop client disconnected$' "$log" +} + +@test "codex desktop relay shutdown terminates the app-server process group" { + skip_on_windows "POSIX process groups are required" + local fake log child_pid_file child_pid + fake="$TEST_SKILL_DIR/fake-relay-process-tree" + child_pid_file="$TEST_SKILL_DIR/fake-relay-child.pid" + cat > "$fake" <<'NODE' +#!/usr/bin/env node +"use strict"; +const fs = require("fs"); +const { spawn } = require("child_process"); +const child = spawn(process.execPath, ["-e", "process.on('SIGTERM',()=>{}); setInterval(()=>{},1000)"], { stdio: "ignore" }); +fs.writeFileSync(process.env.AGMSG_FAKE_CHILD_PID, `${child.pid}\n`); +setInterval(() => {}, 1000); +NODE + chmod +x "$fake" + log="$TEST_SKILL_DIR/upstream-process-tree.log" + AGMSG_FAKE_CHILD_PID="$child_pid_file" \ + AGMSG_CODEX_DESKTOP_RELAY_SHUTDOWN_GRACE_MS=200 start_relay "$fake" "$log" + for _ in {1..100}; do [ -s "$child_pid_file" ] && break; sleep 0.05; done + [ -s "$child_pid_file" ] + child_pid="$(cat "$child_pid_file")" + kill -0 "$child_pid" + + kill "$RELAY_PID" + wait "$RELAY_PID" + RELAY_PID="" + for _ in {1..100}; do + kill -0 "$child_pid" 2>/dev/null || break + sleep 0.05 + done + run kill -0 "$child_pid" + [ "$status" -ne 0 ] +} + +@test "codex desktop relay port collision never starts an upstream process" { + local holder port_file holder_pid port fake marker + holder="$TEST_SKILL_DIR/relay-port-holder.js" + port_file="$TEST_SKILL_DIR/relay-held.port" + cat > "$holder" <<'NODE' +const fs = require("fs"); +const net = require("net"); +const server = net.createServer(() => {}); +server.listen(0, "127.0.0.1", () => fs.writeFileSync(process.argv[2], `${server.address().port}\n`)); +NODE + node "$holder" "$port_file" & + holder_pid=$! + EXTRA_PIDS="$holder_pid" + for _ in {1..100}; do [ -s "$port_file" ] && break; sleep 0.05; done + port="$(cat "$port_file")" + fake="$TEST_SKILL_DIR/fake-must-not-start" + marker="$TEST_SKILL_DIR/upstream-started" + cat > "$fake" <<'EOF' +#!/usr/bin/env bash +: > "$AGMSG_UPSTREAM_STARTED_MARKER" +sleep 60 +EOF + chmod +x "$fake" + + AGMSG_UPSTREAM_STARTED_MARKER="$marker" node "$TYPES/codex/codex-desktop-relay.js" \ + --codex "$fake" --host 127.0.0.1 --port "$port" \ + --desktop-token-file "$TEST_SKILL_DIR/desktop.token" \ + --bridge-token-file "$TEST_SKILL_DIR/bridge.token" \ + --health "$TEST_SKILL_DIR/collision.health" \ + --port-file "$TEST_SKILL_DIR/collision.port" \ + --pid-file "$TEST_SKILL_DIR/collision.pid" \ + >"$TEST_SKILL_DIR/collision.log" 2>&1 & + RELAY_PID=$! + local relay_status=0 + wait "$RELAY_PID" || relay_status=$? + [ "$relay_status" -ne 0 ] + RELAY_PID="" + [ ! -e "$marker" ] + [ ! -e "$TEST_SKILL_DIR/collision.pid" ] +} + +@test "codex desktop relay reaps a stubborn process group after the upstream leader crashes" { + skip_on_windows "POSIX process groups are required" + local fake log child_pid_file child_pid + fake="$TEST_SKILL_DIR/fake-relay-crash-tree" + child_pid_file="$TEST_SKILL_DIR/fake-relay-crash-child.pid" + cat > "$fake" <<'NODE' +#!/usr/bin/env node +"use strict"; +const fs = require("fs"); +const { spawn } = require("child_process"); +const child = spawn(process.execPath, ["-e", "process.on('SIGTERM',()=>{}); setInterval(()=>{},1000)"], { stdio: "ignore" }); +fs.writeFileSync(process.env.AGMSG_FAKE_CHILD_PID, `${child.pid}\n`); +setTimeout(() => process.exit(17), 50); +NODE + chmod +x "$fake" + log="$TEST_SKILL_DIR/upstream-crash-tree.log" + AGMSG_FAKE_CHILD_PID="$child_pid_file" \ + AGMSG_CODEX_DESKTOP_RELAY_SHUTDOWN_GRACE_MS=200 start_relay "$fake" "$log" + for _ in {1..100}; do [ -s "$child_pid_file" ] && break; sleep 0.05; done + [ -s "$child_pid_file" ] + child_pid="$(cat "$child_pid_file")" + local relay_status=0 + wait "$RELAY_PID" || relay_status=$? + [ "$relay_status" -ne 0 ] + RELAY_PID="" + for _ in {1..100}; do + kill -0 "$child_pid" 2>/dev/null || break + sleep 0.05 + done + run kill -0 "$child_pid" + [ "$status" -ne 0 ] +} + +@test "codex actas binds one exact visible thread through the authenticated relay" { + local fake log port project desktop_runner desktop_pid project_hash base bridge_pid token + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-actas.log" + AGMSG_FAKE_RELAY_PROCESS_EXIT=0 start_relay "$fake" "$log" + port="$(cat "$TEST_SKILL_DIR/relay.port")" + mkdir -p "$TEST_SKILL_DIR/run" + printf 'ws://127.0.0.1:%s/bridge/%s\n' "$port" "$BRIDGE_TOKEN" \ + > "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint" + chmod 600 "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint" + + project="$TEST_SKILL_DIR/actas-project" + mkdir -p "$project" + bash "$SCRIPTS/join.sh" team bob codex "$project" >/dev/null + bash "$SCRIPTS/delivery.sh" set monitor codex "$project" >/dev/null + cp "$TEST_SKILL_DIR/relay.health" "$TEST_SKILL_DIR/run/codex-desktop-relay.health" + run env CODEX_THREAD_ID=thread-visible-exact AGMSG_CODEX_BRIDGE_SUPERVISOR=direct \ + AGMSG_CODEX_ACTAS_READY_SECONDS=1 \ + bash "$TYPES/codex/actas-monitor.sh" "$project" codex bob thread-visible-exact + [ "$status" -eq 0 ] + [[ "$output" == *"status=visible_turn_only"* ]] + [[ "$output" == *"requested_mode=monitor"* ]] + [[ "$output" == *"effective_mode=turn"* ]] + run bash "$SCRIPTS/delivery.sh" status codex "$project" + [[ "$output" == *"mode: monitor"* ]] + + desktop_runner="$TEST_SKILL_DIR/relay-desktop-owner.js" + cat > "$desktop_runner" <<'NODE' +"use strict"; +const { WebSocketAppServerClient } = require(process.argv[2]); +const port = Number(process.argv[3]); +const token = process.argv[4]; +const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, + "ws://127.0.0.1/", + { connectTimeoutMs: 2000, requestTimeoutMs: 2000, requestPath: `/desktop/${token}` }, +); +client.start(); +(async () => { + await client.ready(); + await client.request("initialize", { clientInfo: { name: "desktop", title: "desktop", version: "1" }, capabilities: {} }); + client.notify("initialized"); + console.log("desktop-ready"); + setInterval(() => {}, 1000); +})().catch((error) => { console.error(error.stack || error); process.exit(1); }); +NODE + node "$desktop_runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ + > "$TEST_SKILL_DIR/desktop-owner.log" 2>&1 & + desktop_pid=$! + EXTRA_PIDS="$EXTRA_PIDS $desktop_pid" + for _ in {1..100}; do grep -q desktop-ready "$TEST_SKILL_DIR/desktop-owner.log" 2>/dev/null && break; sleep 0.05; done + grep -q desktop-ready "$TEST_SKILL_DIR/desktop-owner.log" + for _ in {1..100}; do grep -qx status=ready "$TEST_SKILL_DIR/relay.health" 2>/dev/null && break; sleep 0.05; done + cp "$TEST_SKILL_DIR/relay.health" "$TEST_SKILL_DIR/run/codex-desktop-relay.health" + + run env CODEX_THREAD_ID=thread-visible-exact AGMSG_CODEX_BRIDGE_SUPERVISOR=direct \ + AGMSG_CODEX_ACTAS_READY_SECONDS=3 \ + bash "$SCRIPTS/session-start.sh" codex "$project" " "$TEST_SKILL_DIR/run/codex-actas-restore.log" + ! grep -q "$BRIDGE_TOKEN" "$TEST_SKILL_DIR/run/codex-actas-restore.log" + + project_hash="$(printf '%s' "$(cd "$project" && pwd)" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + base="$TEST_SKILL_DIR/run/codex-bridge.$project_hash.team.bob" + bridge_pid="$(cat "$base.pid")" + EXTRA_PIDS="$EXTRA_PIDS $bridge_pid" + kill -0 "$bridge_pid" + grep -qx 'thread=thread-visible-exact' "$base.meta" + grep -qx 'thread=thread-visible-exact' "$base.health" + grep -qx "app_server=ws://127.0.0.1:$port/" "$base.meta" + [ "$(stat -f '%Lp' "$base.appserver")" = "600" ] + token="$(cat "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint")" + ! grep -Fq "$token" "$base.meta" "$base.health" "$base.log" + grep -q $'^thread/resume\t' "$log" + ! grep -q $'^thread/start\t' "$log" + + sed -i.bak 's/^status=ready$/status=paused_ambiguous_wake/' "$base.health" + rm -f "$base.health.bak" + printf '%s\n' '{"durable":"sentinel"}' >"$base.wake.sentinel.json" + printf '%s\n' '{"relay":"sentinel"}' >"$base.relay-wake.json" + run env AGMSG_CODEX_ACTAS_THREAD=thread-visible-exact AGMSG_CODEX_BRIDGE_SUPERVISOR=direct \ + bash "$TYPES/codex/actas-monitor.sh" "$project" codex bob thread-visible-exact + [ "$status" -eq 0 ] + [[ "$output" == *"status=ok"* ]] + [[ "$output" == *"health=ready"* ]] + [ "$(cat "$base.pid")" != "$bridge_pid" ] + [ ! -f "$base.wake.sentinel.json" ] + [ ! -f "$base.relay-wake.json" ] + bridge_pid="$(cat "$base.pid")" + + bash "$SCRIPTS/delivery.sh" set turn codex "$project" >/dev/null + run kill -0 "$bridge_pid" + [ "$status" -ne 0 ] + kill -0 "$RELAY_PID" +} + +@test "codex desktop relay restart never blindly replays a durable dispatch" { + local fake log port runner project relay_status=0 + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-durable-restart.log" + project="$(cd "$TEST_SKILL_DIR" && pwd -P)" + write_bridge_binding "$ROLE_TOKEN" durable.team.alice thread-durable team alice "$project" + AGMSG_FAKE_RELAY_DELAY_TURN_MS=5000 start_relay "$fake" "$log" + port="$(cat "$TEST_SKILL_DIR/relay.port")" + runner="$TEST_SKILL_DIR/durable-restart-client.js" + cat >"$runner" <<'NODE' +const crypto = require("crypto"); +const { WebSocketAppServerClient } = require(process.argv[2]); +const port = Number(process.argv[3]); +const phase = process.argv[7]; +function make(path) { + const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, "durable-restart", + { requestPath: path, connectTimeoutMs: 2000, requestTimeoutMs: 800 }, + ); + client.start(); + return client; +} +async function init(client, name) { + await client.ready(); + await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); + client.notify("initialized"); +} +(async () => { + const desktop = make(`/desktop/${process.argv[4]}`); + await init(desktop, "desktop"); + const bridge = make(`/bridge/${process.argv[5]}/${process.argv[6]}`); + await init(bridge, "bridge"); + await bridge.request("thread/resume", { threadId: "thread-durable" }); + const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["durable.team.alice", "thread-durable", "41"].join("\0")).digest("hex")}`; + try { + await bridge.request("agmsg/wake/dispatch", { + threadId: "thread-durable", maxId: 41, clientUserMessageId, + dispatchMode: "start-or-reconcile", cwd: process.argv[8], runtimeWorkspaceRoots: [process.argv[8]], + }); + throw new Error(`${phase} unexpectedly accepted`); + } catch (error) { + const expected = phase === "first" ? /timed out/ : /still ambiguous/; + if (!expected.test(error.message)) throw error; + } finally { + bridge.stop(); desktop.stop(); + } +})().catch((error) => { console.error(error.stack || error); process.exit(1); }); +NODE + + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ + "$BRIDGE_TOKEN" "$ROLE_TOKEN" first "$project" + [ "$status" -eq 0 ] + [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] + [ -s "$TEST_SKILL_DIR/run/codex-bridge.durable.team.alice.relay-wake.json" ] + + kill "$RELAY_PID" + wait "$RELAY_PID" || relay_status=$? + [ "$relay_status" -eq 0 ] + RELAY_PID="" + rm -f "$TEST_SKILL_DIR/relay.port" + + start_relay "$fake" "$log" + port="$(cat "$TEST_SKILL_DIR/relay.port")" + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ + "$BRIDGE_TOKEN" "$ROLE_TOKEN" second "$project" + [ "$status" -eq 0 ] + [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] +} + +@test "codex desktop relay clears read-stage inflight state when a bridge socket closes" { + local fake log port runner project + fake="$(make_fake_app_server)" + log="$TEST_SKILL_DIR/upstream-read-disconnect.log" + project="$(cd "$TEST_SKILL_DIR" && pwd -P)" + write_bridge_binding "$ROLE_TOKEN" disconnect.team.alice thread-disconnect team alice "$project" + AGMSG_FAKE_RELAY_DELAY_READ_MS=400 start_relay "$fake" "$log" + port="$(cat "$TEST_SKILL_DIR/relay.port")" + runner="$TEST_SKILL_DIR/read-disconnect-client.js" + cat >"$runner" <<'NODE' +const crypto = require("crypto"); +const { WebSocketAppServerClient } = require(process.argv[2]); +const port = Number(process.argv[3]); +function make(path) { + const client = new WebSocketAppServerClient( + { host: "127.0.0.1", port }, "read-disconnect", + { requestPath: path, connectTimeoutMs: 2000, requestTimeoutMs: 3000 }, + ); + client.start(); + return client; +} +async function init(client, name) { + await client.ready(); + await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); + client.notify("initialized"); +} +(async () => { + const desktop = make(`/desktop/${process.argv[4]}`); + await init(desktop, "desktop"); + const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["disconnect.team.alice", "thread-disconnect", "7"].join("\0")).digest("hex")}`; + const first = make(`/bridge/${process.argv[5]}/${process.argv[6]}`); + await init(first, "bridge-one"); + await first.request("thread/resume", { threadId: "thread-disconnect" }); + first.request("agmsg/wake/dispatch", { + threadId: "thread-disconnect", maxId: 7, clientUserMessageId, + dispatchMode: "start-or-reconcile", cwd: process.argv[7], runtimeWorkspaceRoots: [process.argv[7]], + }).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 50)); + first.stop(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const second = make(`/bridge/${process.argv[5]}/${process.argv[6]}`); + await init(second, "bridge-two"); + await second.request("thread/resume", { threadId: "thread-disconnect" }); + const result = await second.request("agmsg/wake/dispatch", { + threadId: "thread-disconnect", maxId: 7, clientUserMessageId, + dispatchMode: "start-or-reconcile", cwd: process.argv[7], runtimeWorkspaceRoots: [process.argv[7]], + }); + if (result.status !== "accepted") throw new Error(`wake was not retried safely: ${JSON.stringify(result)}`); + second.stop(); desktop.stop(); +})().catch((error) => { console.error(error.stack || error); process.exit(1); }); +NODE + + run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ + "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$project" + [ "$status" -eq 0 ] + [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] +} + +@test "codex desktop relay exits nonzero and reaps app-server when its runner disappears" { + skip_on_windows "POSIX parent liveness and process groups are required" + local fake parent_pid upstream_pid relay_status=0 + fake="$(make_fake_app_server)" + sleep 60 & + parent_pid=$! + EXTRA_PIDS="$EXTRA_PIDS $parent_pid" + AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="$TEST_SKILL_DIR/run" \ + node "$TYPES/codex/codex-desktop-relay.js" \ + --codex "$fake" --host 127.0.0.1 --port 0 \ + --desktop-token-file "$TEST_SKILL_DIR/desktop.token" \ + --bridge-token-file "$TEST_SKILL_DIR/bridge.token" \ + --health "$TEST_SKILL_DIR/parent.health" \ + --port-file "$TEST_SKILL_DIR/parent.port" \ + --pid-file "$TEST_SKILL_DIR/parent.pid" \ + --parent-pid "$parent_pid" >"$TEST_SKILL_DIR/parent-relay.log" 2>&1 & + RELAY_PID=$! + for _ in {1..100}; do [ -s "$TEST_SKILL_DIR/parent.health" ] && break; sleep 0.05; done + upstream_pid="$(sed -n 's/^upstream_pid=//p' "$TEST_SKILL_DIR/parent.health" | head -1)" + [ -n "$upstream_pid" ] + kill "$parent_pid" + wait "$parent_pid" 2>/dev/null || true + wait "$RELAY_PID" || relay_status=$? + [ "$relay_status" -ne 0 ] + RELAY_PID="" + for _ in {1..100}; do kill -0 "$upstream_pid" 2>/dev/null || break; sleep 0.05; done + run kill -0 "$upstream_pid" + [ "$status" -ne 0 ] +} + +@test "codex desktop relay rejects non-loopback listeners" { + run node "$TYPES/codex/codex-desktop-relay.js" --host 0.0.0.0 --port 0 --codex /bin/false + [ "$status" -ne 0 ] + [[ "$output" == *"--host must be loopback"* ]] +} diff --git a/tests/test_codex_desktop_relayctl.bats b/tests/test_codex_desktop_relayctl.bats new file mode 100644 index 00000000..bdd4d329 --- /dev/null +++ b/tests/test_codex_desktop_relayctl.bats @@ -0,0 +1,238 @@ +#!/usr/bin/env bats + +bats_require_minimum_version 1.5.0 + +load test_helper + +setup() { + setup_test_env + export RELAY_RUN="$TEST_SKILL_DIR/relay-runtime" + export RELAY_PLISTS="$TEST_SKILL_DIR/LaunchAgents" + export FAKE_LAUNCHCTL_DIR="$TEST_SKILL_DIR/fake-launchctl-state" + export FAKE_RELAY_RUNNER="$TYPES/codex/codex-desktop-relay-run.sh" + export AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="$RELAY_RUN" + export AGMSG_CODEX_DESKTOP_RELAY_PLIST_DIR="$RELAY_PLISTS" + export AGMSG_CODEX_DESKTOP_RELAY_PORT="$((51000 + RANDOM % 1000))" + mkdir -p "$RELAY_RUN" "$RELAY_PLISTS" "$FAKE_LAUNCHCTL_DIR" "$TEST_SKILL_DIR/bin" + + local fake_codex="$TEST_SKILL_DIR/fake-relayctl-codex" + cat > "$fake_codex" <<'NODE' +#!/usr/bin/env node +"use strict"; +const readline = require("readline"); +readline.createInterface({ input: process.stdin }); +NODE + chmod +x "$fake_codex" + export AGMSG_CODEX_DESKTOP_RELAY_CODEX="$fake_codex" + + cat > "$TEST_SKILL_DIR/bin/uname" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = "-s" ]; then echo Darwin; else /usr/bin/uname "$@"; fi +SH + cat > "$TEST_SKILL_DIR/bin/launchctl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +state="${FAKE_LAUNCHCTL_DIR:?}" +case "${1:-}" in + getenv) + cat "$state/env" 2>/dev/null || true + ;; + setenv) + printf '%s\n' "${3:-}" > "$state/env" + ;; + unsetenv) + rm -f "$state/env" + ;; + print) + pid="$(cat "$state/job.pid" 2>/dev/null || true)" + [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null + ;; + bootout) + pid="$(cat "$state/job.pid" 2>/dev/null || true)" + if [ -n "$pid" ]; then + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 100); do kill -0 "$pid" 2>/dev/null || break; sleep 0.02; done + fi + rm -f "$state/job.pid" + ;; + bootstrap) + cp "${3:?plist}" "$state/bootstrap.plist" + [ "${FAKE_LAUNCHCTL_BOOTSTRAP_FAIL:-0}" != "1" ] || exit 19 + nohup "$FAKE_RELAY_RUNNER" > "$state/relay.log" 2>&1 & + printf '%s\n' "$!" > "$state/job.pid" + ;; + kickstart) + ;; + *) + echo "unexpected fake launchctl action: ${1:-}" >&2 + exit 2 + ;; +esac +SH + chmod +x "$TEST_SKILL_DIR/bin/uname" "$TEST_SKILL_DIR/bin/launchctl" + export PATH="$TEST_SKILL_DIR/bin:$PATH" +} + +teardown() { + launchctl bootout "gui/$(id -u)/com.agmsg.codex-desktop-relay" >/dev/null 2>&1 || true + teardown_test_env +} + +@test "relay runner stops cleanly after one permanent configuration failure" { + local run="$TEST_SKILL_DIR/runner-terminal" + mkdir -p "$run" + printf 'short\n' >"$run/codex-desktop-relay.desktop-token" + printf '%064d\n' 0 | tr '0' 'b' >"$run/codex-desktop-relay.bridge-token" + chmod 600 "$run"/*-token + + AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="$run" AGMSG_CODEX_DESKTOP_RELAY_PORT=0 \ + run bash "$TYPES/codex/codex-desktop-relay-run.sh" + + [ "$status" -eq 0 ] + [ "$(grep -c 'desktop capability must be 32 random bytes' <<<"$output")" -eq 1 ] +} + +@test "relay runner retries transient app-server crashes with delay" { + local run="$TEST_SKILL_DIR/runner-transient" fake="$TEST_SKILL_DIR/fake-crashing-codex" calls="$TEST_SKILL_DIR/crash-calls" + mkdir -p "$run" + printf '%064d\n' 0 | tr '0' 'a' >"$run/codex-desktop-relay.desktop-token" + printf '%064d\n' 0 | tr '0' 'b' >"$run/codex-desktop-relay.bridge-token" + chmod 600 "$run"/*-token + cat >"$fake" <>"$calls" +exit 1 +EOF + chmod +x "$fake" + + AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="$run" AGMSG_CODEX_DESKTOP_RELAY_PORT=0 \ + AGMSG_CODEX_DESKTOP_RELAY_CODEX="$fake" \ + bash "$TYPES/codex/codex-desktop-relay-run.sh" >"$TEST_SKILL_DIR/runner-transient.log" 2>&1 & + local pid=$! + for _ in {1..150}; do + [ "$(wc -l <"$calls" 2>/dev/null || echo 0)" -ge 2 ] && break + sleep 0.02 + done + [ "$(wc -l <"$calls")" -ge 2 ] + kill -TERM "$pid" + wait "$pid" 2>/dev/null || true +} + +@test "relayctl rotates exposed tokens, reuses private tokens, and redacts every durable surface" { + local desktop_token bridge_token output_text + printf '%064d\n' 0 | tr '0' 'a' > "$RELAY_RUN/codex-desktop-relay.desktop-token" + printf '%064d\n' 0 | tr '0' 'b' > "$RELAY_RUN/codex-desktop-relay.bridge-token" + chmod 644 "$RELAY_RUN/codex-desktop-relay.desktop-token" + chmod 600 "$RELAY_RUN/codex-desktop-relay.bridge-token" + + run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable + [ "$status" -eq 0 ] + [[ "$output" == *"app_server=ws://127.0.0.1:"*"/"* ]] + desktop_token="$(cat "$RELAY_RUN/codex-desktop-relay.desktop-token")" + bridge_token="$(cat "$RELAY_RUN/codex-desktop-relay.bridge-token")" + [ "$desktop_token" != "$(printf '%064d' 0 | tr '0' 'a')" ] + [ "$bridge_token" = "$(printf '%064d' 0 | tr '0' 'b')" ] + [ "$(stat -f '%Lp' "$RELAY_RUN/codex-desktop-relay.desktop-token")" = "600" ] + [ "$(stat -f '%Lp' "$RELAY_RUN/codex-desktop-relay.bridge-token")" = "600" ] + + output_text="$(bash "$TYPES/codex/codex-desktop-relayctl.sh" status)" + [[ "$output_text" != *"$desktop_token"* ]] + [[ "$output_text" != *"$bridge_token"* ]] + ! grep -q "$desktop_token\|$bridge_token" \ + "$FAKE_LAUNCHCTL_DIR/bootstrap.plist" "$RELAY_RUN/codex-desktop-relay.health" \ + "$FAKE_LAUNCHCTL_DIR/relay.log" + grep -A3 -q 'KeepAlive.*' "$FAKE_LAUNCHCTL_DIR/bootstrap.plist" + grep -A2 'KeepAlive' "$FAKE_LAUNCHCTL_DIR/bootstrap.plist" \ + | grep -q 'SuccessfulExit' + + # A second enable must retain capabilities that have remained private. + run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable + [ "$status" -eq 0 ] + [ "$(cat "$RELAY_RUN/codex-desktop-relay.desktop-token")" = "$desktop_token" ] + [ "$(cat "$RELAY_RUN/codex-desktop-relay.bridge-token")" = "$bridge_token" ] +} + +@test "relayctl bootstrap failure rolls back env, plist, runtime, and private endpoints" { + printf '%s\n' 'ws://127.0.0.1:49999/desktop/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + > "$FAKE_LAUNCHCTL_DIR/env" + export FAKE_LAUNCHCTL_BOOTSTRAP_FAIL=1 + + run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable + [ "$status" -ne 0 ] + [ "$(cat "$FAKE_LAUNCHCTL_DIR/env")" = 'ws://127.0.0.1:49999/desktop/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ] + [ ! -e "$RELAY_PLISTS/com.agmsg.codex-desktop-relay.plist" ] + [ ! -e "$RELAY_RUN/codex-desktop-relay.desktop-token" ] + [ ! -e "$RELAY_RUN/codex-desktop-relay.bridge-token" ] + [ ! -e "$RELAY_RUN/codex-desktop-relay.desktop-endpoint" ] + [ ! -e "$RELAY_RUN/codex-desktop-relay.bridge-endpoint" ] + [ ! -e "$RELAY_RUN/codex-desktop-relay.pid" ] +} + +@test "relayctl disable removes its stale Desktop env even when the endpoint file is missing" { + local owned="ws://127.0.0.1:$AGMSG_CODEX_DESKTOP_RELAY_PORT/desktop/$(printf '%064d' 0 | tr '0' 'c')" + printf '%s\n' "$owned" > "$FAKE_LAUNCHCTL_DIR/env" + + run bash "$TYPES/codex/codex-desktop-relayctl.sh" disable + [ "$status" -eq 0 ] + [ ! -e "$FAKE_LAUNCHCTL_DIR/env" ] + [[ "$output" == *"/"* ]] + [[ "$output" != *"$(printf '%064d' 0 | tr '0' 'c')"* ]] +} + +@test "relayctl disable restores the Desktop endpoint that existed before enable" { + local prior='ws://127.0.0.1:49998/desktop/original-endpoint' + printf '%s\n' "$prior" > "$FAKE_LAUNCHCTL_DIR/env" + + run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable + [ "$status" -eq 0 ] + [ "$(cat "$RELAY_RUN/codex-desktop-relay.prior-desktop-endpoint")" = "$prior" ] + [ "$(stat -f '%Lp' "$RELAY_RUN/codex-desktop-relay.prior-desktop-endpoint")" = "600" ] + + run bash "$TYPES/codex/codex-desktop-relayctl.sh" disable + [ "$status" -eq 0 ] + [ "$(cat "$FAKE_LAUNCHCTL_DIR/env")" = "$prior" ] + [ ! -e "$RELAY_RUN/codex-desktop-relay.prior-desktop-endpoint" ] +} + +@test "relayctl rejects a port owned by another listener before partial installation" { + local holder="$TEST_SKILL_DIR/ctl-port-holder.js" port_file="$TEST_SKILL_DIR/ctl-port" holder_pid + cat > "$holder" <<'NODE' +const fs = require("fs"); +const net = require("net"); +const server = net.createServer(() => {}); +server.listen(0, "127.0.0.1", () => fs.writeFileSync(process.argv[2], `${server.address().port}\n`)); +NODE + node "$holder" "$port_file" & + holder_pid=$! + for _ in {1..100}; do [ -s "$port_file" ] && break; sleep 0.05; done + export AGMSG_CODEX_DESKTOP_RELAY_PORT="$(cat "$port_file")" + printf '%s\n' "$holder_pid" > "$RELAY_RUN/codex-desktop-relay.pid" + + run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable + kill "$holder_pid" 2>/dev/null || true + wait "$holder_pid" 2>/dev/null || true + [ "$status" -ne 0 ] + [[ "$output" == *"owned by another listener"* ]] + [ ! -e "$RELAY_PLISTS/com.agmsg.codex-desktop-relay.plist" ] + [ ! -e "$RELAY_RUN/codex-desktop-relay.desktop-token" ] +} + +@test "relayctl status rejects a recycled pid with forged health" { + sleep 60 & + local unrelated_pid=$! + printf '%s\n' "$unrelated_pid" > "$RELAY_RUN/codex-desktop-relay.pid" + printf '%s\n' "$AGMSG_CODEX_DESKTOP_RELAY_PORT" > "$RELAY_RUN/codex-desktop-relay.port" + cat > "$RELAY_RUN/codex-desktop-relay.health" </dev/null || true + wait "$unrelated_pid" 2>/dev/null || true + [ "$status" -ne 0 ] + [[ "$output" == *"status=not_running"* ]] +} diff --git a/tests/test_codex_monitor_lease.bats b/tests/test_codex_monitor_lease.bats index 27f68b68..4d0bd15d 100644 --- a/tests/test_codex_monitor_lease.bats +++ b/tests/test_codex_monitor_lease.bats @@ -29,6 +29,18 @@ arm() { [[ "$output" == *"status=active"* ]] } +@test "enabling the Desktop monitor disarms a legacy scheduled lease" { + arm >/dev/null + + run bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" == *"Disabled legacy Codex heartbeat/watchdog lease state"* ]] || return 1 + + run bash "$LEASE" heartbeat "$TEST_PROJECT" team alice thread-123 --ttl 60 + [ "$status" -eq 3 ] + [[ "$output" == *"status=inactive"* ]] || return 1 +} + @test "heartbeat renews an active lease and fallback stays dormant" { arm >/dev/null diff --git a/tests/test_codex_shim.bats b/tests/test_codex_shim.bats index ce1eb336..e861bf6e 100644 --- a/tests/test_codex_shim.bats +++ b/tests/test_codex_shim.bats @@ -35,11 +35,21 @@ teardown() { teardown_test_env } -@test "codex shim: monitor project routes resume through codex-monitor" { +@test "codex shim: monitor project passes through by default" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$TYPES/codex/codex-shim.sh" resume --last' + [ "$status" -eq 0 ] + grep -q "real-codex <--last>" "$CALL_LOG" + ! grep -q "^monitor" "$CALL_LOG" +} + +@test "codex shim: explicit legacy opt-in routes resume through codex-monitor" { + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 bash "$TYPES/codex/codex-shim.sh" resume --last' + [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> <--last>" "$CALL_LOG" } @@ -47,7 +57,7 @@ teardown() { @test "codex shim: monitor project routes prompt launches through top-level codex" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$TYPES/codex/codex-shim.sh" "fix this"' + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 bash "$TYPES/codex/codex-shim.sh" "fix this"' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> " "$CALL_LOG" @@ -79,6 +89,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ + AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 \ run bash "$TYPES/codex/codex-shim.sh" --cd "$TEST_PROJECT" resume [ "$status" -eq 0 ] @@ -94,6 +105,7 @@ teardown() { [ "$status" -eq 0 ] [[ "$output" == *"codex() {"* ]] [[ "$output" == *"codex-shim.sh"* ]] + [[ "$output" == *"pass-through by default"* ]] [ ! -e "$HOME/.agents/bin/codex" ] } @@ -110,6 +122,7 @@ teardown() { chmod +x "$real_bin/codex" PATH="$HOME/.agents/bin:$real_bin:$PATH" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ + AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 \ run bash -c 'eval "$("$TYPES/codex/codex-shim-install.sh" function)"; cd "$TEST_PROJECT"; codex resume --last' [ "$status" -eq 0 ] @@ -125,6 +138,7 @@ teardown() { chmod +x "$HOME/.agents/bin/codex" PATH="$HOME/.agents/bin:$PATH" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ + AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 \ run bash -c 'eval "$("$TYPES/codex/codex-shim-install.sh" function)"; cd "$TEST_PROJECT"; codex resume --last' [ "$status" -eq 0 ] @@ -138,7 +152,8 @@ teardown() { bash "$TYPES/codex/codex-shim-install.sh" install >/dev/null [ -x "$HOME/.agents/bin/codex" ] - PATH="$HOME/.agents/bin:$PATH" run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" codex resume' + PATH="$HOME/.agents/bin:$PATH" AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 \ + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" codex resume' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <-->" "$CALL_LOG" diff --git a/tests/test_codex_visible_monitor.bats b/tests/test_codex_visible_monitor.bats index 80b23221..83fbea33 100644 --- a/tests/test_codex_visible_monitor.bats +++ b/tests/test_codex_visible_monitor.bats @@ -33,6 +33,22 @@ join_codex_roles() { bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null } +install_fake_launchctl() { + local fakebin="$TEST_SKILL_DIR/fakebin" + export AGMSG_FAKE_LAUNCHCTL_LOG="$TEST_SKILL_DIR/fake-launchctl.log" + mkdir -p "$fakebin" + cat > "$fakebin/launchctl" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$AGMSG_FAKE_LAUNCHCTL_LOG" +case "${1:-}" in + print) exit 1 ;; + *) exit 0 ;; +esac +EOF + chmod +x "$fakebin/launchctl" + export PATH="$fakebin:$PATH" +} + @test "codex actas in turn mode persists the selected role without a background receiver" { join_codex_roles bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null @@ -43,7 +59,7 @@ join_codex_roles() { [[ "$output" == *"status=visible_turn_only"* ]] [[ "$output" == *"name=bob"* ]] - local state="$TEST_SKILL_DIR/run/codex-last-actas.$(project_hash).tsv" + local state="$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" [ -f "$state" ] awk -F '\t' '$2 == "codex" && $3 == "team" && $4 == "bob" { found=1 } END { exit !found }' "$state" @@ -53,34 +69,300 @@ join_codex_roles() { run ! grep -q "session-start.sh" "$hooks" } -@test "codex SessionStart rebinds the last actas role after restart" { +@test "codex actas resolves an allowed role name containing spaces" { + local name='codex receiver' + bash "$SCRIPTS/join.sh" team "$name" codex "$TEST_PROJECT" >/dev/null + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null + + run env CODEX_THREAD_ID=thread-spaced \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex "$name" thread-spaced + + [ "$status" -eq 0 ] + [[ "$output" == *"status=visible_turn_only"* ]] + [[ "$output" == *"name=$name"* ]] +} + +@test "codex SessionStart rebinds only the same exact task and cannot move the role" { join_codex_roles bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null env CODEX_THREAD_ID=thread-before \ bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-before >/dev/null - rm -f "$TEST_SKILL_DIR/run/codex-chat-visible.team.bob.meta" + rm -f "$TEST_SKILL_DIR/run/codex-chat-visible.$(project_hash).team.bob.meta" run env CODEX_THREAD_ID=thread-after \ bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" /dev/null + + env CODEX_THREAD_ID=thread-owner \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-owner >/dev/null + run env CODEX_THREAD_ID=thread-thief \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-thief + + [ "$status" -eq 5 ] + [[ "$output" == *"status=role_held"* ]] + [[ "$output" == *"owner_thread=thread-owner"* ]] + awk -F '\t' '$5 == "thread-owner" { found=1 } END { exit !found }' \ + "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" + awk -F '\t' '$5 == "thread-owner" { found=1 } END { exit !found }' \ + "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" + grep -q '^codex-seat:' "$TEST_SKILL_DIR/run/actas.team__bob.session" +} + +@test "codex actas refuses a role held by a live generic actas owner" { + join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null + setup_live_owner "$TEST_SKILL_DIR/run" "generic-owner" + printf 'generic-owner\n' > "$TEST_SKILL_DIR/run/actas.team__bob.session" + + run env CODEX_THREAD_ID=thread-codex \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-codex + + [ "$status" -eq 5 ] + [[ "$output" == *"status=role_held"* ]] + [ ! -e "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" ] + [ "$(cat "$TEST_SKILL_DIR/run/actas.team__bob.session")" = "generic-owner" ] +} + +@test "codex seats allow different roles in the same project to stay bound to different tasks" { + join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null + + env CODEX_THREAD_ID=thread-bob \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-bob >/dev/null + env CODEX_THREAD_ID=thread-alice \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex alice thread-alice >/dev/null + + awk -F '\t' '$4 == "bob" && $5 == "thread-bob" { found=1 } END { exit !found }' \ + "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" + awk -F '\t' '$4 == "alice" && $5 == "thread-alice" { found=1 } END { exit !found }' \ + "$TEST_SKILL_DIR/run/codex-seat.team.alice.tsv" + [ -e "$TEST_SKILL_DIR/run/codex-chat-visible.$(project_hash).team.bob.meta" ] + [ -e "$TEST_SKILL_DIR/run/codex-chat-visible.$(project_hash).team.alice.meta" ] +} + +@test "codex global role seat refuses the same team role from another project" { + join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null + local other="$TEST_SKILL_DIR/other-seat-project" + mkdir -p "$other" + bash "$SCRIPTS/join.sh" team bob codex "$other" >/dev/null + bash "$SCRIPTS/delivery.sh" set turn codex "$other" >/dev/null + env CODEX_THREAD_ID=thread-owner \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-owner >/dev/null + + run env CODEX_THREAD_ID=thread-other \ + bash "$TYPES/codex/actas-monitor.sh" "$other" codex bob thread-other + + [ "$status" -eq 5 ] + [[ "$output" == *"owner_thread=thread-owner"* ]] +} + +@test "codex fallback boots out the exact meta-less launchd job without changing monitor mode" { + join_codex_roles + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + install_fake_launchctl + local state_key="$(project_hash).team.bob" + local base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" + mkdir -p "$TEST_SKILL_DIR/run" + : > "$base.plist" + + run env CODEX_THREAD_ID=thread-123 \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-123 + + [ "$status" -eq 0 ] + [[ "$output" == *"effective_mode=turn"* ]] + grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$state_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" + [ ! -e "$base.plist" ] + run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" + [[ "$output" == *"mode: monitor"* ]] +} + +@test "codex fallback ignores a forged launch label and unrelated recycled pid" { + skip_on_windows "process liveness assertion uses POSIX kill semantics" + join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null + install_fake_launchctl + local state_key="$(project_hash).team.bob" + local base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" unrelated_pid + mkdir -p "$TEST_SKILL_DIR/run" + sleep 60 & + unrelated_pid=$! + TEST_RECEIVER_PIDS="$TEST_RECEIVER_PIDS $unrelated_pid" + printf '%s\n' "$unrelated_pid" > "$base.pid" + cat > "$base.meta" < "$base.plist" <<'EOF' +Labelcom.example.other-unrelated-job +EOF + + run env CODEX_THREAD_ID=thread-forged \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-forged + + [ "$status" -eq 0 ] + kill -0 "$unrelated_pid" 2>/dev/null + ! grep -q 'com.example' "$AGMSG_FAKE_LAUNCHCTL_LOG" + grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$state_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" + [ ! -e "$base.pid" ] + [ ! -e "$base.meta" ] + [ ! -e "$base.plist" ] +} + +@test "codex SessionEnd releases the exact lease and meta-less launchd job" { + join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null + install_fake_launchctl + env CODEX_THREAD_ID=thread-ending \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-ending >/dev/null + local state_key="$(project_hash).team.bob" + local base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" + : > "$base.plist" + rm -f "$base.meta" "$base.pid" + + printf '%s\n' '{"session_id":"thread-ending"}' | \ + bash "$SCRIPTS/session-end.sh" codex "$TEST_PROJECT" + + grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$state_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" + [ ! -e "$base.plist" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" ] + [ ! -e "$TEST_SKILL_DIR/run/actas.team__bob.session" ] +} + +@test "codex reset removes only the target role's meta-less launchd job and lease" { + join_codex_roles + bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null + install_fake_launchctl + env CODEX_THREAD_ID=thread-drop \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-drop >/dev/null + local bob_key="$(project_hash).team.bob" + local alice_key="$(project_hash).team.alice" + : > "$TEST_SKILL_DIR/run/codex-bridge.$bob_key.plist" + : > "$TEST_SKILL_DIR/run/codex-bridge.$alice_key.plist" + + run bash "$SCRIPTS/reset.sh" "$TEST_PROJECT" codex bob + + [ "$status" -eq 0 ] + grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$bob_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" + ! grep -q "com.agmsg.codex-bridge.$alice_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" + [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.$bob_key.plist" ] + [ -e "$TEST_SKILL_DIR/run/codex-bridge.$alice_key.plist" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" ] + [ ! -e "$TEST_SKILL_DIR/run/actas.team__bob.session" ] +} + +@test "codex reset stops a bridge owned by a non-ASCII team and role" { + skip_on_windows "process ownership assertion uses POSIX command lines" + local team='日本チーム' name='受信係' thread='thread-japanese' + bash "$SCRIPTS/join.sh" "$team" "$name" codex "$TEST_PROJECT" >/dev/null + install_fake_launchctl + local safe_team safe_name state_key base bridge_pid + safe_team="$(SKILL_DIR="$TEST_SKILL_DIR" bash -c '. "$1/lib/actas-lock.sh"; _actas_lock_encode "$2"' _ "$SCRIPTS" "$team")" + safe_name="$(SKILL_DIR="$TEST_SKILL_DIR" bash -c '. "$1/lib/actas-lock.sh"; _actas_lock_encode "$2"' _ "$SCRIPTS" "$name")" + state_key="$(project_hash).$safe_team.$safe_name" + base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" + mkdir -p "$TEST_SKILL_DIR/run" + cat > "$TYPES/codex/codex-bridge.js" <<'EOF' +#!/usr/bin/env bash +trap 'exit 0' TERM INT +while :; do sleep 1; done +EOF + chmod +x "$TYPES/codex/codex-bridge.js" + "$TYPES/codex/codex-bridge.js" --project "$TEST_PROJECT" --type codex \ + --team "$team" --name "$name" --state-key "$state_key" \ + --app-server-file "$base.appserver" --thread "$thread" & + bridge_pid=$! + TEST_RECEIVER_PIDS="$bridge_pid" + printf '%s\n' "$bridge_pid" > "$base.pid" + cat > "$base.meta" < "$base.plist" + + run bash "$SCRIPTS/reset.sh" "$TEST_PROJECT" codex "$name" + + [ "$status" -eq 0 ] + run ! kill -0 "$bridge_pid" + [ ! -e "$base.pid" ] + [ ! -e "$base.meta" ] + [ ! -e "$base.plist" ] + TEST_RECEIVER_PIDS="" +} + +@test "codex delivery off removes only this project's meta-less jobs and preserves the global relay" { + join_codex_roles + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + install_fake_launchctl + env CODEX_THREAD_ID=thread-off \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-off >/dev/null + local project_key="$(project_hash).team.bob" + local other_project="$TEST_SKILL_DIR/other-project" other_hash other_key relay_pid + mkdir -p "$other_project" + other_hash="$(printf '%s' "$(cd "$other_project" && pwd)" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + other_key="$other_hash.team.bob" + : > "$TEST_SKILL_DIR/run/codex-bridge.$project_key.plist" + : > "$TEST_SKILL_DIR/run/codex-bridge.$other_key.plist" + sleep 60 & + relay_pid=$! + TEST_RECEIVER_PIDS="$TEST_RECEIVER_PIDS $relay_pid" + printf '%s\n' "$relay_pid" > "$TEST_SKILL_DIR/run/codex-desktop-relay.pid" + + run bash "$SCRIPTS/delivery.sh" set off codex "$TEST_PROJECT" + + [ "$status" -eq 0 ] + grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$project_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" + ! grep -q "com.agmsg.codex-bridge.$other_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" + [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.$project_key.plist" ] + [ -e "$TEST_SKILL_DIR/run/codex-bridge.$other_key.plist" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" ] + kill -0 "$relay_pid" +} + +@test "codex visible Stop hook exposes only pending metadata for the last actas role" { join_codex_roles bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null env CODEX_THREAD_ID=thread-123 \ bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-123 >/dev/null bash "$SCRIPTS/send.sh" team alice bob "visible-bob-message" >/dev/null - run bash "$SCRIPTS/check-inbox.sh" codex "$TEST_PROJECT" /dev/null + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + local server port_file bridge_pid + server="$TEST_SKILL_DIR/stalled-relay.js" + port_file="$TEST_SKILL_DIR/stalled-relay.port" + cat > "$server" <<'NODE' +#!/usr/bin/env node +const crypto = require("crypto"); +const fs = require("fs"); +const net = require("net"); +const portFile = process.argv[2]; +const server = net.createServer((socket) => { + let buffer = Buffer.alloc(0); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + const header = buffer.slice(0, end).toString("utf8"); + const key = (/sec-websocket-key:\s*([^\r\n]+)/i.exec(header) || [])[1]; + if (!key) return socket.destroy(); + const accept = crypto.createHash("sha1").update(`${key.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64"); + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); + socket.removeAllListeners("data"); + }); +}); +server.listen(0, "127.0.0.1", () => fs.writeFileSync(portFile, String(server.address().port))); +NODE + node "$server" "$port_file" & + local server_pid=$! + TEST_RECEIVER_PIDS="$server_pid" + for _ in {1..50}; do [ -s "$port_file" ] && break; sleep 0.05; done + [ -s "$port_file" ] + local port="$(cat "$port_file")" + mkdir -p "$TEST_SKILL_DIR/run" + printf 'ws://127.0.0.1:%s/bridge/%s\n' "$port" "$(printf '%064d' 0 | tr '0' 'b')" \ + > "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint" + chmod 600 "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint" + cat > "$TEST_SKILL_DIR/run/codex-desktop-relay.health" </dev/null" + [ "$status" -ne 0 ] run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ --team team --name bob --timeout 0 [ "$status" -eq 0 ] @@ -195,7 +541,7 @@ EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] - [[ "$output" == *"mode: turn"* ]] + [[ "$output" == *"mode: monitor"* ]] } @test "delivery turn stops an app monitor even when no bridge pidfile exists" { @@ -203,7 +549,14 @@ EOF bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - sleep 60 & + cat > "$TYPES/codex/codex-app-monitor.sh" <<'EOF' +#!/usr/bin/env bash +trap 'exit 0' TERM INT +while :; do sleep 1; done +EOF + chmod +x "$TYPES/codex/codex-app-monitor.sh" + "$TYPES/codex/codex-app-monitor.sh" \ + "$(cd "$TEST_PROJECT" && pwd -P)" codex team alice thread-123 & local app_pid=$! mkdir -p "$TEST_SKILL_DIR/run" printf '%s\n' "$app_pid" > "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.pid" @@ -240,10 +593,44 @@ EOF [[ "$output" == *"status=inactive"* ]] } +@test "delivery cleanup removes forged app-monitor state without signaling its pid or label" { + skip_on_windows "process liveness assertion uses POSIX kill semantics" + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + install_fake_launchctl + + sleep 60 & + local unrelated_pid=$! + TEST_RECEIVER_PIDS="$TEST_RECEIVER_PIDS $unrelated_pid" + local base="$TEST_SKILL_DIR/run/codex-app-monitor.team.alice" + mkdir -p "$TEST_SKILL_DIR/run" + printf '%s\n' "$unrelated_pid" > "$base.pid" + cat > "$base.meta" < "$base.plist" <<'EOF' +Labelcom.example.other-unrelated-job +EOF + + run bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" + + [ "$status" -eq 0 ] + kill -0 "$unrelated_pid" 2>/dev/null + ! grep -q 'com.example' "$AGMSG_FAKE_LAUNCHCTL_LOG" + [ ! -e "$base.pid" ] + [ ! -e "$base.meta" ] + [ ! -e "$base.plist" ] +} + @test "dropping one codex role stops only that role's background receiver" { skip_on_windows "background receiver liveness uses POSIX signals" join_codex_roles - local sleeper="$TEST_SKILL_DIR/codex-app-monitor.sh" bob_pid alice_pid + local sleeper="$TYPES/codex/codex-app-monitor.sh" bob_pid alice_pid cat > "$sleeper" <<'EOF' #!/usr/bin/env bash trap 'exit 0' TERM INT @@ -253,9 +640,9 @@ done EOF chmod +x "$sleeper" - "$sleeper" & + "$sleeper" "$TEST_PROJECT" codex team bob thread-bob & bob_pid=$! - "$sleeper" & + "$sleeper" "$TEST_PROJECT" codex team alice thread-alice & alice_pid=$! TEST_RECEIVER_PIDS="$bob_pid $alice_pid" mkdir -p "$TEST_SKILL_DIR/run" @@ -331,14 +718,14 @@ EOF bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" "$sleeper" <<'EOF' #!/usr/bin/env bash trap 'exit 0' TERM INT while :; do sleep 1; done EOF chmod +x "$sleeper" - "$sleeper" & + "$sleeper" "$TEST_PROJECT" codex team bob thread-ending & receiver_pid=$! TEST_RECEIVER_PIDS="$receiver_pid" mkdir -p "$TEST_SKILL_DIR/run" @@ -361,14 +748,14 @@ EOF @test "codex SessionEnd stops only the receiver bound to that visible thread" { skip_on_windows "background receiver liveness uses POSIX signals" join_codex_roles - local sleeper="$TEST_SKILL_DIR/codex-app-monitor.sh" receiver_pid + local sleeper="$TYPES/codex/codex-app-monitor.sh" receiver_pid cat > "$sleeper" <<'EOF' #!/usr/bin/env bash trap 'exit 0' TERM INT while :; do sleep 1; done EOF chmod +x "$sleeper" - "$sleeper" & + "$sleeper" "$TEST_PROJECT" codex team bob thread-ending & receiver_pid=$! TEST_RECEIVER_PIDS="$receiver_pid" mkdir -p "$TEST_SKILL_DIR/run" @@ -399,11 +786,13 @@ EOF @test "codex monitor never falls back to hidden resume or a new task" { grep -q 'background-only handling is prohibited' "$TYPES/codex/codex-app-monitor.sh" - grep -q 'visible_app_server_unavailable' "$TYPES/codex/actas-monitor.sh" + grep -q 'desktop_relay_endpoint_unavailable' "$TYPES/codex/actas-monitor.sh" run grep -q 'start_codex_app_monitor "$THREAD_ID"' "$TYPES/codex/actas-monitor.sh" [ "$status" -ne 0 ] run grep -q 'start_bridge ""' "$TYPES/codex/actas-monitor.sh" [ "$status" -ne 0 ] run grep -q 'THREAD_ID="new"' "$TYPES/codex/actas-monitor.sh" [ "$status" -ne 0 ] + run grep -Eq 'codex-app-monitor|background-thread-resume|unix://|THREAD_ID="loaded"|resolve_thread_id|thread/start' "$TYPES/codex/actas-monitor.sh" + [ "$status" -ne 0 ] } diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index b3497cf4..b9b66ea2 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -693,12 +693,10 @@ JSON unset CLAUDE_CODE_SESSION_ID } -@test "session-start.sh for codex matches rollout cwd via a symlinked project path (#160)" { +@test "session-start.sh for codex never infers an exact thread from a symlink-matched rollout" { skip_on_windows "codex bridge launch and symlink path on Windows (#182)" - # agmsg opens the project through a symlink (linkproj), but Codex records the - # canonical/physical cwd (realproj) in session_meta. A raw string compare - # misses the rollout, so the thread never resolves and the bridge never - # starts. With physical-path canonicalization the two reconcile. + # A rollout can match the same project through a physical/symlink spelling, + # but it is not proof that this visible Codex task owns that thread. local realproj="$TEST_PROJECT/realproj" local linkproj="$TEST_PROJECT/linkproj" mkdir -p "$realproj" @@ -722,22 +720,16 @@ printf '%s\n' "$*" >> "$AGMSG_TEST_LOG" EOF chmod +x "$fake" - # env -u CODEX_THREAD_ID forces the rollout-scan fallback that does the - # compare — without it, a CODEX_THREAD_ID inherited from the parent env (e.g. - # running the suite inside a Codex session) short-circuits the resolver and - # this test never exercises the path it's meant to cover. + # Legacy launcher inputs are intentionally ignored as well: without an exact + # current thread and a matching saved actas binding, SessionStart is quiet. AGMSG_CODEX_BRIDGE_APP_SERVER="unix://$TEST_SKILL_DIR/run/codex-app-server.test.sock" \ AGMSG_CODEX_BRIDGE_CMD="$fake" \ AGMSG_TEST_LOG="$log" \ env -u CODEX_THREAD_ID bash "$SCRIPTS/session-start.sh" codex "$linkproj" >/dev/null - for _ in {1..20}; do - [ -f "$log" ] && break - sleep 0.1 - done - - [ -f "$log" ] - grep -q -- "--thread thread-sym" "$log" + [ ! -f "$log" ] + run bash -c "compgen -G '$TEST_SKILL_DIR/run/codex-bridge.*.pid' >/dev/null" + [ "$status" -ne 0 ] } # --- gemini agent tests --- @@ -1447,7 +1439,7 @@ JSON } # --- Codex monitor bridge (#41) --- -@test "session-start.sh for codex starts bridge when monitor launcher env is present" { +@test "session-start.sh for codex ignores legacy launcher env without a saved exact binding" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null local fake="$TEST_SKILL_DIR/fake-codex-bridge" local log="$TEST_SKILL_DIR/fake-codex-bridge.log" @@ -1464,20 +1456,12 @@ EOF CODEX_THREAD_ID="thread-123" \ bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null - for _ in {1..20}; do - [ -f "$log" ] && break - sleep 0.1 - done - - [ -f "$log" ] - grep -q -- "--project $TEST_PROJECT" "$log" - grep -q -- "--thread thread-123" "$log" - grep -q -- "--app-server unix://$TEST_SKILL_DIR/run/codex-app-server.test.sock" "$log" - run grep -q -- "--inline-inbox" "$log" + [ ! -f "$log" ] + run bash -c "compgen -G '$TEST_SKILL_DIR/run/codex-bridge.*.pid' >/dev/null" [ "$status" -ne 0 ] } -@test "session-start.sh for codex stays quiet without monitor launcher env" { +@test "session-start.sh for codex stays quiet without a saved exact actas binding" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null local fake="$TEST_SKILL_DIR/fake-codex-bridge" local log="$TEST_SKILL_DIR/fake-codex-bridge.log" @@ -1496,13 +1480,14 @@ EOF @test "delivery set monitor (codex): installs persistent SessionStart plus visible Stop fallback" { run bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" [ "$status" -eq 0 ] - [[ "$output" == *"Codex monitor beta is enabled"* ]] - [[ "$output" == *"codex() {"* ]] - [[ "$output" == *"codex-shim.sh"* ]] - [[ "$output" == *"launch with codex"* ]] - [[ "$output" == *"Optional global PATH shim is still available"* ]] - [[ "$output" == *"For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md"* ]] - [[ "$output" != *"Monitor tool"* ]] + [[ "$output" == *"Codex visible monitor beta is enabled"* ]] || return 1 + [[ "$output" == *"authenticated Desktop relay"* ]] || return 1 + [[ "$output" == *"intended visible Codex task"* ]] || return 1 + [[ "$output" == *"mail remains unread"* ]] || return 1 + [[ "$output" != *"codex-shim.sh"* ]] || return 1 + [[ "$output" != *"codex() {"* ]] || return 1 + [[ "$output" == *"For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md"* ]] || return 1 + [[ "$output" != *"Monitor tool"* ]] || return 1 [ ! -e "$HOME/.agents/bin/codex" ] local hook_file="$TEST_PROJECT/.codex/hooks.json" [ -f "$hook_file" ] @@ -1518,7 +1503,7 @@ EOF grep -q "check-inbox.sh" "$hook_file" } -@test "delivery status (codex): live bridge reports alive and suppresses watch count" { +@test "delivery status (codex): live bridge reports the visible exact-thread runtime" { skip_on_windows "codex bridge status liveness under Git Bash (#182)" bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null @@ -1535,20 +1520,21 @@ project=$TEST_PROJECT team=team name=alice type=codex +thread=thread-status EOF printf '%s\n' 99999999 > "$TEST_SKILL_DIR/run/watch.fake.pid" run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex bridge: team/alice alive (pid $bpid)"* ]] - [[ "$output" != *"watch processes:"* ]] + [[ "$output" == *"Codex visible bridge: team/alice active pid=$bpid thread=thread-status"* ]] || return 1 + [[ "$output" != *"watch processes:"* ]] || return 1 kill "$bpid" 2>/dev/null || true trap - EXIT } -@test "delivery status (codex): stale bridge pidfile is reported as stale" { +@test "delivery status (codex): dead bridge metadata is reported as stale" { skip_on_windows "codex bridge status liveness under Git Bash (#182)" bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null @@ -1565,16 +1551,17 @@ project=$TEST_PROJECT team=team name=alice type=codex +thread=thread-stale EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex bridge: team/alice stale pidfile (pid $dead_pid not running)"* ]] - [[ "$output" != *"watch processes:"* ]] + [[ "$output" == *"Codex visible bridge: team/alice stale thread=thread-stale"* ]] || return 1 + [[ "$output" != *"watch processes:"* ]] || return 1 } -@test "delivery status (codex): bridge metadata mismatch is reported as stale" { +@test "delivery status (codex): another project's bridge metadata is excluded" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null mkdir -p "$TEST_SKILL_DIR/run" @@ -1591,11 +1578,11 @@ EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex bridge: team/alice stale pidfile (metadata mismatch)"* ]] - [[ "$output" != *"watch processes:"* ]] + [[ "$output" == *"Codex visible bridge: no role is currently bound for this project"* ]] || return 1 + [[ "$output" != *"watch processes:"* ]] || return 1 } -@test "delivery status (codex): missing bridge metadata is reported as stale" { +@test "delivery status (codex): a pidfile without metadata is not reported as a bridge" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null mkdir -p "$TEST_SKILL_DIR/run" @@ -1605,22 +1592,22 @@ EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex bridge: team/alice stale pidfile (missing metadata)"* ]] - [[ "$output" != *"watch processes:"* ]] + [[ "$output" == *"Codex visible bridge: no role is currently bound for this project"* ]] || return 1 + [[ "$output" != *"watch processes:"* ]] || return 1 } -@test "delivery status (codex): identity with no bridge reports not running" { +@test "delivery status (codex): identity without a runtime reports no bound role" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex bridge: team/alice not running"* ]] - [[ "$output" != *"watch processes:"* ]] + [[ "$output" == *"Codex visible bridge: no role is currently bound for this project"* ]] || return 1 + [[ "$output" != *"watch processes:"* ]] || return 1 } -@test "delivery status (codex): multiple identities are enumerated independently" { +@test "delivery status (codex): reports only identities with runtime metadata" { skip_on_windows "codex bridge status liveness under Git Bash (#182)" bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null @@ -1638,30 +1625,37 @@ project=$TEST_PROJECT team=team name=alice type=codex +thread=thread-alice EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] - [[ "$output" == *"Codex bridge: team/alice alive (pid $bpid)"* ]] - [[ "$output" == *"Codex bridge: team/bob not running"* ]] - [[ "$output" != *"watch processes:"* ]] + [[ "$output" == *"Codex visible bridge: team/alice active pid=$bpid thread=thread-alice"* ]] || return 1 + [[ "$output" != *"team/bob"* ]] || return 1 + [[ "$output" != *"watch processes:"* ]] || return 1 kill "$bpid" 2>/dev/null || true trap - EXIT } -@test "delivery status (codex): monitor mode with no identities is explicit" { +@test "delivery status (codex): monitor mode with no bound role is explicit" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex bridge: no identities registered for this project"* ]] - [[ "$output" != *"watch processes:"* ]] + [[ "$output" == *"Codex visible bridge: no role is currently bound for this project"* ]] || return 1 + [[ "$output" != *"watch processes:"* ]] || return 1 } -@test "session-start.sh for codex resolves thread id from rollout when CODEX_THREAD_ID is unset" { +@test "session-start.sh for codex preserves the saved exact thread when CODEX_THREAD_ID is unset" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + env CODEX_THREAD_ID=thread-owner \ + bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex alice thread-owner >/dev/null + local project_hash + project_hash="$(printf '%s' "$(cd "$TEST_PROJECT" && pwd)" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + rm -f "$TEST_SKILL_DIR/run/codex-chat-visible.$project_hash.team.alice.meta" local fake="$TEST_SKILL_DIR/fake-codex-bridge" local log="$TEST_SKILL_DIR/fake-codex-bridge.log" cat >"$fake" <<'EOF' @@ -1670,8 +1664,8 @@ printf '%s\n' "$*" >> "$AGMSG_TEST_LOG" EOF chmod +x "$fake" - # Fresh/exec Codex sessions do not export CODEX_THREAD_ID; the hook must read - # the thread id from the newest rollout whose session_meta cwd matches (#41). + # A matching rollout is still ambiguous across visible tasks and must not + # replace the explicit thread-owner binding. local rollout_dir="$TEST_SKILL_DIR/home/.codex/sessions/2026/06/17" mkdir -p "$rollout_dir" printf '%s\n' "{\"type\":\"session_meta\",\"payload\":{\"id\":\"rollout-thread-999\",\"cwd\":\"$TEST_PROJECT\"}}" \ @@ -1684,9 +1678,11 @@ EOF AGMSG_TEST_LOG="$log" \ env -u CODEX_THREAD_ID bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null - for _ in {1..20}; do [ -f "$log" ] && break; sleep 0.1; done - [ -f "$log" ] - grep -q -- "--thread rollout-thread-999" "$log" + [ ! -f "$log" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-chat-visible.$project_hash.team.alice.meta" ] + grep -q '^codex-seat:' "$TEST_SKILL_DIR/run/actas.team__alice.session" + awk -F '\t' '$5 == "thread-owner" { found=1 } END { exit !found }' \ + "$TEST_SKILL_DIR/run/codex-seat.team.alice.tsv" } @test "delivery set monitor (codex): warns loudly when Node is missing" { @@ -1699,33 +1695,50 @@ EOF [[ "$output" == *"monitor delivery will NOT start"* ]] } -@test "delivery set off (codex): stops the bridge, cleans run files, notes shell profile cleanup" { +@test "delivery set off (codex): stops the bridge and preserves the shared relay" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null mkdir -p "$TEST_SKILL_DIR/run" - # Stand in for a live bridge with a real process we can check kill -0 against. - sleep 60 & + source "$SCRIPTS/lib/hash.sh" + local h; h="$(printf '%s' "$TEST_PROJECT" | agmsg_sha1)" + local state_key="$h.team.alice" + local base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" + local bridge="$TYPES/codex/codex-bridge.js" + cat > "$bridge" <<'EOF' +#!/usr/bin/env bash +trap 'exit 0' TERM INT +while :; do sleep 1; done +EOF + chmod +x "$bridge" + "$bridge" --project "$TEST_PROJECT" --type codex --team team --name alice \ + --state-key "$state_key" --app-server-file "$base.appserver" --thread thread-off & local bpid=$! - echo "$bpid" > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" - echo "pid=$bpid" > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.meta" - : > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.log" + echo "$bpid" > "$base.pid" + cat > "$base.meta" < "$base.log" # The launcher's stale-binding sidecar + the project's shared app-server record # must be torn down too. Use a non-codex pid for the server record so the # cmdline guard skips the kill — the record is still dropped. - : > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.appserver" - source "$SCRIPTS/lib/hash.sh" - local h; h="$(printf '%s' "$TEST_PROJECT" | agmsg_sha1)" + : > "$base.appserver" echo 2147483647 > "$TEST_SKILL_DIR/run/codex-app-server.$h.pid" : > "$TEST_SKILL_DIR/run/codex-app-server.$h.port" : > "$TEST_SKILL_DIR/run/codex-app-server.$h.version" run bash "$SCRIPTS/delivery.sh" set off codex "$TEST_PROJECT" [ "$status" -eq 0 ] - [[ "$output" == *"Stopped 1 Codex bridge"* ]] - [[ "$output" == *"shim"* ]] + [[ "$output" == *"Stopped 1"*"Codex bridge"* ]] || return 1 + [[ "$output" == *"shared Codex Desktop relay remains installed"* ]] || return 1 + [[ "$output" != *"shim"* ]] || return 1 ! kill -0 "$bpid" 2>/dev/null - [ ! -f "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" ] - [ ! -f "$TEST_SKILL_DIR/run/codex-bridge.team.alice.meta" ] - [ ! -f "$TEST_SKILL_DIR/run/codex-bridge.team.alice.appserver" ] + [ ! -f "$base.pid" ] + [ ! -f "$base.meta" ] + [ ! -f "$base.appserver" ] [ ! -f "$TEST_SKILL_DIR/run/codex-app-server.$h.pid" ] [ ! -f "$TEST_SKILL_DIR/run/codex-app-server.$h.port" ] [ ! -f "$TEST_SKILL_DIR/run/codex-app-server.$h.version" ] diff --git a/tests/test_uninstall_codex_monitor.bats b/tests/test_uninstall_codex_monitor.bats new file mode 100644 index 00000000..01eecc71 --- /dev/null +++ b/tests/test_uninstall_codex_monitor.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats + +setup() { + export HOME="$(mktemp -d)" + export INSTALLED="$HOME/.agents/skills/agmsg" + export TEST_LOG="$HOME/uninstall-actions.log" + mkdir -p "$INSTALLED/run" "$INSTALLED/scripts/drivers/types/codex" "$HOME/fakebin" + : > "$INSTALLED/.agmsg" + TEST_PID="" + + cat > "$HOME/fakebin/launchctl" <<'EOF' +#!/usr/bin/env bash +printf 'launchctl %s\n' "$*" >> "$TEST_LOG" +exit 0 +EOF + chmod +x "$HOME/fakebin/launchctl" + export PATH="$HOME/fakebin:$PATH" + + cat > "$INSTALLED/scripts/drivers/types/codex/codex-desktop-relayctl.sh" <<'EOF' +#!/usr/bin/env bash +printf 'relayctl %s\n' "$*" >> "$TEST_LOG" +EOF + chmod +x "$INSTALLED/scripts/drivers/types/codex/codex-desktop-relayctl.sh" +} + +teardown() { + if [ -n "${TEST_PID:-}" ]; then + kill "$TEST_PID" 2>/dev/null || true + wait "$TEST_PID" 2>/dev/null || true + fi + rm -rf "$HOME" +} + +@test "uninstall removes a meta-less role job and disables the global Desktop relay" { + local base="$INSTALLED/run/codex-bridge.deadbeef.team.bob" + cat > "$base.plist" <<'EOF' + + + Label + com.agmsg.codex-bridge.deadbeef.team.bob + +EOF + + run bash "$BATS_TEST_DIRNAME/../uninstall.sh" --yes --keep-data + + [ "$status" -eq 0 ] + grep -qx "launchctl bootout gui/$(id -u)/com.agmsg.codex-bridge.deadbeef.team.bob" "$TEST_LOG" + grep -qx 'relayctl disable' "$TEST_LOG" + [ ! -e "$base.plist" ] +} + +@test "uninstall derives the managed label and does not signal a forged pid" { + local project="$HOME/project" base="$INSTALLED/run/codex-bridge.deadbeef.team.bob" + mkdir -p "$project" + sleep 60 & + TEST_PID=$! + printf '%s\n' "$TEST_PID" > "$base.pid" + cat > "$base.meta" < "$base.plist" <<'EOF' + + + Label + com.example.other-unrelated-job + +EOF + + run bash "$BATS_TEST_DIRNAME/../uninstall.sh" --yes --keep-data + + [ "$status" -eq 0 ] + kill -0 "$TEST_PID" 2>/dev/null + grep -qx "launchctl bootout gui/$(id -u)/com.agmsg.codex-bridge.deadbeef.team.bob" "$TEST_LOG" + ! grep -q 'com.example' "$TEST_LOG" + [ ! -e "$base.pid" ] + [ ! -e "$base.meta" ] + [ ! -e "$base.plist" ] +} diff --git a/uninstall.sh b/uninstall.sh index e206b7a9..93ebd679 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -64,6 +64,99 @@ echo "" REMOVED=false +codex_bridge_label_for_base() { + local base_name="${1##*/}" suffix + case "$base_name" in codex-bridge.*) suffix="${base_name#codex-bridge.}" ;; *) return 1 ;; esac + case "$suffix" in ""|*[!A-Za-z0-9._%-]*) return 1 ;; esac + printf 'com.agmsg.codex-bridge.%s' "$suffix" +} + +canonical_project_path() { + local project="$1" + if [ -d "$project" ]; then + (cd -P "$project" 2>/dev/null && pwd -P) + else + printf '%s' "$project" + fi +} + +installed_bridge_pid_matches() { + local pid="$1" base="$2" meta="$3" state_key project canonical_project type team name thread cmd expected prefix + [ -f "$meta" ] || return 1 + state_key="${base##*/codex-bridge.}" + case "$state_key" in ""|*[!A-Za-z0-9._%-]*) return 1 ;; esac + project="$(sed -n 's/^project=//p' "$meta" | head -1)" + type="$(sed -n 's/^type=//p' "$meta" | head -1)" + team="$(sed -n 's/^team=//p' "$meta" | head -1)" + name="$(sed -n 's/^name=//p' "$meta" | head -1)" + thread="$(sed -n 's/^thread=//p' "$meta" | head -1)" + canonical_project="$(canonical_project_path "$project")" + [ "$type" = "codex" ] && [ -n "$canonical_project" ] && [ -n "$team" ] \ + && [ -n "$name" ] && [ -n "$thread" ] || return 1 + case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac + cmd="$(ps -o args= -p "$pid" 2>/dev/null || true)" + expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --state-key $state_key --app-server-file $base.appserver --thread $thread" + case " $cmd " in *" $expected "*) return 0 ;; esac + # Pre-state-key bridge records are still removable, but signaling one also + # requires the full legacy project/role/thread argv contract. + prefix="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --app-server " + case " $cmd " in *" $prefix"*" --thread $thread "*) return 0 ;; esac + return 1 +} + +# --- 0. Stop Codex role bridges and remove the global Desktop relay --- +# This is the only automatic path that removes the shared relay itself. Normal +# delivery mode changes stop project-owned role bridges but keep the relay for +# other projects and future visible tasks. +for SKILL_DIR in "${SKILL_DIRS[@]}"; do + for plist in "$SKILL_DIR"/run/codex-bridge.*.plist; do + [ -f "$plist" ] || continue + base="${plist%.plist}" + label="$(codex_bridge_label_for_base "$base" 2>/dev/null || true)" + [ -n "$label" ] || continue + if command -v launchctl >/dev/null 2>&1; then + launchctl bootout "gui/$(id -u)/$label" >/dev/null 2>&1 || true + fi + pid="$(cat "$base.pid" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + if installed_bridge_pid_matches "$pid" "$base" "$base.meta"; then + kill "$pid" 2>/dev/null || true + fi + fi + rm -f "$base.pid" "$base.meta" "$base.health" "$base.appserver" \ + "$base.plist" "$base.log" "$base.last-ids" "$base.binding" + rm -f "$base".wake.*.json "$base.relay-wake.json" + done + for meta in "$SKILL_DIR"/run/codex-bridge.*.meta; do + [ -f "$meta" ] || continue + base="${meta%.meta}" + label="$(codex_bridge_label_for_base "$base" 2>/dev/null || true)" + [ -n "$label" ] || continue + if command -v launchctl >/dev/null 2>&1; then + launchctl bootout "gui/$(id -u)/$label" >/dev/null 2>&1 || true + fi + pid="$(cat "$base.pid" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + if installed_bridge_pid_matches "$pid" "$base" "$meta"; then + kill "$pid" 2>/dev/null || true + fi + fi + rm -f "$base.pid" "$base.meta" "$base.health" "$base.appserver" \ + "$base.plist" "$base.log" "$base.last-ids" "$base.binding" + rm -f "$base".wake.*.json "$base.relay-wake.json" + done + rm -f "$SKILL_DIR"/run/codex-seat.*.tsv + for lock in "$SKILL_DIR"/run/actas.*.session; do + [ -f "$lock" ] || continue + owner="$(head -1 "$lock" 2>/dev/null || true)" + case "$owner" in codex-seat:*) rm -f "$lock" ;; esac + done + relayctl="$SKILL_DIR/scripts/drivers/types/codex/codex-desktop-relayctl.sh" + if [ -x "$relayctl" ]; then + "$relayctl" disable >/dev/null 2>&1 || true + fi +done + # --- 1. Remove slash commands and hooks from joined projects --- for SKILL_DIR in "${SKILL_DIRS[@]}"; do TEAMS_DIR="$SKILL_DIR/teams" From e14c367e433dfce967d0eebaf0c6a648978b22e5 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Tue, 14 Jul 2026 23:35:45 +0900 Subject: [PATCH 10/12] =?UTF-8?q?Revert=20"[auto]=20Codex=20Desktop?= =?UTF-8?q?=E5=8F=AF=E8=A6=96=E7=9B=A3=E8=A6=96=E3=82=92=E5=AE=8C=E6=88=90?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8e8adf2bb629d11046ebfeba48a1765c58397b39. --- .gitignore | 1 - README.md | 15 +- docs/codex-monitor-beta.md | 326 +++-- install.sh | 12 +- scripts/check-inbox.sh | 60 +- scripts/delivery.sh | 227 +-- scripts/drivers/types/codex/_delivery.sh | 213 ++- scripts/drivers/types/codex/_session-start.sh | 164 ++- scripts/drivers/types/codex/actas-monitor.sh | 793 ++++++----- .../types/codex/codex-bridge-launcher.sh | 55 +- scripts/drivers/types/codex/codex-bridge.js | 817 ++++------- .../types/codex/codex-desktop-relay-run.sh | 61 - .../types/codex/codex-desktop-relay.js | 1223 ----------------- .../types/codex/codex-desktop-relayctl.sh | 336 ----- .../drivers/types/codex/codex-shim-install.sh | 11 +- scripts/drivers/types/codex/codex-shim.sh | 10 +- scripts/drivers/types/codex/template.md | 37 +- scripts/lib/actas-lock.sh | 120 +- scripts/lib/subscription.sh | 20 +- scripts/reset.sh | 178 +-- scripts/session-end.sh | 118 +- tests/test_actas_lock.bats | 20 - tests/test_codex_bridge.bats | 202 ++- tests/test_codex_bridge_persistence.bats | 210 --- tests/test_codex_desktop_relay.bats | 1155 ---------------- tests/test_codex_desktop_relayctl.bats | 238 ---- tests/test_codex_monitor_lease.bats | 12 - tests/test_codex_shim.bats | 21 +- tests/test_codex_visible_monitor.bats | 425 +----- tests/test_delivery.bats | 167 ++- tests/test_uninstall_codex_monitor.bats | 83 -- uninstall.sh | 93 -- 32 files changed, 1529 insertions(+), 5894 deletions(-) mode change 100755 => 100644 scripts/drivers/types/codex/_delivery.sh mode change 100755 => 100644 scripts/drivers/types/codex/_session-start.sh delete mode 100755 scripts/drivers/types/codex/codex-desktop-relay-run.sh delete mode 100755 scripts/drivers/types/codex/codex-desktop-relay.js delete mode 100755 scripts/drivers/types/codex/codex-desktop-relayctl.sh delete mode 100644 tests/test_codex_bridge_persistence.bats delete mode 100644 tests/test_codex_desktop_relay.bats delete mode 100644 tests/test_codex_desktop_relayctl.bats delete mode 100644 tests/test_uninstall_codex_monitor.bats diff --git a/.gitignore b/.gitignore index 01e91225..8745b97f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # Runtime data (created at install destination, not in repo) db/ teams/ -/run/ .DS_Store .codex/ .claude/ diff --git a/README.md b/README.md index b2493179..bb2c7b9c 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ How incoming messages reach your agent. Pick one at first join via the prompt, o | mode | mechanism | latency | who it's for | |---|---|---|---| -| **`monitor`** (default on Claude Code) | Claude Code: Monitor tool stream. Codex beta: authenticated Desktop relay + exact-thread role bridge | ~5s after attachment | Claude Code users; opted-in Codex Desktop users | +| **`monitor`** (default on Claude Code) | SessionStart hook → Monitor tool → blocking SQLite stream | ~5s | Claude Code users wanting real-time push | | **`turn`** (default on Codex / Copilot CLI / OpenCode) | Stop hook fires `check-inbox.sh` between assistant turns | until your next interaction | Codex / Copilot CLI / OpenCode (no Monitor tool); Claude Code users on a quieter loop | | **`both`** | monitor primary, turn as per-session safety net | ~5s; falls back to turn-end on watcher failure | belt-and-suspenders | | **`off`** | no automatic delivery | manual `/agmsg` only | minimalists | @@ -302,9 +302,9 @@ $agmsg — or /skills → agmsg Codex supports `mode monitor` as a **beta** visible app-server receiver, plus `mode turn` and `mode off`. -> ⚠️ **Monitor is active only when the Codex Desktop relay and the exact-thread role bridge both report ready.** The relay multiplexes Desktop and the bridge onto one app-server, so a bridge-initiated turn is rendered in the same task. Bridge access requires both the relay's private seed and a `0600` per-role binding fixed to the canonical project, role, and exact thread; the shared seed alone cannot attach. Enabling it requires one Codex Desktop restart. A temporary fallback keeps the requested mode as `monitor`, uses the visible turn path for that task, and leaves mail unread. Background `codex exec resume`, heartbeat, cron, rollout inference, and scheduled polling are prohibited. +> ⚠️ **Monitor is active only when a visible app-server bridge attaches to the persisted Codex task.** Background `codex exec resume` delivery is prohibited because a successful CLI turn can consume and answer mail without displaying the handling in Codex Desktop. When no visible bridge is available, agmsg keeps mail unread and downgrades the effective mode to `turn`. No heartbeat, cron, or scheduled polling task is created. -Run `agmsg actas ` in the intended visible task to bind its exact thread. SessionStart restores that role only in the same exact task; another task cannot infer or steal the binding. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). +If you prefer a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). ### GitHub Copilot CLI @@ -473,7 +473,6 @@ sandbox_mode = "workspace-write" writable_roots = [ "~/.agents/skills/agmsg/db", "~/.agents/skills/agmsg/teams", - "~/.agents/skills/agmsg/run", ] ``` @@ -484,7 +483,6 @@ If you installed agmsg under a custom command name, adjust the path accordingly: writable_roots = [ "~/.agents/skills/m/db", "~/.agents/skills/m/teams", - "~/.agents/skills/m/run", ] ``` @@ -497,10 +495,9 @@ writable_roots = [ ] ``` -Codex supports `mode turn`, `mode off`, and an opted-in Desktop `mode monitor` -beta. The beta uses an authenticated local relay because Codex does not expose -Claude Code's Monitor tool. The sandbox allowlist is required for manual -commands, turn-end checks, and the monitor's private runtime state. +Codex only supports `mode turn` and `mode off`; it does not have Claude Code's +Monitor tool. The sandbox allowlist is still required for writes performed by +manual `$agmsg` commands and turn-end inbox checks. Some Codex runtimes or automations may inject a managed permission profile for a single run. In that case, the run-specific writable roots must also include the diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index dcfd7301..156d203c 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -1,25 +1,38 @@ # Codex Monitor Beta -Codex does not expose Claude Code's Monitor tool. -agmsg therefore uses one authenticated loopback relay to let Codex Desktop and a role bridge share the same app-server. -An inbound wake starts a turn only in the exact visible Codex task selected by `actas`. +Codex does not expose Claude Code's Monitor tool. agmsg's Codex monitor beta can +deliver mail only through an app-server bridge that renders the handling in the +same visible Codex thread. The last explicit `actas` role is rebound from +SessionStart after restart or compaction. -> **Experimental beta.** Monitor is active only while the Desktop relay and the exact-thread role bridge both report `ready`. -> If Desktop, the relay, or the bridge is unavailable, the requested mode remains `monitor`, the current task uses the visible turn fallback, and mail remains unread. +> ⚠️ **Experimental beta — read before enabling.** This changes how Codex starts. +> Monitor mode is active only after a visible app-server bridge attaches to the +> selected thread. If no bridge is available, agmsg keeps mail unread and changes +> the effective mode to `turn`. Background `codex exec resume`, cron, heartbeat, +> and scheduled polling are prohibited because they can process mail without +> showing the work to the human operator. -## Enable +## Quick Start + +Enable monitor mode in a project: ```bash ~/.agents/skills/agmsg/scripts/delivery.sh set monitor codex "$PWD" ``` -Then run `agmsg actas ` in the Codex task that should receive the role's messages. -The task must provide an exact `CODEX_THREAD_ID`; agmsg never guesses it from rollout files, a loaded-task list, or a newly created thread. +The command: + +1. Enables Codex SessionStart/SessionEnd hooks plus the visible Stop-hook + fallback for the project. +2. Persists the last explicit `actas` role so SessionStart can rebind it. +3. After `actas` binds a concrete thread, attaches the visible app-server bridge + for that exact team/role/thread tuple or downgrades to `turn`. +4. Prints a shell function that routes interactive `codex` launches through the + monitor shim. -On macOS, enabling monitor mode installs the per-user Desktop relay LaunchAgent. -Restart Codex Desktop once so it inherits the relay endpoint. -Until Desktop reconnects, `actas-monitor.sh` reports a visible-turn fallback instead of claiming that monitoring is active. -Enabling this relay also disarms any lease state left by the earlier heartbeat/watchdog design, so both delivery paths cannot remain active for the same project. +The bridge may observe unread count and high-water id, but it does not read +message bodies or mark messages read. The visible persisted thread owns the +official inbox read, substantive work, progress reporting, and any reply. The Codex sandbox must allow writes to the installed skill's runtime state: @@ -29,134 +42,233 @@ The Codex sandbox must allow writes to the installed skill's runtime state: ~/.agents/skills//run ``` -## Architecture +`install.sh` and `install.sh --update` add these writable roots to +`~/.codex/config.toml` when that file exists. -```mermaid -flowchart LR - desktop["Visible Codex Desktop"] -->|private desktop capability| relay["loopback Desktop relay"] - bridge["exact-thread role bridge"] -->|bridge seed + private role capability| relay - relay --> appserver["one stdio app-server"] - bridge --> watch["one watch-once process"] - watch --> db[("agmsg SQLite")] - bridge -->|deterministic wake dispatch| relay - relay -->|turn/start with exact thread id| desktop +Add the printed function to your shell profile. It looks like: + +```bash +codex() { + ~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-shim.sh "$@" +} ``` -The relay listens only on `127.0.0.1`. -Desktop uses one private capability, while bridges require both a shared bridge seed and a random per-role capability. -The Desktop capability, bridge seed, role binding, and role endpoint are regular non-symlink `0600` files. -Capabilities are not written to plists, logs, health files, status output, or public bridge metadata. +Restart the shell, then launch Codex normally: -The role binding fixes the canonical project, type, team, role, exact thread id, and role state key before the relay accepts a bridge connection. -Possession of the shared bridge seed alone cannot open a role connection. -Only one bridge may use a role binding at a time. +```bash +codex +``` -The bridge is scoped by that binding. -It may call only the relay methods needed to resume that thread, dispatch a deterministic wake, and manage its owned `watch-once.sh` process. -The relay rejects bridge attempts to create a thread, address another thread, override project roots or history, spawn another command, or kill another client's process. -Approval and other server requests are routed only to the visible Desktop client. +In monitor-mode projects, the function routes interactive Codex launches through +the bridge. Outside monitor-mode projects, it passes through to the real Codex. -## Exact-task lease +When Codex.app is opened normally, SessionStart restores the last `actas` role. +If no visible app-server is available, the effective delivery mode becomes +`turn`; the Stop hook checks the inbox on a later visible assistant turn. agmsg +does not claim that autonomous monitoring is active in this state. -An explicit `actas` stores the project, type, team, role, and exact thread id in a global `(team, role)` Codex seat. -It also claims the same atomic role-lock namespace used by generic `actas`, so a Claude and a Codex task cannot concurrently own the same inbox role. -SessionStart restores the role only when the current exact thread id equals the stored id. -A different or new Codex task cannot take the role implicitly and must run `actas` explicitly. -If another task still holds the role, that explicit claim is rejected until the original task ends or the role is dropped/reset. +`mode off`, `mode turn`, `drop`/`reset`, and SessionEnd stop the matching +receiver and remove its LaunchAgent/runtime files. No cron, heartbeat, or +scheduled polling automation is created for this path. -SessionEnd, `drop`/`reset`, `mode turn`, and `mode off` stop the matching role bridge and release its lease. -Cleanup derives the launchd label from the project and role, so it still removes a job when a crash has already deleted `.meta` or `.pid`. -Project mode changes do not stop the global relay; uninstall or an explicit `codex-desktop-relayctl.sh disable` does. +## Optional PATH Shim -## Unread and acknowledgement boundary +If you prefer the previous global PATH shim setup, install it explicitly: -The bridge observes only unread count and `max_id` through `watch-once.sh`. -It does not read message bodies and never updates `read_at`. -The Codex Stop hook is also peek-only. +```bash +~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-shim-install.sh install +``` -Only the official command below, run inside the visible task, acknowledges Codex mail: +Then put `~/.agents/bin` before the real Codex binary on PATH: ```bash -~/.agents/skills//scripts/inbox.sh +export PATH="$HOME/.agents/bin:$PATH" ``` -The bridge starts a turn that instructs the visible task to run that command, handle the message, show progress and results in the same task, and send any reply through `send.sh`. +If `~/.agents/bin/codex` already exists and is not the agmsg shim, agmsg leaves +it untouched. You can either move that command aside and run the install command +again, or launch monitor sessions explicitly: -Each role and exact thread has a private `0600` wake-state file. -The bridge records `observed`, `accepted`, and `ack_confirmed` phases with an atomic rename and a file `fsync`. -The wake's `clientUserMessageId` is a deterministic SHA-256 marker derived from the role state key, exact thread, and unread `max_id`; it contains none of those raw values. - -The relay keeps a separate private, directory-synced dispatch record. -It writes `dispatching` before `turn/start` and `accepted` after app-server accepts the turn. -After a relay or Desktop restart, an existing dispatch record permits history reconciliation only; it never permits a blind second `turn/start`. +```bash +~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-monitor.sh +``` -Before the relay calls `turn/start`, it reads the exact thread history and checks that marker. -If the first response was lost after app-server accepted the turn, a retry returns only `reconciled`; it never exposes thread history to the bridge and never starts a second turn. +For custom command names, replace `agmsg` with the installed skill name: -| Condition | Health/status | Automatic action | Unread handling | -| --- | --- | --- | --- | -| New `max_id` | `ready` → `observed` internally | Dispatch once with the deterministic marker | Remains unread | -| Dispatch response or history check is unavailable | `paused_ambiguous_wake` | Retry reconciliation with capped exponential delay and the same marker | Remains unread; no blind resend | -| The same accepted `max_id` is still unread | `waiting_for_ack` | Re-arm after a bounded delay without another dispatch | Remains unread | -| `watch-once.sh` reports no unread mail | `ready`; state becomes `ack_confirmed` | Continue watching | The visible task already acknowledged it through `inbox.sh` | -| Watcher fails below the configured limit | `waiting_watch_retry` | Retry with capped exponential delay | Remains unread | -| Watcher repeatedly fails | `paused_watch_failure` | Keep the bridge alive and continue bounded retries | Remains unread | -| app-server or relay connection is lost | `retrying_transient` | Reconnect in-process with capped exponential delay | Remains unread | -| Exact `thread/resume` is invalid | `terminal_thread_error` | Exit successfully and retain a tombstone | Remains unread | +```bash +~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh +``` -`paused_ambiguous_wake` is a fail-closed transient state, not permission to resend. -The bridge stays attached and retries only the history reconciliation path. -It does not wait for another SessionStart or explicit `actas` to recover. +## What The Shim Does -The role and relay LaunchAgents use `KeepAlive.SuccessfulExit=false`. -Unexpected crashes are restarted, while terminal thread errors and permanent configuration failures exit successfully so launchd does not loop. -The relay runner owns capped app-server retry backoff and passes its pid to the relay; if the runner disappears, the relay exits nonzero and reaps its app-server process group before launchd restarts the pair. +The shell function and optional PATH shim only wrap interactive Codex TUI +launches: -## Fallback behavior +```bash +codex +codex resume +codex "fix this bug" +``` -Fallback is per task and does not rewrite the project's requested mode from `monitor` to `turn`. -This matters during the initial Desktop restart: a pre-restart fallback must be able to attach automatically on the next SessionStart of the same task. +Noninteractive subcommands pass through to the real Codex binary: -Fallback writes a project/role-scoped visible status record and leaves unread rows untouched. -It never invokes `codex exec resume`, starts a hidden Codex session, creates a new app-server thread, scans rollout files, or schedules polling. +```bash +codex exec ... +codex app-server ... +codex login +codex logout +``` -## Status and shutdown +The shim also passes through when the current project is not in Codex monitor +mode. + +## Bridge Mechanics + +`codex-monitor.sh` starts (or reuses) an agmsg-managed Codex app-server socket +under `~/.agents/skills//run/`, starts the out-of-sandbox bridge launcher, +and then connects the Codex TUI to that socket with `--remote`. + +Codex fires the SessionStart hook on the session's **first turn** (the first +message you send), not the moment the TUI opens — so the bridge does not exist +until you interact once after a restart. + +The SessionStart hook is designed to **not** start the bridge directly — a +hook-launched process was observed to run inside the Codex sandbox and fail to +connect to the unix socket (EPERM). Instead: + +> Note: this EPERM-avoidance design (the launcher + request-file rendezvous +> below) is under review — in practice the hook has been seen to launch a +> detached bridge directly and connect fine, suggesting the launcher layer may +> be redundant. See #153. + +1. `session-start.sh` (the hook) resolves the thread id — `CODEX_THREAD_ID` when + set, otherwise the newest Codex rollout whose `session_meta` cwd matches the + project (fresh / `codex exec` sessions never export `CODEX_THREAD_ID`) — and + writes a **request file** under `run/` (it never touches the socket). +2. `codex-bridge-launcher.sh`, started by `codex-monitor.sh` **outside** the + sandbox, reads the request file and starts `codex-bridge.js`. +3. The bridge connects to the same app-server over **WebSocket-over-UDS**, + resumes the thread, and arms `watch-once.sh` via the app-server `process/spawn` + API (which checks the agmsg DB for unread rows, `read_at IS NULL`). +4. On unread state it starts a turn on that thread with an instruction to run + the official `inbox.sh`. The bridge does not read the message body or mark it + read on the normal path. The visible Codex turn owns reading, substantive + work, verification, and any reply, then the bridge re-arms after the turn. + +Turns are serialized (one per thread): a message that arrives while a turn is +running stays unread and is delivered after the turn completes. The turn ends +via `turn/completed`, a `thread/status` idle, or a watchdog (the real app-server +does not reliably send `turn/completed`); only then is the next `watch-once` +armed. If a turn does not consume the unread message, the same `max_id` reappears +and the bridge stops instead of looping. -```bash -~/.agents/skills/agmsg/scripts/delivery.sh status codex "$PWD" -~/.agents/skills/agmsg/scripts/delivery.sh set turn codex "$PWD" -~/.agents/skills/agmsg/scripts/delivery.sh set off codex "$PWD" -~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-desktop-relayctl.sh status -~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-desktop-relayctl.sh disable +```mermaid +flowchart TD + user["User runs codex"] --> shim["codex shell function or ~/.agents/bin/codex shim"] + shim --> mode{"Project delivery mode?"} + mode -- "not monitor / codex exec / --version" --> real["real codex"] + mode -- "monitor (interactive)" --> monitor["codex-monitor.sh"] + + monitor --> server{"app-server socket exists?"} + server -- "no" --> startServer["codex app-server --listen unix://..."] + server -- "yes" --> reuseServer["reuse socket"] + monitor --> launcher["codex-bridge-launcher.sh (outside sandbox)"] + startServer --> remote["codex --remote unix://..."] + reuseServer --> remote + + remote --> hook["SessionStart hook → session-start.sh (in sandbox)"] + hook --> thread["resolve thread: CODEX_THREAD_ID || newest matching rollout"] + thread --> request["write request file under run/ (no socket — EPERM)"] + request -.-> launcher + launcher --> bridge["codex-bridge.js → app-server (WebSocket-over-UDS)"] + bridge --> watch["arm watch-once.sh (process/spawn)"] + watch --> db[("agmsg SQLite DB (read_at IS NULL)")] + db --> unread{"Unread message?"} + unread -- "no (timeout)" --> watch + unread -- "yes" --> turn["turn/start with official inbox instruction"] + turn --> tui["Current Codex TUI thread"] + tui --> inbox["official inbox.sh reads and marks messages"] + inbox --> work["substantive work, verification, and reply"] + work --> ended["turn ends: completed / idle / watchdog"] + ended --> watch ``` -A healthy monitor requires all of the following: +## Worker Guardrails + +> ⚠️ **Never poll agmsg by launching a full Codex/Claude session on a short +> interval.** Use a shell-only gate first and start the heavy agent only when +> there is actually something to handle. -- relay health is `ready`; -- a visible Desktop client is connected and initialized; -- the role bridge process and its owned metadata are live; -- bridge health is `ready` for the stored exact thread; -- the role's private endpoint matches the relay's current private endpoint. +### Case study: empty-poll OOM (#163) -`mode turn` and `mode off` stop only bridges owned by the selected project. -Another project's bridge and the shared relay remain running. +A user wired an autonomous worker as a `cron` job (`FREQ=MINUTELY;INTERVAL=3`) +that launched a **full Codex session every 3 minutes** to check the agmsg inbox, +git state, GitHub issues, and so on. The approval/away-window had already +expired, so almost every tick returned `No new messages.` — yet each tick still +spun up a complete Codex session with a long prompt and project context. -## Prohibited worker shape +About 60 Codex sessions were created in under three hours. Codex Desktop keeps a +transcript / trace / tool-output / local log DB per session, so the no-op runs +accumulated: `~/.codex/logs_2.sqlite` grew to ~2.2 GB (plus ~1.1 GB WAL), Codex +memory climbed to ~158 GB, and macOS hit a Low-Memory / jetsam state that forced +a hard restart. -Do not combine `watch-once.sh` with cron, heartbeat, scheduled automation, or `codex exec resume`. -That pattern creates heavyweight Codex sessions even when no useful work is visible, can consume mail outside the user's task, and previously caused runaway task and log-database growth. +This is **not** an agmsg transport or SQLite bug. The root cause is the worker +shape: a short-interval scheduler that runs a heavyweight agent as the poller, +with no cheap no-op path, so empty inboxes still pay the full cost — and Codex +Desktop's per-session UI/log accumulation amplifies it. -`watch-once.sh` is only a shell gate used by the exact visible bridge: +### `watch-once.sh` is not a Codex Desktop delivery fallback + +`watch-once.sh` is a shell-only, one-shot inbox oracle. The visible app-server +bridge uses it to avoid starting a turn on an empty inbox. ```text -exit 0 unread inbound exists (status=pending count= max_id=) -exit 2 nothing pending -exit 1 configuration or runtime error +exit 0 unread inbound exists (prints: status=pending count= max_id=) +exit 2 nothing pending (prints: status=timeout) +exit 1 configuration / runtime error ``` -## Related files - -- [Desktop relay](../scripts/drivers/types/codex/codex-desktop-relay.js) -- [Relay controller](../scripts/drivers/types/codex/codex-desktop-relayctl.sh) -- [Role bridge](../scripts/drivers/types/codex/codex-bridge.js) -- [Role binding](../scripts/drivers/types/codex/actas-monitor.sh) +Do not combine it with a scheduler and `codex exec` as a substitute for Codex +Desktop delivery. That path cannot guarantee that the received message, +progress, reply, and result appear in the user's visible thread. + +### Defense in depth + +For a separately authorized non-Desktop worker, layer these on top of the gate: + +- **Single-flight lock per `(team, agent)`** so overlapping ticks don't stack + concurrent agents: + ```bash + exec 9>"/tmp/agmsg-worker.myteam.myagent.lock" + flock -n 9 || exit 0 # another tick is still running; skip this one + ``` +- **Approval / away-window expiry check before launch.** If the worker is only + authorized for a window, verify it hasn't expired *before* starting the agent, + and disable the worker (or exit) once it has — don't leave it `ACTIVE` past its + window. +- **Exponential backoff on repeated no-ops.** After N consecutive empty gates, + widen the interval so an idle worker stops hammering. +- **Max-run cap.** Bound total runs (e.g. `COUNT` for `cron`) and prefer + intervals measured in minutes, not seconds. +- **Codex Desktop note.** Codex Desktop retains transcript / tool-output / trace + per session and a local log DB (`~/.codex/logs_*.sqlite`). Even short no-op + sessions accumulate there, so a high-frequency spawner is far heavier than the + per-run wall-clock suggests. The shell gate above avoids creating those + sessions at all on empty ticks. + +### Emergency stop (runaway worker) + +1. Make the worker inactive / unschedule the `cron` job so it stops spawning. +2. Back off delivery: `delivery.sh set turn codex "$PROJECT"` (or `off`) to stop + monitor delivery. +3. Kill stale monitors / spawned sessions and any orphaned bridge + (`mode off` tears the bridge down; see #149). +4. Inspect Codex Desktop log-DB bloat: `~/.codex/logs_*.sqlite` and its WAL. + +## Related Details + +- [Delivery modes](../README.md#delivery-modes) +- [Codex bridge implementation](../scripts/drivers/types/codex/codex-bridge.js) +- [Monitor launcher](../scripts/drivers/types/codex/codex-monitor.sh) +- [Codex shim](../scripts/drivers/types/codex/codex-shim.sh) diff --git a/install.sh b/install.sh index 9b078b66..f9ce147d 100755 --- a/install.sh +++ b/install.sh @@ -296,9 +296,7 @@ if [ "$UPDATE_ONLY" = true ]; then cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true chmod +x "$SKILL_DIR/scripts/"*.sh chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true - # Refresh the optional Codex compatibility shim (~/.agents/bin/codex) if - # it is already installed. The current Desktop relay does not install or - # require this pass-through wrapper. + # Refresh the Codex monitor shim (~/.agents/bin/codex) if it's ours. --update # cp's the new codex-shim-install.sh but does not re-run it, so a shim from an # older install keeps its stale baked exec path after the # types/ -> scripts/drivers/types/ move. Re-running install regenerates it with @@ -307,7 +305,7 @@ if [ "$UPDATE_ONLY" = true ]; then CODEX_SHIM="$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" if [ -x "$CODEX_SHIM" ] && AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" status 2>/dev/null | grep -q '^installed:'; then AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" install >/dev/null 2>&1 \ - && echo " + refreshed optional Codex compatibility shim (~/.agents/bin/codex)" + && echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" fi install_windows_helpers INSTALLED_VERSION="$(agmsg_source_version)" @@ -373,12 +371,12 @@ cp "$SCRIPT_DIR/uninstall.sh" "$SKILL_DIR/uninstall.sh" 2>/dev/null && chmod +x cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true chmod +x "$SKILL_DIR/scripts/"*.sh chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true -# Re-point an existing optional Codex compatibility shim at the new path on a -# reinstall over an older layout (no-op when no agmsg shim is present). +# Re-point an existing Codex monitor shim at the new path on a reinstall over an +# older layout (no-op when no agmsg shim is present). See the --update block above. CODEX_SHIM="$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" if [ -x "$CODEX_SHIM" ] && AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" status 2>/dev/null | grep -q '^installed:'; then AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" install >/dev/null 2>&1 \ - && echo " + refreshed optional Codex compatibility shim (~/.agents/bin/codex)" + && echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" fi install_windows_helpers diff --git a/scripts/check-inbox.sh b/scripts/check-inbox.sh index a26a8a22..f36c0bbe 100755 --- a/scripts/check-inbox.sh +++ b/scripts/check-inbox.sh @@ -18,6 +18,8 @@ source "$SCRIPT_DIR/lib/actas-lock.sh" source "$SCRIPT_DIR/lib/resolve-project.sh" # agmsg_agent_pid, for instance-id derivation # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/type-registry.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/hash.sh" # Some Stop-hook runtimes (codex, copilot) want an explicit JSON status object # even when there is nothing to deliver; others (claude-code) stay silent. This @@ -90,25 +92,23 @@ if [ -z "$AGENT" ] || [ -z "$TEAMS" ]; then exit 0 fi -# Codex seats are globally keyed by team + role and contain one project plus -# exact task id. Select only the seat owned by this visible task; never fall -# back to another role in the same project when the exact id is unavailable. +# Codex actas changes the visible role for sends. In turn delivery, use that +# same role for receives so a chat-visible fallback does not keep polling the +# first registered identity for the project. if [ "$TYPE" = "codex" ]; then PROJECT_RESOLVED="$(agmsg_resolve_project "$PROJECT" "$TYPE")" - ACTAS_THREAD="${AGMSG_CODEX_ACTAS_THREAD:-${CODEX_THREAD_ID:-}}" - case "$ACTAS_THREAD" in ""|loaded|current|unresolved) exit 0 ;; esac - ACTAS_MATCHES=0 - for ACTAS_STATE in "$SKILL_DIR/run"/codex-seat.*.tsv; do - [ -f "$ACTAS_STATE" ] || continue - IFS=$'\t' read -r ACTAS_PROJECT ACTAS_TYPE ACTAS_TEAM ACTAS_NAME ACTAS_SAVED_THREAD _ACTAS_TS < "$ACTAS_STATE" || true - if [ "$ACTAS_PROJECT" = "$PROJECT_RESOLVED" ] && [ "$ACTAS_TYPE" = "$TYPE" ] \ - && [ "$ACTAS_SAVED_THREAD" = "$ACTAS_THREAD" ]; then + PROJECT_HASH="$(printf '%s' "$PROJECT_RESOLVED" | agmsg_sha1)" + ACTAS_STATE="$SKILL_DIR/run/codex-last-actas.$PROJECT_HASH.tsv" + if [ -f "$ACTAS_STATE" ]; then + IFS=$'\t' read -r ACTAS_PROJECT ACTAS_TYPE ACTAS_TEAM ACTAS_NAME _ACTAS_TS < "$ACTAS_STATE" || true + if [ "$ACTAS_PROJECT" = "$PROJECT_RESOLVED" ] \ + && [ "$ACTAS_TYPE" = "$TYPE" ] \ + && [ -n "${ACTAS_TEAM:-}" ] \ + && [ -n "${ACTAS_NAME:-}" ]; then AGENT="$ACTAS_NAME" TEAMS="$ACTAS_TEAM" - ACTAS_MATCHES=$((ACTAS_MATCHES + 1)) fi - done - [ "$ACTAS_MATCHES" = "1" ] || exit 0 + fi fi # Cooldown check. The marker is hook runtime state, not message storage, so it @@ -134,9 +134,7 @@ fi mkdir -p "$SKILL_DIR/run" touch "$MARKER" -# Check for unread messages. Codex Stop hooks only surface a pending notice; -# they must not acknowledge mail on behalf of the visible task. The explicit -# official inbox command remains the Codex read boundary. +# Check for unread messages and mark as read DB="$(agmsg_db_path)" if [ ! -f "$DB" ]; then exit 0; fi @@ -158,27 +156,10 @@ for team in "${TEAM_LIST[@]}"; do # role. That asymmetry is the Codex caveat documented in README — if a # Codex session actas'd into , check-inbox is still polling # whatever whoami chose first, not . - if [ "$TYPE" != "codex" ]; then - state=$(actas_lock_state "$team" "$AGENT" "${SESSION_ID:-}") - case "$state" in - other:*) continue ;; - esac - fi - - if [ "$TYPE" = "codex" ]; then - # The Stop hook is only a visibility nudge. Do not select message ids, - # senders, timestamps, or bodies here; the explicit inbox.sh invocation in - # the visible task is the sole Codex content-read and acknowledgement path. - COUNT=$(agmsg_sqlite "$DB" " - SELECT count(*) FROM messages - WHERE team='$team_sql' AND to_agent='$AGENT_SQL' AND read_at IS NULL; - ") - case "$COUNT" in ''|0|*[!0-9]*) ;; *) - OUTPUT+="$COUNT unread agmsg message(s) pending for team=$team role=$AGENT."$'\n' - ;; - esac - continue - fi + state=$(actas_lock_state "$team" "$AGENT" "${SESSION_ID:-}") + case "$state" in + other:*) continue ;; + esac RESULT=$(agmsg_sqlite "$DB" " SELECT id || char(31) || from_agent || char(31) || replace(replace(body, char(10), '\n'), char(9), '\t') || char(31) || created_at @@ -213,9 +194,6 @@ fi # New messages found if [ -n "$OUTPUT" ]; then - if [ "$TYPE" = "codex" ]; then - OUTPUT+="Run the official inbox command in this visible Codex task before handling these messages: $SCRIPT_DIR/inbox.sh $AGENT"$'\n' - fi # Escape for JSON: backslash, double-quote, newlines, tabs (macOS/Linux compatible) ESCAPED=$(printf '%s' "$OUTPUT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | awk '{if(NR>1) printf "\\n"; printf "%s",$0}') cat </dev/null; do state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" case "$state" in Z*) return 0 ;; esac @@ -339,9 +334,6 @@ wait_for_recorded_pid_exit() { check=$((check + 1)) done if kill -0 "$pid" 2>/dev/null; then - # Re-prove ownership immediately before escalation. The original process - # can exit after TERM and its PID can be recycled during this wait. - "$matcher" "$pid" "$@" || return 0 kill -KILL "$pid" 2>/dev/null || return 1 check=0 while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do @@ -354,14 +346,9 @@ wait_for_recorded_pid_exit() { ! kill -0 "$pid" 2>/dev/null } -bootout_managed_codex_label() { - local label="$1" label_suffix check=0 domain - case "$label" in - com.agmsg.codex-bridge.*) label_suffix="${label#com.agmsg.codex-bridge.}" ;; - com.agmsg.codex-app-monitor.*) label_suffix="${label#com.agmsg.codex-app-monitor.}" ;; - *) return 0 ;; - esac - case "$label_suffix" in ""|*[!A-Za-z0-9._%-]*) return 0 ;; esac +bootout_recorded_label() { + local label="$1" check=0 domain + [ -n "$label" ] || return 0 command -v launchctl >/dev/null 2>&1 || return 0 domain="gui/$(id -u)" launchctl bootout "$domain/$label" >/dev/null 2>&1 || true @@ -371,151 +358,9 @@ bootout_managed_codex_label() { done } -codex_bridge_label_for_base() { - local base_name="${1##*/}" suffix - case "$base_name" in codex-bridge.*) suffix="${base_name#codex-bridge.}" ;; *) return 1 ;; esac - case "$suffix" in ""|*[!A-Za-z0-9._%-]*) return 1 ;; esac - printf 'com.agmsg.codex-bridge.%s' "$suffix" -} - -codex_app_monitor_label() { - local project="$1" team="$2" name="$3" hash safe_team safe_name - hash="$(printf '%s' "$(agmsg_canonical_path "$project")" | agmsg_sha1)" - safe_team="$(printf '%s' "$team" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" - safe_name="$(printf '%s' "$name" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" - [ -n "$hash" ] && [ -n "$safe_team" ] && [ -n "$safe_name" ] || return 1 - printf 'com.agmsg.codex-app-monitor.%s.%s.%s' "$hash" "$safe_team" "$safe_name" -} - -codex_current_bridge_pid_matches() { - local pid="$1" base="$2" project="$3" team="$4" name="$5" thread="$6" state_key cmd expected canonical_project - state_key="${base##*/codex-bridge.}" - [ -n "$project" ] && [ -n "$state_key" ] && [ -n "$team" ] \ - && [ -n "$name" ] && [ -n "$thread" ] || return 1 - case "$state_key" in *[!A-Za-z0-9._%-]*) return 1 ;; esac - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - canonical_project="$(agmsg_canonical_path "$project")" - [ -n "$canonical_project" ] || return 1 - expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --state-key $state_key --app-server-file $base.appserver --thread $thread" - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case " $cmd " in *" $expected "*) return 0 ;; esac - return 1 -} - -codex_legacy_bridge_pid_matches() { - local pid="$1" base="$2" project="$3" team="$4" name="$5" thread="$6" cmd prefix canonical_project - [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - canonical_project="$(agmsg_canonical_path "$project")" - [ -n "$canonical_project" ] || return 1 - prefix="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --app-server " - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case " $cmd " in *" $prefix"*" --thread $thread "*) return 0 ;; esac - return 1 -} - -codex_app_monitor_pid_matches() { - local pid="$1" _base="$2" project="$3" team="$4" name="$5" thread="$6" cmd expected - [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - project="$(agmsg_canonical_path "$project")" - [ -n "$project" ] || return 1 - expected="$SKILL_DIR/scripts/drivers/types/codex/codex-app-monitor.sh $project codex $team $name $thread" - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case " $cmd " in *" $expected "*) return 0 ;; esac - return 1 -} - stop_codex_bridge() { local project="$1" local pairs team name pidfile bpid killed=0 app_pidfile app_pid app_meta app_label app_plist - local scoped_meta scoped_base scoped_pid scoped_label scoped_project chat scoped_plist - local scoped_type scoped_team scoped_name scoped_thread legacy_thread - local project_hash seat saved_project saved_type saved_team saved_name saved_thread _saved_at - - project_hash="$(printf '%s' "$project" | agmsg_sha1)" - for seat in "$RUN_DIR"/codex-seat.*.tsv; do - [ -f "$seat" ] || continue - IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true - if [ "$saved_project" = "$project" ] && [ "$saved_type" = "codex" ]; then - actas_codex_seat_release "$saved_team" "$saved_name" "$saved_project" "$saved_thread" - fi - done - actas_codex_release_markers "$project" - - # A launchd job can outlive the bridge metadata during a crash/shutdown - # window. Plist names are project-hash scoped, so boot out and remove every - # deterministically owned job for this project even when .meta/.pid is gone. - for scoped_plist in "$RUN_DIR"/codex-bridge."$project_hash".*.plist; do - [ -f "$scoped_plist" ] || continue - scoped_base="${scoped_plist%.plist}" - scoped_label="$(codex_bridge_label_for_base "$scoped_base" 2>/dev/null || true)" - [ -n "$scoped_label" ] && bootout_managed_codex_label "$scoped_label" - scoped_pid="$(cat "$scoped_base.pid" 2>/dev/null || true)" - if [ -n "$scoped_pid" ] && kill -0 "$scoped_pid" 2>/dev/null; then - scoped_meta="$scoped_base.meta" - scoped_project="$(sed -n 's/^project=//p' "$scoped_meta" 2>/dev/null | head -1)" - scoped_type="$(sed -n 's/^type=//p' "$scoped_meta" 2>/dev/null | head -1)" - scoped_team="$(sed -n 's/^team=//p' "$scoped_meta" 2>/dev/null | head -1)" - scoped_name="$(sed -n 's/^name=//p' "$scoped_meta" 2>/dev/null | head -1)" - scoped_thread="$(sed -n 's/^thread=//p' "$scoped_meta" 2>/dev/null | head -1)" - if [ -n "$scoped_project" ] && [ "$scoped_type" = "codex" ] \ - && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] \ - && codex_current_bridge_pid_matches "$scoped_pid" "$scoped_base" \ - "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" \ - && kill "$scoped_pid" 2>/dev/null; then - killed=$((killed + 1)) - wait_for_recorded_pid_exit "$scoped_pid" codex_current_bridge_pid_matches \ - "$scoped_base" "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" || return 1 - fi - fi - rm -f "$scoped_base.pid" "$scoped_base.meta" "$scoped_base.log" \ - "$scoped_base.appserver" "$scoped_base.health" "$scoped_base.plist" \ - "$scoped_base.last-ids" "$scoped_base.binding" - rm -f "$scoped_base".wake.*.json "$scoped_base.relay-wake.json" - done - - # Current bridges are keyed by project hash + role. Select them by the owned - # metadata boundary, not by a team/name filename that can collide across - # projects. The global Desktop relay is intentionally not stopped here. - for scoped_meta in "$RUN_DIR"/codex-bridge.*.meta; do - [ -f "$scoped_meta" ] || continue - scoped_project="$(sed -n 's/^project=//p' "$scoped_meta" | head -1)" - [ -n "$scoped_project" ] || continue - [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] || continue - [ "$(sed -n 's/^type=//p' "$scoped_meta" | head -1)" = "codex" ] || continue - scoped_base="${scoped_meta%.meta}" - scoped_label="$(codex_bridge_label_for_base "$scoped_base" 2>/dev/null || true)" - [ -n "$scoped_label" ] || continue - bootout_managed_codex_label "$scoped_label" - scoped_pid="$(cat "$scoped_base.pid" 2>/dev/null || true)" - if [ -n "$scoped_pid" ] && kill -0 "$scoped_pid" 2>/dev/null; then - scoped_team="$(sed -n 's/^team=//p' "$scoped_meta" | head -1)" - scoped_name="$(sed -n 's/^name=//p' "$scoped_meta" | head -1)" - scoped_thread="$(sed -n 's/^thread=//p' "$scoped_meta" | head -1)" - if codex_current_bridge_pid_matches "$scoped_pid" "$scoped_base" \ - "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" \ - && kill "$scoped_pid" 2>/dev/null; then - killed=$((killed + 1)) - if ! wait_for_recorded_pid_exit "$scoped_pid" codex_current_bridge_pid_matches \ - "$scoped_base" "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then - echo "delivery: Codex bridge pid $scoped_pid did not stop; preserving its run files" >&2 - return 1 - fi - fi - fi - rm -f "$scoped_base.pid" "$scoped_base.meta" "$scoped_base.log" \ - "$scoped_base.appserver" "$scoped_base.health" "$scoped_base.plist" \ - "$scoped_base.last-ids" "$scoped_base.binding" - rm -f "$scoped_base".wake.*.json "$scoped_base.relay-wake.json" - done - for chat in "$RUN_DIR"/codex-chat-visible.*.meta; do - [ -f "$chat" ] || continue - scoped_project="$(sed -n 's/^project=//p' "$chat" | head -1)" - [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] || continue - [ "$(sed -n 's/^type=//p' "$chat" | head -1)" = "codex" ] || continue - rm -f "$chat" - done pairs=$("$SCRIPT_DIR/identities.sh" "$project" codex 2>/dev/null || true) if [ -n "$pairs" ]; then while IFS=$'\t' read -r team name _rest; do @@ -524,21 +369,9 @@ stop_codex_bridge() { if [ -f "$pidfile" ]; then bpid=$(cat "$pidfile" 2>/dev/null || true) if [ -n "$bpid" ] && kill -0 "$bpid" 2>/dev/null; then - legacy_thread="$(sed -n 's/^thread=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" - scoped_project="$(sed -n 's/^project=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" - scoped_type="$(sed -n 's/^type=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" - scoped_team="$(sed -n 's/^team=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" - scoped_name="$(sed -n 's/^name=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" - if [ -n "$scoped_project" ] && [ "$scoped_type" = "codex" ] \ - && [ "$scoped_team" = "$team" ] \ - && [ "$scoped_name" = "$name" ] \ - && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] \ - && codex_legacy_bridge_pid_matches "$bpid" "${pidfile%.pid}" \ - "$scoped_project" "$team" "$name" "$legacy_thread" \ - && kill "$bpid" 2>/dev/null; then + if kill "$bpid" 2>/dev/null; then killed=$((killed + 1)) - if ! wait_for_recorded_pid_exit "$bpid" codex_legacy_bridge_pid_matches \ - "${pidfile%.pid}" "$scoped_project" "$team" "$name" "$legacy_thread"; then + if ! wait_for_recorded_pid_exit "$bpid"; then echo "delivery: Codex bridge pid $bpid did not stop; preserving its run files" >&2 return 1 fi @@ -548,32 +381,25 @@ stop_codex_bridge() { # .appserver records which app-server URL the bridge was bound to (the # launcher's stale-binding guard); drop it with the rest so it cannot # mislead a later launcher. - rm -f "$pidfile" "${pidfile%.pid}.meta" "${pidfile%.pid}.log" \ - "${pidfile%.pid}.appserver" "${pidfile%.pid}.health" + rm -f "$pidfile" "${pidfile%.pid}.meta" "${pidfile%.pid}.log" "${pidfile%.pid}.appserver" app_pidfile="$RUN_DIR/codex-app-monitor.$team.$name.pid" app_meta="$RUN_DIR/codex-app-monitor.$team.$name.meta" app_plist="$RUN_DIR/codex-app-monitor.$team.$name.plist" - app_label="$(codex_app_monitor_label "$project" "$team" "$name" 2>/dev/null || true)" - [ -n "$app_label" ] && bootout_managed_codex_label "$app_label" + app_label="" + if [ -f "$app_meta" ]; then + app_label=$(sed -n 's/^launch_label=//p' "$app_meta" | head -1) + fi + if [ -z "$app_label" ] && [ -f "$app_plist" ]; then + app_label=$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$app_plist") + fi + bootout_recorded_label "$app_label" if [ -f "$app_pidfile" ]; then app_pid=$(cat "$app_pidfile" 2>/dev/null || true) if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then - scoped_project="$(sed -n 's/^project=//p' "$app_meta" 2>/dev/null | head -1)" - scoped_type="$(sed -n 's/^type=//p' "$app_meta" 2>/dev/null | head -1)" - scoped_team="$(sed -n 's/^team=//p' "$app_meta" 2>/dev/null | head -1)" - scoped_name="$(sed -n 's/^name=//p' "$app_meta" 2>/dev/null | head -1)" - legacy_thread="$(sed -n 's/^thread=//p' "$app_meta" 2>/dev/null | head -1)" - if [ -n "$scoped_project" ] && [ "$scoped_type" = "codex" ] \ - && [ "$scoped_team" = "$team" ] \ - && [ "$scoped_name" = "$name" ] \ - && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$project")" ] \ - && codex_app_monitor_pid_matches "$app_pid" "${app_pidfile%.pid}" \ - "$scoped_project" "$team" "$name" "$legacy_thread" \ - && kill "$app_pid" 2>/dev/null; then + if kill "$app_pid" 2>/dev/null; then killed=$((killed + 1)) - if ! wait_for_recorded_pid_exit "$app_pid" codex_app_monitor_pid_matches \ - "${app_pidfile%.pid}" "$scoped_project" "$team" "$name" "$legacy_thread"; then + if ! wait_for_recorded_pid_exit "$app_pid"; then echo "delivery: Codex app monitor pid $app_pid did not stop; preserving its run files" >&2 return 1 fi @@ -597,13 +423,22 @@ EOF # Tear down the project's shared app-server too. It is keyed per project # (codex-app-server..{pid,port,version}); turning delivery off means no - # bridge needs its stale records. The app-server process itself has no agmsg-owned script - # or project/role argv, so a pidfile alone is intentionally insufficient - # proof to signal it; remove the project-scoped records without signaling. - local server_pidfile + # bridge needs it, and leaving it running keeps a stale port the next launch + # would have to recreate anyway. Only kill the recorded pid when its cmdline + # confirms it is our app-server (a recycled pid could be unrelated); drop the + # record either way. + local project_hash server_pidfile server_pid server_cmd + project_hash="$(printf '%s' "$project" | agmsg_sha1 2>/dev/null || true)" if [ -n "$project_hash" ]; then server_pidfile="$RUN_DIR/codex-app-server.$project_hash.pid" if [ -f "$server_pidfile" ]; then + server_pid="$(cat "$server_pidfile" 2>/dev/null || true)" + if [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then + server_cmd="$(compat_get_cmdline "$server_pid" 2>/dev/null || true)" + case "$server_cmd" in + *codex*app-server*) kill "$server_pid" 2>/dev/null || true ;; + esac + fi rm -f "$RUN_DIR/codex-app-server.$project_hash.pid" \ "$RUN_DIR/codex-app-server.$project_hash.port" \ "$RUN_DIR/codex-app-server.$project_hash.version" \ diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh old mode 100755 new mode 100644 index 636476a7..d1653af2 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -1,9 +1,19 @@ #!/usr/bin/env bash +# codex delivery plug. +# +# codex keeps the default JSON event-hooks apply (agmsg_delivery_apply); it adds +# enable/disable side effects (print the monitor shim setup on enable, stop the +# receiver on disable) and replaces the runtime status summary with Codex +# receiver liveness. Monitor mode may use only a visible app-server bridge; +# turn/off and visible-turn fallback never start a background receiver. +# Sourced into delivery.sh's context, +# so SKILL_DIR, SCRIPT_DIR, RUN_DIR, agmsg_resolve_node, CODEX_MONITOR_DOC_URL +# and stop_codex_bridge are in scope. +# Args (both hooks): on_enable ; on_disable . -# Codex delivery plug. Monitor means one authenticated Desktop relay plus one -# exact-thread, project-owned bridge. Foreground Stop delivery remains the safe -# fallback and does not mark Codex mail read. - +# Codex monitor mode always includes the visible Stop-hook fallback. The +# SessionStart hook preserves/rebinds the monitor after restart; the Stop hook +# is the safe path when no app-server can inject into the visible thread. agmsg_delivery_apply() { local type="$1" project="$2" mode="$3" if [ "$mode" = "monitor" ]; then @@ -13,63 +23,70 @@ agmsg_delivery_apply() { fi } +# `monitor` is the user-facing mode name even though Codex installs both hook +# types internally. Keep status stable for callers and existing automation. agmsg_delivery_status() { agmsg_delivery_status_default "$@" | sed '1s/^mode: both$/mode: monitor/' } agmsg_delivery_on_enable() { - local project="$3" legacy_cleanup - legacy_cleanup=$("$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ - disarm-project "$project" 2>/dev/null || true) - if printf '%s\n' "$legacy_cleanup" | grep -q 'lease_id='; then - echo "Disabled legacy Codex heartbeat/watchdog lease state for this project." - fi echo "Codex visible monitor beta is enabled." - echo "Unread mail is delivered only through the authenticated Desktop relay into the exact visible task." - echo "If the relay, Desktop, exact thread, or role bridge is unavailable, delivery downgrades to turn and mail remains unread." - case "$SKILL_DIR" in - "$HOME"/.agents/skills/*) - if [ "$(uname -s)" = "Darwin" ] && command -v launchctl >/dev/null 2>&1; then - local relay_status - relay_status="$("$SKILL_DIR/scripts/drivers/types/codex/codex-desktop-relayctl.sh" enable 2>&1 || true)" - if printf '%s\n' "$relay_status" | grep -q '^status='; then - echo "Codex Desktop relay: $relay_status" - echo "Restart Codex Desktop once if it is not yet connected through the relay." - else - echo "WARNING: Codex Desktop relay did not start: $relay_status" - fi - fi - ;; - esac + echo "After actas binds a role, only a visible app-server bridge may deliver unread mail." + echo "If the bridge cannot attach, mail stays unread until the next visible Codex turn." + echo "Background codex exec resume handling is prohibited." + echo "Add this shell function to your interactive shell profile, then restart the shell:" + if "$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" function; then + echo "Future Codex sessions: launch with codex. In monitor-mode projects, the agmsg function routes interactive Codex sessions through the bridge." + echo "Optional global PATH shim is still available with:" + echo " $SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh install" + else + echo "Codex monitor mode is enabled, but the codex shell function could not be printed." + echo "Future Codex sessions: launch with $SKILL_DIR/scripts/drivers/types/codex/codex-monitor.sh, or resolve the setup issue above." + fi + # Node preflight: the bridge (codex-bridge.js) is a Node program, so without + # Node it silently never starts — flag it at enable time. Resolve via the same + # path the runtime uses (lib/node.sh). AGMSG_NODE / AGMSG_CODEX_NODE override. local codex_node codex_node="$(agmsg_resolve_node)" if ! command -v "$codex_node" >/dev/null 2>&1 && [ ! -x "$codex_node" ]; then - echo "WARNING: Node.js ('$codex_node') was not found; monitor delivery will NOT start." + echo "WARNING: Node.js ('$codex_node') was not found. The Codex bridge needs Node —" + echo " monitor delivery will NOT start until Node is installed (or set AGMSG_NODE)." fi - echo "Run agmsg actas in the intended visible Codex task to bind its exact thread." + echo "Run agmsg actas in the intended Codex task to bind the receiver now." + echo "SessionStart rebinds the last role after a later restart." echo "For more info: $CODEX_MONITOR_DOC_URL" } agmsg_delivery_on_disable() { - local project="$2" stopped lease_cleanup - stopped="$(stop_codex_bridge "$project")" + local project="$2" + local stopped lease_cleanup + stopped=$(stop_codex_bridge "$project") if [ "${stopped:-0}" -gt 0 ]; then - echo "Stopped $stopped project-owned Codex bridge process(es) and cleaned their private run files." + echo "Stopped $stopped Codex bridge process(es) for this project and cleaned their run files." fi + # Remove any legacy scheduled-receiver lease left by pre-event-driven builds. lease_cleanup=$("$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ disarm-project "$project" 2>/dev/null || true) [ -n "$lease_cleanup" ] && printf '%s\n' "$lease_cleanup" - echo "The shared Codex Desktop relay remains installed for other projects and future monitor sessions." + echo "Note: shell profile functions are not changed automatically." + echo " If you installed the optional global shim and no other project uses monitor mode, remove it:" + echo " $SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh remove" + echo " # then drop any agmsg Codex function or ~/.agents/bin PATH entry you added for monitor" } agmsg_delivery_stop_directive() { - local project="${PROJECT:-}" mode="${MODE:-}" + local project="${PROJECT:-}" + local mode="${MODE:-}" if [ "$mode" = "turn" ] && [ -n "$project" ]; then - [ "${AGMSG_CODEX_PRESERVE_CURRENT_MONITOR:-}" = "1" ] && return 0 + # Let a failing receiver finish writing fallback health and chat-visible + # metadata before it exits on its own. + if [ "${AGMSG_CODEX_PRESERVE_CURRENT_MONITOR:-}" = "1" ]; then + return 0 + fi local stopped - stopped="$(stop_codex_bridge "$project")" + stopped=$(stop_codex_bridge "$project") if [ "${stopped:-0}" -gt 0 ]; then - echo "Stopped $stopped project-owned Codex bridge process(es) and cleaned their run files." + echo "Stopped $stopped Codex bridge/app monitor process(es) for this project and cleaned their run files." fi "$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ disarm-project "$project" 2>/dev/null || true @@ -77,38 +94,102 @@ agmsg_delivery_stop_directive() { } agmsg_delivery_runtime_status() { - local type="$1" project="$2" relay_status meta meta_project status thread team name pid found=0 chat - relay_status="$("$SKILL_DIR/scripts/drivers/types/codex/codex-desktop-relayctl.sh" status 2>/dev/null || true)" - echo "Codex Desktop relay: ${relay_status:-status=unknown app_server=ws://127.0.0.1/}" - - for meta in "$RUN_DIR"/codex-bridge.*.meta; do - [ -f "$meta" ] || continue - meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" - [ "$(agmsg_canonical_path "$meta_project")" = "$(agmsg_canonical_path "$project")" ] || continue - [ "$(sed -n 's/^type=//p' "$meta" | head -1)" = "$type" ] || continue + local type="$1" project="$2" + local pairs found=0 + pairs=$("$SCRIPT_DIR/identities.sh" "$project" "$type" 2>/dev/null || true) + + if [ -z "$pairs" ]; then + echo "Codex bridge: no identities registered for this project" + return 0 + fi + + while IFS=$'\t' read -r team name _rest; do + if [ -z "$team" ] || [ -z "$name" ]; then + continue + fi found=1 - team="$(sed -n 's/^team=//p' "$meta" | head -1)" - name="$(sed -n 's/^name=//p' "$meta" | head -1)" - thread="$(sed -n 's/^thread=//p' "$meta" | head -1)" - pid="$(sed -n 's/^pid=//p' "$meta" | head -1)" - status="$(sed -n 's/^status=//p' "${meta%.meta}.health" 2>/dev/null | head -1 || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - echo "Codex visible bridge: $team/$name active pid=$pid thread=$thread health=${status:-unknown} app_server=ws://127.0.0.1/" + + local healthfile health_status health_failures health_error health_updated + healthfile="$RUN_DIR/codex-app-monitor.$team.$name.health" + if [ -f "$healthfile" ]; then + health_status=$(awk -F= '/^status=/{sub(/^status=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) + health_failures=$(awk -F= '/^consecutive_failures=/{sub(/^consecutive_failures=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) + health_error=$(awk -F= '/^last_error=/{sub(/^last_error=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) + health_updated=$(awk -F= '/^updated_at=/{sub(/^updated_at=/, ""); print; exit}' "$healthfile" 2>/dev/null || true) + echo "Codex monitor health: $team/$name status=${health_status:-unknown} failures=${health_failures:-unknown} last_error=${health_error:-unknown} updated=${health_updated:-unknown}" + fi + + local base pidfile metafile pid meta_pid meta_project meta_type meta_ok + base="$RUN_DIR/codex-bridge.$team.$name" + pidfile="$base.pid" + metafile="$base.meta" + + local app_base app_pidfile app_metafile app_pid app_thread app_transport + app_base="$RUN_DIR/codex-app-monitor.$team.$name" + app_pidfile="$app_base.pid" + app_metafile="$app_base.meta" + + local chat_metafile chat_project chat_type chat_transport chat_status chat_updated + chat_metafile="$RUN_DIR/codex-chat-visible.$team.$name.meta" + + if [ ! -f "$pidfile" ]; then + if [ -f "$app_pidfile" ] && [ -f "$app_metafile" ]; then + app_pid=$(cat "$app_pidfile" 2>/dev/null || true) + app_thread=$(awk -F= '/^thread=/{sub(/^thread=/, ""); print; exit}' "$app_metafile" 2>/dev/null || true) + app_transport=$(awk -F= '/^transport=/{sub(/^transport=/, ""); print; exit}' "$app_metafile" 2>/dev/null || true) + if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then + echo "Codex app monitor: $team/$name alive (pid $app_pid, thread $app_thread, transport ${app_transport:-codex-background-thread-resume})" + else + echo "Codex app monitor: $team/$name stale pidfile (pid ${app_pid:-empty} not running)" + fi + elif [ -f "$chat_metafile" ]; then + chat_project=$(awk -F= '/^project=/{sub(/^project=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + chat_type=$(awk -F= '/^type=/{sub(/^type=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + chat_transport=$(awk -F= '/^transport=/{sub(/^transport=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + chat_status=$(awk -F= '/^status=/{sub(/^status=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + chat_updated=$(awk -F= '/^updated_at=/{sub(/^updated_at=/, ""); print; exit}' "$chat_metafile" 2>/dev/null || true) + if [ "$chat_project" = "$project" ] && [ "$chat_type" = "$type" ]; then + echo "Codex chat-visible turn: $team/$name armed (transport ${chat_transport:-codex-chat-visible-turn}, status ${chat_status:-waiting_for_chat_turn}, updated ${chat_updated:-unknown})" + else + echo "Codex chat-visible turn: $team/$name stale metadata" + fi + else + echo "Codex bridge: $team/$name not running" + fi + continue + fi + + pid=$(cat "$pidfile" 2>/dev/null || true) + if [ -z "$pid" ]; then + echo "Codex bridge: $team/$name stale pidfile (empty pid)" + continue + fi + + if [ ! -f "$metafile" ]; then + echo "Codex bridge: $team/$name stale pidfile (missing metadata)" + continue + fi + + meta_ok=1 + meta_pid=$(awk -F= '/^pid=/{sub(/^pid=/, ""); print; exit}' "$metafile" 2>/dev/null || true) + meta_project=$(awk -F= '/^project=/{sub(/^project=/, ""); print; exit}' "$metafile" 2>/dev/null || true) + meta_type=$(awk -F= '/^type=/{sub(/^type=/, ""); print; exit}' "$metafile" 2>/dev/null || true) + [ -n "$meta_pid" ] && [ "$meta_pid" != "$pid" ] && meta_ok=0 + [ -n "$meta_project" ] && [ "$meta_project" != "$project" ] && meta_ok=0 + [ -n "$meta_type" ] && [ "$meta_type" != "$type" ] && meta_ok=0 + if [ "$meta_ok" -ne 1 ]; then + echo "Codex bridge: $team/$name stale pidfile (metadata mismatch)" + continue + fi + + if kill -0 "$pid" 2>/dev/null; then + echo "Codex bridge: $team/$name alive (pid $pid)" else - echo "Codex visible bridge: $team/$name stale thread=$thread health=${status:-unknown}" + echo "Codex bridge: $team/$name stale pidfile (pid $pid not running)" fi - done + done <<< "$pairs" - for chat in "$RUN_DIR"/codex-chat-visible.*.meta; do - [ -f "$chat" ] || continue - meta_project="$(sed -n 's/^project=//p' "$chat" | head -1)" - [ "$(agmsg_canonical_path "$meta_project")" = "$(agmsg_canonical_path "$project")" ] || continue - [ "$(sed -n 's/^type=//p' "$chat" | head -1)" = "$type" ] || continue - found=1 - team="$(sed -n 's/^team=//p' "$chat" | head -1)" - name="$(sed -n 's/^name=//p' "$chat" | head -1)" - thread="$(sed -n 's/^thread=//p' "$chat" | head -1)" - echo "Codex visible turn fallback: $team/$name waiting thread=$thread unread_preserved=true" - done - [ "$found" -eq 1 ] || echo "Codex visible bridge: no role is currently bound for this project" + if [ "$found" -eq 0 ]; then + echo "Codex bridge: no identities registered for this project" + fi } diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh old mode 100755 new mode 100644 index d59c0eee..53030457 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -1,37 +1,163 @@ #!/usr/bin/env bash +# codex SessionStart plug — hand the session off to the Codex bridge. +# +# Sourced by session-start.sh in its global context (so it sees TYPE, PROJECT, +# RUN_DIR, SKILL_DIR, SCRIPT_DIR, PAIRS and the helpers agmsg_sha1, +# agmsg_sqlite_mem, agmsg_resolve_node, agmsg_canonical_path, agmsg_agent_pid). +# Defines agmsg_session_start, overriding session-start.sh's default no-op. +# +# Codex has no Monitor tool. When launched through codex-monitor.sh, the TUI is +# attached to a shared app-server. Hand the bridge off so incoming agmsg rows +# become turns in the current Codex thread without exposing socket/thread +# plumbing to the user. With AGMSG_CODEX_BRIDGE_LAUNCHER=1 (set by +# codex-monitor.sh) we only write a request file and let the out-of-sandbox +# launcher start the bridge — a hook-launched bridge cannot connect to the unix +# socket from inside the Codex sandbox (#41). -# Codex SessionStart plug. Rebind only when the current process exports an exact -# thread id. Rollout scanning, command-line socket discovery, launcher request -# files, and thread/start fallback can attach a different task and are forbidden. +# Resolve the current Codex thread id. CODEX_THREAD_ID is only exported on the +# interactive --remote path; fresh and `codex exec` sessions never export it, so +# fall back to the newest rollout file whose session_meta cwd matches the +# project. Codex writes that rollout ~1s before SessionStart, so it is already +# present; a short bounded retry covers the race if it is not. See #41. +agmsg_resolve_codex_thread() { + local project="$1" + if [ -n "${CODEX_THREAD_ID:-}" ]; then + printf '%s' "$CODEX_THREAD_ID" + return 0 + fi + local sessions_dir="$HOME/.codex/sessions" + [ -d "$sessions_dir" ] || return 0 + # Compare PHYSICAL paths. agmsg may open the project via a symlinked/logical + # path (e.g. a workspace under a symlinked home) while Codex records the + # canonical cwd in session_meta. A raw string compare then misses every + # rollout, so the thread is never resolved and the bridge never starts. See + # #160. Canonicalize the project once; canonicalize each rollout cwd per row. + local project_phys + project_phys=$(agmsg_canonical_path "$project") + local waited=0 f first esc cwd cwd_phys tid + while :; do + while IFS= read -r f; do + [ -f "$f" ] || continue + first=$(head -1 "$f" 2>/dev/null) + case "$first" in *'"session_meta"'*) ;; *) continue ;; esac + esc=$(printf '%s' "$first" | sed "s/'/''/g") + cwd=$(agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.cwd'),'')" 2>/dev/null) + cwd_phys=$(agmsg_canonical_path "$cwd") + [ "$cwd_phys" = "$project_phys" ] || continue + tid=$(agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.id'),'')" 2>/dev/null) + if [ -n "$tid" ]; then + printf '%s' "$tid" + return 0 + fi + done </dev/null | head -20) +INNER_EOF + [ "$waited" -ge 2 ] && break + waited=$((waited + 1)) + sleep 1 + done + return 0 +} agmsg_session_start() { + # Compatibility guard for rollouts created by older background receivers. + # Current builds prohibit that transport, but must not duplicate a legacy + # parent receiver during an in-flight upgrade. if [ "${AGMSG_CODEX_BACKGROUND_RESUME:-}" = "1" ]; then exit 0 fi - local thread_id team="" name="" seat log match_count=0 - thread_id="${AGMSG_CODEX_ACTAS_THREAD:-${CODEX_THREAD_ID:-}}" - case "$thread_id" in ""|loaded|current|unresolved) thread_id="" ;; esac - + thread_id="$(agmsg_resolve_codex_thread "$PROJECT")" [ -n "$thread_id" ] || exit 0 - for seat in "$RUN_DIR"/codex-seat.*.tsv; do - [ -f "$seat" ] || continue - IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true - if [ "$saved_project" = "$PROJECT" ] && [ "$saved_type" = "$TYPE" ] \ - && [ "$saved_thread" = "$thread_id" ] \ + + # Prefer the last explicit `agmsg actas` role. A project can register many + # Codex identities, so picking the first row after restart would bind sends + # and receives to different agents. Fall back to the single registered pair + # only when no valid actas state exists. + team=""; name="" + project_hash="$(printf '%s' "$PROJECT" | agmsg_sha1)" + actas_state="$RUN_DIR/codex-last-actas.$project_hash.tsv" + if [ -f "$actas_state" ]; then + IFS=$'\t' read -r saved_project saved_type saved_team saved_name _saved_at < "$actas_state" || true + if [ "$saved_project" = "$PROJECT" ] \ + && [ "$saved_type" = "$TYPE" ] \ && printf '%s\n' "$PAIRS" | grep -Fxq "$(printf '%s\t%s' "$saved_team" "$saved_name")"; then team="$saved_team" name="$saved_name" - match_count=$((match_count + 1)) fi - done - [ "$match_count" = "1" ] || exit 0 + fi + if [ -z "$team" ] || [ -z "$name" ]; then + pair_count=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { c++ } END { print c + 0 }') + [ "$pair_count" = "1" ] || exit 0 + team=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $1; exit }') + name=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $2; exit }') + fi [ -n "$team" ] && [ -n "$name" ] || exit 0 - mkdir -p "$RUN_DIR" 2>/dev/null || true - log="$RUN_DIR/codex-actas-restore.log" - AGMSG_CODEX_ACTAS_THREAD="$thread_id" \ + app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" + if [ -z "$app_server" ]; then + agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true) + if [ -n "$agent_pid" ]; then + agent_cmd=$(compat_get_cmdline "$agent_pid" 2>/dev/null || true) + app_server=$(printf '%s\n' "$agent_cmd" \ + | sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \ + | head -1) + fi + fi + if [ -z "$app_server" ]; then + project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) + socket_path="$RUN_DIR/codex-app-server.$project_hash.sock" + if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then + app_server="unix://$socket_path" + fi + fi + if [ -z "$app_server" ]; then + # Ordinary Codex.app has no externally reachable app-server endpoint. + # actas-monitor.sh records visible-turn fallback and downgrades monitor to + # turn instead of starting an invisible background receiver. + log="$RUN_DIR/codex-actas-restore.log" "$SKILL_DIR/scripts/drivers/types/codex/actas-monitor.sh" \ - "$PROJECT" "$TYPE" "$name" "$thread_id" >> "$log" 2>&1 || true + "$PROJECT" "$TYPE" "$name" "$thread_id" >>"$log" 2>&1 || true + exit 0 + fi + + if [ "${AGMSG_CODEX_BRIDGE_LAUNCHER:-}" = "1" ]; then + project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) + request_file="$RUN_DIR/codex-bridge-request.$project_hash" + tmp_request="$request_file.$$" + mkdir -p "$RUN_DIR" 2>/dev/null || true + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$TYPE" "$team" "$name" "$thread_id" "$app_server" > "$tmp_request" + mv "$tmp_request" "$request_file" + exit 0 + fi + + mkdir -p "$RUN_DIR" 2>/dev/null || true + pidfile="$RUN_DIR/codex-bridge.$team.$name.pid" + if [ -f "$pidfile" ]; then + bridge_pid=$(cat "$pidfile" 2>/dev/null || true) + if [ -n "$bridge_pid" ] && kill -0 "$bridge_pid" 2>/dev/null; then + exit 0 + fi + fi + + log="$RUN_DIR/codex-bridge.$team.$name.log" + # An explicit AGMSG_CODEX_BRIDGE_CMD is a complete runnable (tests, custom + # wrappers) — run it as-is. Only the default codex-bridge.js is launched + # through a resolved Node, since its env-node shebang fails in shells where a + # version-manager Node is not on PATH (#170). + if [ -n "${AGMSG_CODEX_BRIDGE_CMD:-}" ]; then + bridge_run=("$AGMSG_CODEX_BRIDGE_CMD") + else + bridge_run=("$(agmsg_resolve_node)" "$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js") + fi + nohup "${bridge_run[@]}" \ + --project "$PROJECT" \ + --type "$TYPE" \ + --team "$team" \ + --name "$name" \ + --thread "$thread_id" \ + --app-server "$app_server" \ + >>"$log" 2>&1 & exit 0 } diff --git a/scripts/drivers/types/codex/actas-monitor.sh b/scripts/drivers/types/codex/actas-monitor.sh index 2462f741..ec54b319 100755 --- a/scripts/drivers/types/codex/actas-monitor.sh +++ b/scripts/drivers/types/codex/actas-monitor.sh @@ -1,11 +1,15 @@ #!/usr/bin/env bash set -euo pipefail -umask 077 -# Bind one agmsg identity to one exact visible Codex Desktop task. Monitor mode -# has one transport only: a role-scoped bridge connects through the authenticated -# global Desktop relay. Any missing/ambiguous state downgrades to foreground turn -# delivery and leaves unread messages untouched. +# Rebind Codex monitor delivery to an explicit actas identity. +# +# Usage: +# actas-monitor.sh [session_id] +# +# Codex has no native Monitor tool. An explicitly selected monitor/both mode +# binds one agmsg identity to a persisted Codex thread only when a visible +# app-server bridge is available. Without that bridge the effective mode is +# changed to turn, so unread mail waits for the next visible assistant turn. PROJECT="${1:?Usage: actas-monitor.sh [session_id]}" TYPE="${2:?Missing type}" @@ -14,7 +18,7 @@ SESSION_ID="${4:-${CODEX_THREAD_ID:-}}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" -RUN_DIR="${AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR:-$SKILL_DIR/run}" +RUN_DIR="$SKILL_DIR/run" # shellcheck source=../../../lib/hash.sh source "$SCRIPT_DIR/../../../lib/hash.sh" @@ -29,15 +33,23 @@ source "$SCRIPT_DIR/../../../lib/actas-lock.sh" PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")" mkdir -p "$RUN_DIR" + NODE_BIN="$(agmsg_resolve_node)" TAB="$(printf '\t')" -IDS=$("$SCRIPT_DIR/../../../identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ - | awk -F "$TAB" -v want="$NAME" -v tab="$TAB" 'NF >= 2 && $2 == want { print $1 tab $2 }' \ - | sort -u || true) +find_identity() { + "$SCRIPT_DIR/../../../identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ + | awk -v want="$NAME" -v tab="$TAB" 'NF >= 2 && $2 == want { print $1 tab $2 }' \ + | sort -u +} + +IDS="$(find_identity || true)" COUNT="$(printf '%s\n' "$IDS" | grep -c . || true)" case "$COUNT" in - 0) echo "status=not_registered name=$NAME"; exit 2 ;; + 0) + echo "status=not_registered name=$NAME" + exit 2 + ;; 1) IFS="$TAB" read -r TEAM _agent </dev/null \ - | sed -n 's/^mode: //p') - -bootout_label() { - local label="$1" check=0 - case "$label" in - com.agmsg.codex-bridge.*) - case "${label#com.agmsg.codex-bridge.}" in - ""|*[!A-Za-z0-9._%-]*) return 0 ;; - esac - ;; - *) return 0 ;; - esac - command -v launchctl >/dev/null 2>&1 || return 0 - launchctl bootout "$DOMAIN/$label" >/dev/null 2>&1 || true - while [ "$check" -lt 30 ] && launchctl print "$DOMAIN/$label" >/dev/null 2>&1; do +if [ -n "$SESSION_ID" ]; then + CURRENT_OWNER="$(agmsg_normalize_instance_id "$SESSION_ID" "$TYPE")" + actas_lock_release "$TEAM" "$NAME" "$CURRENT_OWNER" 2>/dev/null || true +fi + +record_last_actas() { + local project_hash state tmp + project_hash="$(printf '%s' "$PROJECT" | agmsg_sha1)" + state="$RUN_DIR/codex-last-actas.$project_hash.tsv" + tmp="$state.$$" + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp" + mv "$tmp" "$state" +} + +record_last_actas + +MODE="$("$SCRIPT_DIR/../../../delivery.sh" status "$TYPE" "$PROJECT" 2>/dev/null \ + | sed -n 's/^mode: //p')" + +resolve_thread_id() { + if [ -n "${CODEX_THREAD_ID:-}" ]; then + printf '%s\n' "$CODEX_THREAD_ID" + return 0 + fi + "$NODE_BIN" - "$PROJECT" <<'NODE' +const fs = require("fs"); +const path = require("path"); +const project = fs.realpathSync(process.argv[2]); +const root = path.join(process.env.HOME || "", ".codex", "sessions"); +const files = []; +function walk(dir) { + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) { + try { + files.push({ full, mtime: fs.statSync(full).mtimeMs }); + } catch {} + } + } +} +walk(root); +files.sort((a, b) => b.mtime - a.mtime); +for (const { full } of files.slice(0, 80)) { + let first = ""; + try { + first = fs.readFileSync(full, "utf8").split(/\r?\n/, 1)[0] || ""; + } catch { + continue; + } + try { + const row = JSON.parse(first); + const payload = row && row.payload; + if (!payload || !payload.cwd || !payload.id) continue; + let cwd = ""; + try { + cwd = fs.realpathSync(payload.cwd); + } catch { + cwd = path.resolve(payload.cwd); + } + if (cwd === project) { + console.log(payload.id); + process.exit(0); + } + } catch {} +} +NODE +} + +kill_other_project_receivers() { + local meta pidfile meta_project meta_type meta_team meta_name + for meta in "$RUN_DIR"/codex-bridge.*.meta "$RUN_DIR"/codex-app-monitor.*.meta; do + [ -f "$meta" ] || continue + meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" + meta_type="$(sed -n 's/^type=//p' "$meta" | head -1)" + meta_team="$(sed -n 's/^team=//p' "$meta" | head -1)" + meta_name="$(sed -n 's/^name=//p' "$meta" | head -1)" + [ "$meta_project" = "$PROJECT" ] || continue + [ "$meta_type" = "$TYPE" ] || continue + [ "$meta_team" = "$TEAM" ] && [ "$meta_name" = "$NAME" ] && continue + pidfile="${meta%.meta}.pid" + kill_receiver_files "$pidfile" "$meta" + done +} + +wait_for_pid_exit() { + local pid="$1" check=0 state + while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac sleep 0.1 check=$((check + 1)) done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || return 1 + check=0 + while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do + state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" + case "$state" in Z*) return 0 ;; esac + sleep 0.1 + check=$((check + 1)) + done + fi + ! kill -0 "$pid" 2>/dev/null } -bridge_pid_matches_state() { - local pid="$1" base="$2" meta="$3" state_key expected cmd - local meta_project meta_type meta_team meta_name meta_thread - state_key="${base##*/codex-bridge.}" - case "$state_key" in ""|*[!A-Za-z0-9._%-]*) return 1 ;; esac - [ -f "$meta" ] || return 1 - meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" - meta_type="$(sed -n 's/^type=//p' "$meta" | head -1)" - meta_team="$(sed -n 's/^team=//p' "$meta" | head -1)" - meta_name="$(sed -n 's/^name=//p' "$meta" | head -1)" - meta_thread="$(sed -n 's/^thread=//p' "$meta" | head -1)" - [ -n "$meta_project" ] \ - && [ "$(agmsg_canonical_path "$meta_project")" = "$(agmsg_canonical_path "$PROJECT")" ] \ - && [ "$meta_type" = "$TYPE" ] && [ -n "$meta_team" ] \ - && [ -n "$meta_name" ] && [ -n "$meta_thread" ] || return 1 - case "$meta_thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - expected="$SCRIPT_DIR/codex-bridge.js --project $PROJECT --type $TYPE --team $meta_team --name $meta_name --state-key $state_key --app-server-file $base.appserver --thread $meta_thread" - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case " $cmd " in *" $expected "*) return 0 ;; esac - return 1 +kill_receiver_files() { + local pidfile="$1" meta="$2" pid="" cmd="" label="" kind base plist + base="${pidfile%.pid}" + kind="${base##*/}" + plist="$base.plist" + if [ -f "$meta" ]; then + label="$(sed -n 's/^launch_label=//p' "$meta" | head -1)" + fi + if [ -z "$label" ] && [ -f "$plist" ]; then + label="$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$plist")" + fi + if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then + bootout_label_and_wait "gui/$(id -u)" "$label" + fi + if [ -f "$pidfile" ]; then + pid="$(cat "$pidfile" 2>/dev/null || true)" + fi + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case "$kind:$cmd" in + codex-bridge.*:*codex-bridge.js*|codex-app-monitor.*:*codex-app-monitor.sh*) + kill "$pid" 2>/dev/null || true + if ! wait_for_pid_exit "$pid"; then + echo "actas-monitor: receiver pid $pid did not stop; preserving its run files" >&2 + return 1 + fi + ;; + esac + fi + rm -f "$pidfile" "$meta" "$base.appserver" "$base.log" "$plist" \ + "$base.health" "$base.preflight.log" "$base.last-prompt.txt" \ + "$base.last-message.txt" "$base.last-status" "$base.last-ids" \ + "$base.watch-output" } -stop_bridge_base() { - local base="$1" meta pidfile pid="" label="" state_key - meta="$base.meta" - pidfile="$base.pid" - case "$base" in "$RUN_DIR/codex-bridge.$PROJECT_HASH."*) ;; *) return 0 ;; esac - state_key="${base##*/codex-bridge.}" - case "$state_key" in ""|*[!A-Za-z0-9._%-]*) return 0 ;; esac - label="com.agmsg.codex-bridge.$state_key" - bootout_label "$label" - pid="$(cat "$pidfile" 2>/dev/null || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - if bridge_pid_matches_state "$pid" "$base" "$meta"; then - kill "$pid" 2>/dev/null || true - for _ in $(seq 1 30); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done - bridge_pid_matches_state "$pid" "$base" "$meta" \ - && kill -KILL "$pid" 2>/dev/null || true +xml_escape() { + sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' \ + -e "s/'/\'/g" +} + +plist_string() { + printf '%s' "$1" | xml_escape +} + +codex_app_monitor_label() { + local safe_team safe_name + safe_team="$(printf '%s' "$TEAM" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + safe_name="$(printf '%s' "$NAME" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" + printf 'com.agmsg.codex-app-monitor.%s.%s.%s' "$PROJECT_HASH" "$safe_team" "$safe_name" +} + +bootout_label_and_wait() { + local domain="$1" label="$2" check=0 + launchctl bootout "$domain/$label" >/dev/null 2>&1 || true + while [ "$check" -lt 20 ] && launchctl print "$domain/$label" >/dev/null 2>&1; do + sleep 0.1 + check=$((check + 1)) + done +} + +write_codex_app_monitor_plist() { + local plist="$1" label="$2" thread_id="$3" log="$4" tmp + local codex_bin path_value + if [ -n "${AGMSG_CODEX_APP_MONITOR_CODEX:-}" ]; then + codex_bin="$AGMSG_CODEX_APP_MONITOR_CODEX" + elif [ -x "/Applications/ChatGPT.app/Contents/Resources/codex" ]; then + codex_bin="/Applications/ChatGPT.app/Contents/Resources/codex" + elif [ -x "/Applications/Codex.app/Contents/Resources/codex" ]; then + codex_bin="/Applications/Codex.app/Contents/Resources/codex" + elif [ -x "$HOME/.npm-global/bin/codex" ]; then + codex_bin="$HOME/.npm-global/bin/codex" + else + codex_bin="$(command -v codex 2>/dev/null || true)" + fi + path_value="${PATH:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}" + tmp="$plist.$$" + cat > "$tmp" < + + + + Label + $(plist_string "$label") + ProgramArguments + + $(plist_string "$SCRIPT_DIR/codex-app-monitor.sh") + $(plist_string "$PROJECT") + $(plist_string "$TYPE") + $(plist_string "$TEAM") + $(plist_string "$NAME") + $(plist_string "$thread_id") + + EnvironmentVariables + + HOME + $(plist_string "$HOME") + PATH + $(plist_string "$path_value") + AGMSG_CODEX_APP_MONITOR_LABEL + $(plist_string "$label") + AGMSG_CODEX_APP_MONITOR_CODEX + $(plist_string "${codex_bin:-codex}") + AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME + 1 + AGMSG_CODEX_APP_MONITOR_SUPERVISED + 1 + AGMSG_CODEX_APP_MONITOR_TIMEOUT + $(plist_string "${AGMSG_CODEX_APP_MONITOR_TIMEOUT:-}") + AGMSG_CODEX_APP_MONITOR_INTERVAL + $(plist_string "${AGMSG_CODEX_APP_MONITOR_INTERVAL:-}") + AGMSG_CODEX_APP_MONITOR_MAX_WAKES + $(plist_string "${AGMSG_CODEX_APP_MONITOR_MAX_WAKES:-}") + AGMSG_CODEX_APP_MONITOR_MAX_FAILURES + $(plist_string "${AGMSG_CODEX_APP_MONITOR_MAX_FAILURES:-}") + AGMSG_CODEX_APP_MONITOR_FLAGS + $(plist_string "${AGMSG_CODEX_APP_MONITOR_FLAGS:-}") + AGMSG_CODEX_APP_MONITOR_DISABLE_NOTIFY + $(plist_string "${AGMSG_CODEX_APP_MONITOR_DISABLE_NOTIFY:-}") + + WorkingDirectory + $(plist_string "$PROJECT") + StandardOutPath + $(plist_string "$log") + StandardErrorPath + $(plist_string "$log") + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 2 + + +EOF + mv "$tmp" "$plist" +} + +start_codex_app_monitor() { + local thread_id="$1" + local pidfile log health existing_pid existing_thread existing_health app_monitor_pid label plist domain + local health_status ready_seconds ready_checks check + if [ -z "$thread_id" ]; then + actas_lock_gc_stale >/dev/null 2>&1 || true + start_chat_visible_turn_delivery "" "codex_app_thread_id_unavailable" + fi + + kill_other_project_receivers + kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + + pidfile="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" + log="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.log" + health="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.health" + label="$(codex_app_monitor_label)" + plist="$RUN_DIR/codex-app-monitor.$TEAM.$NAME.plist" + domain="gui/$(id -u)" + if [ -f "$pidfile" ]; then + existing_pid="$(cat "$pidfile" 2>/dev/null || true)" + if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then + existing_thread="$(sed -n 's/^thread=//p' "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" 2>/dev/null | head -1)" + existing_health="$(sed -n 's/^status=//p' "$health" 2>/dev/null | head -1 || true)" + if [ "$existing_thread" = "$thread_id" ]; then + echo "status=already_running team=$TEAM name=$NAME app_monitor_pid=$existing_pid thread=$thread_id transport=codex-background-thread-resume health=${existing_health:-unknown}" + exit 0 + fi + kill_receiver_files "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + else + rm -f "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + fi + fi + rm -f "$health" "$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" + + if command -v launchctl >/dev/null 2>&1 && launchctl print "$domain" >/dev/null 2>&1; then + write_codex_app_monitor_plist "$plist" "$label" "$thread_id" "$log" + bootout_label_and_wait "$domain" "$label" + if ! launchctl bootstrap "$domain" "$plist" >/dev/null 2>&1; then + start_chat_visible_turn_delivery "$thread_id" "app_monitor_launchctl_bootstrap_failed" fi + else + nohup env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + "$SCRIPT_DIR/codex-app-monitor.sh" "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$thread_id" >>"$log" 2>&1 & fi - rm -f "$base.pid" "$base.meta" "$base.health" "$base.appserver" \ - "$base.plist" "$base.log" "$base.last-ids" "$base.binding" - rm -f "$base".wake.*.json "$base.relay-wake.json" + + ready_seconds="${AGMSG_CODEX_APP_MONITOR_READY_SECONDS:-5}" + case "$ready_seconds" in ''|*[!0-9]*) ready_seconds=5 ;; esac + [ "$ready_seconds" -gt 0 ] || ready_seconds=1 + ready_checks=$((ready_seconds * 5)) + check=0 + while [ "$check" -lt "$ready_checks" ]; do + app_monitor_pid="$(cat "$pidfile" 2>/dev/null || true)" + health_status="$(sed -n 's/^status=//p' "$health" 2>/dev/null | head -1 || true)" + if [ "$health_status" = "ready" ] && [ -n "$app_monitor_pid" ] && kill -0 "$app_monitor_pid" 2>/dev/null; then + echo "status=ok team=$TEAM name=$NAME app_monitor_pid=$app_monitor_pid thread=$thread_id transport=codex-background-thread-resume health=ready" + exit 0 + fi + case "$health_status" in preflight_failed|fallback_failed) break ;; esac + if [ -n "$app_monitor_pid" ] && ! kill -0 "$app_monitor_pid" 2>/dev/null; then + break + fi + sleep 0.2 + check=$((check + 1)) + done + + bootout_label_and_wait "$domain" "$label" + rm -f "$pidfile" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + actas_lock_gc_stale >/dev/null 2>&1 || true + start_chat_visible_turn_delivery "$thread_id" "app_monitor_${health_status:-not_ready}" } -start_visible_turn_delivery() { - local thread_id="$1" reason="$2" requested_mode="$MODE" - stop_bridge_base "$BASE" +start_chat_visible_turn_delivery() { + local thread_id="$1" + local reason="${2:-foreground_turn_mode}" + local meta="$RUN_DIR/codex-chat-visible.$TEAM.$NAME.meta" + local requested_mode="$MODE" + + kill_other_project_receivers + kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" + + # A queued foreground check is not a monitor. Preserve an honest delivery + # state so operators do not mistake waiting_for_chat_turn for active receipt. + case "$requested_mode" in + monitor|both) + "$SCRIPT_DIR/../../../delivery.sh" set turn "$TYPE" "$PROJECT" >/dev/null + MODE="turn" + ;; + esac + { printf 'project=%s\n' "$PROJECT" printf 'type=%s\n' "$TYPE" @@ -146,289 +418,124 @@ start_visible_turn_delivery() { printf 'thread=%s\n' "${thread_id:-unresolved}" printf 'transport=codex-chat-visible-turn\n' printf 'status=waiting_for_chat_turn\n' - printf 'requested_mode=%s\n' "$requested_mode" - printf 'effective_mode=turn\n' - printf 'reason=%s\n' "$reason" printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - } > "$CHAT_META" - echo "status=visible_turn_only team=$TEAM name=$NAME thread=${thread_id:-unresolved} transport=codex-chat-visible-turn requested_mode=$requested_mode effective_mode=turn reason=$reason" - exit 0 -} + } > "$meta" -start_unbound_visible_turn() { - local reason="$1" - echo "status=visible_turn_only team=$TEAM name=$NAME thread=unresolved transport=codex-chat-visible-turn requested_mode=$MODE effective_mode=turn reason=$reason bound=false" + echo "status=visible_turn_only team=$TEAM name=$NAME thread=${thread_id:-unresolved} transport=codex-chat-visible-turn requested_mode=$requested_mode effective_mode=$MODE reason=$reason" exit 0 } -claim_role_for_thread() { - local owner_project owner_type owner_team owner_name owner_thread _owner_at tmp claim_output marker - marker="$(actas_codex_owner "$PROJECT" "$THREAD_ID")" - if [ -f "$SEAT" ]; then - IFS=$'\t' read -r owner_project owner_type owner_team owner_name owner_thread _owner_at < "$SEAT" || true - if [ "$owner_project" = "$PROJECT" ] && [ "$owner_type" = "$TYPE" ] \ - && [ "$owner_team" = "$TEAM" ] && [ "$owner_name" = "$NAME" ] \ - && [ "$owner_thread" = "$THREAD_ID" ]; then - if claim_output="$(actas_lock_claim "$TEAM" "$NAME" "$marker" 2>&1)"; then - return 0 - fi - printf 'status=role_held team=%s name=%s owner_thread=%s requested_thread=%s\n' \ - "$TEAM" "$NAME" "${claim_output#held:}" "$THREAD_ID" - return 1 - fi - printf 'status=role_held team=%s name=%s owner_thread=%s requested_thread=%s\n' \ - "$TEAM" "$NAME" "${owner_thread:-unknown}" "$THREAD_ID" - return 1 - fi - if ! claim_output="$(actas_lock_claim "$TEAM" "$NAME" "$marker" 2>&1)"; then - printf 'status=role_held team=%s name=%s owner_thread=%s requested_thread=%s\n' \ - "$TEAM" "$NAME" "${claim_output#held:}" "$THREAD_ID" - return 1 - fi - if ! tmp="$(mktemp "$RUN_DIR/.codex-seat.XXXXXX")"; then - actas_lock_release "$TEAM" "$NAME" "$marker" - return 1 - fi - chmod 600 "$tmp" - printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$PROJECT" "$TYPE" "$TEAM" "$NAME" "$THREAD_ID" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp" - if ln "$tmp" "$SEAT" 2>/dev/null; then - rm -f "$tmp" - return 0 - fi - rm -f "$tmp" - IFS=$'\t' read -r owner_project owner_type owner_team owner_name owner_thread _owner_at < "$SEAT" || true - [ "$owner_project" = "$PROJECT" ] && [ "$owner_type" = "$TYPE" ] \ - && [ "$owner_team" = "$TEAM" ] && [ "$owner_name" = "$NAME" ] \ - && [ "$owner_thread" = "$THREAD_ID" ] && return 0 - actas_lock_release "$TEAM" "$NAME" "$marker" - printf 'status=role_held team=%s name=%s owner_thread=%s requested_thread=%s\n' \ - "$TEAM" "$NAME" "${owner_thread:-unknown}" "$THREAD_ID" - return 1 -} - -release_other_role_for_thread() { - local seat saved_project saved_type saved_team saved_name saved_thread _saved_at key base chat - for seat in "$RUN_DIR"/codex-seat.*.tsv; do - [ -f "$seat" ] || continue - [ "$seat" = "$SEAT" ] && continue - IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true - [ "$saved_project" = "$PROJECT" ] && [ "$saved_type" = "$TYPE" ] \ - && [ "$saved_thread" = "$THREAD_ID" ] || continue - key="$PROJECT_HASH.$(_actas_lock_encode "$saved_team").$(_actas_lock_encode "$saved_name")" - base="$RUN_DIR/codex-bridge.$key" - chat="$RUN_DIR/codex-chat-visible.$key.meta" - stop_bridge_base "$base" - actas_codex_seat_release "$saved_team" "$saved_name" "$saved_project" "$saved_thread" - rm -f "$chat" - done -} - -THREAD_ID="${AGMSG_CODEX_ACTAS_THREAD:-${CODEX_THREAD_ID:-$SESSION_ID}}" -case "$THREAD_ID" in loaded|current|unresolved) THREAD_ID="" ;; esac +THREAD_ID="${AGMSG_CODEX_ACTAS_THREAD:-}" +if [ -z "$THREAD_ID" ]; then + THREAD_ID="$(resolve_thread_id || true)" +fi case "$MODE" in - monitor|both|turn) ;; + monitor|both) + ;; + turn) + start_chat_visible_turn_delivery "$THREAD_ID" "foreground_turn_mode" + ;; off|"") - stop_bridge_base "$BASE" + kill_other_project_receivers + kill_receiver_files "$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" echo "status=receive_disabled team=$TEAM name=$NAME mode=${MODE:-off}" exit 0 ;; - *) echo "status=invalid_mode team=$TEAM name=$NAME mode=$MODE" >&2; exit 4 ;; + *) + echo "status=invalid_mode team=$TEAM name=$NAME mode=$MODE" >&2 + exit 4 + ;; esac -[ -n "$THREAD_ID" ] || start_unbound_visible_turn "exact_thread_id_unavailable" -if ! [[ "$THREAD_ID" =~ ^[A-Za-z0-9._-]+$ ]]; then - start_unbound_visible_turn "exact_thread_id_invalid" -fi - -# Durable Codex seat metadata lives in a dedicated namespace, while atomic -# exclusivity is shared with generic actas through a non-GC role marker. Both -# are released only by the same task changing roles, SessionEnd, reset/drop, -# or a project delivery-mode teardown. -claim_role_for_thread || exit 5 -release_other_role_for_thread +port_alive() { + local port="$1" + (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null +} -[ "$MODE" = "turn" ] && start_visible_turn_delivery "$THREAD_ID" "foreground_turn_mode" +PROJECT_HASH="$(agmsg_sha1 <<<"$PROJECT")" +SERVER_PID="$RUN_DIR/codex-app-server.$PROJECT_HASH.pid" +PORT_FILE="$RUN_DIR/codex-app-server.$PROJECT_HASH.port" -private_regular_file() { - local file="$1" mode - [ -f "$file" ] && [ ! -L "$file" ] || return 1 - mode="$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || true)" - [ "$mode" = "600" ] -} +APP_SERVER="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" +if [ -z "$APP_SERVER" ] && [ -f "$PORT_FILE" ] && [ -f "$SERVER_PID" ]; then + existing_port="$(cat "$PORT_FILE" 2>/dev/null || true)" + existing_pid="$(cat "$SERVER_PID" 2>/dev/null || true)" + if [ -n "$existing_port" ] && [ -n "$existing_pid" ] \ + && kill -0 "$existing_pid" 2>/dev/null && port_alive "$existing_port"; then + APP_SERVER="ws://127.0.0.1:$existing_port" + fi +fi -private_regular_file "$RELAY_ENDPOINT_FILE" \ - || start_visible_turn_delivery "$THREAD_ID" "desktop_relay_endpoint_unavailable" -RELAY_ENDPOINT="$(cat "$RELAY_ENDPOINT_FILE" 2>/dev/null || true)" -if ! printf '%s' "$RELAY_ENDPOINT" \ - | grep -Eq '^ws://127\.0\.0\.1:[0-9]+/bridge/[a-f0-9]{64}$'; then - start_visible_turn_delivery "$THREAD_ID" "desktop_relay_endpoint_invalid" +if [ -z "$APP_SERVER" ]; then + start_chat_visible_turn_delivery "$THREAD_ID" "visible_app_server_unavailable" fi -RELAY_PORT="${RELAY_ENDPOINT#ws://127.0.0.1:}" -RELAY_PORT="${RELAY_PORT%%/*}" -RELAY_STATUS="$(sed -n 's/^status=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" -RELAY_PID="$(sed -n 's/^pid=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" -RELAY_HEALTH_PORT="$(sed -n 's/^port=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" -RELAY_PRIMARY="$(sed -n 's/^primary_connected=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" -RELAY_INITIALIZED="$(sed -n 's/^upstream_initialized=//p' "$RELAY_HEALTH" 2>/dev/null | head -1 || true)" -if [ "$RELAY_STATUS" != "ready" ] || [ "$RELAY_PRIMARY" != "1" ] \ - || [ "$RELAY_INITIALIZED" != "1" ] || [ "$RELAY_HEALTH_PORT" != "$RELAY_PORT" ] \ - || [ -z "$RELAY_PID" ] || ! kill -0 "$RELAY_PID" 2>/dev/null; then - start_visible_turn_delivery "$THREAD_ID" "desktop_relay_not_ready" + +if [ -z "$THREAD_ID" ]; then + THREAD_ID="loaded" fi -bridge_is_ready() { - local pid meta_thread health_status health_thread label token expected_endpoint - pid="$(cat "$PIDFILE" 2>/dev/null || true)" - [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null || return 1 - [ -f "$META" ] && [ -f "$HEALTH" ] && private_regular_file "$APPSERVER_FILE" \ - && private_regular_file "$BINDING_FILE" || return 1 - [ "$(sed -n 's/^project=//p' "$META" | head -1)" = "$PROJECT" ] || return 1 - [ "$(sed -n 's/^type=//p' "$META" | head -1)" = "$TYPE" ] || return 1 - [ "$(sed -n 's/^team=//p' "$META" | head -1)" = "$TEAM" ] || return 1 - [ "$(sed -n 's/^name=//p' "$META" | head -1)" = "$NAME" ] || return 1 - meta_thread="$(sed -n 's/^thread=//p' "$META" | head -1)" - health_status="$(sed -n 's/^status=//p' "$HEALTH" | head -1)" - health_thread="$(sed -n 's/^thread=//p' "$HEALTH" | head -1)" - [ "$meta_thread" = "$THREAD_ID" ] && [ "$health_thread" = "$THREAD_ID" ] || return 1 - [ "$health_status" = "ready" ] || return 1 - [ "$(sed -n 's/^project=//p' "$BINDING_FILE" | head -1)" = "$PROJECT" ] || return 1 - [ "$(sed -n 's/^type=//p' "$BINDING_FILE" | head -1)" = "$TYPE" ] || return 1 - [ "$(sed -n 's/^team=//p' "$BINDING_FILE" | head -1)" = "$TEAM" ] || return 1 - [ "$(sed -n 's/^name=//p' "$BINDING_FILE" | head -1)" = "$NAME" ] || return 1 - [ "$(sed -n 's/^thread=//p' "$BINDING_FILE" | head -1)" = "$THREAD_ID" ] || return 1 - [ "$(sed -n 's/^state_key=//p' "$BINDING_FILE" | head -1)" = "$STATE_KEY" ] || return 1 - token="$(sed -n 's/^token=//p' "$BINDING_FILE" | head -1)" - printf '%s' "$token" | grep -Eq '^[a-f0-9]{64}$' || return 1 - expected_endpoint="$RELAY_ENDPOINT/$token" - [ "$(cat "$APPSERVER_FILE" 2>/dev/null || true)" = "$expected_endpoint" ] || return 1 - label="$(sed -n 's/^launch_label=//p' "$META" | head -1)" - if [ -n "$label" ]; then - [ "$label" = "$LABEL" ] && [ -f "$PLIST" ] || return 1 - launchctl print "$DOMAIN/$label" >/dev/null 2>&1 || return 1 - fi -} +kill_other_project_receivers +kill_receiver_files "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.pid" "$RUN_DIR/codex-app-monitor.$TEAM.$NAME.meta" -if bridge_is_ready; then - echo "status=already_running team=$TEAM name=$NAME bridge_pid=$(cat "$PIDFILE") app_server=ws://127.0.0.1:$RELAY_PORT/ app_server_source=desktop_relay thread=$THREAD_ID active=true health=$(sed -n 's/^status=//p' "$HEALTH" | head -1)" - exit 0 +BRIDGE_PIDFILE="$RUN_DIR/codex-bridge.$TEAM.$NAME.pid" +BRIDGE_LOG="$RUN_DIR/codex-bridge.$TEAM.$NAME.log" +if [ -f "$BRIDGE_PIDFILE" ]; then + existing_bridge="$(cat "$BRIDGE_PIDFILE" 2>/dev/null || true)" + if [ -n "$existing_bridge" ] && kill -0 "$existing_bridge" 2>/dev/null; then + echo "status=already_running team=$TEAM name=$NAME bridge_pid=$existing_bridge app_server=$APP_SERVER thread=$THREAD_ID" + exit 0 + fi + rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" fi -stop_bridge_base "$BASE" -ROLE_TOKEN="$("$NODE_BIN" -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))')" -ROLE_ENDPOINT="$RELAY_ENDPOINT/$ROLE_TOKEN" -BINDING_TMP="$BINDING_FILE.$$" -( - umask 077 - { - printf 'token=%s\n' "$ROLE_TOKEN" - printf 'project=%s\n' "$PROJECT" - printf 'type=%s\n' "$TYPE" - printf 'team=%s\n' "$TEAM" - printf 'name=%s\n' "$NAME" - printf 'thread=%s\n' "$THREAD_ID" - printf 'state_key=%s\n' "$STATE_KEY" - } > "$BINDING_TMP" -) -chmod 600 "$BINDING_TMP" -mv "$BINDING_TMP" "$BINDING_FILE" -APPSERVER_TMP="$APPSERVER_FILE.$$" -(umask 077; printf '%s\n' "$ROLE_ENDPOINT" > "$APPSERVER_TMP") -chmod 600 "$APPSERVER_TMP" -mv "$APPSERVER_TMP" "$APPSERVER_FILE" - -xml_escape() { - sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' -e "s/'/\'/g" +start_bridge() { + local thread_id="$1" + local args=( + "$SCRIPT_DIR/codex-bridge.js" + --project "$PROJECT" + --type "$TYPE" + --team "$TEAM" + --name "$NAME" + --app-server "$APP_SERVER" + ) + if [ -n "$thread_id" ]; then + args+=(--thread "$thread_id") + fi + nohup "$NODE_BIN" "${args[@]}" >>"$BRIDGE_LOG" 2>&1 & + echo "$!" } -plist_string() { printf '%s' "$1" | xml_escape; } -write_bridge_plist() { - local temporary="$PLIST.$$" path_value - path_value="${PATH:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}" - : > "$LOG" - chmod 600 "$LOG" - cat > "$temporary" < - - - Label$(plist_string "$LABEL") - ProgramArguments - $(plist_string "$NODE_BIN") - $(plist_string "$SCRIPT_DIR/codex-bridge.js") - --project$(plist_string "$PROJECT") - --type$(plist_string "$TYPE") - --team$(plist_string "$TEAM") - --name$(plist_string "$NAME") - --state-key$(plist_string "$STATE_KEY") - --app-server-file$(plist_string "$APPSERVER_FILE") - --thread$(plist_string "$THREAD_ID") - - EnvironmentVariables - HOME$(plist_string "$HOME") - PATH$(plist_string "$path_value") - AGMSG_CODEX_BRIDGE_LAUNCH_LABEL$(plist_string "$LABEL") - AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR$(plist_string "$RUN_DIR") - - WorkingDirectory$(plist_string "$PROJECT") - StandardOutPath$(plist_string "$LOG") - StandardErrorPath$(plist_string "$LOG") - RunAtLoad - KeepAliveSuccessfulExit - ThrottleInterval2 - -EOF - chmod 600 "$temporary" - mv "$temporary" "$PLIST" -} +BRIDGE_PID="$(start_bridge "$THREAD_ID")" -BRIDGE_ARGS=( - "$SCRIPT_DIR/codex-bridge.js" - --project "$PROJECT" - --type "$TYPE" - --team "$TEAM" - --name "$NAME" - --state-key "$STATE_KEY" - --app-server-file "$APPSERVER_FILE" - --thread "$THREAD_ID" -) - -SUPERVISOR="direct" -if [ "${AGMSG_CODEX_BRIDGE_SUPERVISOR:-}" != "direct" ] \ - && [ "$(uname -s)" = "Darwin" ] && command -v launchctl >/dev/null 2>&1 \ - && launchctl print "$DOMAIN" >/dev/null 2>&1; then - SUPERVISOR="launchd" - write_bridge_plist - bootout_label "$LABEL" - rm -f "$PIDFILE" "$META" "$HEALTH" - if ! launchctl bootstrap "$DOMAIN" "$PLIST" >/dev/null 2>&1; then - stop_bridge_base "$BASE" - start_visible_turn_delivery "$THREAD_ID" "desktop_bridge_launch_failed" - fi - launchctl kickstart -k "$DOMAIN/$LABEL" >/dev/null 2>&1 || true -else - nohup "$NODE_BIN" "${BRIDGE_ARGS[@]}" >> "$LOG" 2>&1 & +sleep 0.8 +if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + # Codex Desktop rollouts can be unreadable to a standalone app-server while + # the Desktop owns them. Never retry through a background CLI resume because + # that can consume mail without updating the visible Codex thread. + rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + printf 'actas-monitor: thread attach failed; refusing thread/start for thread=%s\n' "$THREAD_ID" >>"$BRIDGE_LOG" + start_chat_visible_turn_delivery "$THREAD_ID" "app_server_thread_attach_failed" fi READY_SECONDS="${AGMSG_CODEX_ACTAS_READY_SECONDS:-8}" -case "$READY_SECONDS" in ''|*[!0-9]*) READY_SECONDS=8 ;; esac -READY_CHECKS=$((READY_SECONDS * 5)) -[ "$READY_CHECKS" -gt 0 ] || READY_CHECKS=1 -for _ in $(seq 1 "$READY_CHECKS"); do - if bridge_is_ready; then - # Recheck the relay after bridge startup; a Desktop disconnect during the - # handshake must downgrade instead of reporting a stale green bridge. - if [ "$(sed -n 's/^status=//p' "$RELAY_HEALTH" | head -1)" = "ready" ] \ - && [ "$(sed -n 's/^primary_connected=//p' "$RELAY_HEALTH" | head -1)" = "1" ] \ - && [ "$(sed -n 's/^upstream_initialized=//p' "$RELAY_HEALTH" | head -1)" = "1" ]; then - rm -f "$CHAT_META" - echo "status=ok team=$TEAM name=$NAME bridge_pid=$(cat "$PIDFILE") app_server=ws://127.0.0.1:$RELAY_PORT/ app_server_source=desktop_relay thread=$THREAD_ID supervisor=$SUPERVISOR active=true health=ready" - exit 0 +sleep "$READY_SECONDS" +if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + actas_lock_gc_stale >/dev/null 2>&1 || true + start_chat_visible_turn_delivery "$THREAD_ID" "app_server_bridge_exited" +fi +case "$APP_SERVER" in + ws://127.0.0.1:*) + check_port="${APP_SERVER#ws://127.0.0.1:}" + check_port="${check_port%%/*}" + if ! port_alive "$check_port"; then + rm -f "$BRIDGE_PIDFILE" "$RUN_DIR/codex-bridge.$TEAM.$NAME.meta" + actas_lock_gc_stale >/dev/null 2>&1 || true + start_chat_visible_turn_delivery "$THREAD_ID" "app_server_exited" fi - break - fi - sleep 0.2 -done + ;; +esac -stop_bridge_base "$BASE" -start_visible_turn_delivery "$THREAD_ID" "desktop_bridge_not_ready" +echo "status=ok team=$TEAM name=$NAME bridge_pid=$BRIDGE_PID app_server=$APP_SERVER thread=$THREAD_ID" diff --git a/scripts/drivers/types/codex/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh index 4a39db81..2f06f68b 100755 --- a/scripts/drivers/types/codex/codex-bridge-launcher.sh +++ b/scripts/drivers/types/codex/codex-bridge-launcher.sh @@ -4,9 +4,11 @@ set -euo pipefail # Runs outside Codex's tool sandbox and owns the app-server connection: it starts # codex-bridge.js for this project's single codex identity. # -# This legacy launcher is fail-closed: it launches only when its request file -# contains one exact thread id. Loaded-thread discovery and thread creation can -# select a different visible task and are forbidden. +# On codex 0.141+ the SessionStart hook cannot resolve the thread id +# (CODEX_THREAD_ID is not exported and no rollout is written for --remote +# sessions), so the bridge discovers the live TUI thread itself via +# thread/loaded/list (`--thread loaded`). If an older codex DID write a request +# file with a real thread id, that id is used instead. See #170, #41. TYPE="${1:?Usage: codex-bridge-launcher.sh }" PROJECT="${2:?Missing project_path}" @@ -53,8 +55,6 @@ done pidfile="$RUN_DIR/codex-bridge.$team.$name.pid" log="$RUN_DIR/codex-bridge.$team.$name.log" -: >"$log" -chmod 600 "$log" # Records the app-server URL the live bridge was launched against, so a later # launcher instance can tell a bridge bound to a stale app-server (old port, # from before a codex upgrade) from one bound to the current server. See #197/#237. @@ -69,20 +69,12 @@ else bridge_run=("$NODE_BIN" "$SCRIPT_DIR/codex-bridge.js") fi -bridge_pid_matches() { - local pid="$1" app_server="$2" thread="$3" expected cmd - [ -n "$app_server" ] && [ -n "$thread" ] || return 1 - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - expected="$SCRIPT_DIR/codex-bridge.js --project $PROJECT --type $TYPE --team $team --name $name --thread $thread --app-server $app_server" - cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)" - case " $cmd " in *" $expected "*) return 0 ;; esac - return 1 -} - while kill -0 "$PARENT_PID" 2>/dev/null; do - # Resolve an exact thread before the reuse check. Missing, reserved, or unsafe - # values leave the launcher idle without creating another Codex task. - thread_id="" + # Resolve the app-server URL (and thread) this iteration would launch against + # FIRST, so the reuse check can compare a live bridge's bound server with the + # current one. Thread source: a request file (older-codex hook) wins; otherwise + # discover the live TUI thread via thread/loaded/list. + thread_id="loaded" req_app_server="$APP_SERVER" if [ -f "$REQUEST_FILE" ]; then request="$(cat "$REQUEST_FILE" 2>/dev/null || true)" @@ -90,17 +82,10 @@ while kill -0 "$PARENT_PID" 2>/dev/null; do IFS="$TAB" read -r _rtype _rteam _rname _rthread _rapp </dev/null || true)" @@ -112,15 +97,11 @@ EOF # this, but guard the race where the old bridge has not exited yet by the # time a new launcher re-checks: an app-server mismatch means tear it down. bound_url="$(cat "$appserver_file" 2>/dev/null || true)" - bound_thread="$(sed -n 's/^thread=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" - if bridge_pid_matches "$bridge_pid" "$bound_url" "$bound_thread" \ - && [ "$bound_url" = "$req_app_server" ] && [ "$bound_thread" = "$thread_id" ]; then + if [ "$bound_url" = "$req_app_server" ]; then sleep 0.3 continue fi - if bridge_pid_matches "$bridge_pid" "$bound_url" "$bound_thread"; then - kill "$bridge_pid" 2>/dev/null || true - fi + kill "$bridge_pid" 2>/dev/null || true rm -f "$pidfile" "$appserver_file" fi fi @@ -137,13 +118,3 @@ EOF printf '%s' "$req_app_server" > "$appserver_file" sleep 1 done - -# The launcher owns only the exact legacy bridge recorded for this parent. -# Never signal a recycled pid or another project's bridge during TUI teardown. -bridge_pid="$(cat "$pidfile" 2>/dev/null || true)" -bound_url="$(cat "$appserver_file" 2>/dev/null || true)" -bound_thread="$(sed -n 's/^thread=//p' "${pidfile%.pid}.meta" 2>/dev/null | head -1)" -if [ -n "$bridge_pid" ] && kill -0 "$bridge_pid" 2>/dev/null \ - && bridge_pid_matches "$bridge_pid" "$bound_url" "$bound_thread"; then - kill "$bridge_pid" 2>/dev/null || true -fi diff --git a/scripts/drivers/types/codex/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js index 07e6a3fd..11d1df7e 100755 --- a/scripts/drivers/types/codex/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -8,12 +8,10 @@ const net = require("net"); const path = require("path"); const readline = require("readline"); -process.umask(0o077); - const SCRIPT_DIR = __dirname; // .../scripts/drivers/types/codex (codex siblings live here) const SKILL_DIR = path.resolve(SCRIPT_DIR, "..", "..", "..", ".."); // skill root const SCRIPTS_DIR = path.join(SKILL_DIR, "scripts"); // type-independent engine scripts (identities/inbox/send) -const RUN_DIR = path.resolve(process.env.AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR || path.join(SKILL_DIR, "run")); +const RUN_DIR = path.join(SKILL_DIR, "run"); // Git Bash on Windows cannot exec a .sh path directly — spawnSync of the script // fails with EFTYPE. Invoke the helper scripts through bash on every platform. @@ -31,10 +29,10 @@ Options: --type Agent type for identity resolution (default: codex). --team Limit wakeups to one team. --name Limit wakeups to one agent name. - --state-key Private runtime suffix chosen by the launcher. --timeout watch-once timeout before re-arming (default: 300). --interval watch-once poll interval (default: 2). --max-wakes Stop after n wakeups, useful for tests. + --stale-wake-limit Stop after n repeated unchanged wakeups (default: 1). --connect-timeout-ms Max wait for direct app-server connect/upgrade (default: 10000). --request-timeout-ms @@ -43,10 +41,12 @@ Options: Stop after n consecutive watch-once failures; 0 disables (default: 3). --app-server Connect through an existing app-server endpoint. Supports unix://PATH or ws://host:port over WebSocket. - --app-server-file - Read the endpoint from a private (0600) file. - --thread Resume this exact existing app-server thread. - --inline-inbox Rejected: background inbox reads are unsafe. + --thread + Resume an existing app-server thread. "current" uses + CODEX_THREAD_ID; "loaded" discovers the live TUI thread + via thread/loaded/list (codex 0.141+, see #170). + --loaded-timeout Max wait for a loaded thread to appear (default: 30000). + --inline-inbox Read inbox in the bridge and include message text in the turn input. --resolve-only Print resolved team/name and exit. --help Show this help. @@ -58,20 +58,6 @@ function die(message) { process.exit(1); } -class TerminalBridgeError extends Error { - constructor(message) { - super(message); - this.name = "TerminalBridgeError"; - } -} - -class ExistingBridgeError extends TerminalBridgeError { - constructor(message) { - super(message); - this.name = "ExistingBridgeError"; - } -} - // Convert a native Windows path into the MSYS/Git-Bash POSIX form that agmsg // registration data is keyed by (Git Bash stores e.g. `/c/Users/me/proj`). // A drive-letter path `C:\...`/`C:/...` becomes `/c/...` and its backslashes @@ -94,14 +80,12 @@ function parseArgs(argv) { timeout: Number(process.env.AGMSG_WATCH_ONCE_TIMEOUT || 300), interval: Number(process.env.AGMSG_WATCH_ONCE_INTERVAL || 2), maxWakes: 0, + staleWakeLimit: Number(process.env.AGMSG_CODEX_BRIDGE_STALE_WAKE_LIMIT || 1), connectTimeoutMs: Number(process.env.AGMSG_CODEX_BRIDGE_CONNECT_TIMEOUT_MS || 10000), requestTimeoutMs: Number(process.env.AGMSG_CODEX_BRIDGE_REQUEST_TIMEOUT_MS || 30000), watchFailureLimit: Number(process.env.AGMSG_CODEX_BRIDGE_WATCH_FAILURE_LIMIT || 3), inlineInbox: false, turnTimeout: Number(process.env.AGMSG_CODEX_BRIDGE_TURN_TIMEOUT || 60), - retryBaseMs: Number(process.env.AGMSG_CODEX_BRIDGE_RETRY_BASE_MS || 5000), - retryMaxMs: Number(process.env.AGMSG_CODEX_BRIDGE_RETRY_MAX_MS || 300000), - sameUnreadDelayMs: Number(process.env.AGMSG_CODEX_BRIDGE_SAME_UNREAD_DELAY_MS || 30000), }; for (let i = 0; i < argv.length; i += 1) { @@ -118,14 +102,14 @@ function parseArgs(argv) { opts.team = argv[++i]; } else if (arg === "--name") { opts.name = argv[++i]; - } else if (arg === "--state-key") { - opts.stateKey = argv[++i]; } else if (arg === "--timeout") { opts.timeout = Number(argv[++i]); } else if (arg === "--interval") { opts.interval = Number(argv[++i]); } else if (arg === "--max-wakes") { opts.maxWakes = Number(argv[++i]); + } else if (arg === "--stale-wake-limit") { + opts.staleWakeLimit = Number(argv[++i]); } else if (arg === "--connect-timeout-ms") { opts.connectTimeoutMs = Number(argv[++i]); } else if (arg === "--request-timeout-ms") { @@ -136,12 +120,12 @@ function parseArgs(argv) { opts.turnTimeout = Number(argv[++i]); } else if (arg === "--app-server") { opts.appServer = argv[++i]; - } else if (arg === "--app-server-file") { - opts.appServerFile = path.resolve(argv[++i]); } else if (arg === "--thread") { opts.threadId = argv[++i]; + } else if (arg === "--loaded-timeout") { + opts.loadedTimeout = Number(argv[++i]); } else if (arg === "--inline-inbox") { - opts.inlineInboxRequested = true; + opts.inlineInbox = true; } else { die(`unknown option: ${arg}`); } @@ -152,6 +136,9 @@ function parseArgs(argv) { if (!Number.isFinite(opts.timeout) || opts.timeout <= 0) die("--timeout must be a positive number"); if (!Number.isFinite(opts.interval) || opts.interval <= 0) die("--interval must be a positive number"); if (!Number.isFinite(opts.maxWakes) || opts.maxWakes < 0) die("--max-wakes must be a non-negative number"); + if (!Number.isFinite(opts.staleWakeLimit) || opts.staleWakeLimit < 0) { + die("--stale-wake-limit must be a non-negative number"); + } if (!Number.isFinite(opts.connectTimeoutMs) || opts.connectTimeoutMs < 0) { die("--connect-timeout-ms must be a non-negative number"); } @@ -164,29 +151,14 @@ function parseArgs(argv) { if (!Number.isFinite(opts.turnTimeout) || opts.turnTimeout < 0) { die("--turn-timeout must be a non-negative number"); } - for (const key of ["retryBaseMs", "retryMaxMs", "sameUnreadDelayMs"]) { - if (!Number.isFinite(opts[key]) || opts[key] < 1) die(`${key} must be a positive number`); - } - if (opts.appServer && opts.appServerFile) die("pass only one of --app-server or --app-server-file"); - if (opts.appServerFile) opts.appServer = readPrivateEndpoint(opts.appServerFile); - if (!opts.resolveOnly && (!opts.threadId || !/^[A-Za-z0-9._-]+$/.test(opts.threadId))) { - die("one exact --thread id is required"); + if (opts.threadId === "current") { + opts.threadId = process.env.CODEX_THREAD_ID || ""; + if (!opts.threadId) die("--thread current requires CODEX_THREAD_ID"); } - if (!opts.resolveOnly && ["loaded", "current", "unresolved"].includes(opts.threadId)) { - die("one exact --thread id is required; discovery aliases are not supported"); - } - if (opts.inlineInboxRequested) { - die("--inline-inbox is disabled; only the visible Codex task may run the official inbox command"); - } - if (opts.stateKey && !/^[A-Za-z0-9._%-]+$/.test(opts.stateKey)) die("--state-key contains unsafe characters"); opts.project = path.resolve(opts.project); if (!fs.existsSync(opts.project) || !fs.statSync(opts.project).isDirectory()) { die(`project path is not a directory: ${opts.project}`); } - // Preserve the registry spelling for identities.sh (macOS may register - // /var while realpath is /private/var), and use the canonical root for all - // relay-authorized app-server requests. - opts.canonicalProject = fs.realpathSync(opts.project); return opts; } @@ -241,17 +213,12 @@ class AppServerClient { this.pending = new Map(); this.handlers = new Map(); this.child = null; - this.disconnectHandler = null; - this.intentionalStop = false; } start() { const [bin, ...args] = this.command; - const childEnv = { ...process.env }; - delete childEnv.CODEX_APP_SERVER_WS_URL; this.child = spawn(bin, args, { cwd: this.cwd, - env: childEnv, stdio: ["pipe", "pipe", "pipe"], }); @@ -264,17 +231,15 @@ class AppServerClient { }); this.child.on("exit", (code, signal) => { - const error = new Error(`app-server exited (${code ?? signal})`); for (const { reject } of this.pending.values()) { - reject(error); + reject(new Error(`app-server exited (${code || signal})`)); } this.pending.clear(); - if (!this.intentionalStop && this.disconnectHandler) this.disconnectHandler(error); }); - // app-server stderr can contain user-visible model content. Keep bridge - // logs as lifecycle telemetry only. - this.child.stderr.on("data", () => {}); + this.child.stderr.on("data", (chunk) => { + process.stderr.write(chunk); + }); const lines = readline.createInterface({ input: this.child.stdout }); lines.on("line", (line) => this.handleLine(line)); @@ -284,40 +249,16 @@ class AppServerClient { this.handlers.set(method, handler); } - onDisconnect(handler) { - this.disconnectHandler = handler; - } - handleLine(line) { if (!line.trim()) return; let message; try { message = JSON.parse(line); } catch (error) { - console.error("codex-bridge: ignoring non-json app-server line"); + console.error(`codex-bridge: ignoring non-json app-server line: ${line}`); return; } - if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) { - const handler = this.handlers.get(message.method); - if (!handler) { - this.sendJson({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32601, message: `No handler for ${message.method}` }, - }); - return; - } - Promise.resolve(handler(message.params || {})).then( - (result) => this.sendJson({ jsonrpc: "2.0", id: message.id, result: result || {} }), - (error) => this.sendJson({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32000, message: error.message || String(error) }, - }), - ); - return; - } if (Object.prototype.hasOwnProperty.call(message, "id")) { const pending = this.pending.get(message.id); if (!pending) return; @@ -377,10 +318,6 @@ class AppServerClient { this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); } - sendJson(value) { - this.child.stdin.write(`${JSON.stringify(value)}\n`); - } - dispatch(method, params) { try { Promise.resolve(this.handlers.get(method)(params)).catch((error) => { @@ -392,7 +329,6 @@ class AppServerClient { } stop() { - this.intentionalStop = true; if (this.child && !this.child.killed) { this.child.kill("SIGTERM"); } @@ -408,7 +344,6 @@ class WebSocketAppServerClient { constructor(connectOptions, label, opts = {}) { this.connectOptions = connectOptions; this.label = label || "app-server"; - this.requestPath = opts.requestPath || "/"; this.connectTimeoutMs = opts.connectTimeoutMs || 0; this.requestTimeoutMs = opts.requestTimeoutMs || 0; this.nextId = 1; @@ -423,7 +358,6 @@ class WebSocketAppServerClient { // Set when WE close the socket (shutdown); distinguishes an intentional stop // from the app-server going away under us. this.intentionalStop = false; - this.disconnectHandler = null; } start() { @@ -465,7 +399,7 @@ class WebSocketAppServerClient { this.socket.on("connect", () => { this.socket.write( [ - `GET ${this.requestPath} HTTP/1.1`, + "GET / HTTP/1.1", "Host: localhost", "Upgrade: websocket", "Connection: Upgrade", @@ -494,7 +428,10 @@ class WebSocketAppServerClient { // starts a fresh one against the new app-server — delivery silently // stops. Exit instead; the launcher then relaunches a fresh bridge bound // to the current app-server. Skipped when WE closed the socket. - if (!this.intentionalStop && this.disconnectHandler) this.disconnectHandler(error); + if (!this.intentionalStop) { + console.error(`codex-bridge: ${error.message}; exiting so a fresh bridge can attach`); + process.exit(1); + } }); }); } @@ -507,10 +444,6 @@ class WebSocketAppServerClient { this.handlers.set(method, handler); } - onDisconnect(handler) { - this.disconnectHandler = handler; - } - handleData(chunk, resolveStart, rejectStart) { if (!this.handshakeComplete) { this.handshakeBuffer = Buffer.concat([this.handshakeBuffer, chunk]); @@ -604,27 +537,7 @@ class WebSocketAppServerClient { try { message = JSON.parse(line); } catch (_) { - console.error("codex-bridge: ignoring non-json app-server message"); - return; - } - if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) { - const handler = this.handlers.get(message.method); - if (!handler) { - this.sendJson({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32601, message: `No handler for ${message.method}` }, - }); - return; - } - Promise.resolve(handler(message.params || {})).then( - (result) => this.sendJson({ jsonrpc: "2.0", id: message.id, result: result || {} }), - (error) => this.sendJson({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32000, message: error.message || String(error) }, - }), - ); + console.error(`codex-bridge: ignoring non-json app-server message: ${line}`); return; } if (Object.prototype.hasOwnProperty.call(message, "id")) { @@ -753,70 +666,42 @@ class CodexBridge { this.threadIdle = true; this.turnActive = false; this.turnTimer = null; - this.pendingWakeMaxId = 0; + this.pendingWake = false; this.watchHandle = null; this.wakeCount = 0; + this.lastWakeMaxId = 0; + this.staleWakeCount = 0; this.watchFailureCount = 0; - this.wakeRetryCount = 0; this.watchRearmTimer = null; + this.inlineInboxText = ""; + this.inlineInboxIdsFile = path.join(RUN_DIR, `codex-bridge.${identity.team}.${identity.name}.last-ids`); + this.deliveredInboxPending = false; this.stopping = false; - this.preserveHealthOnExit = false; - this.signalHandler = null; - this.lifetimeSettled = false; - this.lifetime = new Promise((resolve, reject) => { - this.resolveLifetime = (value) => { - if (this.lifetimeSettled) return; - this.lifetimeSettled = true; - resolve(value); - }; - this.rejectLifetime = (error) => { - if (this.lifetimeSettled) return; - this.lifetimeSettled = true; - reject(error); - }; - }); - const stateKey = opts.stateKey || `${identity.team}.${identity.name}`; - this.stateKey = stateKey; - this.pidfile = path.join(RUN_DIR, `codex-bridge.${stateKey}.pid`); - this.metafile = path.join(RUN_DIR, `codex-bridge.${stateKey}.meta`); - this.healthfile = path.join(RUN_DIR, `codex-bridge.${stateKey}.health`); - const threadHash = crypto.createHash("sha256").update(this.threadId).digest("hex").slice(0, 24); - this.threadHash = threadHash; - this.wakeStateFile = path.join(RUN_DIR, `codex-bridge.${stateKey}.wake.${threadHash}.json`); + this.pidfile = path.join(RUN_DIR, `codex-bridge.${identity.team}.${identity.name}.pid`); + this.metafile = path.join(RUN_DIR, `codex-bridge.${identity.team}.${identity.name}.meta`); } async run() { fs.mkdirSync(RUN_DIR, { recursive: true }); this.ensureSingleInstance(); this.writeMeta(); - this.writeHealth("connecting"); this.installSignals(); this.client.on("process/exited", this.clientHandler("process/exited", (params) => this.onProcessExited(params))); this.client.on("error", this.clientHandler("error", (params) => this.onServerError(params))); this.client.on("item/agentMessage/delta", this.clientHandler("item/agentMessage/delta", (params) => this.onAgentMessageDelta(params))); this.client.on("thread/status/changed", this.clientHandler("thread/status/changed", (params) => this.onThreadStatus(params))); - this.client.on("turn/started", this.clientHandler("turn/started", (params) => { - if (params.threadId !== this.threadId) return; + this.client.on("turn/started", this.clientHandler("turn/started", () => { this.turnActive = true; this.threadIdle = false; })); this.client.on("turn/completed", this.clientHandler("turn/completed", (params) => this.onTurnCompleted(params))); - this.client.on("turn/failed", this.clientHandler("turn/failed", (params) => this.onTurnCompleted(params))); - this.client.onDisconnect((error) => { - if (!this.stopping) this.rejectLifetime(error); - }); + this.client.on("turn/failed", this.clientHandler("turn/failed", () => this.onTurnCompleted())); this.client.start(); await this.client.ready?.(); - this.writeHealth("connected"); await this.initialize(); await this.ensureThread(); - this.writeHealth("thread_attached"); await this.armWatch(); - this.writeMeta(); - this.writeHealth("ready"); - this.readyAt = Date.now(); - await this.lifetime; } clientHandler(method, handler) { @@ -831,13 +716,12 @@ class CodexBridge { failClientHandler(method, error) { console.error(`codex-bridge: ${method} handler failed: ${error.message}`); - this.writeHealth("retrying_transient", `${method}: ${error.message}`); - this.rejectLifetime(error); + this.shutdown().finally(() => process.exit(1)); } writeMeta() { - atomicWritePrivate(this.pidfile, `${process.pid}\n`); - atomicWritePrivate( + fs.writeFileSync(this.pidfile, `${process.pid}\n`); + fs.writeFileSync( this.metafile, [ `pid=${process.pid}`, @@ -845,78 +729,20 @@ class CodexBridge { `team=${this.identity.team}`, `name=${this.identity.name}`, `type=${this.opts.type}`, - `thread=${this.threadId || ""}`, - `app_server=${redactAppServer(this.opts.appServer || "stdio://")}`, - `launch_label=${process.env.AGMSG_CODEX_BRIDGE_LAUNCH_LABEL || ""}`, ].join("\n") + "\n", ); } - writeHealth(status, detail = "") { - const temporary = `${this.healthfile}.${process.pid}.tmp`; - try { - fs.writeFileSync( - temporary, - [ - `status=${status}`, - `pid=${process.pid}`, - `thread=${this.threadId || "unresolved"}`, - `detail=${String(detail).replace(/[\r\n]+/g, " ")}`, - `updated_at=${new Date().toISOString()}`, - "", - ].join("\n"), - ); - fs.chmodSync(temporary, 0o600); - fs.renameSync(temporary, this.healthfile); - } catch (_) { - try { fs.unlinkSync(temporary); } catch (_) {} - } - } - - readWakeState({ tolerateCorrupt = false } = {}) { - if (!fs.existsSync(this.wakeStateFile)) return null; - try { - const stat = fs.lstatSync(this.wakeStateFile); - if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) { - throw new Error("wake state is not a private regular file"); - } - const state = JSON.parse(fs.readFileSync(this.wakeStateFile, "utf8")); - const expectedId = wakeClientMessageId(this.stateKey, this.threadId, state.maxId); - if ( - state.version !== 1 - || state.threadHash !== this.threadHash - || !Number.isInteger(state.maxId) - || state.maxId <= 0 - || !["observed", "dispatching", "accepted", "ack_confirmed"].includes(state.phase) - || state.clientUserMessageId !== expectedId - ) { - throw new Error("wake state failed validation"); - } - return state; - } catch (error) { - if (tolerateCorrupt) return null; - throw new Error(`cannot trust durable wake state: ${error.message}`); - } - } - - writeWakeState(state) { - const value = { - version: 1, - threadHash: this.threadHash, - maxId: state.maxId, - phase: state.phase, - clientUserMessageId: state.clientUserMessageId, - updatedAt: new Date().toISOString(), - }; - atomicWritePrivate(this.wakeStateFile, `${JSON.stringify(value)}\n`); - } - installSignals() { - this.signalHandler = () => { - this.shutdown().finally(() => this.resolveLifetime()); + const stop = () => { + this.shutdown().finally(() => process.exit(0)); }; - process.once("SIGINT", this.signalHandler); - process.once("SIGTERM", this.signalHandler); + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + process.on("exit", () => { + this.client.stop(); + this.cleanupMeta(); + }); } async initialize() { @@ -935,25 +761,58 @@ class CodexBridge { this.client.notify("initialized"); } + async resolveLoadedThread() { + // codex 0.141+ does not export CODEX_THREAD_ID to hooks and writes no rollout + // for --remote sessions, so the thread id cannot be resolved out-of-band. + // Ask the app-server which thread the live TUI has loaded instead. See #170. + const deadline = Date.now() + (this.opts.loadedTimeout || 30000); + for (;;) { + const response = await this.client.request("thread/loaded/list", {}); + const ids = response && Array.isArray(response.data) ? response.data : []; + if (ids.length > 0) { + if (ids.length > 1) { + console.error( + `codex-bridge: ${ids.length} threads loaded; attaching to the first (${ids[0]})`, + ); + } + return ids[0]; + } + if (Date.now() >= deadline) { + die("no loaded codex thread found via thread/loaded/list"); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + async ensureThread() { - let response; - try { - response = await this.client.request("thread/resume", { + if (this.threadId === "loaded") { + this.threadId = await this.resolveLoadedThread(); + console.error(`codex-bridge: discovered loaded thread ${this.threadId}`); + } + if (this.threadId) { + const response = await this.client.request("thread/resume", { threadId: this.threadId, - cwd: this.opts.canonicalProject, - runtimeWorkspaceRoots: [this.opts.canonicalProject], + cwd: this.opts.project, + runtimeWorkspaceRoots: [this.opts.project], excludeTurns: true, }); - } catch (error) { - throw new TerminalBridgeError(`cannot resume exact thread ${this.threadId}: ${error.message}`); - } - if (!response.thread || response.thread.id !== this.threadId) { - throw new TerminalBridgeError("thread/resume did not return the requested thread id"); + if (!response.thread || response.thread.id !== this.threadId) { + die("thread/resume did not return the requested thread id"); + } + const type = response.thread.status && response.thread.status.type; + this.threadIdle = type !== "active"; + this.turnActive = type === "active"; + console.error(`codex-bridge: resumed thread ${this.threadId}`); + return; } - const type = response.thread.status && response.thread.status.type; - this.threadIdle = type !== "active"; - this.turnActive = type === "active"; - console.error(`codex-bridge: resumed thread ${this.threadId}`); + const response = await this.client.request("thread/start", { + cwd: this.opts.project, + runtimeWorkspaceRoots: [this.opts.project], + ephemeral: false, + }); + this.threadId = response.thread && response.thread.id; + if (!this.threadId) die("thread/start did not return a thread id"); + console.error(`codex-bridge: started thread ${this.threadId}`); } async armWatch() { @@ -963,7 +822,7 @@ class CodexBridge { this.watchHandle = handle; const ownerId = `agmsg-codex-bridge-${process.pid}.${process.pid}`; const command = [ - fs.existsSync("/bin/bash") ? "/bin/bash" : BASH_BIN, + BASH_BIN, path.join(SCRIPT_DIR, "watch-once.sh"), // watch-once.sh resolves the subscription set through the same exact // project-key lookup as identities.sh, so it needs the POSIX form of the @@ -986,7 +845,7 @@ class CodexBridge { await this.client.request("process/spawn", { command, processHandle: handle, - cwd: this.opts.canonicalProject, + cwd: this.opts.project, outputBytesCap: 8192, timeoutMs: (this.opts.timeout + this.opts.interval + 10) * 1000, }); @@ -1004,86 +863,42 @@ class CodexBridge { if (params.exitCode === 0) { this.watchFailureCount = 0; const maxId = parseMaxId(params.stdout); - if (!maxId) { - this.writeHealth("paused_ambiguous_wake", "watch-once returned pending without a valid max_id"); - this.scheduleWatchRearm(this.nextRetryDelay()); - return; + if (this.isStaleWake(maxId)) { + await this.shutdown(); + process.exit(1); } - - let state; - try { - state = this.readWakeState(); - } catch (error) { - this.writeHealth("paused_ambiguous_wake", error.message); - this.scheduleWatchRearm(this.opts.sameUnreadDelayMs); - return; - } - if (state && maxId < state.maxId) { - this.writeHealth( - "paused_ambiguous_wake", - `unread max_id ${maxId} is older than durable max_id ${state.maxId}`, - ); - this.scheduleWatchRearm(this.opts.sameUnreadDelayMs); - return; - } - if (state && maxId === state.maxId && ["accepted", "ack_confirmed"].includes(state.phase)) { - this.writeHealth("waiting_for_ack", `unread max_id ${maxId} is already ${state.phase}`); - this.scheduleWatchRearm(this.opts.sameUnreadDelayMs); - return; - } - - const clientUserMessageId = wakeClientMessageId(this.stateKey, this.threadId, maxId); - if (!state || maxId > state.maxId) { - this.writeWakeState({ maxId, phase: "observed", clientUserMessageId }); - } - this.pendingWakeMaxId = maxId; - console.error(`codex-bridge: observed unread max_id ${maxId} for ${this.identity.team}/${this.identity.name}`); + this.pendingWake = true; + this.wakeCount += 1; + console.error(`codex-bridge: wakeup ${this.wakeCount} for ${this.identity.team}/${this.identity.name}`); await this.tryStartTurn(); return; } if (params.exitCode === 2) { this.watchFailureCount = 0; - this.wakeRetryCount = 0; - const state = this.readWakeState({ tolerateCorrupt: true }); - if (state && state.phase !== "ack_confirmed") { - this.writeWakeState({ ...state, phase: "ack_confirmed" }); - } - this.writeHealth("ready"); await this.armWatch(); return; } this.watchFailureCount += 1; - const detail = sanitizeLogDetail(params.stderr || `exit ${params.exitCode}`); + const detail = [params.stderr, params.stdout].filter(Boolean).join("\n").trim(); console.error(`codex-bridge: watch-once failed with exit ${params.exitCode}${detail ? `: ${detail}` : ""}`); if (this.opts.watchFailureLimit > 0 && this.watchFailureCount >= this.opts.watchFailureLimit) { - this.writeHealth( - "paused_watch_failure", - `${this.watchFailureCount} consecutive watch-once failure(s)`, + console.error( + `codex-bridge: stopping after ${this.watchFailureCount} consecutive watch-once failure(s)`, ); - } else { - this.writeHealth("waiting_watch_retry", `${this.watchFailureCount} watch-once failure(s)`); + await this.shutdown(); + process.exit(1); } - this.scheduleWatchRearm(this.nextRetryDelay()); - } - - nextRetryDelay() { - const exponent = Math.min(this.wakeRetryCount, 12); - this.wakeRetryCount += 1; - return Math.min(this.opts.retryBaseMs * (2 ** exponent), this.opts.retryMaxMs); + this.scheduleWatchRearm(); } - scheduleWatchRearm(delayMs = this.opts.retryBaseMs) { + scheduleWatchRearm() { if (this.stopping || this.watchHandle || this.watchRearmTimer) return; this.watchRearmTimer = setTimeout(() => { this.watchRearmTimer = null; - this.armWatch().catch((error) => { - this.watchFailureCount += 1; - this.writeHealth("waiting_watch_retry", sanitizeLogDetail(error.message)); - this.scheduleWatchRearm(this.nextRetryDelay()); - }); - }, delayMs); + this.armWatch().catch((error) => this.failClientHandler("process/exited", error)); + }, 5000); } clearWatchRearmTimer() { @@ -1113,7 +928,7 @@ class CodexBridge { async onTurnCompleted(params = {}) { if (params.threadId && params.threadId !== this.threadId) return; if (params.turn && params.turn.error) { - console.error(`codex-bridge: turn completed with error code ${sanitizeLogDetail(params.turn.error.code || "unknown")}`); + console.error(`codex-bridge: turn completed with error: ${JSON.stringify(params.turn.error)}`); } else { console.error(`codex-bridge: turn completed on thread ${this.threadId}`); } @@ -1128,10 +943,10 @@ class CodexBridge { this.clearTurnWatchdog(); this.turnActive = false; this.threadIdle = true; + this.markDeliveredInboxRead(); if (this.opts.maxWakes && this.wakeCount >= this.opts.maxWakes) { await this.shutdown(); - this.resolveLifetime(); - return; + process.exit(0); } // A wake can arrive while a turn is still active — the bridge resumed an // already-active thread (SessionStart fires on the first user turn), or a @@ -1139,7 +954,7 @@ class CodexBridge { // was set. Deliver that pending wake now instead of re-arming: a fresh // watch-once would re-observe the same unread max_id and the stale-wake // guard would stop the bridge with exit 1 before the message is delivered. - if (this.pendingWakeMaxId) { + if (this.pendingWake) { await this.tryStartTurn(); return; } @@ -1150,75 +965,44 @@ class CodexBridge { } async tryStartTurn() { - if (!this.pendingWakeMaxId || this.turnActive || !this.threadIdle) return; - const maxId = this.pendingWakeMaxId; - const clientUserMessageId = wakeClientMessageId(this.stateKey, this.threadId, maxId); - let state; - try { - state = this.readWakeState(); - } catch (error) { - this.writeHealth("paused_ambiguous_wake", "durable wake state is unavailable"); - this.scheduleWakeRetry(this.nextRetryDelay()); - return; - } - if (!state || state.maxId !== maxId || !["observed", "dispatching"].includes(state.phase)) { - this.writeHealth("paused_ambiguous_wake", "durable wake phase does not permit dispatch"); - this.scheduleWakeRetry(this.nextRetryDelay()); - return; - } - if (state.phase === "observed") { - // Record bridge-side intent before asking the relay. The relay owns the - // later at-most-once boundary: it fsyncs its own dispatch state before - // turn/start. After a relay restart, only that relay state decides - // whether this request may start or must reconcile. - this.writeWakeState({ maxId, phase: "dispatching", clientUserMessageId }); + if (!this.pendingWake || this.turnActive || !this.threadIdle) return; + if (this.opts.inlineInbox) { + this.inlineInboxText = this.readInboxForPrompt(); + if (!this.inlineInboxText.trim()) { + console.error("codex-bridge: pending wake had no inbox output; re-arming"); + this.pendingWake = false; + await this.armWatch(); + return; + } } + const prompt = this.buildPrompt(); + this.turnActive = true; + this.threadIdle = false; try { - const response = await this.client.request("agmsg/wake/dispatch", { + await this.client.request("turn/start", { threadId: this.threadId, - maxId, - clientUserMessageId, - dispatchMode: "start-or-reconcile", - cwd: this.opts.canonicalProject, - runtimeWorkspaceRoots: [this.opts.canonicalProject], + input: [{ type: "text", text: prompt, text_elements: [] }], + cwd: this.opts.project, + runtimeWorkspaceRoots: [this.opts.project], }); - if (!response || !["accepted", "reconciled"].includes(response.status) || response.maxId !== maxId) { - throw new Error("wake dispatch returned an ambiguous result"); - } - this.writeWakeState({ maxId, phase: "accepted", clientUserMessageId }); - this.pendingWakeMaxId = 0; - this.wakeRetryCount = 0; - if (response.status === "accepted") { - this.wakeCount += 1; - this.turnActive = true; - this.threadIdle = false; - console.error(`codex-bridge: accepted wakeup ${this.wakeCount} on thread ${this.threadId}`); - this.startTurnWatchdog(); - return; + console.error(`codex-bridge: started turn on thread ${this.threadId}`); + this.pendingWake = false; + if (this.opts.inlineInbox && fs.existsSync(this.inlineInboxIdsFile)) { + const ids = fs.readFileSync(this.inlineInboxIdsFile, "utf8").trim(); + this.deliveredInboxPending = Boolean(ids); } - - console.error(`codex-bridge: reconciled wake max_id ${maxId} on thread ${this.threadId}`); - this.writeHealth("waiting_for_ack", `reconciled max_id ${maxId}`); - if (this.turnActive) this.startTurnWatchdog(); - else this.scheduleWatchRearm(this.opts.sameUnreadDelayMs); + // Bound how long we treat the turn as active. The real app-server may + // never send turn/completed; the watchdog (and thread/status idle) drive + // onTurnEnded so detection re-arms instead of sleeping forever. See #41. + this.startTurnWatchdog(); } catch (error) { - // A timeout can happen after app-server accepted turn/start. The relay - // reconciles the deterministic client id against thread history on the - // next attempt, so never issue an unmarked fallback turn here. - this.writeHealth("paused_ambiguous_wake", "wake dispatch response unavailable; reconciliation pending"); - console.error(`codex-bridge: wake max_id ${maxId} is ambiguous; retrying reconciliation later`); - this.scheduleWakeRetry(this.nextRetryDelay()); + this.turnActive = false; + this.threadIdle = true; + this.clearTurnWatchdog(); + throw error; } } - scheduleWakeRetry(delayMs) { - if (this.stopping || this.watchRearmTimer) return; - this.watchRearmTimer = setTimeout(() => { - this.watchRearmTimer = null; - this.tryStartTurn().catch((error) => this.failClientHandler("agmsg/wake/dispatch", error)); - }, delayMs); - } - startTurnWatchdog() { this.clearTurnWatchdog(); if (!this.opts.turnTimeout) return; @@ -1243,14 +1027,12 @@ class CodexBridge { onServerError(params) { if (params.threadId && params.threadId !== this.threadId) return; - const code = params.code || (params.error && params.error.code) || "unknown"; - console.error(`codex-bridge: server error code ${sanitizeLogDetail(code)}`); + console.error(`codex-bridge: server error: ${JSON.stringify(params)}`); } onAgentMessageDelta(params) { if (params.threadId !== this.threadId) return; - // Agent content belongs only in the visible Codex task. The bridge log is - // lifecycle telemetry and must never become a second transcript. + process.stderr.write(params.delta); } buildPrompt() { @@ -1273,6 +1055,20 @@ class CodexBridge { "5. If you do not reply, state why in the visible status. ACK-only mail still requires a visible receipt notice.", "6. Do not treat inbox consumption, DB writes, monitor delivery, send.sh, or a successful process exit as complete unless the handling result is visible in the Codex thread UI.", ].join("\n"); + if (this.opts.inlineInbox) { + return [ + `agmsg delivered the following unread messages for ${this.identity.team}/${this.identity.name}:`, + "", + this.inlineInboxText.trim(), + "", + "Continue the conversation in this Codex thread. If a reply to an agmsg sender is needed, send it with:", + `${send} ${this.identity.team} ${this.identity.name} `, + "", + autonomousHandlingContract, + "", + visibleUiRequirement, + ].join("\n"); + } return [ `agmsg has unread messages for ${this.identity.team}/${this.identity.name}.`, "The bridge did not read or acknowledge their contents.", @@ -1285,16 +1081,59 @@ class CodexBridge { ].join("\n"); } - async pauseWithoutRestart(status, detail) { - this.writeHealth(status, detail); - this.preserveHealthOnExit = true; - await this.shutdown({ preserveHealth: true }); + readInboxForPrompt() { + try { + fs.writeFileSync(this.inlineInboxIdsFile, ""); + } catch (_) { + // Best effort. inbox-peek will report an error if the path cannot be used. + } + const result = spawnSync(BASH_BIN, [ + path.join(SCRIPTS_DIR, "inbox-peek.sh"), + this.identity.team, + this.identity.name, + "--quiet", + "--ids-file", + this.inlineInboxIdsFile, + ], { + cwd: this.opts.project, + encoding: "utf8", + }); + if (result.error) { + console.error(`codex-bridge: inbox.sh failed: ${result.error.message}`); + return ""; + } + if (result.status !== 0) { + console.error(`codex-bridge: inbox.sh exited ${result.status}: ${(result.stderr || "").trim()}`); + return ""; + } + return result.stdout || ""; } - async shutdown({ preserveHealth = false } = {}) { + markDeliveredInboxRead() { + if (!this.opts.inlineInbox || !this.deliveredInboxPending) return; + this.deliveredInboxPending = false; + const result = spawnSync(BASH_BIN, [ + path.join(SCRIPTS_DIR, "mark-read.sh"), + this.identity.team, + this.identity.name, + "--ids-file", + this.inlineInboxIdsFile, + ], { + cwd: this.opts.project, + encoding: "utf8", + }); + if (result.error) { + console.error(`codex-bridge: mark-read.sh failed: ${result.error.message}`); + return; + } + if (result.status !== 0) { + console.error(`codex-bridge: mark-read.sh exited ${result.status}: ${(result.stderr || "").trim()}`); + } + } + + async shutdown() { if (this.stopping) return; this.stopping = true; - this.preserveHealthOnExit = this.preserveHealthOnExit || preserveHealth; this.clearWatchRearmTimer(); this.clearTurnWatchdog(); if (this.watchHandle) { @@ -1307,11 +1146,6 @@ class CodexBridge { } this.client.stop(); this.cleanupMeta(); - if (this.signalHandler) { - process.removeListener("SIGINT", this.signalHandler); - process.removeListener("SIGTERM", this.signalHandler); - this.signalHandler = null; - } } cleanupMeta() { @@ -1333,9 +1167,7 @@ class CodexBridge { return; } - const files = [this.pidfile, this.metafile]; - if (!this.preserveHealthOnExit) files.push(this.healthfile); - for (const file of files) { + for (const file of [this.pidfile, this.metafile]) { try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (_) { @@ -1349,48 +1181,38 @@ class CodexBridge { if (!existing) return; try { process.kill(existing, 0); - const meta = readKeyValueFile(this.metafile); - const command = readProcessCommand(existing); - const expectedScript = fs.realpathSync(__filename); - const ownsPid = Boolean( - meta - && meta.pid === String(existing) - && meta.project === this.opts.project - && meta.team === this.identity.team - && meta.name === this.identity.name - && meta.type === this.opts.type - && meta.thread === this.threadId - && command.includes(expectedScript) - && command.includes(`--state-key ${this.stateKey}`) - ); - if (ownsPid) { - throw new ExistingBridgeError( - `bridge already running for ${this.identity.team}/${this.identity.name} (pid ${existing})`, - ); - } - // The pid was recycled or the metadata is not sufficient to prove - // ownership. Remove only this role's stale runtime files; never signal an - // unrelated live process. - this.removeStaleRuntimeFiles(); - return; + die(`bridge already running for ${this.identity.team}/${this.identity.name} (pid ${existing})`); } catch (error) { - if (error instanceof ExistingBridgeError) throw error; if (error && error.code === "ESRCH") { - this.removeStaleRuntimeFiles(); + for (const file of [this.pidfile, this.metafile]) { + try { + if (fs.existsSync(file)) fs.unlinkSync(file); + } catch (_) { + // Best-effort stale cleanup. + } + } return; } - throw new TerminalBridgeError(`cannot verify existing bridge pid ${existing}: ${error.message}`); + die(`cannot verify existing bridge pid ${existing}: ${error.message}`); } } - removeStaleRuntimeFiles() { - for (const file of [this.pidfile, this.metafile, this.healthfile]) { - try { - if (fs.existsSync(file)) fs.unlinkSync(file); - } catch (_) { - // Best-effort removal of files owned by this exact state key. - } + isStaleWake(maxId) { + if (maxId <= 0 || this.lastWakeMaxId !== maxId) { + this.lastWakeMaxId = maxId; + this.staleWakeCount = 0; + return false; } + + this.staleWakeCount += 1; + console.error( + `codex-bridge: unread max_id is still ${maxId}; inbox was not marked read after the prior wakeup`, + ); + if (this.opts.staleWakeLimit > 0 && this.staleWakeCount >= this.opts.staleWakeLimit) { + console.error("codex-bridge: stopping to avoid a repeated wakeup loop"); + return true; + } + return false; } } @@ -1411,24 +1233,15 @@ function appServerCommand(opts = {}) { } function parseWsTarget(url) { - // wss:// would need TLS, which the plain net socket below does not do. - let parsed; - try { - parsed = new URL(url); - } catch (_) { - die("--app-server must be a valid ws:// URL"); - } - if (parsed.protocol !== "ws:" || parsed.username || parsed.password || !parsed.hostname || !parsed.port) { - die("--app-server must be ws://host:port[/path]"); - } - const port = Number(parsed.port); + // ws://host:port → { host, port }. wss:// would need TLS, which the plain + // net socket below does not do; the agmsg app-server is loopback ws only. + const match = /^ws:\/\/([^/:]+):(\d+)\/?$/.exec(url); + if (!match) die(`--app-server ${url} must be ws://host:port`); + const port = Number(match[2]); if (!Number.isInteger(port) || port <= 0 || port > 65535) { - die("--app-server has an invalid port"); + die(`--app-server ${url} has an invalid port`); } - return { - connectOptions: { host: parsed.hostname, port }, - requestPath: `${parsed.pathname || "/"}${parsed.search || ""}`, - }; + return { host: match[1], port }; } function createAppServerClient(opts) { @@ -1440,39 +1253,11 @@ function createAppServerClient(opts) { } if (opts.appServer && opts.appServer.startsWith("ws://")) { const target = parseWsTarget(opts.appServer); - return new WebSocketAppServerClient( - target.connectOptions, - redactAppServer(opts.appServer), - { ...opts, requestPath: target.requestPath }, - ); + return new WebSocketAppServerClient(target, opts.appServer, opts); } return new AppServerClient(appServerCommand(opts), opts.project, opts); } -function readPrivateEndpoint(file) { - let endpoint; - try { - const stat = fs.lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink()) die("--app-server-file must name a regular non-symlink file"); - if ((stat.mode & 0o077) !== 0) die("--app-server-file must not be group/world accessible"); - endpoint = fs.readFileSync(file, "utf8").trim(); - } catch (error) { - die(`cannot read --app-server-file: ${error.message}`); - } - if (!endpoint) die("--app-server-file is empty"); - return endpoint; -} - -function redactAppServer(endpoint) { - if (!String(endpoint).startsWith("ws://")) return endpoint; - try { - const parsed = new URL(endpoint); - return `ws://${parsed.host}/`; - } catch (_) { - return "ws://"; - } -} - function readVersion() { try { return fs.readFileSync(path.join(SKILL_DIR, "VERSION"), "utf8").trim(); @@ -1496,79 +1281,6 @@ function parseMaxId(stdout) { return match ? Number(match[1]) : 0; } -function wakeClientMessageId(stateKey, threadId, maxId) { - const digest = crypto - .createHash("sha256") - .update(`${stateKey}\0${threadId}\0${maxId}`) - .digest("hex"); - return `agmsg-wake-v1-${digest}`; -} - -function atomicWritePrivate(file, contents) { - const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`; - let fd; - try { - fd = fs.openSync(temporary, "wx", 0o600); - fs.writeFileSync(fd, contents, "utf8"); - fs.fsyncSync(fd); - fs.closeSync(fd); - fd = undefined; - fs.renameSync(temporary, file); - try { - const dirFd = fs.openSync(path.dirname(file), "r"); - try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); } - } catch (_) { - // Some filesystems do not support directory fsync. The file itself was - // still fully synced before the atomic rename. - } - } catch (error) { - if (fd !== undefined) { - try { fs.closeSync(fd); } catch (_) {} - } - try { fs.unlinkSync(temporary); } catch (_) {} - throw error; - } -} - -function readKeyValueFile(file) { - try { - const stat = fs.lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink()) return null; - const result = {}; - for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { - const index = line.indexOf("="); - if (index <= 0) continue; - result[line.slice(0, index)] = line.slice(index + 1); - } - return result; - } catch (_) { - return null; - } -} - -function readProcessCommand(pid) { - try { - const proc = `/proc/${pid}/cmdline`; - if (fs.existsSync(proc)) return fs.readFileSync(proc).toString("utf8").replace(/\0/g, " ").trim(); - } catch (_) { - // Fall through to ps, which is available on macOS. - } - const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }); - return result.status === 0 ? String(result.stdout || "").trim() : ""; -} - -function sanitizeLogDetail(value) { - return String(value == null ? "" : value) - .replace(/[\r\n\t]+/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, 240); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - async function main() { const opts = parseArgs(process.argv.slice(2)); if (opts.help) { @@ -1582,41 +1294,12 @@ async function main() { return; } - let retryCount = 0; - for (;;) { - const bridge = new CodexBridge(opts, identity); - try { - await bridge.run(); - return; - } catch (error) { - if (error instanceof ExistingBridgeError) { - console.error(`codex-bridge: ${error.message}`); - return; - } - if (error instanceof TerminalBridgeError) { - if (!opts.stateKey) throw error; - bridge.writeHealth("terminal_thread_error", error.message); - await bridge.shutdown({ preserveHealth: true }); - console.error(`codex-bridge: terminal configuration error: ${error.message}`); - return; - } - if (!opts.stateKey) { - await bridge.shutdown(); - throw error; - } - if (bridge.readyAt && Date.now() - bridge.readyAt >= 300000) retryCount = 0; - retryCount += 1; - const delayMs = Math.min(opts.retryBaseMs * (2 ** Math.min(retryCount - 1, 12)), opts.retryMaxMs); - bridge.writeHealth("retrying_transient", `${sanitizeLogDetail(error.message)}; retry in ${delayMs}ms`); - await bridge.shutdown({ preserveHealth: true }); - console.error(`codex-bridge: transient failure; retrying in ${delayMs}ms: ${sanitizeLogDetail(error.message)}`); - await sleep(delayMs); - } - } + const bridge = new CodexBridge(opts, identity); + await bridge.run(); } if (require.main === module) { main().catch((error) => die(error.message)); } -module.exports = { toPosixPath, WebSocketAppServerClient }; +module.exports = { toPosixPath }; diff --git a/scripts/drivers/types/codex/codex-desktop-relay-run.sh b/scripts/drivers/types/codex/codex-desktop-relay-run.sh deleted file mode 100755 index 338ed0f5..00000000 --- a/scripts/drivers/types/codex/codex-desktop-relay-run.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -umask 077 -unset CODEX_APP_SERVER_WS_URL - -# LaunchAgent entry point. ctl writes private capabilities/endpoints before the -# job starts. The token values are read by the relay process from 0600 files and -# never appear in argv, the plist, status, or logs. - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" -RUN_DIR="${AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR:-$SKILL_DIR/run}" - -# shellcheck source=../../../lib/node.sh -source "$SCRIPT_DIR/../../../lib/node.sh" - -HOST="${AGMSG_CODEX_DESKTOP_RELAY_HOST:-127.0.0.1}" -PORT="${AGMSG_CODEX_DESKTOP_RELAY_PORT:-49643}" -NODE_BIN="$(agmsg_resolve_node)" - -mkdir -p "$RUN_DIR" - -child_pid="" -stopping=0 -stop_child() { - stopping=1 - if [ -n "$child_pid" ] && kill -0 "$child_pid" 2>/dev/null; then - kill -TERM "$child_pid" 2>/dev/null || true - fi -} -trap stop_child INT TERM - -delay=2 -while :; do - started_at="$(date +%s)" - set +e - "$NODE_BIN" "$SCRIPT_DIR/codex-desktop-relay.js" \ - --host "$HOST" \ - --port "$PORT" \ - --desktop-token-file "$RUN_DIR/codex-desktop-relay.desktop-token" \ - --bridge-token-file "$RUN_DIR/codex-desktop-relay.bridge-token" \ - --health "$RUN_DIR/codex-desktop-relay.health" \ - --port-file "$RUN_DIR/codex-desktop-relay.port" \ - --pid-file "$RUN_DIR/codex-desktop-relay.pid" \ - --parent-pid "$$" & - child_pid=$! - wait "$child_pid" - status=$? - child_pid="" - set -e - [ "$stopping" = "0" ] || exit 0 - # EX_USAGE/configuration errors need a corrected install, not a two-second - # restart loop. Transient app-server exits are retried with capped backoff. - [ "$status" -ne 64 ] || exit 0 - if [ $(( $(date +%s) - started_at )) -ge 300 ]; then - delay=2 - fi - sleep "$delay" - [ "$delay" -ge 60 ] || delay=$((delay * 2)) - [ "$delay" -le 60 ] || delay=60 -done diff --git a/scripts/drivers/types/codex/codex-desktop-relay.js b/scripts/drivers/types/codex/codex-desktop-relay.js deleted file mode 100755 index 5c5acaa2..00000000 --- a/scripts/drivers/types/codex/codex-desktop-relay.js +++ /dev/null @@ -1,1223 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -// Codex Desktop owns one app-server connection. agmsg needs a second client so -// it can inject a turn into that same visible thread, but codex app-server's -// websocket listener accepts only one active frontend. This relay keeps one -// stdio app-server upstream and multiplexes Desktop plus agmsg bridge clients. -// It never reads agmsg mail; the bridge only starts a visible Codex turn. - -const { spawn } = require("child_process"); -const crypto = require("crypto"); -const fs = require("fs"); -const net = require("net"); -const path = require("path"); -const readline = require("readline"); - -const SCRIPT_DIR = __dirname; -const SKILL_DIR = path.resolve(SCRIPT_DIR, "..", "..", "..", ".."); -const RUN_DIR = path.resolve(process.env.AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR || path.join(SKILL_DIR, "run")); -const DEFAULT_HOST = "127.0.0.1"; -const DEFAULT_PORT = 49643; -const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - -function usage() { - console.log(`Usage: codex-desktop-relay.js [options] - -Options: - --host Loopback host (default: ${DEFAULT_HOST}). - --port Listen port; 0 selects a free port (default: ${DEFAULT_PORT}). - --codex Codex CLI that supplies app-server. - --desktop-token-file - 0600 capability used only by Codex Desktop. - --bridge-token-file - 0600 capability used only by agmsg bridges. - --health Health state file. - --port-file File that receives the actual listen port. - --pid-file File that receives the relay pid. - --parent-pid Supervising runner pid; relay stops if it disappears. - --help Show this help.`); -} - -function die(message) { - console.error(`codex-desktop-relay: ${message}`); - // 64 distinguishes permanent invocation/configuration failures from an - // app-server crash. The LaunchAgent runner retries only transient exits. - process.exit(64); -} - -function parseArgs(argv) { - const opts = { - host: process.env.AGMSG_CODEX_DESKTOP_RELAY_HOST || DEFAULT_HOST, - port: Number(process.env.AGMSG_CODEX_DESKTOP_RELAY_PORT || DEFAULT_PORT), - codex: process.env.AGMSG_CODEX_DESKTOP_RELAY_CODEX || findCodex(), - desktopTokenFile: process.env.AGMSG_CODEX_DESKTOP_RELAY_DESKTOP_TOKEN_FILE || "", - bridgeTokenFile: process.env.AGMSG_CODEX_DESKTOP_RELAY_BRIDGE_TOKEN_FILE || "", - health: path.join(RUN_DIR, "codex-desktop-relay.health"), - portFile: path.join(RUN_DIR, "codex-desktop-relay.port"), - pidFile: path.join(RUN_DIR, "codex-desktop-relay.pid"), - parentPid: Number(process.env.AGMSG_CODEX_DESKTOP_RELAY_PARENT_PID || 0), - shutdownGraceMs: Number(process.env.AGMSG_CODEX_DESKTOP_RELAY_SHUTDOWN_GRACE_MS || 5000), - }; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--help" || arg === "-h") opts.help = true; - else if (arg === "--host") opts.host = argv[++index]; - else if (arg === "--port") opts.port = Number(argv[++index]); - else if (arg === "--codex") opts.codex = argv[++index]; - else if (arg === "--desktop-token-file") opts.desktopTokenFile = path.resolve(argv[++index]); - else if (arg === "--bridge-token-file") opts.bridgeTokenFile = path.resolve(argv[++index]); - else if (arg === "--health") opts.health = path.resolve(argv[++index]); - else if (arg === "--port-file") opts.portFile = path.resolve(argv[++index]); - else if (arg === "--pid-file") opts.pidFile = path.resolve(argv[++index]); - else if (arg === "--parent-pid") opts.parentPid = Number(argv[++index]); - else die(`unknown option: ${arg}`); - } - if (opts.help) return opts; - if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) { - die("--port must be an integer from 0 to 65535"); - } - if (!Number.isFinite(opts.shutdownGraceMs) || opts.shutdownGraceMs < 0) { - die("shutdown grace must be a non-negative number"); - } - if (!Number.isSafeInteger(opts.parentPid) || opts.parentPid < 0 || opts.parentPid === process.pid) { - die("--parent-pid must be a positive supervising pid"); - } - if (opts.host !== "127.0.0.1" && opts.host !== "localhost" && opts.host !== "::1") { - die("--host must be loopback"); - } - if (!opts.codex) die("Codex CLI not found; pass --codex"); - if (!opts.desktopTokenFile || !opts.bridgeTokenFile) { - die("--desktop-token-file and --bridge-token-file are required"); - } - opts.desktopToken = readCapability(opts.desktopTokenFile, "desktop"); - opts.bridgeToken = readCapability(opts.bridgeTokenFile, "bridge"); - if (opts.desktopToken === opts.bridgeToken) die("desktop and bridge capabilities must differ"); - return opts; -} - -function readCapability(file, role) { - let value = ""; - try { - const stat = fs.lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink()) die(`${role} capability must be a regular non-symlink file`); - const mode = stat.mode & 0o777; - if ((mode & 0o077) !== 0) die(`${role} capability file must not be group/world accessible`); - value = fs.readFileSync(file, "utf8").trim(); - } catch (error) { - die(`cannot read ${role} capability file: ${error.message}`); - } - if (!/^[a-f0-9]{64}$/.test(value)) die(`${role} capability must be 32 random bytes encoded as hex`); - return value; -} - -function readBridgeBinding(token) { - let names = []; - try { - names = fs.readdirSync(RUN_DIR).filter((name) => /^codex-bridge\..+\.binding$/.test(name)); - } catch (_) { - return null; - } - for (const name of names) { - try { - const file = path.join(RUN_DIR, name); - const stat = fs.lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) continue; - const values = new Map(); - for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { - const index = line.indexOf("="); - if (index > 0) values.set(line.slice(0, index), line.slice(index + 1)); - } - const storedToken = values.get("token") || ""; - if (!/^[a-f0-9]{64}$/.test(storedToken) || storedToken.length !== token.length) continue; - if (!crypto.timingSafeEqual(Buffer.from(storedToken), Buffer.from(token))) continue; - const binding = { - token: storedToken, - project: values.get("project") || "", - type: values.get("type") || "", - team: values.get("team") || "", - name: values.get("name") || "", - threadId: values.get("thread") || "", - stateKey: values.get("state_key") || "", - }; - if (!binding.project || binding.type !== "codex" || !binding.team || !binding.name - || !binding.threadId || !/^[A-Za-z0-9._%-]+$/.test(binding.stateKey)) continue; - binding.project = fs.realpathSync(binding.project); - return binding; - } catch (_) { - // Ignore malformed or concurrently replaced bindings. - } - } - return null; -} - -function hasOnlyKeys(value, allowed) { - return Object.keys(value).every((key) => allowed.has(key)); -} - -function sameRuntimeRoots(value, project) { - return Array.isArray(value) && value.length === 1 && String(value[0]) === project; -} - -function resolvesToProject(value, project) { - try { - return fs.realpathSync(String(value || "")) === project; - } catch (_) { - return false; - } -} - -function capabilityEquals(actual, expected) { - const left = Buffer.from(String(actual || "")); - const right = Buffer.from(String(expected || "")); - return left.length === right.length && crypto.timingSafeEqual(left, right); -} - -function containsClientMessageId(value, expected) { - if (!value || typeof value !== "object") return false; - if (String(value.clientId || "") === expected) return true; - if (Array.isArray(value)) return value.some((entry) => containsClientMessageId(entry, expected)); - return Object.values(value).some((entry) => containsClientMessageId(entry, expected)); -} - -function expectedWakeClientId(binding, maxId) { - const digest = crypto.createHash("sha256") - .update(`${binding.stateKey}\0${binding.threadId}\0${maxId}`) - .digest("hex"); - return `agmsg-wake-v1-${digest}`; -} - -function relayWakeStateFile(binding) { - return path.join(RUN_DIR, `codex-bridge.${binding.stateKey}.relay-wake.json`); -} - -function readRelayWakeState(binding) { - const file = relayWakeStateFile(binding); - if (!fs.existsSync(file)) return null; - const stat = fs.lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) { - throw new Error("relay wake state is not a private regular file"); - } - const state = JSON.parse(fs.readFileSync(file, "utf8")); - const expectedThreadHash = crypto.createHash("sha256").update(binding.threadId).digest("hex"); - if ( - state.version !== 1 - || state.stateKey !== binding.stateKey - || state.threadHash !== expectedThreadHash - || !Number.isSafeInteger(state.maxId) - || state.maxId <= 0 - || !["dispatching", "accepted"].includes(state.phase) - || state.clientUserMessageId !== expectedWakeClientId(binding, state.maxId) - ) { - throw new Error("relay wake state failed validation"); - } - return state; -} - -function writeRelayWakeState(binding, maxId, phase, clientUserMessageId) { - const value = { - version: 1, - stateKey: binding.stateKey, - threadHash: crypto.createHash("sha256").update(binding.threadId).digest("hex"), - maxId, - phase, - clientUserMessageId, - updatedAt: new Date().toISOString(), - }; - atomicWriteDurable(relayWakeStateFile(binding), `${JSON.stringify(value)}\n`); -} - -function buildWakePrompt(binding) { - const inbox = path.join(SKILL_DIR, "scripts", "inbox.sh"); - const send = path.join(SKILL_DIR, "scripts", "send.sh"); - return [ - `agmsg has unread messages for ${binding.team}/${binding.name}.`, - "The bridge did not read or acknowledge their contents.", - "Continue the conversation in this same Codex thread. If a reply is needed, send it with:", - `${send} ${binding.team} ${binding.name} `, - "", - "Autonomous handling contract:", - `1. Your first tool call must be this official inbox command: ${inbox} ${binding.team} ${binding.name}`, - "2. Do not read the agmsg database or team files directly. The resumed Codex task alone owns message reading and acknowledgement through inbox.sh.", - "3. For a substantive request, new evidence, correction, or blocker, continue the in-scope work through verification and send an evidence-backed reply with the official send.sh command above. Do not stop after an ACK or status-only reply.", - "4. Do not reply to ACK-only, thanks-only, or status-only mail that contains no new request, evidence, correction, or blocker.", - "5. Preserve existing approval, production, customer-data, credential, and destructive-action boundaries.", - "", - "Visible UI requirement:", - '1. Before the inbox tool call, post "agmsg受信を検知しました。内容を確認します。" in the visible Codex thread.', - '2. Immediately after inbox.sh and before any other tool call, post a Japanese update starting with "agmsg受信:" and include sender, received body or safe summary, planned action, and whether you will reply.', - "3. Keep substantive work in the visible thread and post short progress updates before major actions.", - "4. Finish with sender, instruction, action, reply target and summary, remaining blocker, and next step.", - "5. If no reply is sent, state why. ACK-only mail still requires a visible receipt notice.", - "6. Do not treat inbox consumption, DB writes, monitor delivery, send.sh, or process exit as complete unless the handling result is visible in this task.", - ].join("\n"); -} - -function findCodex() { - const candidates = [ - "/Applications/ChatGPT.app/Contents/Resources/codex", - "/Applications/Codex.app/Contents/Resources/codex", - path.join(process.env.HOME || "", ".codex", "packages", "standalone", "current", "codex"), - ]; - return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || "codex"; -} - -function atomicWrite(file, contents) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - const temporary = `${file}.${process.pid}.tmp`; - fs.writeFileSync(temporary, contents, { mode: 0o600 }); - fs.renameSync(temporary, file); -} - -function atomicWriteDurable(file, contents) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`; - let fd; - try { - fd = fs.openSync(temporary, "wx", 0o600); - fs.writeFileSync(fd, contents, "utf8"); - fs.fsyncSync(fd); - fs.closeSync(fd); - fd = undefined; - fs.renameSync(temporary, file); - try { - const dirFd = fs.openSync(path.dirname(file), "r"); - try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); } - } catch (_) { - // The file itself is durable even where directory fsync is unsupported. - } - } catch (error) { - if (fd !== undefined) { - try { fs.closeSync(fd); } catch (_) {} - } - try { fs.unlinkSync(temporary); } catch (_) {} - throw error; - } -} - -function encodeFrame(opcode, payload) { - const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || "", "utf8"); - let header; - if (body.length < 126) { - header = Buffer.from([0x80 | opcode, body.length]); - } else if (body.length <= 0xffff) { - header = Buffer.alloc(4); - header[0] = 0x80 | opcode; - header[1] = 126; - header.writeUInt16BE(body.length, 2); - } else { - header = Buffer.alloc(10); - header[0] = 0x80 | opcode; - header[1] = 127; - header.writeUInt32BE(0, 2); - header.writeUInt32BE(body.length, 6); - } - return Buffer.concat([header, body]); -} - -class RelayClient { - constructor(relay, socket, id) { - this.relay = relay; - this.socket = socket; - this.id = id; - this.buffer = Buffer.alloc(0); - this.handshakeComplete = false; - this.closed = false; - this.initialized = false; - this.initializeResponseSent = false; - this.isBridge = false; - this.role = ""; - this.binding = null; - this.threadId = ""; - this.projectRoot = ""; - this.fragments = []; - this.fragmentOpcode = 0; - socket.on("data", (chunk) => this.onData(chunk)); - socket.on("error", (error) => relay.log(`client ${id} socket error: ${error.message}`)); - socket.on("close", () => this.close()); - } - - onData(chunk) { - this.buffer = Buffer.concat([this.buffer, chunk]); - if (!this.handshakeComplete && !this.handleHandshake()) return; - this.handleFrames(); - } - - handleHandshake() { - const end = this.buffer.indexOf("\r\n\r\n"); - if (end === -1) { - if (this.buffer.length > 64 * 1024) this.rejectHandshake(431, "Request Header Fields Too Large"); - return false; - } - const header = this.buffer.slice(0, end).toString("utf8"); - this.buffer = this.buffer.slice(end + 4); - const lines = header.split(/\r\n/); - const request = /^(GET)\s+([^\s]+)\s+HTTP\/1\.[01]$/.exec(lines.shift() || ""); - const headers = new Map(); - for (const line of lines) { - const colon = line.indexOf(":"); - if (colon === -1) continue; - headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim()); - } - const key = headers.get("sec-websocket-key") || ""; - if (!request || !key || !/websocket/i.test(headers.get("upgrade") || "")) { - this.rejectHandshake(400, "Bad Request"); - return false; - } - const authentication = this.relay.authenticatePath(request[2]); - if (!authentication) { - this.rejectHandshake(403, "Forbidden"); - return false; - } - this.role = authentication.role; - this.binding = authentication.binding || null; - const accept = crypto.createHash("sha1").update(`${key}${WS_GUID}`).digest("base64"); - this.socket.write([ - "HTTP/1.1 101 Switching Protocols", - "Upgrade: websocket", - "Connection: Upgrade", - `Sec-WebSocket-Accept: ${accept}`, - "", - "", - ].join("\r\n")); - this.handshakeComplete = true; - this.relay.onClientOpen(this); - return true; - } - - rejectHandshake(status, text) { - if (!this.socket.destroyed) this.socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\n\r\n`); - } - - handleFrames() { - while (this.buffer.length >= 2) { - const first = this.buffer[0]; - const second = this.buffer[1]; - const final = (first & 0x80) !== 0; - const opcode = first & 0x0f; - const masked = (second & 0x80) !== 0; - let length = second & 0x7f; - let offset = 2; - if (length === 126) { - if (this.buffer.length < 4) return; - length = this.buffer.readUInt16BE(2); - offset = 4; - } else if (length === 127) { - if (this.buffer.length < 10) return; - const high = this.buffer.readUInt32BE(2); - const low = this.buffer.readUInt32BE(6); - if (high !== 0) return this.protocolError("frame too large"); - length = low; - offset = 10; - } - if (!masked) return this.protocolError("client frame was not masked"); - if (this.buffer.length < offset + 4 + length) return; - const mask = this.buffer.slice(offset, offset + 4); - offset += 4; - const payload = Buffer.from(this.buffer.slice(offset, offset + length)); - for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4]; - this.buffer = this.buffer.slice(offset + length); - - if (opcode === 0x8) { - this.sendFrame(0x8, payload); - this.socket.end(); - return; - } - if (opcode === 0x9) { - this.sendFrame(0x0a, payload); - continue; - } - if (opcode === 0x0a) continue; - if (opcode === 0x1 && final) { - this.relay.onClientText(this, payload.toString("utf8")); - continue; - } - if (opcode === 0x1 && !final) { - this.fragmentOpcode = opcode; - this.fragments = [payload]; - continue; - } - if (opcode === 0x0 && this.fragmentOpcode) { - this.fragments.push(payload); - if (final) { - const full = Buffer.concat(this.fragments); - this.fragments = []; - this.fragmentOpcode = 0; - this.relay.onClientText(this, full.toString("utf8")); - } - continue; - } - return this.protocolError(`unsupported opcode ${opcode}`); - } - } - - protocolError(message) { - this.relay.log(`client ${this.id} protocol error: ${message}`); - this.sendFrame(0x8, Buffer.from([0x03, 0xea])); - this.socket.destroy(); - } - - sendJson(message) { - if (!this.closed && this.handshakeComplete && !this.socket.destroyed) { - this.sendFrame(0x1, Buffer.from(JSON.stringify(message), "utf8")); - } - } - - sendFrame(opcode, payload) { - if (!this.socket.destroyed) this.socket.write(encodeFrame(opcode, payload)); - } - - close() { - if (this.closed) return; - this.closed = true; - this.relay.onClientClose(this); - } -} - -class CodexDesktopRelay { - constructor(opts) { - this.opts = opts; - this.clients = new Set(); - this.primary = null; - this.nextClientId = 1; - this.nextUpstreamId = 1; - this.nextServerRequestId = 1; - this.pending = new Map(); - this.serverRequests = new Map(); - this.initializeWaiters = []; - this.initializeRequestId = null; - this.initializeResult = null; - this.initializeError = null; - this.upstreamInitializedNotificationSent = false; - this.processOwners = new Map(); - this.wakeInflight = new Map(); - this.server = null; - this.child = null; - this.upstreamPgid = 0; - this.actualPort = 0; - this.parentTimer = null; - this.shuttingDown = false; - } - - run() { - this.server = net.createServer((socket) => { - socket.setNoDelay(true); - this.clients.add(new RelayClient(this, socket, this.nextClientId++)); - this.writeHealth("listening"); - }); - this.server.on("error", (error) => { - this.log(`listen failed: ${error.message}`); - this.shutdown(1); - }); - this.server.listen(this.opts.port, this.opts.host, () => { - const address = this.server.address(); - this.actualPort = typeof address === "object" && address ? address.port : this.opts.port; - this.startUpstream(); - atomicWrite(this.opts.portFile, `${this.actualPort}\n`); - atomicWrite(this.opts.pidFile, `${process.pid}\n`); - this.writeHealth("listening"); - this.log(`listening on ws://${this.opts.host}:${this.actualPort}`); - }); - const stop = () => this.shutdown(0); - process.on("SIGINT", stop); - process.on("SIGTERM", stop); - process.on("SIGHUP", stop); - this.monitorParent(); - } - - monitorParent() { - if (!this.opts.parentPid) return; - const check = () => { - try { - process.kill(this.opts.parentPid, 0); - } catch (error) { - if (error && error.code === "EPERM") return; - this.log("supervising runner disappeared; stopping relay and app-server"); - this.shutdown(1); - } - }; - check(); - if (!this.shuttingDown) this.parentTimer = setInterval(check, 250); - } - - authenticatePath(requestPath) { - const desktop = /^\/desktop\/([a-f0-9]{64})$/.exec(requestPath); - if (desktop && capabilityEquals(desktop[1], this.opts.desktopToken)) return { role: "desktop" }; - const match = /^\/bridge\/([a-f0-9]{64})\/([a-f0-9]{64})$/.exec(requestPath); - if (!match) return null; - if (!capabilityEquals(match[1], this.opts.bridgeToken)) return null; - const binding = readBridgeBinding(match[2]); - if (!binding) return null; - const alreadyConnected = [...this.clients].some( - (client) => client.role === "bridge" && !client.closed && client.binding - && client.binding.stateKey === binding.stateKey, - ); - if (alreadyConnected) return null; - return { role: "bridge", binding }; - } - - startUpstream() { - const args = ["-c", "features.code_mode_host=true", "app-server", "--analytics-default-enabled"]; - const childEnv = { ...process.env }; - delete childEnv.CODEX_APP_SERVER_WS_URL; - this.child = spawn(this.opts.codex, args, { - cwd: process.env.HOME || process.cwd(), - env: { - ...childEnv, - CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "Codex Desktop", - LOG_FORMAT: "json", - RUST_LOG: process.env.RUST_LOG || "warn", - }, - stdio: ["pipe", "pipe", "pipe"], - detached: process.platform !== "win32", - }); - this.upstreamPgid = this.child.pid || 0; - this.child.once("error", (error) => { - this.log(`app-server start failed: ${error.message}`); - this.shutdown(1); - }); - this.child.once("exit", (code, signal) => { - if (this.shuttingDown) return; - this.log(`app-server exited (${code ?? signal}); relay will restart`); - this.shutdown(1); - }); - // Never mirror app-server stderr into the relay lifecycle log; it may - // include visible conversation content. - this.child.stderr.on("data", () => {}); - readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.onUpstreamLine(line)); - } - - onClientOpen(client) { - this.log(`${client.role} client ${client.id} connected`); - this.writeHealth(this.isReady() ? "ready" : "waiting_for_desktop"); - } - - onClientClose(client) { - this.clients.delete(client); - this.initializeWaiters = this.initializeWaiters.filter((waiter) => waiter.client !== client); - for (const [id, pending] of this.pending) { - if (pending.client !== client) continue; - this.pending.delete(id); - if (pending.wakeDispatch && pending.wakeDispatch.inflightKey) { - // The durable relay state, not this in-memory map, owns ambiguity after - // turn/start. A read-stage disconnect has not started a turn and is - // therefore safe to retry after the bridge reconnects. - this.wakeInflight.delete(pending.wakeDispatch.inflightKey); - } - } - for (const [id, request] of this.serverRequests) { - if (request.client !== client) continue; - this.serverRequests.delete(id); - this.sendUpstream({ - jsonrpc: "2.0", - id: request.upstreamId, - error: { code: -32000, message: "Visible Codex Desktop client disconnected" }, - }); - } - for (const [handle, owner] of this.processOwners) { - if (owner.client !== client) continue; - this.processOwners.delete(handle); - const upstreamId = this.nextUpstreamId++; - this.pending.set(upstreamId, { internal: true, upstreamHandle: handle }); - this.sendUpstream({ - jsonrpc: "2.0", - id: upstreamId, - method: "process/kill", - params: { processHandle: handle }, - }); - } - if (this.primary === client) { - this.primary = [...this.clients].find( - (candidate) => candidate.role === "desktop" && !candidate.closed, - ) || null; - this.flushInitializeWaiters(); - } - this.log(`${client.role || "unknown"} client ${client.id} disconnected`); - this.writeHealth(this.isReady() ? "ready" : "waiting_for_desktop"); - } - - onClientText(client, text) { - let message; - try { - message = JSON.parse(text); - } catch (_) { - client.sendJson({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }); - return; - } - - if (message.method === "initialize" && Object.prototype.hasOwnProperty.call(message, "id")) { - this.onInitialize(client, message); - return; - } - if (message.method === "initialized" && !Object.prototype.hasOwnProperty.call(message, "id")) { - if (!client.initializeResponseSent) { - client.socket.destroy(); - return; - } - client.initialized = true; - if (client === this.primary && this.initializeResult && !this.upstreamInitializedNotificationSent) { - this.sendUpstream({ jsonrpc: "2.0", method: "initialized", params: message.params || {} }); - this.upstreamInitializedNotificationSent = true; - this.flushInitializeWaiters(); - } - this.writeHealth(this.isReady() ? "ready" : "waiting_for_desktop"); - return; - } - - if (!message.method && Object.prototype.hasOwnProperty.call(message, "id")) { - const serverRequest = this.serverRequests.get(String(message.id)); - if (client === this.primary && serverRequest && serverRequest.client === client) { - this.serverRequests.delete(String(message.id)); - this.sendUpstream({ ...message, id: serverRequest.upstreamId }); - return; - } - } - - if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) { - if (!client.initialized || !this.isReady()) { - client.sendJson({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32002, message: "Visible Codex Desktop is not ready" }, - }); - return; - } - const routed = this.prepareClientRequest(client, message); - if (!routed) return; - const upstreamId = this.nextUpstreamId++; - this.pending.set(upstreamId, { - client, - clientId: message.id, - method: message.method, - originalHandle: routed.originalHandle || "", - upstreamHandle: routed.upstreamHandle || "", - requestedThreadId: routed.requestedThreadId || "", - requestedProjectRoot: routed.requestedProjectRoot || "", - wakeDispatch: routed.wakeDispatch || null, - }); - this.sendUpstream({ ...routed.message, id: upstreamId }); - return; - } - - if (client.role === "desktop") this.sendUpstream(message); - } - - isReady() { - return Boolean( - this.initializeResult && this.upstreamInitializedNotificationSent - && this.primary && this.primary.initialized && !this.primary.closed - ); - } - - rejectClientRequest(client, id, message) { - client.sendJson({ jsonrpc: "2.0", id, error: { code: -32601, message } }); - } - - prepareClientRequest(client, message) { - if (client.role === "desktop") return { message }; - const allowed = new Set(["thread/resume", "agmsg/wake/dispatch", "process/spawn", "process/kill"]); - if (!allowed.has(message.method)) { - this.rejectClientRequest(client, message.id, `Bridge method is not allowed: ${message.method}`); - return null; - } - const params = message.params && typeof message.params === "object" ? { ...message.params } : {}; - if (message.method === "thread/resume") { - const threadId = String(params.threadId || ""); - const binding = client.binding; - const allowedKeys = new Set(["threadId", "cwd", "runtimeWorkspaceRoots", "excludeTurns"]); - if (!binding || threadId !== binding.threadId || client.threadId && client.threadId !== threadId - || !hasOnlyKeys(params, allowedKeys) - || params.cwd && params.cwd !== binding.project - || params.runtimeWorkspaceRoots && !sameRuntimeRoots(params.runtimeWorkspaceRoots, binding.project) - || params.excludeTurns !== undefined && params.excludeTurns !== true) { - this.rejectClientRequest(client, message.id, "Bridge requires one exact thread id"); - return null; - } - return { - message: { - ...message, - params: { threadId, cwd: binding.project, runtimeWorkspaceRoots: [binding.project], excludeTurns: true }, - }, - requestedThreadId: threadId, - requestedProjectRoot: binding.project, - }; - } - if (message.method === "agmsg/wake/dispatch") { - const binding = client.binding; - const allowedKeys = new Set([ - "threadId", "maxId", "clientUserMessageId", "dispatchMode", "cwd", "runtimeWorkspaceRoots", - ]); - const maxId = Number(params.maxId); - const clientUserMessageId = String(params.clientUserMessageId || ""); - const dispatchMode = String(params.dispatchMode || ""); - if (!client.threadId || params.threadId !== client.threadId || !binding - || !hasOnlyKeys(params, allowedKeys) - || params.cwd && params.cwd !== client.projectRoot - || params.runtimeWorkspaceRoots && !sameRuntimeRoots(params.runtimeWorkspaceRoots, client.projectRoot) - || !Number.isSafeInteger(maxId) || maxId <= 0 - || clientUserMessageId !== expectedWakeClientId(binding, maxId) - || !["start-or-reconcile", "reconcile-only"].includes(dispatchMode)) { - this.rejectClientRequest(client, message.id, "Bridge wake does not match its exact role binding"); - return null; - } - const inflightKey = clientUserMessageId; - const inflight = this.wakeInflight.get(inflightKey); - let durableState; - try { - durableState = readRelayWakeState(binding); - } catch (error) { - this.rejectClientRequest(client, message.id, `Cannot trust relay wake state: ${error.message}`); - return null; - } - if (durableState && durableState.maxId > maxId) { - this.rejectClientRequest(client, message.id, "Bridge wake is older than relay durable state"); - return null; - } - const durableSameWake = durableState && durableState.maxId === maxId - && durableState.clientUserMessageId === clientUserMessageId; - if (dispatchMode === "reconcile-only" || inflight || durableSameWake) { - // The bridge may time out while the original thread/read or turn/start - // is still in flight. A retry is reconciliation-only: if history does - // not contain the marker yet, fail closed instead of starting a second - // turn concurrently with the original request. - return { - message: { - jsonrpc: "2.0", - method: "thread/read", - params: { threadId: client.threadId, includeTurns: true }, - }, - wakeDispatch: { - clientUserMessageId, - maxId, - inflightKey, - reconcileOnly: true, - binding, - }, - }; - } - this.wakeInflight.set(inflightKey, { stage: "read" }); - return { - message: { - jsonrpc: "2.0", - method: "thread/read", - params: { threadId: client.threadId, includeTurns: true }, - }, - wakeDispatch: { - clientUserMessageId, - maxId, - inflightKey, - binding, - turnParams: { - threadId: client.threadId, - input: [{ type: "text", text: buildWakePrompt(binding), text_elements: [] }], - cwd: client.projectRoot, - runtimeWorkspaceRoots: [client.projectRoot], - clientUserMessageId, - }, - }, - }; - } - if (!client.threadId) { - this.rejectClientRequest(client, message.id, "Bridge must resume its exact thread before spawning a watcher"); - return null; - } - const originalHandle = String(params.processHandle || ""); - if (!/^agmsg-watch-[A-Za-z0-9._-]+$/.test(originalHandle)) { - this.rejectClientRequest(client, message.id, "Bridge process handle is invalid"); - return null; - } - if (message.method === "process/spawn") { - const command = Array.isArray(params.command) ? params.command : []; - const binding = client.binding; - const allowedKeys = new Set(["command", "processHandle", "cwd", "outputBytesCap", "timeoutMs"]); - const timeoutSeconds = Number(command[12]); - const intervalSeconds = Number(command[14]); - const validShape = command.length === 15 - && String(command[0] || "") === "/bin/bash" - && String(command[1] || "") === path.join(SCRIPT_DIR, "watch-once.sh") - && resolvesToProject(command[2], binding.project) - && String(command[3] || "") === binding.type - && command[4] === "--team" && command[5] === binding.team - && command[6] === "--name" && command[7] === binding.name - && command[8] === "--owner" && /^agmsg-codex-bridge-[0-9]+\.[0-9]+$/.test(String(command[9] || "")) - && command[10] === "--claim" - && command[11] === "--timeout" && /^[1-9][0-9]*$/.test(String(command[12] || "")) - && command[13] === "--interval" && /^[1-9][0-9]*$/.test(String(command[14] || "")); - const expectedTimeoutMs = (timeoutSeconds + intervalSeconds + 10) * 1000; - const validParams = hasOnlyKeys(params, allowedKeys) - && params.cwd === binding.project - && params.outputBytesCap === 8192 - && Number.isInteger(params.timeoutMs) && params.timeoutMs === expectedTimeoutMs - && params.timeoutMs > 0 && params.timeoutMs <= 86400000; - if (!validShape || !validParams) { - this.rejectClientRequest(client, message.id, "Bridge may spawn only watch-once.sh"); - return null; - } - const upstreamHandle = `agmsg-relay-${client.id}-${originalHandle}`; - if (this.processOwners.has(upstreamHandle)) { - this.rejectClientRequest(client, message.id, "Bridge process handle already exists"); - return null; - } - this.processOwners.set(upstreamHandle, { client, originalHandle }); - params.processHandle = upstreamHandle; - params.cwd = binding.project; - return { message: { ...message, params }, originalHandle, upstreamHandle }; - } - if (!hasOnlyKeys(params, new Set(["processHandle"]))) { - this.rejectClientRequest(client, message.id, "Bridge may kill only its owned watch process"); - return null; - } - const ownerEntry = [...this.processOwners.entries()].find( - ([, owner]) => owner.client === client && owner.originalHandle === originalHandle, - ); - if (!ownerEntry) { - this.rejectClientRequest(client, message.id, "Bridge does not own this process handle"); - return null; - } - params.processHandle = ownerEntry[0]; - return { - message: { ...message, params }, - originalHandle, - upstreamHandle: ownerEntry[0], - }; - } - - onInitialize(client, message) { - client.isBridge = client.role === "bridge"; - if (client.role === "desktop" && !this.primary) this.primary = client; - this.initializeWaiters.push({ client, clientId: message.id }); - if (this.initializeResult || this.initializeError) { - this.flushInitializeWaiters(); - return; - } - if (client !== this.primary || this.initializeRequestId !== null) { - this.writeHealth("waiting_for_desktop"); - return; - } - const upstreamId = this.nextUpstreamId++; - this.initializeRequestId = upstreamId; - this.pending.set(upstreamId, { initialize: true }); - this.sendUpstream({ ...message, id: upstreamId }); - this.writeHealth("initializing"); - } - - flushInitializeWaiters() { - const remaining = []; - for (const waiter of this.initializeWaiters.splice(0)) { - if (this.initializeError) { - waiter.client.sendJson({ jsonrpc: "2.0", id: waiter.clientId, error: this.initializeError }); - } else if ( - this.initializeResult - && (waiter.client === this.primary || this.isReady()) - ) { - waiter.client.initializeResponseSent = true; - waiter.client.sendJson({ jsonrpc: "2.0", id: waiter.clientId, result: this.initializeResult }); - } else { - remaining.push(waiter); - } - } - this.initializeWaiters.push(...remaining); - let status = "waiting_for_desktop"; - if (this.initializeError) status = "initialization_failed"; - else if (this.isReady()) status = "ready"; - else if (this.initializeRequestId !== null) status = "initializing"; - this.writeHealth(status); - } - - onUpstreamLine(line) { - if (!line.trim()) return; - let message; - try { - message = JSON.parse(line); - } catch (_) { - this.log("ignoring non-json app-server line"); - return; - } - - if (Object.prototype.hasOwnProperty.call(message, "id") && !message.method) { - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - if (pending.initialize) { - this.initializeRequestId = null; - if (message.error) this.initializeError = message.error; - else this.initializeResult = message.result; - this.flushInitializeWaiters(); - return; - } - if (pending.internal) { - if (pending.upstreamHandle) this.processOwners.delete(pending.upstreamHandle); - return; - } - if (pending.wakeDispatch) { - const dispatch = pending.wakeDispatch; - if (dispatch.reconcileOnly) { - if (!message.error && containsClientMessageId(message.result, dispatch.clientUserMessageId)) { - writeRelayWakeState( - dispatch.binding, - dispatch.maxId, - "accepted", - dispatch.clientUserMessageId, - ); - pending.client.sendJson({ - jsonrpc: "2.0", - id: pending.clientId, - result: { status: "reconciled", maxId: dispatch.maxId }, - }); - } else { - pending.client.sendJson({ - jsonrpc: "2.0", - id: pending.clientId, - error: { code: -32004, message: "Wake acceptance is still ambiguous" }, - }); - } - return; - } - if (dispatch.stage === "turn") { - this.wakeInflight.delete(dispatch.inflightKey); - if (message.error) { - pending.client.sendJson({ ...message, id: pending.clientId }); - } else { - writeRelayWakeState( - dispatch.binding, - dispatch.maxId, - "accepted", - dispatch.clientUserMessageId, - ); - pending.client.sendJson({ - jsonrpc: "2.0", - id: pending.clientId, - result: { - status: "accepted", - maxId: dispatch.maxId, - turnId: String(message.result && message.result.turn && message.result.turn.id || ""), - }, - }); - } - return; - } - if (message.error) { - this.wakeInflight.delete(dispatch.inflightKey); - pending.client.sendJson({ ...message, id: pending.clientId }); - return; - } - if (containsClientMessageId(message.result, dispatch.clientUserMessageId)) { - this.wakeInflight.delete(dispatch.inflightKey); - writeRelayWakeState( - dispatch.binding, - dispatch.maxId, - "accepted", - dispatch.clientUserMessageId, - ); - pending.client.sendJson({ - jsonrpc: "2.0", - id: pending.clientId, - result: { status: "reconciled", maxId: dispatch.maxId }, - }); - return; - } - const inflight = this.wakeInflight.get(dispatch.inflightKey); - if (inflight) inflight.stage = "turn"; - writeRelayWakeState( - dispatch.binding, - dispatch.maxId, - "dispatching", - dispatch.clientUserMessageId, - ); - const upstreamId = this.nextUpstreamId++; - this.pending.set(upstreamId, { - client: pending.client, - clientId: pending.clientId, - method: "agmsg/wake/dispatch", - wakeDispatch: { ...dispatch, stage: "turn" }, - }); - this.sendUpstream({ - jsonrpc: "2.0", - id: upstreamId, - method: "turn/start", - params: dispatch.turnParams, - }); - return; - } - if (pending.method === "process/spawn" && message.error && pending.upstreamHandle) { - this.processOwners.delete(pending.upstreamHandle); - } - if (pending.method === "thread/resume" && !message.error) { - const returnedThreadId = String( - message.result && message.result.thread && message.result.thread.id || "", - ); - if (!returnedThreadId || returnedThreadId !== pending.requestedThreadId) { - pending.client.sendJson({ - jsonrpc: "2.0", - id: pending.clientId, - error: { code: -32003, message: "App-server resumed a different thread" }, - }); - return; - } - pending.client.threadId = returnedThreadId; - pending.client.projectRoot = pending.requestedProjectRoot; - } - pending.client.sendJson({ ...message, id: pending.clientId }); - return; - } - - if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) { - const primary = this.primary; - if (!primary || primary.closed) { - this.sendUpstream({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32000, message: "No visible Codex Desktop client is connected" }, - }); - return; - } - const relayId = `agmsg-relay-server-${this.nextServerRequestId++}`; - this.serverRequests.set(relayId, { client: primary, upstreamId: message.id }); - primary.sendJson({ ...message, id: relayId }); - return; - } - - if (message.method && message.method.startsWith("process/")) { - const upstreamHandle = String(message.params && message.params.processHandle || ""); - const owner = this.processOwners.get(upstreamHandle); - if (owner) { - if (message.method === "process/exited") this.processOwners.delete(upstreamHandle); - owner.client.sendJson({ - ...message, - params: { ...message.params, processHandle: owner.originalHandle }, - }); - } else { - for (const client of this.clients) { - if (client.role === "desktop" && client.initialized && !client.closed) client.sendJson(message); - } - } - return; - } - - for (const client of this.clients) { - if (!client.initialized || client.closed) continue; - if (client.role === "desktop") { - client.sendJson(message); - continue; - } - const bridgeNotifications = new Set([ - "thread/status/changed", - "turn/started", - "turn/completed", - "turn/failed", - "item/agentMessage/delta", - "error", - ]); - if ( - bridgeNotifications.has(message.method) - && client.threadId - && message.params - && message.params.threadId === client.threadId - ) { - client.sendJson(message); - } - } - } - - sendUpstream(message) { - if (!this.child || !this.child.stdin || this.child.stdin.destroyed) return; - this.child.stdin.write(`${JSON.stringify(message)}\n`); - } - - writeHealth(status) { - const primaryConnected = Boolean(this.primary && !this.primary.closed); - const initializedClients = [...this.clients].filter((client) => client.initialized).length; - const contents = [ - `status=${status === "ready" && !this.isReady() ? "waiting_for_desktop" : status}`, - `pid=${process.pid}`, - `port=${this.actualPort || this.opts.port}`, - `upstream_pid=${this.child && this.child.pid || ""}`, - `clients=${this.clients.size}`, - `initialized_clients=${initializedClients}`, - `primary_connected=${primaryConnected ? 1 : 0}`, - `upstream_initialized=${this.initializeResult ? 1 : 0}`, - `updated_at=${new Date().toISOString()}`, - "", - ].join("\n"); - try { - atomicWrite(this.opts.health, contents); - } catch (error) { - this.log(`health write failed: ${error.message}`); - } - } - - log(message) { - console.error(`codex-desktop-relay: ${message}`); - } - - shutdown(code) { - if (this.shuttingDown) return; - this.shuttingDown = true; - if (this.parentTimer) { - clearInterval(this.parentTimer); - this.parentTimer = null; - } - this.writeHealth(code === 0 ? "stopped" : "failed"); - for (const client of this.clients) { - if (!client.socket.destroyed) client.socket.destroy(); - } - if (this.server) { - try { this.server.close(); } catch (_) {} - } - for (const file of [this.opts.pidFile]) { - try { fs.unlinkSync(file); } catch (_) {} - } - const finish = () => process.exit(code); - if (!this.child || !this.upstreamPgid) return finish(); - - if (process.platform === "win32") { - if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill("SIGTERM"); - const timer = setTimeout(() => { - if (this.child && this.child.exitCode === null) this.child.kill("SIGKILL"); - finish(); - }, this.opts.shutdownGraceMs); - this.child.once("exit", () => { clearTimeout(timer); finish(); }); - return; - } - - const pgid = this.upstreamPgid; - const groupAlive = () => { - try { - process.kill(-pgid, 0); - return true; - } catch (error) { - return error && error.code === "EPERM"; - } - }; - const signalGroup = (signal) => { - try { process.kill(-pgid, signal); } catch (_) {} - }; - const deadline = Date.now() + this.opts.shutdownGraceMs; - signalGroup("SIGTERM"); - const awaitGroupExit = () => { - if (!groupAlive()) return finish(); - if (Date.now() >= deadline) { - signalGroup("SIGKILL"); - const killDeadline = Date.now() + 1000; - const awaitKilled = () => { - if (!groupAlive() || Date.now() >= killDeadline) return finish(); - setTimeout(awaitKilled, 50); - }; - setTimeout(awaitKilled, 50); - return; - } - setTimeout(awaitGroupExit, 50); - }; - setTimeout(awaitGroupExit, 50); - } -} - -function main() { - const opts = parseArgs(process.argv.slice(2)); - if (opts.help) return usage(); - new CodexDesktopRelay(opts).run(); -} - -if (require.main === module) main(); - -module.exports = { CodexDesktopRelay, RelayClient, encodeFrame, parseArgs }; diff --git a/scripts/drivers/types/codex/codex-desktop-relayctl.sh b/scripts/drivers/types/codex/codex-desktop-relayctl.sh deleted file mode 100755 index b2a49d21..00000000 --- a/scripts/drivers/types/codex/codex-desktop-relayctl.sh +++ /dev/null @@ -1,336 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -umask 077 - -# Install and inspect the single per-user relay shared by Codex Desktop and -# role-scoped agmsg bridges. Capabilities live only in private runtime files; -# status, logs, argv, and the LaunchAgent plist expose redacted endpoints. - -ACTION="${1:-status}" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" -RUN_DIR="${AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR:-$SKILL_DIR/run}" -HOST="${AGMSG_CODEX_DESKTOP_RELAY_HOST:-127.0.0.1}" -PORT="${AGMSG_CODEX_DESKTOP_RELAY_PORT:-49643}" -LABEL="com.agmsg.codex-desktop-relay" -DOMAIN="gui/$(id -u)" -PLIST_DIR="${AGMSG_CODEX_DESKTOP_RELAY_PLIST_DIR:-$HOME/Library/LaunchAgents}" -PLIST="$PLIST_DIR/$LABEL.plist" -HEALTH="$RUN_DIR/codex-desktop-relay.health" -PIDFILE="$RUN_DIR/codex-desktop-relay.pid" -PORT_FILE="$RUN_DIR/codex-desktop-relay.port" -LOG="$RUN_DIR/codex-desktop-relay.log" -DESKTOP_TOKEN_FILE="$RUN_DIR/codex-desktop-relay.desktop-token" -BRIDGE_TOKEN_FILE="$RUN_DIR/codex-desktop-relay.bridge-token" -DESKTOP_ENDPOINT_FILE="$RUN_DIR/codex-desktop-relay.desktop-endpoint" -BRIDGE_ENDPOINT_FILE="$RUN_DIR/codex-desktop-relay.bridge-endpoint" -PRIOR_DESKTOP_ENDPOINT_FILE="$RUN_DIR/codex-desktop-relay.prior-desktop-endpoint" - -xml_escape() { - sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' -e "s/'/\'/g" -} - -plist_string() { - printf '%s' "$1" | xml_escape -} - -validate_port() { - case "$PORT" in ''|*[!0-9]*) return 1 ;; esac - [ "$PORT" -gt 0 ] && [ "$PORT" -le 65535 ] -} - -generate_capability() { - local file="$1" temporary value="" mode="" - mkdir -p "$RUN_DIR" - if [ -L "$file" ]; then - rm -f "$file" - elif [ -f "$file" ]; then - mode="$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || true)" - value="$(cat "$file" 2>/dev/null || true)" - if [ "$mode" = "600" ] && printf '%s' "$value" | grep -Eq '^[a-f0-9]{64}$'; then - return 0 - fi - rm -f "$file" - elif [ -e "$file" ]; then - rm -rf "$file" - fi - value="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" - printf '%s' "$value" | grep -Eq '^[a-f0-9]{64}$' || { - echo "codex-desktop-relayctl: capability generation failed" >&2 - exit 1 - } - temporary="$file.$$" - (umask 077; printf '%s\n' "$value" > "$temporary") - chmod 600 "$temporary" - mv "$temporary" "$file" -} - -write_private_endpoints() { - local desktop_token bridge_token temporary - desktop_token="$(cat "$DESKTOP_TOKEN_FILE")" - bridge_token="$(cat "$BRIDGE_TOKEN_FILE")" - temporary="$DESKTOP_ENDPOINT_FILE.$$" - (umask 077; printf 'ws://%s:%s/desktop/%s\n' "$HOST" "$PORT" "$desktop_token" > "$temporary") - chmod 600 "$temporary" - mv "$temporary" "$DESKTOP_ENDPOINT_FILE" - temporary="$BRIDGE_ENDPOINT_FILE.$$" - (umask 077; printf 'ws://%s:%s/bridge/%s\n' "$HOST" "$PORT" "$bridge_token" > "$temporary") - chmod 600 "$temporary" - mv "$temporary" "$BRIDGE_ENDPOINT_FILE" -} - -prepare_private_runtime() { - generate_capability "$DESKTOP_TOKEN_FILE" - generate_capability "$BRIDGE_TOKEN_FILE" - if [ "$(cat "$DESKTOP_TOKEN_FILE")" = "$(cat "$BRIDGE_TOKEN_FILE")" ]; then - rm -f "$BRIDGE_TOKEN_FILE" - generate_capability "$BRIDGE_TOKEN_FILE" - fi - write_private_endpoints -} - -write_plist() { - local temporary path_value - mkdir -p "$PLIST_DIR" "$RUN_DIR" - : > "$LOG" - chmod 600 "$LOG" - temporary="$PLIST.$$" - path_value="${PATH:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}" - cat > "$temporary" < - - - - Label - $(plist_string "$LABEL") - ProgramArguments - - $(plist_string "$SCRIPT_DIR/codex-desktop-relay-run.sh") - - EnvironmentVariables - - HOME - $(plist_string "$HOME") - PATH - $(plist_string "$path_value") - AGMSG_CODEX_DESKTOP_RELAY_HOST - $(plist_string "$HOST") - AGMSG_CODEX_DESKTOP_RELAY_PORT - $(plist_string "$PORT") - AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR - $(plist_string "$RUN_DIR") - - RunAtLoad - - KeepAlive - - SuccessfulExit - - - ThrottleInterval - 2 - StandardOutPath - $(plist_string "$LOG") - StandardErrorPath - $(plist_string "$LOG") - - -EOF - chmod 600 "$temporary" - mv "$temporary" "$PLIST" -} - -bootout() { - launchctl bootout "$DOMAIN/$LABEL" >/dev/null 2>&1 || true - for _ in $(seq 1 30); do - launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || return 0 - sleep 0.1 - done -} - -relay_pid_matches() { - local pid="$1" cmd expected - cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)" - expected="$SCRIPT_DIR/codex-desktop-relay.js --host $HOST --port $PORT --desktop-token-file $DESKTOP_TOKEN_FILE --bridge-token-file $BRIDGE_TOKEN_FILE --health $HEALTH --port-file $PORT_FILE --pid-file $PIDFILE --parent-pid " - case " $cmd " in *" $expected"*) return 0 ;; esac - return 1 -} - -port_owned_by_other_process() { - command -v lsof >/dev/null 2>&1 || return 1 - local own_pid listeners pid - own_pid="$(cat "$PIDFILE" 2>/dev/null || true)" - listeners="$(lsof -nP -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true)" - [ -n "$listeners" ] || return 1 - while IFS= read -r pid; do - [ -n "$pid" ] || continue - if [ -n "$own_pid" ] && [ "$pid" = "$own_pid" ] \ - && relay_pid_matches "$pid"; then - continue - fi - return 0 - done <<< "$listeners" - return 1 -} - -desktop_env_is_owned() { - local value="$1" expected="${2:-}" prefix token - [ -n "$value" ] || return 1 - [ -z "$expected" ] || [ "$value" != "$expected" ] || return 0 - prefix="ws://$HOST:$PORT/desktop/" - case "$value" in - "$prefix"*) token="${value#"$prefix"}" ;; - *) return 1 ;; - esac - printf '%s' "$token" | grep -Eq '^[a-f0-9]{64}$' -} - -status() { - local pid="" health_status="" display_status="" health_pid="" health_port="" primary="" initialized="" actual_port="" - pid="$(cat "$PIDFILE" 2>/dev/null || true)" - health_status="$(sed -n 's/^status=//p' "$HEALTH" 2>/dev/null | head -1 || true)" - health_pid="$(sed -n 's/^pid=//p' "$HEALTH" 2>/dev/null | head -1 || true)" - health_port="$(sed -n 's/^port=//p' "$HEALTH" 2>/dev/null | head -1 || true)" - primary="$(sed -n 's/^primary_connected=//p' "$HEALTH" 2>/dev/null | head -1 || true)" - initialized="$(sed -n 's/^upstream_initialized=//p' "$HEALTH" 2>/dev/null | head -1 || true)" - actual_port="$(cat "$PORT_FILE" 2>/dev/null || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null \ - && relay_pid_matches "$pid" && [ "$health_pid" = "$pid" ] \ - && [ "$health_port" = "$actual_port" ] && [ "$actual_port" = "$PORT" ] \ - && [ -n "$health_status" ] && [ "$health_status" != "failed" ] \ - && [ "$health_status" != "stopped" ]; then - display_status="$health_status" - if [ "$display_status" = "ready" ] \ - && { [ "$primary" != "1" ] || [ "$initialized" != "1" ]; }; then - display_status="waiting_for_desktop" - fi - printf 'status=%s pid=%s app_server=ws://%s:%s/ primary_connected=%s upstream_initialized=%s\n' \ - "${display_status:-running}" "$pid" "$HOST" "${actual_port:-$PORT}" "${primary:-0}" "${initialized:-0}" - return 0 - fi - printf 'status=not_running app_server=ws://%s:%s/\n' "$HOST" "$PORT" - return 1 -} - -restore_file() { - local backup_dir="$1" file="$2" name - name="$(basename "$file")" - if [ -e "$backup_dir/$name" ]; then - cp -p "$backup_dir/$name" "$file" - else - rm -f "$file" - fi -} - -enable_relay() { - local backup_dir prior_env existing_endpoint had_plist=0 file temporary - if port_owned_by_other_process; then - echo "codex-desktop-relayctl: port $PORT is already owned by another listener" >&2 - return 1 - fi - mkdir -p "$RUN_DIR" "$PLIST_DIR" - backup_dir="$(mktemp -d "$RUN_DIR/.relayctl.rollback.XXXXXX")" - prior_env="$(launchctl getenv CODEX_APP_SERVER_WS_URL 2>/dev/null || true)" - if [ -f "$PLIST" ]; then - cp -p "$PLIST" "$backup_dir/$(basename "$PLIST")" - had_plist=1 - fi - for file in "$DESKTOP_TOKEN_FILE" "$BRIDGE_TOKEN_FILE" "$DESKTOP_ENDPOINT_FILE" \ - "$BRIDGE_ENDPOINT_FILE" "$PRIOR_DESKTOP_ENDPOINT_FILE"; do - [ ! -f "$file" ] || cp -p "$file" "$backup_dir/$(basename "$file")" - done - - existing_endpoint="$(cat "$DESKTOP_ENDPOINT_FILE" 2>/dev/null || true)" - if [ ! -f "$PRIOR_DESKTOP_ENDPOINT_FILE" ]; then - temporary="$PRIOR_DESKTOP_ENDPOINT_FILE.$$" - if desktop_env_is_owned "$prior_env" "$existing_endpoint"; then - (umask 077; : > "$temporary") - else - (umask 077; printf '%s\n' "$prior_env" > "$temporary") - fi - chmod 600 "$temporary" - mv "$temporary" "$PRIOR_DESKTOP_ENDPOINT_FILE" - fi - - prepare_private_runtime - write_plist - bootout - rm -f "$PIDFILE" "$PORT_FILE" "$HEALTH" - if ! launchctl bootstrap "$DOMAIN" "$PLIST"; then - : - else - launchctl kickstart -k "$DOMAIN/$LABEL" >/dev/null 2>&1 || true - for _ in $(seq 1 50); do - if status >/dev/null 2>&1; then - if launchctl setenv CODEX_APP_SERVER_WS_URL "$(cat "$DESKTOP_ENDPOINT_FILE")"; then - rm -rf "$backup_dir" - status - return 0 - fi - break - fi - sleep 0.1 - done - fi - - bootout - for file in "$DESKTOP_TOKEN_FILE" "$BRIDGE_TOKEN_FILE" "$DESKTOP_ENDPOINT_FILE" \ - "$BRIDGE_ENDPOINT_FILE" "$PRIOR_DESKTOP_ENDPOINT_FILE"; do - restore_file "$backup_dir" "$file" - done - if [ "$had_plist" -eq 1 ]; then - restore_file "$backup_dir" "$PLIST" - else - rm -f "$PLIST" - fi - rm -f "$PIDFILE" "$PORT_FILE" "$HEALTH" - if [ -n "$prior_env" ]; then - launchctl setenv CODEX_APP_SERVER_WS_URL "$prior_env" >/dev/null 2>&1 || true - else - launchctl unsetenv CODEX_APP_SERVER_WS_URL >/dev/null 2>&1 || true - fi - if [ "$had_plist" -eq 1 ]; then - launchctl bootstrap "$DOMAIN" "$PLIST" >/dev/null 2>&1 || true - launchctl kickstart -k "$DOMAIN/$LABEL" >/dev/null 2>&1 || true - fi - rm -rf "$backup_dir" - echo "codex-desktop-relayctl: relay did not start; see $LOG" >&2 - return 1 -} - -case "$ACTION" in - enable|start) - if [ "$(uname -s)" != "Darwin" ] || ! command -v launchctl >/dev/null 2>&1; then - echo "codex-desktop-relayctl: Codex Desktop relay currently requires macOS launchctl" >&2 - exit 2 - fi - validate_port || { echo "codex-desktop-relayctl: invalid port: $PORT" >&2; exit 2; } - enable_relay - ;; - status) - status - ;; - disable|stop|remove) - if command -v launchctl >/dev/null 2>&1; then - bootout - current="$(launchctl getenv CODEX_APP_SERVER_WS_URL 2>/dev/null || true)" - expected="$(cat "$DESKTOP_ENDPOINT_FILE" 2>/dev/null || true)" - if desktop_env_is_owned "$current" "$expected"; then - prior="$(cat "$PRIOR_DESKTOP_ENDPOINT_FILE" 2>/dev/null || true)" - if [ -n "$prior" ]; then - launchctl setenv CODEX_APP_SERVER_WS_URL "$prior" - else - launchctl unsetenv CODEX_APP_SERVER_WS_URL - fi - fi - fi - rm -f "$PLIST" "$PIDFILE" "$PORT_FILE" "$HEALTH" \ - "$DESKTOP_TOKEN_FILE" "$BRIDGE_TOKEN_FILE" "$DESKTOP_ENDPOINT_FILE" \ - "$BRIDGE_ENDPOINT_FILE" "$PRIOR_DESKTOP_ENDPOINT_FILE" - printf 'status=stopped app_server=ws://%s:%s/\n' "$HOST" "$PORT" - ;; - *) - echo "Usage: codex-desktop-relayctl.sh enable|status|disable" >&2 - exit 2 - ;; -esac diff --git a/scripts/drivers/types/codex/codex-shim-install.sh b/scripts/drivers/types/codex/codex-shim-install.sh index ff0a6fc0..9660fcfd 100755 --- a/scripts/drivers/types/codex/codex-shim-install.sh +++ b/scripts/drivers/types/codex/codex-shim-install.sh @@ -9,15 +9,14 @@ usage() { cat <&2 exit 1 fi - # shell_quote only formats its argument; this block never reads TARGET. - # shellcheck disable=SC2094 { echo "#!/usr/bin/env bash" echo "set -euo pipefail" diff --git a/scripts/drivers/types/codex/codex-shim.sh b/scripts/drivers/types/codex/codex-shim.sh index 5d316f95..e1927c47 100755 --- a/scripts/drivers/types/codex/codex-shim.sh +++ b/scripts/drivers/types/codex/codex-shim.sh @@ -4,10 +4,9 @@ set -euo pipefail # Optional Codex entrypoint shim for agmsg monitor mode. # # Install this as ~/.agents/bin/codex before the real Codex binary on PATH. -# The current Desktop monitor does not need this legacy TUI transport. The shim -# therefore passes through by default; AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 is the -# explicit compatibility opt-in for routing interactive monitor projects -# through codex-monitor.sh. +# In projects whose Codex delivery mode is `monitor`, interactive Codex TUI +# launches are routed through codex-monitor.sh. Everything else is passed +# through to the real Codex command unchanged. if [ "${AGMSG_CODEX_SHIM_WRAPPER:-}" = "1" ] && [ -n "${AGMSG_CODEX_SHIM_SCRIPT_DIR:-}" ]; then SCRIPT_DIR="$AGMSG_CODEX_SHIM_SCRIPT_DIR" @@ -119,8 +118,7 @@ is_monitor_project() { real_codex="$(resolve_real_codex)" -if [ "${AGMSG_CODEX_SHIM_DISABLE:-}" = "1" ] || [ "${AGMSG_CODEX_BRIDGE:-}" = "1" ] \ - || [ "${AGMSG_CODEX_LEGACY_MONITOR_SHIM:-}" != "1" ]; then +if [ "${AGMSG_CODEX_SHIM_DISABLE:-}" = "1" ] || [ "${AGMSG_CODEX_BRIDGE:-}" = "1" ]; then exec "$real_codex" "$@" fi diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index d55fe3ca..4a99e1fb 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -84,8 +84,8 @@ Four possible outputs: ### Codex visible monitor invariant -`mode monitor` may deliver mail only through the authenticated Desktop relay -and a role bridge bound to this exact visible Codex task. +`mode monitor` may deliver mail only through an app-server bridge that inserts +the handling into the same visible Codex thread. 1. A shell watcher may detect unread state, but it must not read the body, mark it read, start substantive work, or reply by itself. @@ -94,27 +94,16 @@ and a role bridge bound to this exact visible Codex task. action, and whether a reply is needed before any other tool call. 4. Keep progress, decisions, blockers, replies, and the final result visible in the same Codex thread. ACK-only mail still requires a visible receipt notice. -5. Background receivers, `codex exec resume`, rollout inference, loaded-task - discovery, and new-thread fallback are prohibited. -6. If the relay or bridge cannot attach, keep the requested project mode as - `monitor`, report the current effective mode as `turn`, leave mail unread, - and retry only from SessionStart of the same stored exact thread or a new - explicit `actas`. -7. A different Codex task must not inherit or steal the stored role. SessionStart - may restore it only when its exact thread id equals the stored thread id. -8. `mode turn`, `mode off`, `drop`/`reset`, and SessionEnd stop the matching - role bridge. They must not stop another project's bridge; mode changes keep - the shared Desktop relay installed. -9. Do not create cron, heartbeat, or scheduled polling jobs for Codex delivery. +5. Background `codex exec resume` delivery is prohibited. +6. If a visible bridge cannot attach, keep mail unread, change the effective + mode to `turn`, and fall back to the next visible turn. +7. Do not create cron, heartbeat, or scheduled polling jobs for Codex delivery. **If no arguments provided (DEFAULT action — always do this when the command is invoked without arguments):** 1. **IMMEDIATELY** run inbox check for each TEAM: `~/.agents/skills/__SKILL_NAME__/scripts/inbox.sh $TEAM $AGENT` -2. If messages are returned, before any other tool call show the sender and body - or a safe summary, what you will do, and whether a reply is needed. Do not - handle the message invisibly in the background. -3. If the active AGENT name starts with `codex-pro-`, route unread messages through Oracle GPT-5.5 Pro by running `~/.agents/skills/__SKILL_NAME__/scripts/oracle-pro-reply.sh $TEAM $AGENT` instead of answering them directly. Use `ORACLE_PRO_DRY_RUN=1` only when the user asks to preview the browser run. -4. Do NOT ask the user what to do — just run the inbox check. -5. If there are messages for a normal agent, read and respond appropriately. To reply: +2. If the active AGENT name starts with `codex-pro-`, route unread messages through Oracle GPT-5.5 Pro by running `~/.agents/skills/__SKILL_NAME__/scripts/oracle-pro-reply.sh $TEAM $AGENT` instead of answering them directly. Use `ORACLE_PRO_DRY_RUN=1` only when the user asks to preview the browser run. +3. Do NOT ask the user what to do — just run the inbox check. +4. If there are messages for a normal agent, read and respond appropriately. To reply: `~/.agents/skills/__SKILL_NAME__/scripts/send.sh $TEAM $AGENT ""` If argument is "history": @@ -142,7 +131,7 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): 2. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" codex` to see whether the role is already registered for this (project, type). 3. If the name does not appear in the output, join under the existing team. For a single team, run `~/.agents/skills/__SKILL_NAME__/scripts/join.sh codex "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. 4. Set the session's active FROM to `` for every `send.sh` call until another `actas`. -5. Rebind the receive side by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. In monitor/both mode, allow only the authenticated Desktop relay plus an exact-thread role bridge. If it cannot attach, keep the requested mode as `monitor`, report effective `turn` for this task, and leave mail unread. Never start a background receiver or infer/create a thread. +5. Rebind the receive side by running `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"`. In monitor/both mode, allow only a visible app-server bridge. If it cannot attach, keep mail unread and downgrade to `turn`. Never start a background receiver. 6. Record this session as the role's seat so it can be resumed later (best-effort): determine which team `` belongs to (from the identities output / the join above), then run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh "$(pwd)"`. It writes the record only when this session's codex thread id is unambiguous; otherwise it records nothing and the role simply boots fresh next time (no harm). 7. If `` starts with `codex-pro-`, tell the user: "Now acting as ``. This role is an Oracle GPT-5.5 Pro consult route; run `$__SKILL_NAME__` to process its unread inbox through `oracle-pro-reply.sh`. Real runs may control the signed-in ChatGPT browser." 8. Otherwise tell the user: "Now acting as ``. Sends and receive are bound to ``." @@ -176,9 +165,9 @@ If argument is "mode" (no further args): If argument starts with "mode" followed by a mode name (e.g. "mode monitor"): 1. Parse the mode. Codex supports `monitor`, `turn`, `both`, and `off`. `monitor` already installs the visible turn fallback; `both` is an explicit equivalent for diagnostics. 2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` -3. If mode is `monitor` or `both` and an active role is already bound to this exact task, run `actas-monitor.sh` with that concrete thread id. Do not infer a thread or take a role from another task. -4. If mode is `turn` or `off`, confirm that the matching role bridge stopped. Do not call a visible-turn fallback a background receiver. -5. If mode is `monitor` or `both`, report whether the relay and exact-thread bridge attached. If they did not, report requested `monitor` and effective `turn`; do not rewrite the requested mode or describe fallback as an active monitor. +3. If mode is `monitor` or `both` and an active role can be resolved, run `actas-monitor.sh` for that role and concrete thread id. +4. If mode is `turn` or `off`, confirm that the background receiver stopped. +5. If mode is `monitor` or `both`, report whether a visible app-server bridge attached. If it did not attach, report that the effective mode was downgraded to `turn`. Never describe visible-turn fallback as an active monitor. If argument is "hook on" (legacy alias): 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set turn codex "$(pwd)"` diff --git a/scripts/lib/actas-lock.sh b/scripts/lib/actas-lock.sh index e77d51b4..7b00f584 100644 --- a/scripts/lib/actas-lock.sh +++ b/scripts/lib/actas-lock.sh @@ -60,35 +60,6 @@ actas_lock_path() { printf '%s/actas.%s__%s.session' "$(_actas_lock_dir)" "$t" "$a" } -# Durable Codex Desktop seat metadata for (team, agent). Unlike a generic -# actas owner, a Codex task has no long-lived shell PID that can prove -# liveness, so its exact visible thread is recorded separately and released by -# SessionEnd/reset/delivery teardown. -codex_seat_path() { - local team="$1" agent="$2" - local t a; t="$(_actas_lock_encode "$team")"; a="$(_actas_lock_encode "$agent")" - printf '%s/codex-seat.%s.%s.tsv' "$(_actas_lock_dir)" "$t" "$a" -} - -# Generic actas and Codex must still contend on the same atomic role lock. -# The durable marker is deliberately not PID-backed: its matching seat is -# owned until an explicit Codex lifecycle boundary releases it. -actas_codex_owner() { - local project="$1" thread="$2" - printf 'codex-seat:%s:%s' "$(_actas_lock_encode "$project")" "$(_actas_lock_encode "$thread")" -} - -actas_codex_seat_matches() { - local team="$1" agent="$2" project="$3" seat - local saved_project saved_type saved_team saved_agent saved_thread _saved_at - seat="$(codex_seat_path "$team" "$agent")" - [ -f "$seat" ] || return 1 - IFS=$'\t' read -r saved_project saved_type saved_team saved_agent saved_thread _saved_at < "$seat" || true - [ "$saved_project" = "$project" ] && [ "$saved_type" = "codex" ] \ - && [ "$saved_team" = "$team" ] && [ "$saved_agent" = "$agent" ] \ - && [ -n "$saved_thread" ] -} - # Readiness sentinel path for (team, agent). watch.sh creates this when an # exclusive (actas) watcher attaches and removes it on exit, so the file is # present iff a live watcher is currently receiving for that role. `spawn` @@ -126,90 +97,19 @@ actas_lock_owner() { # so existing callers (gc_stale, watch.sh subscription, session-start GC) need # no change. Empty token → not alive. actas_lock_sid_alive() { - case "$1" in - codex-seat:*) return 0 ;; - esac agmsg_instance_alive "$1" } -# Release one exact Codex seat and its shared actas role marker. Callers pass -# the metadata they already validated; mismatched files are never removed. -actas_codex_seat_release() { - local team="$1" agent="$2" project="$3" thread="$4" - local seat saved_project saved_type saved_team saved_agent saved_thread _saved_at owner - seat="$(codex_seat_path "$team" "$agent")" - owner="$(actas_codex_owner "$project" "$thread")" - if [ -f "$seat" ]; then - IFS=$'\t' read -r saved_project saved_type saved_team saved_agent saved_thread _saved_at < "$seat" || true - if [ "$saved_project" = "$project" ] && [ "$saved_type" = "codex" ] \ - && [ "$saved_team" = "$team" ] && [ "$saved_agent" = "$agent" ] \ - && [ "$saved_thread" = "$thread" ]; then - rm -f "$seat" - fi - fi - actas_lock_release "$team" "$agent" "$owner" -} - -# Recover marker-only crash windows where the role lock was claimed before -# the seat metadata link was installed. Project and optional thread matching -# keep cleanup scoped to the caller's Codex lifecycle boundary. -actas_codex_release_markers() { - local project="$1" thread="${2:-}" dir f owner prefix exact_suffix - dir="$(_actas_lock_dir)" - [ -d "$dir" ] || return 0 - prefix="codex-seat:$(_actas_lock_encode "$project"):" - exact_suffix="" - [ -z "$thread" ] || exact_suffix="$(_actas_lock_encode "$thread")" - for f in "$dir"/actas.*.session; do - [ -f "$f" ] || continue - owner="$(head -1 "$f" 2>/dev/null || true)" - case "$owner" in - "$prefix"*) ;; - *) continue ;; - esac - [ -z "$exact_suffix" ] || [ "$owner" = "$prefix$exact_suffix" ] || continue - [ "$(head -1 "$f" 2>/dev/null || true)" = "$owner" ] && rm -f "$f" - done -} - -# Release marker-only crash residue for one role without disturbing another -# Codex role in the same project. -actas_codex_release_role_marker() { - local team="$1" agent="$2" project="$3" owner prefix - owner="$(actas_lock_owner "$team" "$agent")" - prefix="codex-seat:$(_actas_lock_encode "$project"):" - case "$owner" in - "$prefix"*) actas_lock_release "$team" "$agent" "$owner" ;; - esac -} - # Internal: attempt one atomic claim. Echoes "ok" on success, "held:" # when another sid currently owns it, or "stale" when the existing lock's # owner is dead (caller should retry after removing). _actas_lock_try_claim() { local team="$1" agent="$2" sid="$3" - local lock dir tmp existing seat seat_project seat_type seat_team seat_agent seat_thread _seat_at seat_owner + local lock dir tmp existing lock="$(actas_lock_path "$team" "$agent")" dir="$(_actas_lock_dir)" mkdir -p "$dir" 2>/dev/null || true - # A seat written by an earlier Codex version may predate the shared marker. - # Treat it as held, except when the exact task is repairing its own marker. - seat="$(codex_seat_path "$team" "$agent")" - if [ -f "$seat" ]; then - IFS=$'\t' read -r seat_project seat_type seat_team seat_agent seat_thread _seat_at < "$seat" || true - if [ "$seat_type" = "codex" ] && [ "$seat_team" = "$team" ] \ - && [ "$seat_agent" = "$agent" ] && [ -n "$seat_project" ] && [ -n "$seat_thread" ]; then - seat_owner="$(actas_codex_owner "$seat_project" "$seat_thread")" - else - seat_owner="codex-seat:malformed" - fi - if [ "$seat_owner" != "$sid" ]; then - printf 'held:%s\n' "$seat_owner" - return 0 - fi - fi - tmp="$(mktemp "$dir/.actas-claim.XXXXXX" 2>/dev/null)" || return 1 printf '%s\n' "$sid" > "$tmp" @@ -329,23 +229,7 @@ actas_lock_gc_stale() { # Echoes one of: free | mine | other: actas_lock_state() { local team="$1" agent="$2" sid="$3" - local owner seat seat_project seat_type seat_team seat_agent seat_thread _seat_at - seat="$(codex_seat_path "$team" "$agent")" - if [ -f "$seat" ]; then - IFS=$'\t' read -r seat_project seat_type seat_team seat_agent seat_thread _seat_at < "$seat" || true - if [ "$seat_type" = "codex" ] && [ "$seat_team" = "$team" ] \ - && [ "$seat_agent" = "$agent" ] && [ -n "$seat_project" ] && [ -n "$seat_thread" ]; then - owner="$(actas_codex_owner "$seat_project" "$seat_thread")" - else - owner="codex-seat:malformed" - fi - if [ "$owner" = "$sid" ]; then - echo "mine" - else - printf 'other:%s\n' "$owner" - fi - return 0 - fi + local owner owner="$(actas_lock_owner "$team" "$agent")" if [ -z "$owner" ]; then echo "free"; return 0 diff --git a/scripts/lib/subscription.sh b/scripts/lib/subscription.sh index 6dddf493..79cad883 100644 --- a/scripts/lib/subscription.sh +++ b/scripts/lib/subscription.sh @@ -20,7 +20,7 @@ agmsg_sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } agmsg_subscription_pairs() { local project="$1" type="$2" owner_id="$3" active_name="${4:-}" claim_mode="${5:-}" local scripts_dir="$SKILL_DIR/scripts" - local pairs filtered skipped held state result codex_seat_owned + local pairs filtered skipped held state result pairs="$("$scripts_dir/identities.sh" "$project" "$type")" if [ -n "$active_name" ]; then @@ -35,27 +35,19 @@ agmsg_subscription_pairs() { local team agent while IFS=$'\t' read -r team agent; do [ -z "$team" ] && continue - codex_seat_owned="" - if [ "$type" = "codex" ] && actas_codex_seat_matches "$team" "$agent" "$project"; then - codex_seat_owned=1 - fi state=$(actas_lock_state "$team" "$agent" "$owner_id") case "$state" in other:*) - if [ -n "$codex_seat_owned" ] && [[ "${state#other:}" == codex-seat:* ]]; then - : + if [ -n "$active_name" ] && [ "$claim_mode" = "claim" ]; then + held="${held:+$held }${team}/${agent}(${state#other:})" else - if [ -n "$active_name" ] && [ "$claim_mode" = "claim" ]; then - held="${held:+$held }${team}/${agent}(${state#other:})" - else - skipped="${skipped:+$skipped }${team}/${agent}(${state#other:})" - fi - continue + skipped="${skipped:+$skipped }${team}/${agent}(${state#other:})" fi + continue ;; esac - if [ -n "$active_name" ] && [ "$claim_mode" = "claim" ] && [ -z "$codex_seat_owned" ]; then + if [ -n "$active_name" ] && [ "$claim_mode" = "claim" ]; then result=$(actas_lock_claim "$team" "$agent" "$owner_id" 2>/dev/null || true) case "$result" in held:*) diff --git a/scripts/reset.sh b/scripts/reset.sh index c7372354..b8a1f6cf 100755 --- a/scripts/reset.sh +++ b/scripts/reset.sh @@ -28,8 +28,6 @@ source "$SCRIPT_DIR/lib/storage.sh" source "$SCRIPT_DIR/lib/registry-lock.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/compat.sh" -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/hash.sh" # Resolve the session's real project root (see #92) so a drop issued from a # subdir/worktree clears the registration on the project the session lives in. @@ -69,43 +67,8 @@ REMOVED=0 TOUCHED_TEAMS=0 LOCK_FAILED=0 -codex_current_bridge_pid_matches() { - local pid="$1" base="$2" project="$3" team="$4" name="$5" thread="$6" - local state_key expected cmd - state_key="${base##*/codex-bridge.}" - [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 - case "$state_key" in *[!A-Za-z0-9._%-]*) return 1 ;; esac - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --state-key $state_key --app-server-file $base.appserver --thread $thread" - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case " $cmd " in *" $expected "*) return 0 ;; esac - return 1 -} - -codex_legacy_bridge_pid_matches() { - local pid="$1" project="$2" team="$3" name="$4" thread="$5" app_server="$6" expected cmd - [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] \ - && [ -n "$thread" ] && [ -n "$app_server" ] || return 1 - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --thread $thread --app-server $app_server" - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case " $cmd " in *" $expected "*) return 0 ;; esac - return 1 -} - -codex_app_monitor_pid_matches() { - local pid="$1" project="$2" team="$3" name="$4" thread="$5" expected cmd - [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - expected="$SKILL_DIR/scripts/drivers/types/codex/codex-app-monitor.sh $project codex $team $name $thread" - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case " $cmd " in *" $expected "*) return 0 ;; esac - return 1 -} - wait_for_codex_receiver_exit() { - local pid="$1" matcher="$2" check=0 state - shift 2 + local pid="$1" check=0 state while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" case "$state" in Z*) return 0 ;; esac @@ -113,7 +76,6 @@ wait_for_codex_receiver_exit() { check=$((check + 1)) done if kill -0 "$pid" 2>/dev/null; then - "$matcher" "$pid" "$@" || return 0 kill -KILL "$pid" 2>/dev/null || return 1 check=0 while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do @@ -128,101 +90,8 @@ wait_for_codex_receiver_exit() { stop_codex_role_receiver() { local team="$1" name="$2" kind base pidfile metafile pid cmd label thread plist check domain - local scoped_meta scoped_project scoped_type scoped_team scoped_name scoped_thread chat project_hash safe_team safe_name state_key - local app_server app_safe_team app_safe_name - local seat saved_project saved_type saved_team saved_name saved_thread _saved_at [ "$AGENT_TYPE" = "codex" ] || return 0 - project_hash="$(printf '%s' "$PROJECT_PATH" | agmsg_sha1)" - safe_team="$(_actas_lock_encode "$team")" - safe_name="$(_actas_lock_encode "$name")" - state_key="$project_hash.$safe_team.$safe_name" - seat="$(codex_seat_path "$team" "$name")" - if [ -f "$seat" ]; then - IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true - if [ "$saved_project" = "$PROJECT_PATH" ] && [ "$saved_type" = "$AGENT_TYPE" ] \ - && [ "$saved_team" = "$team" ] && [ "$saved_name" = "$name" ]; then - actas_codex_seat_release "$saved_team" "$saved_name" "$saved_project" "$saved_thread" - fi - fi - actas_codex_release_role_marker "$team" "$name" "$PROJECT_PATH" - base="$SKILL_DIR/run/codex-bridge.$state_key" - label="com.agmsg.codex-bridge.$state_key" - if command -v launchctl >/dev/null 2>&1; then - domain="gui/$(id -u)" - launchctl bootout "$domain/$label" >/dev/null 2>&1 || true - fi - pid="$(cat "$base.pid" 2>/dev/null || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - scoped_meta="$base.meta" - scoped_project="$(sed -n 's/^project=//p' "$scoped_meta" 2>/dev/null | head -1)" - scoped_type="$(sed -n 's/^type=//p' "$scoped_meta" 2>/dev/null | head -1)" - scoped_team="$(sed -n 's/^team=//p' "$scoped_meta" 2>/dev/null | head -1)" - scoped_name="$(sed -n 's/^name=//p' "$scoped_meta" 2>/dev/null | head -1)" - scoped_thread="$(sed -n 's/^thread=//p' "$scoped_meta" 2>/dev/null | head -1)" - if [ "$scoped_type" = "codex" ] \ - && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] \ - && [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] \ - && codex_current_bridge_pid_matches "$pid" "$base" \ - "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then - kill "$pid" 2>/dev/null || true - wait_for_codex_receiver_exit "$pid" codex_current_bridge_pid_matches "$base" \ - "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" || return 1 - fi - fi - rm -f "$base.pid" "$base.meta" "$base.appserver" "$base.log" \ - "$base.plist" "$base.health" "$base.last-ids" "$base.binding" - rm -f "$base".wake.*.json "$base.relay-wake.json" - rm -f "$SKILL_DIR/run/codex-chat-visible.$state_key.meta" - - # Current bridge artifacts include the project hash. Match their owned meta - # fields so dropping one role never removes the same team/name in another - # project. - for scoped_meta in "$SKILL_DIR/run"/codex-bridge.*.meta; do - [ -f "$scoped_meta" ] || continue - scoped_project="$(sed -n 's/^project=//p' "$scoped_meta" | head -1)" - scoped_type="$(sed -n 's/^type=//p' "$scoped_meta" | head -1)" - scoped_team="$(sed -n 's/^team=//p' "$scoped_meta" | head -1)" - scoped_name="$(sed -n 's/^name=//p' "$scoped_meta" | head -1)" - scoped_thread="$(sed -n 's/^thread=//p' "$scoped_meta" | head -1)" - [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] || continue - [ "$scoped_type" = "codex" ] || continue - [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] || continue - base="${scoped_meta%.meta}" - case "${base##*/codex-bridge.}" in - ""|*[!A-Za-z0-9._%-]*) label="" ;; - *) label="com.agmsg.codex-bridge.${base##*/codex-bridge.}" ;; - esac - if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then - domain="gui/$(id -u)" - launchctl bootout "$domain/$label" >/dev/null 2>&1 || true - fi - pid="$(cat "$base.pid" 2>/dev/null || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - if codex_current_bridge_pid_matches "$pid" "$base" \ - "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then - kill "$pid" 2>/dev/null || true - if ! wait_for_codex_receiver_exit "$pid" codex_current_bridge_pid_matches "$base" \ - "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then - echo "reset: Codex receiver pid $pid did not stop; preserving its run files" >&2 - return 1 - fi - fi - fi - rm -f "$base.pid" "$base.meta" "$base.appserver" "$base.log" \ - "$base.plist" "$base.health" "$base.last-ids" "$base.binding" - rm -f "$base".wake.*.json "$base.relay-wake.json" - done - for chat in "$SKILL_DIR/run"/codex-chat-visible.*.meta; do - [ -f "$chat" ] || continue - scoped_project="$(sed -n 's/^project=//p' "$chat" | head -1)" - scoped_team="$(sed -n 's/^team=//p' "$chat" | head -1)" - scoped_name="$(sed -n 's/^name=//p' "$chat" | head -1)" - [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] || continue - [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] || continue - rm -f "$chat" - done - for kind in codex-bridge codex-app-monitor; do base="$SKILL_DIR/run/$kind.$team.$name" pidfile="$base.pid" @@ -230,10 +99,12 @@ stop_codex_role_receiver() { plist="$base.plist" label="" if [ "$kind" = "codex-app-monitor" ]; then - app_safe_team="$(printf '%s' "$team" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" - app_safe_name="$(printf '%s' "$name" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" - [ -n "$app_safe_team" ] && [ -n "$app_safe_name" ] \ - && label="com.agmsg.codex-app-monitor.$project_hash.$app_safe_team.$app_safe_name" + if [ -f "$metafile" ]; then + label="$(sed -n 's/^launch_label=//p' "$metafile" | head -1)" + fi + if [ -z "$label" ] && [ -f "$plist" ]; then + label="$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$plist")" + fi if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then domain="gui/$(id -u)" launchctl bootout "$domain/$label" >/dev/null 2>&1 || true @@ -247,40 +118,14 @@ stop_codex_role_receiver() { if [ -f "$pidfile" ]; then pid="$(cat "$pidfile" 2>/dev/null || true)" if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - scoped_project="$(sed -n 's/^project=//p' "$metafile" 2>/dev/null | head -1)" - scoped_type="$(sed -n 's/^type=//p' "$metafile" 2>/dev/null | head -1)" - scoped_team="$(sed -n 's/^team=//p' "$metafile" 2>/dev/null | head -1)" - scoped_name="$(sed -n 's/^name=//p' "$metafile" 2>/dev/null | head -1)" - scoped_thread="$(sed -n 's/^thread=//p' "$metafile" 2>/dev/null | head -1)" - app_server="$(cat "$base.appserver" 2>/dev/null || true)" - case "$kind" in - codex-bridge) - if [ "$scoped_type" = "codex" ] \ - && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] \ - && [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] \ - && codex_legacy_bridge_pid_matches "$pid" "$scoped_project" \ - "$scoped_team" "$scoped_name" "$scoped_thread" "$app_server"; then - kill "$pid" 2>/dev/null || true - if ! wait_for_codex_receiver_exit "$pid" codex_legacy_bridge_pid_matches \ - "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread" "$app_server"; then - echo "reset: Codex receiver pid $pid did not stop; preserving its run files" >&2 - return 1 - fi - fi - ;; - codex-app-monitor) - if [ "$scoped_type" = "codex" ] \ - && [ "$(agmsg_canonical_path "$scoped_project")" = "$(agmsg_canonical_path "$PROJECT_PATH")" ] \ - && [ "$scoped_team" = "$team" ] && [ "$scoped_name" = "$name" ] \ - && codex_app_monitor_pid_matches "$pid" "$scoped_project" \ - "$scoped_team" "$scoped_name" "$scoped_thread"; then + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case "$kind:$cmd" in + codex-bridge:*codex-bridge.js*|codex-app-monitor:*codex-app-monitor.sh*) kill "$pid" 2>/dev/null || true - if ! wait_for_codex_receiver_exit "$pid" codex_app_monitor_pid_matches \ - "$scoped_project" "$scoped_team" "$scoped_name" "$scoped_thread"; then + if ! wait_for_codex_receiver_exit "$pid"; then echo "reset: Codex receiver pid $pid did not stop; preserving its run files" >&2 return 1 fi - fi ;; esac fi @@ -296,7 +141,6 @@ stop_codex_role_receiver() { "$base.health" "$base.preflight.log" "$base.last-prompt.txt" \ "$base.last-message.txt" "$base.last-status" "$base.last-ids" \ "$base.watch-output" - rm -f "$base".wake.*.json "$base.relay-wake.json" done rm -f "$SKILL_DIR/run/codex-chat-visible.$team.$name.meta" } diff --git a/scripts/session-end.sh b/scripts/session-end.sh index a7054f4e..5a0444f3 100755 --- a/scripts/session-end.sh +++ b/scripts/session-end.sh @@ -28,8 +28,6 @@ RUN_DIR="$SKILL_DIR/run" source "$SCRIPT_DIR/lib/actas-lock.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/resolve-project.sh" -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/hash.sh" # Compatibility guard for legacy background-resume processes created before # that transport was disabled. Their parent owns cleanup, so this hook must not @@ -53,32 +51,8 @@ fi PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")" -codex_current_bridge_pid_matches() { - local pid="$1" base="$2" project="$3" type="$4" team="$5" name="$6" thread="$7" - local state_key expected cmd - state_key="${base##*/codex-bridge.}" - [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 - case "$state_key" in *[!A-Za-z0-9._%-]*) return 1 ;; esac - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type $type --team $team --name $name --state-key $state_key --app-server-file $base.appserver --thread $thread" - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case " $cmd " in *" $expected "*) return 0 ;; esac - return 1 -} - -codex_app_monitor_pid_matches() { - local pid="$1" project="$2" type="$3" team="$4" name="$5" thread="$6" expected cmd - [ -n "$project" ] && [ -n "$team" ] && [ -n "$name" ] && [ -n "$thread" ] || return 1 - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - expected="$SKILL_DIR/scripts/drivers/types/codex/codex-app-monitor.sh $project $type $team $name $thread" - cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" - case " $cmd " in *" $expected "*) return 0 ;; esac - return 1 -} - wait_for_codex_receiver_exit() { - local pid="$1" matcher="$2" check=0 state - shift 2 + local pid="$1" check=0 state while [ "$check" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true)" case "$state" in Z*) return 0 ;; esac @@ -86,7 +60,6 @@ wait_for_codex_receiver_exit() { check=$((check + 1)) done if kill -0 "$pid" 2>/dev/null; then - "$matcher" "$pid" "$@" || return 0 kill -KILL "$pid" 2>/dev/null || return 1 check=0 while [ "$check" -lt 10 ] && kill -0 "$pid" 2>/dev/null; do @@ -101,41 +74,7 @@ wait_for_codex_receiver_exit() { stop_codex_thread_receivers() { local meta base kind pidfile pid cmd meta_project meta_type meta_thread team name - local label plist domain check chat project_hash seat saved_project saved_type - local saved_team saved_name saved_thread _saved_at safe_team safe_name state_key - - project_hash="$(printf '%s' "$PROJECT" | agmsg_sha1)" - for seat in "$RUN_DIR"/codex-seat.*.tsv; do - [ -f "$seat" ] || continue - IFS=$'\t' read -r saved_project saved_type saved_team saved_name saved_thread _saved_at < "$seat" || true - if [ "$saved_project" = "$PROJECT" ] && [ "$saved_type" = "$TYPE" ] \ - && [ "$saved_thread" = "$SESSION_ID" ]; then - safe_team="$(_actas_lock_encode "$saved_team")" - safe_name="$(_actas_lock_encode "$saved_name")" - state_key="$project_hash.$safe_team.$safe_name" - base="$RUN_DIR/codex-bridge.$state_key" - label="com.agmsg.codex-bridge.$state_key" - if command -v launchctl >/dev/null 2>&1; then - domain="gui/$(id -u)" - launchctl bootout "$domain/$label" >/dev/null 2>&1 || true - fi - pid="$(cat "$base.pid" 2>/dev/null || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - if codex_current_bridge_pid_matches "$pid" "$base" \ - "$saved_project" "$saved_type" "$saved_team" "$saved_name" "$saved_thread"; then - kill "$pid" 2>/dev/null || true - wait_for_codex_receiver_exit "$pid" codex_current_bridge_pid_matches "$base" \ - "$saved_project" "$saved_type" "$saved_team" "$saved_name" "$saved_thread" || true - fi - fi - rm -f "$base.pid" "$base.meta" "$base.appserver" "$base.log" \ - "$base.plist" "$base.health" "$base.last-ids" \ - "$base.binding" "$RUN_DIR/codex-chat-visible.$state_key.meta" - rm -f "$base".wake.*.json "$base.relay-wake.json" - actas_codex_seat_release "$saved_team" "$saved_name" "$saved_project" "$saved_thread" - fi - done - actas_codex_release_markers "$PROJECT" "$SESSION_ID" + local label plist domain check chat for meta in "$RUN_DIR"/codex-bridge.*.meta "$RUN_DIR"/codex-app-monitor.*.meta; do [ -f "$meta" ] || continue meta_project="$(sed -n 's/^project=//p' "$meta" | head -1)" @@ -149,23 +88,10 @@ stop_codex_thread_receivers() { kind="${base##*/}" pidfile="$base.pid" plist="$base.plist" - team="$(sed -n 's/^team=//p' "$meta" | head -1)" - name="$(sed -n 's/^name=//p' "$meta" | head -1)" - label="" - case "$kind" in - codex-bridge.*) - case "${kind#codex-bridge.}" in - ""|*[!A-Za-z0-9._%-]*) ;; - *) label="com.agmsg.$kind" ;; - esac - ;; - codex-app-monitor.*) - safe_team="$(printf '%s' "$team" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" - safe_name="$(printf '%s' "$name" | LC_ALL=C tr -c 'A-Za-z0-9._-' '-')" - [ -n "$safe_team" ] && [ -n "$safe_name" ] \ - && label="com.agmsg.codex-app-monitor.$project_hash.$safe_team.$safe_name" - ;; - esac + label="$(sed -n 's/^launch_label=//p' "$meta" | head -1)" + if [ -z "$label" ] && [ -f "$plist" ]; then + label="$(awk '/Label<\/key>/{getline; sub(/^[[:space:]]*/, ""); sub(/<\/string>[[:space:]]*$/, ""); print; exit}' "$plist")" + fi if [ -n "$label" ] && command -v launchctl >/dev/null 2>&1; then domain="gui/$(id -u)" launchctl bootout "$domain/$label" >/dev/null 2>&1 || true @@ -178,36 +104,24 @@ stop_codex_thread_receivers() { pid="$(cat "$pidfile" 2>/dev/null || true)" if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - case "$kind" in - codex-bridge.*) - if codex_current_bridge_pid_matches "$pid" "$base" \ - "$meta_project" "$meta_type" "$team" "$name" "$meta_thread"; then - kill "$pid" 2>/dev/null || true - if ! wait_for_codex_receiver_exit "$pid" codex_current_bridge_pid_matches "$base" \ - "$meta_project" "$meta_type" "$team" "$name" "$meta_thread"; then - echo "session-end: Codex receiver pid $pid did not stop; preserving its run files" >&2 - continue - fi - fi - ;; - codex-app-monitor.*) - if codex_app_monitor_pid_matches "$pid" \ - "$meta_project" "$meta_type" "$team" "$name" "$meta_thread"; then - kill "$pid" 2>/dev/null || true - if ! wait_for_codex_receiver_exit "$pid" codex_app_monitor_pid_matches \ - "$meta_project" "$meta_type" "$team" "$name" "$meta_thread"; then - echo "session-end: Codex receiver pid $pid did not stop; preserving its run files" >&2 - continue - fi + cmd="$(compat_get_cmdline "$pid" 2>/dev/null || true)" + case "$kind:$cmd" in + codex-bridge.*:*codex-bridge.js*|codex-app-monitor.*:*codex-app-monitor.sh*) + kill "$pid" 2>/dev/null || true + if ! wait_for_codex_receiver_exit "$pid"; then + echo "session-end: Codex receiver pid $pid did not stop; preserving its run files" >&2 + continue fi ;; esac fi + + team="$(sed -n 's/^team=//p' "$meta" | head -1)" + name="$(sed -n 's/^name=//p' "$meta" | head -1)" rm -f "$pidfile" "$meta" "$base.appserver" "$base.log" "$plist" \ "$base.health" "$base.preflight.log" "$base.last-prompt.txt" \ "$base.last-message.txt" "$base.last-status" "$base.last-ids" \ "$base.watch-output" - rm -f "$base".wake.*.json "$base.relay-wake.json" if [ -n "$team" ] && [ -n "$name" ]; then rm -f "$RUN_DIR/codex-chat-visible.$team.$name.meta" fi diff --git a/tests/test_actas_lock.bats b/tests/test_actas_lock.bats index 936cf230..cc1872b1 100644 --- a/tests/test_actas_lock.bats +++ b/tests/test_actas_lock.bats @@ -217,26 +217,6 @@ live_pid() { echo "$$"; } [ -f "$(actas_lock_path "T" "alice")" ] } -@test "Codex seat survives GC and blocks a generic actas claim" { - local seat owner project - project="$TEST_SKILL_DIR/project" - seat="$(codex_seat_path "T" "alice")" - owner="$(actas_codex_owner "$project" "thread-visible")" - printf '%s\tcodex\tT\talice\tthread-visible\t2026-07-14T00:00:00Z\n' \ - "$project" > "$seat" - chmod 600 "$seat" - - run actas_lock_gc_stale - [ "$status" -eq 0 ] - [ "$output" = "0" ] - [ -f "$seat" ] - - run actas_lock_claim "T" "alice" "generic-owner" - [ "$status" -eq 1 ] - [ "$output" = "held:$owner" ] - [ -f "$seat" ] -} - # --- state classification --- @test "state: free when no lock exists" { diff --git a/tests/test_codex_bridge.bats b/tests/test_codex_bridge.bats index 0d414cb5..d7979133 100644 --- a/tests/test_codex_bridge.bats +++ b/tests/test_codex_bridge.bats @@ -90,14 +90,6 @@ EOF [ "$output" = $'team\talice' ] } -@test "codex-bridge: accepts percent-encoded non-ASCII role state keys" { - skip_on_windows "codex bridge identity resolution on Windows (#182)" - run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice \ - --state-key 'project.%E6%97%A5%E6%9C%AC%E8%AA%9E.alice' --resolve-only - [ "$status" -eq 0 ] - [ "$output" = $'team\talice' ] -} - @test "codex-bridge: resolve-only rejects ambiguous identities" { skip_on_windows "codex bridge identity resolution on Windows (#182)" run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --resolve-only @@ -107,7 +99,7 @@ EOF @test "codex-bridge: rejects unsupported app-server endpoints" { skip_on_windows "codex bridge identity resolution on Windows (#182)" - run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --thread thread-test --app-server http://127.0.0.1:9999 + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --app-server http://127.0.0.1:9999 [ "$status" -eq 1 ] [[ "$output" =~ "supports only unix://PATH or ws://host:port" ]] } @@ -172,8 +164,8 @@ function handleMessage(socket, message) { }, }); }, 10); - } else if (message.method === "agmsg/wake/dispatch") { - sendFrame(socket, { jsonrpc: "2.0", id: message.id, result: { status: "accepted", maxId: message.params.maxId } }); + } else if (message.method === "turn/start") { + sendFrame(socket, { jsonrpc: "2.0", id: message.id, result: {} }); setTimeout(() => { sendFrame(socket, { jsonrpc: "2.0", @@ -259,11 +251,11 @@ EOF [ "$status" -eq 0 ] [[ "$output" =~ "resumed thread thread-existing" ]] - [[ "$output" =~ "accepted wakeup 1" ]] + [[ "$output" =~ "started turn" ]] grep -q "initialize" "$log" grep -q "thread/resume" "$log" grep -q "process/spawn" "$log" - grep -q "agmsg/wake/dispatch" "$log" + grep -q "turn/start" "$log" } @test "codex-bridge: connects to ws://host:port app-server endpoints" { @@ -318,8 +310,8 @@ function handleMessage(socket, message) { params: { processHandle: message.params.processHandle, exitCode: 0, stdout: "status=pending count=1 max_id=1\n", stderr: "" }, }); }, 10); - } else if (message.method === "agmsg/wake/dispatch") { - sendFrame(socket, { jsonrpc: "2.0", id: message.id, result: { status: "accepted", maxId: message.params.maxId } }); + } else if (message.method === "turn/start") { + sendFrame(socket, { jsonrpc: "2.0", id: message.id, result: {} }); setTimeout(() => { sendFrame(socket, { jsonrpc: "2.0", method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: "turn-1" } } }); }, 10); @@ -556,7 +548,6 @@ EOF } @test "codex-bridge: refuses when the same identity already has a live bridge" { - skip "replaced by PID-reuse ownership coverage in test_codex_bridge_persistence.bats" skip_on_windows "codex bridge identity resolution on Windows (#182)" mkdir -p "$TEST_SKILL_DIR/run" echo "$$" > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" @@ -567,14 +558,11 @@ EOF } @test "codex-bridge: starts a turn when app-server reports watch-once pending" { - skip "replaced by durable wake-dispatch coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" fi - bash "$SCRIPTS/send.sh" team bob alice "turn ack is not a read receipt" >/dev/null - local fake="$TEST_SKILL_DIR/fake-app-server.js" cat >"$fake" <<'EOF' const readline = require("readline"); @@ -640,69 +628,9 @@ EOF [ "$status" -eq 0 ] [[ "$output" =~ "wakeup 1" ]] - [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='turn ack is not a read receipt';")" = "1" ] -} - -@test "codex-bridge: turn failure and bridge restart keep the same message unread" { - skip "replaced by cross-restart deterministic-id coverage in test_codex_bridge_persistence.bats" - bash "$SCRIPTS/send.sh" team bob alice "retry the same unread message" >/dev/null - - local fake="$TEST_SKILL_DIR/fake-app-server-unread-retry.js" - cat >"$fake" <<'EOF' -const readline = require("readline"); -const rl = readline.createInterface({ input: process.stdin }); -function send(value) { process.stdout.write(`${JSON.stringify(value)}\n`); } -rl.on("line", (line) => { - const message = JSON.parse(line); - if (message.method === "initialize") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - } else if (message.method === "thread/resume") { - send({ jsonrpc: "2.0", id: message.id, result: { thread: { id: message.params.threadId, status: { type: "idle" } } } }); - } else if (message.method === "process/spawn") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - setTimeout(() => send({ - jsonrpc: "2.0", - method: "process/exited", - params: { processHandle: message.params.processHandle, exitCode: 0, stdout: "status=pending count=1 max_id=1\n", stderr: "" }, - }), 10); - } else if (message.method === "turn/start") { - send({ jsonrpc: "2.0", id: message.id, result: { turn: { id: "turn-retry" } } }); - if (process.env.AGMSG_FAKE_TURN_EVENT === "crash") { - setTimeout(() => process.exit(17), 10); - } else { - setTimeout(() => send({ - jsonrpc: "2.0", - method: process.env.AGMSG_FAKE_TURN_EVENT, - params: { threadId: message.params.threadId, turn: { id: "turn-retry", error: { message: "injected" } } }, - }), 10); - } - } else if (message.method === "process/kill") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - } -}); -EOF - - run env AGMSG_FAKE_TURN_EVENT=turn/failed AGMSG_CODEX_APP_SERVER_CMD="node $fake" \ - node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice \ - --thread thread-retry --require-thread --timeout 1 --interval 1 --max-wakes 1 - [ "$status" -eq 0 ] - [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='retry the same unread message';")" = "1" ] - - run env AGMSG_FAKE_TURN_EVENT=crash AGMSG_CODEX_APP_SERVER_CMD="node $fake" \ - node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice \ - --thread thread-retry --require-thread --timeout 1 --interval 1 --max-wakes 1 - [ "$status" -eq 0 ] - [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='retry the same unread message';")" = "1" ] - - run env AGMSG_FAKE_TURN_EVENT=turn/completed AGMSG_CODEX_APP_SERVER_CMD="node $fake" \ - node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice \ - --thread thread-retry --require-thread --timeout 1 --interval 1 --max-wakes 1 - [ "$status" -eq 0 ] - [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='retry the same unread message';")" = "1" ] } @test "codex-bridge: resumes an existing thread before arming" { - skip "replaced by exact-thread coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -748,7 +676,7 @@ EOF ! grep -q "thread/start" "$log" } -@test "codex-bridge: --thread loaded is rejected before thread discovery" { +@test "codex-bridge: --thread loaded discovers the live thread via thread/loaded/list" { run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -789,14 +717,15 @@ rl.on("line", (line) => { EOF AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ - --project "$PROJ" --team team --name alice --thread loaded --timeout 20 + --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 5000 --timeout 20 - [ "$status" -ne 0 ] - [[ "$output" =~ "discovery aliases are not supported" ]] - [ ! -e "$log" ] + [ "$status" -eq 0 ] + grep -q "thread/loaded/list" "$log" + grep -q "thread/resume" "$log" + ! grep -q "thread/start" "$log" } -@test "codex-bridge: --thread loaded is rejected even when no thread is loaded" { +@test "codex-bridge: --thread loaded errors when no thread is loaded in time" { run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -822,24 +751,89 @@ rl.on("line", (line) => { EOF AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ - --project "$PROJ" --team team --name alice --thread loaded --timeout 20 + --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 1500 --timeout 20 [ "$status" -ne 0 ] - [[ "$output" =~ "discovery aliases are not supported" ]] + [[ "$output" =~ "no loaded codex thread" ]] } -@test "codex-bridge: rejects background inline inbox reads and preserves unread mail" { +@test "codex-bridge: inline-inbox includes unread message text in turn input" { + run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' + if [ "$status" -ne 0 ]; then + skip "node child_process.spawn is not available in this sandbox" + fi + bash "$SCRIPTS/send.sh" team bob alice "inline body reaches prompt" >/dev/null - run node "$TYPES/codex/codex-bridge.js" \ - --project "$PROJ" --team team --name alice --thread thread-inline --timeout 1 --interval 1 --max-wakes 1 --inline-inbox - [ "$status" -ne 0 ] - [[ "$output" =~ "only the visible Codex task" ]] - [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NULL FROM messages WHERE body='inline body reaches prompt';")" = "1" ] + local fake="$TEST_SKILL_DIR/fake-app-server-inline.js" + cat >"$fake" <<'EOF' +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin }); + +function send(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } else if (message.method === "thread/start") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { thread: { id: "thread-1", status: { type: "idle" } } }, + }); + } else if (message.method === "process/spawn") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + setTimeout(() => { + send({ + jsonrpc: "2.0", + method: "process/exited", + params: { + processHandle: message.params.processHandle, + exitCode: 0, + stdout: "status=pending count=1 max_id=1\n", + stderr: "", + }, + }); + }, 10); + } else if (message.method === "turn/start") { + if (!message.params.input[0].text.includes("inline body reaches prompt")) { + send({ jsonrpc: "2.0", id: message.id, error: { message: "missing inline inbox body" } }); + return; + } + if (!message.params.input[0].text.includes('starting with "agmsg受信:"')) { + send({ jsonrpc: "2.0", id: message.id, error: { message: "missing visible progress contract" } }); + return; + } + if (!message.params.input[0].text.includes("Before each major action")) { + send({ jsonrpc: "2.0", id: message.id, error: { message: "missing visible major-action contract" } }); + return; + } + send({ jsonrpc: "2.0", id: message.id, result: {} }); + setTimeout(() => { + send({ + jsonrpc: "2.0", + method: "turn/completed", + params: { threadId: message.params.threadId, turn: { id: "turn-1" } }, + }); + }, 10); + } else if (message.method === "process/kill") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } +}); +EOF + + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ + --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --max-wakes 1 --inline-inbox + + [ "$status" -eq 0 ] + [[ "$output" =~ "started turn" ]] + [ "$(sqlite3 "$TEST_SKILL_DIR/db/messages.db" "SELECT read_at IS NOT NULL FROM messages WHERE body='inline body reaches prompt';")" = "1" ] } @test "codex-bridge: stops instead of looping on the same unread max_id" { - skip "replaced by bounded same-max retry coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -898,18 +892,12 @@ EOF AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] [[ "$output" =~ "wakeup 1" ]] [[ "$output" =~ "stopping to avoid a repeated wakeup loop" ]] - local health="$TEST_SKILL_DIR/run/codex-bridge.team.alice.health" - grep -qx 'status=paused_stale_unread' "$health" - grep -q 'unread max_id 7 remained unchanged' "$health" - [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" ] - [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.team.alice.meta" ] } @test "codex-bridge: stops after the configured watch-once failure limit" { - skip "replaced by unattended watch retry coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -954,17 +942,12 @@ EOF --project "$PROJ" --team team --name alice --timeout 1 --interval 1 \ --request-timeout-ms 1000 --watch-failure-limit 1 - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] [[ "$output" =~ "watch-once failed with exit 1: fake watch failure" ]] [[ "$output" =~ "stopping after 1 consecutive watch-once failure" ]] - local health="$TEST_SKILL_DIR/run/codex-bridge.team.alice.health" - grep -qx 'status=paused_watch_failure' "$health" - grep -q '1 consecutive watch-once failure' "$health" - [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" ] } @test "codex-bridge: watch-once timeout exit does not count toward failure limit" { - skip "replaced by durable state-machine coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1030,7 +1013,6 @@ EOF } @test "codex-bridge: re-arm spawn request timeout exits without a phantom watch" { - skip "replaced by bounded watch retry coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1083,7 +1065,6 @@ EOF } @test "codex-bridge: delayed re-arm after sub-limit watch failure times out fatally" { - skip "replaced by bounded watch retry coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1145,7 +1126,6 @@ EOF # --- re-arm regression (#41): real app-server may never send turn/completed --- @test "codex-bridge: re-arms after a turn via the watchdog when no turn/completed arrives" { - skip "replaced by durable state-machine coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1191,7 +1171,6 @@ EOF } @test "codex-bridge: re-arms after a turn when the app-server reports idle (not turn/completed)" { - skip "replaced by durable state-machine coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" @@ -1238,7 +1217,6 @@ EOF } @test "codex-bridge: delivers a wake observed while the resumed thread was still active (no stale-stop)" { - skip "replaced by durable state-machine coverage in test_codex_bridge_persistence.bats" run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then skip "node child_process.spawn is not available in this sandbox" diff --git a/tests/test_codex_bridge_persistence.bats b/tests/test_codex_bridge_persistence.bats deleted file mode 100644 index 4f948eae..00000000 --- a/tests/test_codex_bridge_persistence.bats +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env bats - -load test_helper - -setup() { - setup_test_env - export PROJ="$TEST_SKILL_DIR/proj" - mkdir -p "$PROJ" - bash "$SCRIPTS/join.sh" team alice codex "$PROJ" >/dev/null - export BRIDGE="$TYPES/codex/codex-bridge.js" - export FAKE="$TEST_SKILL_DIR/fake-persistent-app-server.js" - export FAKE_LOG="$TEST_SKILL_DIR/fake-persistent-app-server.log" - write_fake_app_server -} - -teardown() { - teardown_test_env -} - -write_fake_app_server() { - cat >"$FAKE" <<'EOF' -const fs = require("fs"); -const readline = require("readline"); -const log = process.argv[2]; -const mode = process.env.FAKE_MODE || "normal"; -const maxId = Number(process.env.FAKE_MAX_ID || 1); -let dispatches = 0; -let spawns = 0; -function send(value) { process.stdout.write(`${JSON.stringify(value)}\n`); } -function record(value) { fs.appendFileSync(log, `${value}\n`); } -readline.createInterface({ input: process.stdin }).on("line", (line) => { - const message = JSON.parse(line); - if (message.method === "initialize") { - record("initialize"); - send({ jsonrpc: "2.0", id: message.id, result: {} }); - } else if (message.method === "thread/resume") { - record(`resume ${message.params.threadId}`); - if (mode === "bad-thread") { - send({ jsonrpc: "2.0", id: message.id, error: { message: "thread not found" } }); - } else { - send({ jsonrpc: "2.0", id: message.id, result: { thread: { id: message.params.threadId, status: { type: "idle" } } } }); - } - } else if (message.method === "process/spawn") { - spawns += 1; - record(`spawn ${spawns}`); - send({ jsonrpc: "2.0", id: message.id, result: {} }); - setTimeout(() => send({ - jsonrpc: "2.0", - method: "process/exited", - params: { - processHandle: message.params.processHandle, - exitCode: mode === "watch-fail" ? 1 : 0, - stdout: mode === "watch-fail" ? "" : `status=pending count=1 max_id=${maxId}\n`, - stderr: mode === "watch-fail" ? "injected watcher failure" : "", - }, - }), 5); - } else if (message.method === "agmsg/wake/dispatch") { - dispatches += 1; - record(`dispatch ${message.params.maxId} ${message.params.clientUserMessageId}`); - if (mode === "ambiguous-once" && dispatches === 1) return; - record(`accepted ${message.params.maxId}`); - send({ jsonrpc: "2.0", id: message.id, result: { status: "accepted", maxId: message.params.maxId } }); - if (mode === "secret-delta") { - send({ jsonrpc: "2.0", method: "item/agentMessage/delta", params: { threadId: message.params.threadId, delta: "TOP-SECRET-CONTENT" } }); - } - setTimeout(() => send({ - jsonrpc: "2.0", - method: "turn/completed", - params: { threadId: message.params.threadId, turn: { id: `turn-${message.params.maxId}` } }, - }), 10); - } else if (message.method === "process/kill") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - } -}); -EOF -} - -write_timeout_runner() { - local runner="$TEST_SKILL_DIR/run-with-timeout.js" - cat >"$runner" <<'EOF' -const { spawn } = require("child_process"); -const timeout = Number(process.argv[2]); -const child = spawn(process.argv[3], process.argv.slice(4), { env: process.env, stdio: ["ignore", "pipe", "pipe"] }); -let output = ""; -child.stdout.on("data", (chunk) => { output += chunk; }); -child.stderr.on("data", (chunk) => { output += chunk; }); -const timer = setTimeout(() => child.kill("SIGTERM"), timeout); -child.on("close", (code) => { - clearTimeout(timer); - process.stdout.write(output); - process.exit(code == null ? 1 : code); -}); -EOF - printf '%s\n' "$runner" -} - -bridge_args() { - printf '%s\n' \ - --project "$PROJ" --type codex --team team --name alice \ - --state-key state-team-alice --thread thread-exact \ - --timeout 1 --interval 1 --turn-timeout 1 --request-timeout-ms 200 -} - -@test "codex-bridge persistence: requires an exact thread and rejects discovery aliases" { - run node "$BRIDGE" --project "$PROJ" --team team --name alice - [ "$status" -ne 0 ] - [[ "$output" =~ "one exact --thread id is required" ]] - - run node "$BRIDGE" --project "$PROJ" --team team --name alice --thread loaded - [ "$status" -ne 0 ] - [[ "$output" =~ "discovery aliases are not supported" ]] -} - -@test "codex-bridge persistence: a recycled unrelated pid is never killed or treated as the owner" { - mkdir -p "$TEST_SKILL_DIR/run" - local base="$TEST_SKILL_DIR/run/codex-bridge.state-team-alice" - printf '%s\n' "$$" >"$base.pid" - cat >"$base.meta" </dev/null || stat -c '%a' "$state")" = "600" ] -} - -@test "codex-bridge persistence: an ambiguous dispatch retries the same deterministic id" { - FAKE_MODE=ambiguous-once \ - AGMSG_CODEX_BRIDGE_RETRY_BASE_MS=20 AGMSG_CODEX_BRIDGE_RETRY_MAX_MS=20 \ - AGMSG_CODEX_APP_SERVER_CMD="node $FAKE $FAKE_LOG" \ - run node "$BRIDGE" $(bridge_args) --request-timeout-ms 40 --max-wakes 1 - - [ "$status" -eq 0 ] - [ "$(grep -c '^dispatch 1 ' "$FAKE_LOG")" -eq 2 ] - [ "$(grep -c '^accepted 1$' "$FAKE_LOG")" -eq 1 ] - [ "$(awk '/^dispatch 1 /{print $3}' "$FAKE_LOG" | sort -u | wc -l | tr -d ' ')" -eq 1 ] - [[ "$output" =~ "paused" || "$output" =~ "ambiguous" ]] -} - -@test "codex-bridge persistence: repeated watcher failures back off without exiting" { - local log="$TEST_SKILL_DIR/bridge.out" - FAKE_MODE=watch-fail \ - AGMSG_CODEX_BRIDGE_RETRY_BASE_MS=20 AGMSG_CODEX_BRIDGE_RETRY_MAX_MS=40 \ - AGMSG_CODEX_APP_SERVER_CMD="node $FAKE $FAKE_LOG" \ - node "$BRIDGE" $(bridge_args) --watch-failure-limit 1 >"$log" 2>&1 & - local pid=$! - for _ in {1..60}; do - [ "$(grep -c '^spawn ' "$FAKE_LOG" 2>/dev/null || true)" -ge 3 ] && break - sleep 0.02 - done - kill -0 "$pid" - grep -qx 'status=paused_watch_failure' "$TEST_SKILL_DIR/run/codex-bridge.state-team-alice.health" - kill -TERM "$pid" - wait "$pid" -} - -@test "codex-bridge persistence: managed terminal thread errors exit cleanly without a restart loop" { - FAKE_MODE=bad-thread AGMSG_CODEX_APP_SERVER_CMD="node $FAKE $FAKE_LOG" \ - run node "$BRIDGE" $(bridge_args) - - [ "$status" -eq 0 ] - [ "$(grep -c '^initialize$' "$FAKE_LOG")" -eq 1 ] - grep -qx 'status=terminal_thread_error' "$TEST_SKILL_DIR/run/codex-bridge.state-team-alice.health" - [[ "$output" =~ "terminal configuration error" ]] -} - -@test "codex-bridge persistence: bridge logs never contain agent message deltas" { - FAKE_MODE=secret-delta AGMSG_CODEX_APP_SERVER_CMD="node $FAKE $FAKE_LOG" \ - run node "$BRIDGE" $(bridge_args) --max-wakes 1 - - [ "$status" -eq 0 ] - [[ "$output" != *"TOP-SECRET-CONTENT"* ]] -} diff --git a/tests/test_codex_desktop_relay.bats b/tests/test_codex_desktop_relay.bats deleted file mode 100644 index 21a75469..00000000 --- a/tests/test_codex_desktop_relay.bats +++ /dev/null @@ -1,1155 +0,0 @@ -#!/usr/bin/env bats - -bats_require_minimum_version 1.5.0 - -load test_helper - -setup() { - setup_test_env - RELAY_PID="" - EXTRA_PIDS="" - DESKTOP_TOKEN="$(printf 'd%.0s' {1..64})" - BRIDGE_TOKEN="$(printf 'b%.0s' {1..64})" - ROLE_TOKEN="$(printf 'a%.0s' {1..64})" - printf '%s\n' "$DESKTOP_TOKEN" > "$TEST_SKILL_DIR/desktop.token" - printf '%s\n' "$BRIDGE_TOKEN" > "$TEST_SKILL_DIR/bridge.token" - chmod 600 "$TEST_SKILL_DIR/desktop.token" "$TEST_SKILL_DIR/bridge.token" -} - -teardown() { - local pid - if [ -n "${RELAY_PID:-}" ]; then - kill "$RELAY_PID" 2>/dev/null || true - wait "$RELAY_PID" 2>/dev/null || true - fi - for pid in ${EXTRA_PIDS:-}; do - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - done - teardown_test_env -} - -make_fake_app_server() { - local fake="$TEST_SKILL_DIR/fake-relay-codex" - cat > "$fake" <<'NODE' -#!/usr/bin/env node -"use strict"; -const fs = require("fs"); -const readline = require("readline"); -const log = process.env.AGMSG_FAKE_RELAY_LOG; -const historyFile = process.env.AGMSG_FAKE_RELAY_HISTORY_FILE || ""; -if (log) fs.appendFileSync(log, `ws-env=${process.env.CODEX_APP_SERVER_WS_URL ? "set" : "unset"}\n`); -function send(value) { process.stdout.write(`${JSON.stringify(value)}\n`); } -readline.createInterface({ input: process.stdin }).on("line", (line) => { - const message = JSON.parse(line); - if (log) fs.appendFileSync( - log, - `${message.method || "response"}\t${String(message.id ?? "")}\t${String(message.params && message.params.processHandle || "")}\t${String(message.error && message.error.message || "")}\n`, - ); - if (message.method === "initialize") { - send({ jsonrpc: "2.0", id: message.id, result: { serverInfo: { name: "fake", version: "1" } } }); - } else if (message.method === "thread/resume") { - send({ jsonrpc: "2.0", id: message.id, result: { thread: { id: message.params.threadId } } }); - } else if (message.method === "test/echo") { - send({ jsonrpc: "2.0", id: message.id, result: { value: message.params.value } }); - } else if (message.method === "thread/read") { - const finishRead = () => { - const clientId = historyFile && fs.existsSync(historyFile) ? fs.readFileSync(historyFile, "utf8").trim() : ""; - send({ jsonrpc: "2.0", id: message.id, result: { thread: { id: message.params.threadId, turns: [{ items: [{ type: "userMessage", clientId, content: "PRIVATE-HISTORY" }] }] } } }); - }; - const readDelay = Number(process.env.AGMSG_FAKE_RELAY_DELAY_READ_MS || 0); - if (readDelay > 0) setTimeout(finishRead, readDelay); else finishRead(); - } else if (message.method === "turn/start") { - if (historyFile && process.env.AGMSG_FAKE_RELAY_DROP_FIRST_TURN === "1" && !fs.existsSync(historyFile)) { - fs.writeFileSync(historyFile, message.params.clientUserMessageId); - return; - } - const finishTurn = () => { - if (historyFile) fs.writeFileSync(historyFile, message.params.clientUserMessageId); - send({ jsonrpc: "2.0", id: message.id, result: { turn: { id: "turn-1" } } }); - send({ jsonrpc: "2.0", method: "turn/started", params: { threadId: message.params.threadId } }); - send({ jsonrpc: "2.0", method: "item/agentMessage/delta", params: { threadId: message.params.threadId, delta: "visible" } }); - send({ jsonrpc: "2.0", method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: "turn-1" } } }); - }; - const delay = Number(process.env.AGMSG_FAKE_RELAY_DELAY_TURN_MS || 0); - if (delay > 0) setTimeout(finishTurn, delay); else finishTurn(); - } else if (message.method === "process/spawn") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - if (process.env.AGMSG_FAKE_RELAY_PROCESS_EXIT === "0") return; - setTimeout(() => send({ - jsonrpc: "2.0", - method: "process/output", - params: { processHandle: message.params.processHandle, stdout: "partial", stderr: "" }, - }), 10); - setTimeout(() => send({ - jsonrpc: "2.0", - method: "process/exited", - params: { processHandle: message.params.processHandle, exitCode: 0, stdout: "status=timeout\n", stderr: "" }, - }), 50); - } else if (message.method === "process/kill") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - } else if (message.method === "test/triggerServer") { - send({ jsonrpc: "2.0", id: message.id, result: {} }); - send({ jsonrpc: "2.0", id: "server-disconnect", method: "test/serverRequest", params: { value: 1 } }); - } -}); -NODE - chmod +x "$fake" - printf '%s\n' "$fake" -} - -start_relay() { - local fake="$1" log="$2" - AGMSG_FAKE_RELAY_LOG="$log" \ - AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="${AGMSG_TEST_RELAY_RUN_DIR:-$TEST_SKILL_DIR/run}" \ - node "$TYPES/codex/codex-desktop-relay.js" \ - --codex "$fake" --host 127.0.0.1 --port 0 \ - --desktop-token-file "$TEST_SKILL_DIR/desktop.token" \ - --bridge-token-file "$TEST_SKILL_DIR/bridge.token" \ - --health "$TEST_SKILL_DIR/relay.health" \ - --port-file "$TEST_SKILL_DIR/relay.port" \ - --pid-file "$TEST_SKILL_DIR/relay.pid" \ - >"$TEST_SKILL_DIR/relay.log" 2>&1 & - RELAY_PID=$! - for _ in {1..100}; do - [ -s "$TEST_SKILL_DIR/relay.port" ] && return 0 - kill -0 "$RELAY_PID" 2>/dev/null || return 1 - sleep 0.05 - done - return 1 -} - -write_bridge_binding() { - local token="$1" state_key="$2" thread="$3" team="${4:-team}" name="${5:-alice}" project="${6:-$TEST_SKILL_DIR}" - mkdir -p "$TEST_SKILL_DIR/run" - cat > "$TEST_SKILL_DIR/run/codex-bridge.$state_key.binding" < "$runner" <<'NODE' -"use strict"; -const fs = require("fs"); -const crypto = require("crypto"); -const path = require("path"); -const { WebSocketAppServerClient } = require(process.argv[2]); -const port = Number(process.argv[3]); -const desktopToken = process.argv[4]; -const bridgeSeed = process.argv[5]; -const roleToken = process.argv[6]; -function client(role) { - const requestPath = role === "desktop" ? `/desktop/${desktopToken}` : `/bridge/${bridgeSeed}/${roleToken}`; - const value = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, - `ws://127.0.0.1:${port}/`, - { connectTimeoutMs: 3000, requestTimeoutMs: 3000, requestPath }, - ); - value.start(); - return value; -} -async function initialize(value, name, title) { - await value.ready(); - const result = await value.request("initialize", { - clientInfo: { name, title, version: "1" }, - capabilities: { experimentalApi: true, requestAttestation: false, optOutNotificationMethods: [] }, - }); - value.notify("initialized"); - return result; -} -(async () => { - const bridge = client("bridge"); - let bridgeInitialized = false; - const bridgeInit = initialize(bridge, "codex-desktop", "forged Desktop name").then(() => { - bridgeInitialized = true; - }); - await new Promise((resolve) => setTimeout(resolve, 150)); - if (bridgeInitialized) throw new Error("bridge initialized before a visible Desktop client connected"); - const desktop = client("desktop"); - await initialize(desktop, "codex-desktop", "Codex Desktop"); - await bridgeInit; - - const [desktopEcho, bridgeResume] = await Promise.all([ - desktop.request("test/echo", { value: "desktop" }), - bridge.request("thread/resume", { threadId: "thread-visible" }), - ]); - if (desktopEcho.value !== "desktop" || bridgeResume.thread.id !== "thread-visible") { - throw new Error("request responses crossed clients"); - } - let methodRejected = false; - try { - await bridge.request("test/echo", { value: "bridge" }); - } catch (error) { - methodRejected = /not allowed/.test(error.message); - } - if (!methodRejected) throw new Error("bridge method allowlist was bypassed"); - - const seen = { desktopStarted: false, desktopDelta: false, bridgeCompleted: false }; - desktop.on("turn/started", () => { seen.desktopStarted = true; }); - desktop.on("item/agentMessage/delta", () => { seen.desktopDelta = true; }); - bridge.on("turn/completed", () => { seen.bridgeCompleted = true; }); - const project = fs.realpathSync(path.dirname(process.argv[7])); - const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["default.team.alice", "thread-visible", "1"].join("\0")).digest("hex")}`; - let injectedPayloadRejected = false; - try { - await bridge.request("agmsg/wake/dispatch", { - threadId: "thread-visible", maxId: 1, clientUserMessageId, - dispatchMode: "start-or-reconcile", cwd: project, runtimeWorkspaceRoots: [project], - input: "UNTRUSTED-WAKE-PAYLOAD", - }); - } catch (error) { - injectedPayloadRejected = /exact role binding/.test(error.message); - } - if (!injectedPayloadRejected) throw new Error("bridge supplied wake payload was accepted"); - const wakeResult = await bridge.request("agmsg/wake/dispatch", { - threadId: "thread-visible", - maxId: 1, - clientUserMessageId, - dispatchMode: "start-or-reconcile", - cwd: project, - runtimeWorkspaceRoots: [project], - }); - if (JSON.stringify(wakeResult).includes("PRIVATE-HISTORY") || wakeResult.status !== "accepted" || wakeResult.maxId !== 1) { - throw new Error(`wake response was not sanitized: ${JSON.stringify(wakeResult)}`); - } - await new Promise((resolve) => setTimeout(resolve, 100)); - if (!seen.desktopStarted || !seen.desktopDelta || !seen.bridgeCompleted) { - throw new Error(`notifications were not broadcast: ${JSON.stringify(seen)}`); - } - bridge.stop(); - await new Promise((resolve) => setTimeout(resolve, 100)); - const health = fs.readFileSync(process.argv[7], "utf8"); - if (!/^status=ready$/m.test(health)) throw new Error(`bridge close lowered relay health: ${health}`); - desktop.stop(); - console.log("relay-visible-broadcast-ok"); -})().catch((error) => { - console.error(error.stack || error); - process.exit(1); -}); -NODE - - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ - "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$TEST_SKILL_DIR/relay.health" - [ "$status" -eq 0 ] - [[ "$output" == *"relay-visible-broadcast-ok"* ]] - [ "$(grep -c $'^initialize\t' "$log")" -eq 1 ] - [ "$(awk -F '\t' '$1 == "test/echo" || $1 == "thread/resume" { print $2 }' "$log" | sort -u | wc -l | tr -d ' ')" -eq 2 ] - grep -qx 'status=waiting_for_desktop' "$TEST_SKILL_DIR/relay.health" - grep -qx "upstream_initialized=1" "$TEST_SKILL_DIR/relay.health" - grep -qx "primary_connected=0" "$TEST_SKILL_DIR/relay.health" - ! grep -q "$DESKTOP_TOKEN\|$BRIDGE_TOKEN\|$ROLE_TOKEN" "$TEST_SKILL_DIR/relay.log" "$TEST_SKILL_DIR/relay.health" - [ -e "$TEST_SKILL_DIR/run/codex-bridge.default.team.alice.binding" ] -} - -@test "codex desktop relay rejects missing and incorrect capability paths" { - local fake log port runner - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-auth.log" - write_bridge_binding "$ROLE_TOKEN" auth.team.alice thread-auth - start_relay "$fake" "$log" - port="$(cat "$TEST_SKILL_DIR/relay.port")" - runner="$TEST_SKILL_DIR/relay-auth-test.js" - cat > "$runner" <<'NODE' -"use strict"; -const { WebSocketAppServerClient } = require(process.argv[2]); -const crypto = require("crypto"); -const port = Number(process.argv[3]); -const bridgeSeed = process.argv[4]; -const roleToken = process.argv[5]; -async function rejected(requestPath) { - const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, - `ws://127.0.0.1:${port}/`, - { connectTimeoutMs: 1000, requestTimeoutMs: 1000, requestPath }, - ); - client.start(); - try { - await client.ready(); - } catch (error) { - client.stop(); - return /403 Forbidden/.test(error.message); - } - client.stop(); - return false; -} -(async () => { - if (!await rejected("/") - || !await rejected(`/bridge/${"0".repeat(64)}/${roleToken}`) - || !await rejected(`/bridge/${bridgeSeed}`) - || !await rejected(`/bridge/${bridgeSeed}/${"0".repeat(64)}`)) { - throw new Error("unauthorized websocket path was accepted"); - } - console.log("relay-capability-rejected"); -})().catch((error) => { console.error(error.stack || error); process.exit(1); }); -NODE - - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$BRIDGE_TOKEN" "$ROLE_TOKEN" - [ "$status" -eq 0 ] - [[ "$output" == *"relay-capability-rejected"* ]] -} - -@test "codex desktop relay rejects symlink capability and binding files" { - local fake real_binding binding_path - fake="$(make_fake_app_server)" - ln -s "$TEST_SKILL_DIR/desktop.token" "$TEST_SKILL_DIR/desktop-link.token" - run node "$TYPES/codex/codex-desktop-relay.js" \ - --codex "$fake" --host 127.0.0.1 --port 0 \ - --desktop-token-file "$TEST_SKILL_DIR/desktop-link.token" \ - --bridge-token-file "$TEST_SKILL_DIR/bridge.token" - [ "$status" -ne 0 ] - [[ "$output" == *"regular non-symlink file"* ]] - - ln -s "$TEST_SKILL_DIR/bridge.token" "$TEST_SKILL_DIR/bridge-link.token" - run node "$TYPES/codex/codex-desktop-relay.js" \ - --codex "$fake" --host 127.0.0.1 --port 0 \ - --desktop-token-file "$TEST_SKILL_DIR/desktop.token" \ - --bridge-token-file "$TEST_SKILL_DIR/bridge-link.token" - [ "$status" -ne 0 ] - [[ "$output" == *"regular non-symlink file"* ]] - - write_bridge_binding "$ROLE_TOKEN" symlink.team.alice thread-symlink - binding_path="$TEST_SKILL_DIR/run/codex-bridge.symlink.team.alice.binding" - real_binding="$binding_path.real" - mv "$binding_path" "$real_binding" - ln -s "$real_binding" "$binding_path" - start_relay "$fake" "$TEST_SKILL_DIR/upstream-symlink.log" - local port runner - port="$(cat "$TEST_SKILL_DIR/relay.port")" - runner="$TEST_SKILL_DIR/relay-binding-symlink-test.js" - cat > "$runner" <<'NODE' -"use strict"; -const { WebSocketAppServerClient } = require(process.argv[2]); -const port = Number(process.argv[3]); -const seed = process.argv[4]; -const role = process.argv[5]; -const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, - "ws://127.0.0.1/", - { connectTimeoutMs: 1000, requestTimeoutMs: 1000, requestPath: `/bridge/${seed}/${role}` }, -); -client.start(); -client.ready().then(() => { - client.stop(); - throw new Error("symlink binding was accepted"); -}).catch((error) => { - client.stop(); - if (!/403 Forbidden/.test(error.message)) throw error; - console.log("relay-binding-symlink-rejected"); -}); -NODE - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$BRIDGE_TOKEN" "$ROLE_TOKEN" - [ "$status" -eq 0 ] - [[ "$output" == *"relay-binding-symlink-rejected"* ]] -} - -@test "codex desktop relay honors a validated custom run directory for role bindings" { - local fake log port runner custom - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-custom-run.log" - custom="$TEST_SKILL_DIR/private-run" - mkdir -p "$custom" - cat >"$custom/codex-bridge.custom.team.alice.binding" <"$runner" <<'NODE' -const { WebSocketAppServerClient } = require(process.argv[2]); -const port = Number(process.argv[3]); -function make(path) { - const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, "custom-run", { requestPath: path, connectTimeoutMs: 1000, requestTimeoutMs: 1000 }, - ); - client.start(); - return client; -} -async function init(client, name) { - await client.ready(); - await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); - client.notify("initialized"); -} -(async () => { - const desktop = make(`/desktop/${process.argv[4]}`); - await init(desktop, "desktop"); - const bridge = make(`/bridge/${process.argv[5]}/${process.argv[6]}`); - await init(bridge, "bridge"); - const result = await bridge.request("thread/resume", { threadId: "thread-custom" }); - if (result.thread.id !== "thread-custom") throw new Error("custom binding was not loaded"); - bridge.stop(); desktop.stop(); -})().catch((error) => { console.error(error.stack || error); process.exit(1); }); -NODE - - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" "$BRIDGE_TOKEN" "$ROLE_TOKEN" - [ "$status" -eq 0 ] - grep -q $'^thread/resume\t' "$log" -} - -@test "codex desktop relay removes its endpoint capability from the app-server child environment" { - local fake log - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-child-env.log" - CODEX_APP_SERVER_WS_URL="ws://127.0.0.1:1/secret-capability" start_relay "$fake" "$log" - - for _ in {1..50}; do [ -s "$log" ] && break; sleep 0.02; done - grep -qx 'ws-env=unset' "$log" - ! grep -q 'secret-capability' "$TEST_SKILL_DIR/relay.log" "$log" -} - -@test "codex desktop relay reconciles an accepted wake after the first response is lost" { - local fake log port runner history project - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-reconcile.log" - history="$TEST_SKILL_DIR/persisted-client-id" - project="$(cd "$TEST_SKILL_DIR" && pwd -P)" - write_bridge_binding "$ROLE_TOKEN" reconcile.team.alice thread-reconcile team alice "$project" - AGMSG_FAKE_RELAY_HISTORY_FILE="$history" AGMSG_FAKE_RELAY_DROP_FIRST_TURN=1 start_relay "$fake" "$log" - port="$(cat "$TEST_SKILL_DIR/relay.port")" - runner="$TEST_SKILL_DIR/reconcile-client.js" - cat >"$runner" <<'NODE' -const crypto = require("crypto"); -const { WebSocketAppServerClient } = require(process.argv[2]); -const port = Number(process.argv[3]); -function make(path, timeout) { - const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, "reconcile", { requestPath: path, connectTimeoutMs: 1000, requestTimeoutMs: timeout }, - ); - client.start(); - return client; -} -async function init(client, name) { - await client.ready(); - await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); - client.notify("initialized"); -} -(async () => { - const desktop = make(`/desktop/${process.argv[4]}`, 1000); - await init(desktop, "desktop"); - const bridge = make(`/bridge/${process.argv[5]}/${process.argv[6]}`, 100); - await init(bridge, "bridge"); - await bridge.request("thread/resume", { threadId: "thread-reconcile" }); - const maxId = 9; - const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["reconcile.team.alice", "thread-reconcile", String(maxId)].join("\0")).digest("hex")}`; - const params = { - threadId: "thread-reconcile", maxId, clientUserMessageId, - dispatchMode: "start-or-reconcile", - cwd: process.argv[7], runtimeWorkspaceRoots: [process.argv[7]], - }; - let timedOut = false; - try { await bridge.request("agmsg/wake/dispatch", params); } catch (error) { timedOut = /timed out/.test(error.message); } - if (!timedOut) throw new Error("the injected lost response did not time out"); - params.dispatchMode = "reconcile-only"; - const result = await bridge.request("agmsg/wake/dispatch", params); - if (JSON.stringify(result) !== JSON.stringify({ status: "reconciled", maxId })) { - throw new Error(`history leaked or reconciliation failed: ${JSON.stringify(result)}`); - } - bridge.stop(); desktop.stop(); -})().catch((error) => { console.error(error.stack || error); process.exit(1); }); -NODE - - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$project" - [ "$status" -eq 0 ] - [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] - [ "$(grep -c $'^thread/read\t' "$log")" -eq 2 ] -} - -@test "codex desktop relay never starts a concurrent duplicate while acceptance is pending" { - local fake log port runner history project - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-inflight.log" - history="$TEST_SKILL_DIR/inflight-client-id" - project="$(cd "$TEST_SKILL_DIR" && pwd -P)" - write_bridge_binding "$ROLE_TOKEN" inflight.team.alice thread-inflight team alice "$project" - AGMSG_FAKE_RELAY_HISTORY_FILE="$history" AGMSG_FAKE_RELAY_DELAY_TURN_MS=200 start_relay "$fake" "$log" - port="$(cat "$TEST_SKILL_DIR/relay.port")" - runner="$TEST_SKILL_DIR/inflight-client.js" - cat >"$runner" <<'NODE' -const crypto = require("crypto"); -const { WebSocketAppServerClient } = require(process.argv[2]); -const port = Number(process.argv[3]); -function make(path, timeout) { - const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, "inflight", { requestPath: path, connectTimeoutMs: 1000, requestTimeoutMs: timeout }, - ); - client.start(); return client; -} -async function init(client, name) { - await client.ready(); - await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); - client.notify("initialized"); -} -(async () => { - const desktop = make(`/desktop/${process.argv[4]}`, 1000); await init(desktop, "desktop"); - const bridge = make(`/bridge/${process.argv[5]}/${process.argv[6]}`, 80); await init(bridge, "bridge"); - await bridge.request("thread/resume", { threadId: "thread-inflight" }); - const maxId = 10; - const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["inflight.team.alice", "thread-inflight", String(maxId)].join("\0")).digest("hex")}`; - const params = { threadId: "thread-inflight", maxId, clientUserMessageId, dispatchMode: "start-or-reconcile", cwd: process.argv[7], runtimeWorkspaceRoots: [process.argv[7]] }; - try { await bridge.request("agmsg/wake/dispatch", params); } catch (_) {} - params.dispatchMode = "reconcile-only"; - let ambiguous = false; - try { await bridge.request("agmsg/wake/dispatch", params); } catch (error) { ambiguous = /still ambiguous/.test(error.message); } - if (!ambiguous) throw new Error("retry did not fail closed while the original turn was pending"); - await new Promise((resolve) => setTimeout(resolve, 250)); - const result = await bridge.request("agmsg/wake/dispatch", params); - if (result.status !== "reconciled") throw new Error(`accepted turn was not reconciled: ${JSON.stringify(result)}`); - bridge.stop(); desktop.stop(); -})().catch((error) => { console.error(error.stack || error); process.exit(1); }); -NODE - - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$project" - [ "$status" -eq 0 ] - [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] - [ "$(grep -c $'^thread/read\t' "$log")" -eq 3 ] -} - -@test "codex bridge rejects a symlink app-server endpoint file" { - local project endpoint - project="$TEST_SKILL_DIR/symlink-project" - mkdir -p "$project" - endpoint="$TEST_SKILL_DIR/private-app-server.endpoint" - printf 'ws://127.0.0.1:1/bridge/%s/%s\n' "$BRIDGE_TOKEN" "$ROLE_TOKEN" > "$endpoint" - chmod 600 "$endpoint" - ln -s "$endpoint" "$TEST_SKILL_DIR/app-server-link.endpoint" - - run node "$TYPES/codex/codex-bridge.js" \ - --project "$project" --app-server-file "$TEST_SKILL_DIR/app-server-link.endpoint" - [ "$status" -ne 0 ] - [[ "$output" == *"regular non-symlink file"* ]] -} - -@test "codex desktop relay isolates bridge process handles and routes exit only to the owner" { - local fake log port runner second_token - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-process.log" - second_token="$(printf 'c%.0s' {1..64})" - write_bridge_binding "$ROLE_TOKEN" first.team.alice thread-one team alice - write_bridge_binding "$second_token" second.team.bob thread-two team bob - start_relay "$fake" "$log" - port="$(cat "$TEST_SKILL_DIR/relay.port")" - runner="$TEST_SKILL_DIR/relay-process-owner-test.js" - cat > "$runner" <<'NODE' -"use strict"; -const { WebSocketAppServerClient } = require(process.argv[2]); -const crypto = require("crypto"); -const port = Number(process.argv[3]); -const desktopToken = process.argv[4]; -const bridgeSeed = process.argv[5]; -const firstToken = process.argv[6]; -const secondToken = process.argv[7]; -const project = process.argv[8]; -const watchOnce = process.argv[9]; -function wakeId(stateKey, threadId, maxId) { - return `agmsg-wake-v1-${crypto.createHash("sha256").update([stateKey, threadId, String(maxId)].join("\0")).digest("hex")}`; -} -function make(role, tokenOverride = "") { - const requestPath = role === "desktop" ? `/desktop/${desktopToken}` : `/bridge/${bridgeSeed}/${tokenOverride}`; - const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, - `ws://127.0.0.1:${port}/`, - { connectTimeoutMs: 2000, requestTimeoutMs: 2000, requestPath }, - ); - client.start(); - return client; -} -async function initialize(client, name) { - await client.ready(); - await client.request("initialize", { - clientInfo: { name, title: name, version: "1" }, - capabilities: { experimentalApi: true, requestAttestation: false, optOutNotificationMethods: [] }, - }); - client.notify("initialized"); -} -(async () => { - const desktop = make("desktop"); - await initialize(desktop, "desktop"); - const first = make("bridge", firstToken); - const second = make("bridge", secondToken); - await Promise.all([initialize(first, "bridge-one"), initialize(second, "bridge-two")]); - const duplicate = make("bridge", firstToken); - let duplicateRejected = false; - try { await duplicate.ready(); } catch (error) { duplicateRejected = /403 Forbidden/.test(error.message); } - duplicate.stop(); - let wrongThreadRejected = false; - try { - await first.request("thread/resume", { threadId: "thread-two" }); - } catch (error) { wrongThreadRejected = /exact thread/.test(error.message); } - let resumeOverrideRejected = false; - try { - await first.request("thread/resume", { threadId: "thread-one", history: [] }); - } catch (error) { resumeOverrideRejected = /exact thread/.test(error.message); } - await Promise.all([ - first.request("thread/resume", { threadId: "thread-one" }), - second.request("thread/resume", { threadId: "thread-two" }), - ]); - const seen = { - desktopOwner: false, - desktopOwnOutput: false, - desktopOwnExit: false, - firstOutput: "", - firstExit: "", - firstThreads: [], - secondUnexpectedProcess: false, - secondThreads: [], - }; - desktop.on("process/output", (params) => { - if (params.processHandle === "desktop-owned") seen.desktopOwnOutput = true; - else seen.desktopOwner = true; - }); - desktop.on("process/exited", (params) => { - if (params.processHandle === "desktop-owned") seen.desktopOwnExit = true; - else seen.desktopOwner = true; - }); - first.on("process/output", (params) => { seen.firstOutput = params.processHandle; }); - first.on("process/exited", (params) => { seen.firstExit = params.processHandle; }); - second.on("process/output", () => { seen.secondUnexpectedProcess = true; }); - second.on("process/exited", () => { seen.secondUnexpectedProcess = true; }); - first.on("turn/started", (params) => { seen.firstThreads.push(params.threadId); }); - second.on("turn/started", (params) => { seen.secondThreads.push(params.threadId); }); - await Promise.all([ - first.request("agmsg/wake/dispatch", { - threadId: "thread-one", maxId: 1, - clientUserMessageId: wakeId("first.team.alice", "thread-one", 1), - dispatchMode: "start-or-reconcile", - cwd: project, runtimeWorkspaceRoots: [project], - }), - second.request("agmsg/wake/dispatch", { - threadId: "thread-two", maxId: 1, - clientUserMessageId: wakeId("second.team.bob", "thread-two", 1), - dispatchMode: "start-or-reconcile", - cwd: project, runtimeWorkspaceRoots: [project], - }), - ]); - await first.request("process/spawn", { - command: [ - "/bin/bash", watchOnce, project, "codex", - "--team", "team", "--name", "alice", - "--owner", "agmsg-codex-bridge-123.123", "--claim", - "--timeout", "300", "--interval", "2", - ], - processHandle: "agmsg-watch-owned", - cwd: project, - outputBytesCap: 8192, - timeoutMs: 312000, - }); - let arbitraryCommandRejected = false; - try { - await first.request("process/spawn", { - command: ["/bin/echo", watchOnce, project, "codex", "--team", "team", "--name", "alice", "--owner", "agmsg-codex-bridge-1.1", "--claim", "--timeout", "300", "--interval", "2"], - processHandle: "agmsg-watch-arbitrary", cwd: project, outputBytesCap: 8192, timeoutMs: 312000, - }); - } catch (error) { arbitraryCommandRejected = /spawn only/.test(error.message); } - let spawnOverrideRejected = false; - try { - await first.request("process/spawn", { - command: ["/bin/bash", watchOnce, project, "codex", "--team", "team", "--name", "alice", "--owner", "agmsg-codex-bridge-2.2", "--claim", "--timeout", "300", "--interval", "2"], - processHandle: "agmsg-watch-env", cwd: project, outputBytesCap: 8192, timeoutMs: 312000, - env: { PATH: "/tmp" }, - }); - } catch (error) { spawnOverrideRejected = /spawn only/.test(error.message); } - let timeoutMismatchRejected = false; - try { - await first.request("process/spawn", { - command: ["/bin/bash", watchOnce, project, "codex", "--team", "team", "--name", "alice", "--owner", "agmsg-codex-bridge-3.3", "--claim", "--timeout", "300", "--interval", "2"], - processHandle: "agmsg-watch-timeout", cwd: project, outputBytesCap: 8192, timeoutMs: 311999, - }); - } catch (error) { timeoutMismatchRejected = /spawn only/.test(error.message); } - let killOverrideRejected = false; - try { - await first.request("process/kill", { processHandle: "agmsg-watch-owned", signal: "SIGKILL" }); - } catch (error) { killOverrideRejected = /kill only/.test(error.message); } - let rejected = false; - try { - await second.request("process/kill", { processHandle: "agmsg-watch-owned" }); - } catch (error) { - rejected = /does not own/.test(error.message); - } - await new Promise((resolve) => setTimeout(resolve, 120)); - await desktop.request("process/spawn", { - command: ["/bin/true"], - processHandle: "desktop-owned", - }); - await new Promise((resolve) => setTimeout(resolve, 120)); - if ( - !duplicateRejected - || !wrongThreadRejected - || !resumeOverrideRejected - || !arbitraryCommandRejected - || !spawnOverrideRejected - || !timeoutMismatchRejected - || !killOverrideRejected - || !rejected - || seen.firstOutput !== "agmsg-watch-owned" - || seen.firstExit !== "agmsg-watch-owned" - || seen.secondUnexpectedProcess - || seen.desktopOwner - || !seen.desktopOwnOutput - || !seen.desktopOwnExit - || seen.firstThreads.join(",") !== "thread-one" - || seen.secondThreads.join(",") !== "thread-two" - ) { - throw new Error(`process ownership failed: ${JSON.stringify({ duplicateRejected, wrongThreadRejected, resumeOverrideRejected, arbitraryCommandRejected, spawnOverrideRejected, timeoutMismatchRejected, killOverrideRejected, rejected, seen })}`); - } - desktop.stop(); first.stop(); second.stop(); - console.log("relay-process-owner-ok"); -})().catch((error) => { console.error(error.stack || error); process.exit(1); }); -NODE - - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ - "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$second_token" "$(cd "$TEST_SKILL_DIR" && pwd -P)" \ - "$(cd "$(dirname "$TYPES/codex/watch-once.sh")" && pwd -P)/watch-once.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"relay-process-owner-ok"* ]] - grep -Eq $'^process/spawn\t[0-9]+\tagmsg-relay-[0-9]+-agmsg-watch-owned\t$' "$log" -} - -@test "codex desktop relay sends server requests only to Desktop and fails them on disconnect" { - local fake log port runner - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-server-request.log" - write_bridge_binding "$ROLE_TOKEN" server.team.alice thread-server - start_relay "$fake" "$log" - port="$(cat "$TEST_SKILL_DIR/relay.port")" - runner="$TEST_SKILL_DIR/relay-server-request-test.js" - cat > "$runner" <<'NODE' -"use strict"; -const { WebSocketAppServerClient } = require(process.argv[2]); -const port = Number(process.argv[3]); -const desktopToken = process.argv[4]; -const bridgeSeed = process.argv[5]; -const roleToken = process.argv[6]; -function make(role) { - const requestPath = role === "desktop" ? `/desktop/${desktopToken}` : `/bridge/${bridgeSeed}/${roleToken}`; - const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, - "ws://127.0.0.1/", - { connectTimeoutMs: 2000, requestTimeoutMs: 2000, requestPath }, - ); - client.start(); - return client; -} -async function initialize(client, name) { - await client.ready(); - await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); - client.notify("initialized"); -} -(async () => { - const desktop = make("desktop"); - await initialize(desktop, "desktop"); - const bridge = make("bridge"); - await initialize(bridge, "bridge"); - let desktopSeen = false; - let bridgeSeen = false; - desktop.on("test/serverRequest", () => { - desktopSeen = true; - return new Promise(() => {}); - }); - bridge.on("test/serverRequest", () => { bridgeSeen = true; return {}; }); - await desktop.request("test/triggerServer", {}); - for (let index = 0; index < 50 && !desktopSeen; index += 1) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - if (!desktopSeen || bridgeSeen) throw new Error("server request was not isolated to Desktop"); - desktop.stop(); - await new Promise((resolve) => setTimeout(resolve, 100)); - bridge.stop(); - console.log("relay-server-request-disconnect-ok"); -})().catch((error) => { console.error(error.stack || error); process.exit(1); }); -NODE - - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" "$BRIDGE_TOKEN" "$ROLE_TOKEN" - [ "$status" -eq 0 ] - [[ "$output" == *"relay-server-request-disconnect-ok"* ]] - grep -Eq $'^response\tserver-disconnect\t\tVisible Codex Desktop client disconnected$' "$log" -} - -@test "codex desktop relay shutdown terminates the app-server process group" { - skip_on_windows "POSIX process groups are required" - local fake log child_pid_file child_pid - fake="$TEST_SKILL_DIR/fake-relay-process-tree" - child_pid_file="$TEST_SKILL_DIR/fake-relay-child.pid" - cat > "$fake" <<'NODE' -#!/usr/bin/env node -"use strict"; -const fs = require("fs"); -const { spawn } = require("child_process"); -const child = spawn(process.execPath, ["-e", "process.on('SIGTERM',()=>{}); setInterval(()=>{},1000)"], { stdio: "ignore" }); -fs.writeFileSync(process.env.AGMSG_FAKE_CHILD_PID, `${child.pid}\n`); -setInterval(() => {}, 1000); -NODE - chmod +x "$fake" - log="$TEST_SKILL_DIR/upstream-process-tree.log" - AGMSG_FAKE_CHILD_PID="$child_pid_file" \ - AGMSG_CODEX_DESKTOP_RELAY_SHUTDOWN_GRACE_MS=200 start_relay "$fake" "$log" - for _ in {1..100}; do [ -s "$child_pid_file" ] && break; sleep 0.05; done - [ -s "$child_pid_file" ] - child_pid="$(cat "$child_pid_file")" - kill -0 "$child_pid" - - kill "$RELAY_PID" - wait "$RELAY_PID" - RELAY_PID="" - for _ in {1..100}; do - kill -0 "$child_pid" 2>/dev/null || break - sleep 0.05 - done - run kill -0 "$child_pid" - [ "$status" -ne 0 ] -} - -@test "codex desktop relay port collision never starts an upstream process" { - local holder port_file holder_pid port fake marker - holder="$TEST_SKILL_DIR/relay-port-holder.js" - port_file="$TEST_SKILL_DIR/relay-held.port" - cat > "$holder" <<'NODE' -const fs = require("fs"); -const net = require("net"); -const server = net.createServer(() => {}); -server.listen(0, "127.0.0.1", () => fs.writeFileSync(process.argv[2], `${server.address().port}\n`)); -NODE - node "$holder" "$port_file" & - holder_pid=$! - EXTRA_PIDS="$holder_pid" - for _ in {1..100}; do [ -s "$port_file" ] && break; sleep 0.05; done - port="$(cat "$port_file")" - fake="$TEST_SKILL_DIR/fake-must-not-start" - marker="$TEST_SKILL_DIR/upstream-started" - cat > "$fake" <<'EOF' -#!/usr/bin/env bash -: > "$AGMSG_UPSTREAM_STARTED_MARKER" -sleep 60 -EOF - chmod +x "$fake" - - AGMSG_UPSTREAM_STARTED_MARKER="$marker" node "$TYPES/codex/codex-desktop-relay.js" \ - --codex "$fake" --host 127.0.0.1 --port "$port" \ - --desktop-token-file "$TEST_SKILL_DIR/desktop.token" \ - --bridge-token-file "$TEST_SKILL_DIR/bridge.token" \ - --health "$TEST_SKILL_DIR/collision.health" \ - --port-file "$TEST_SKILL_DIR/collision.port" \ - --pid-file "$TEST_SKILL_DIR/collision.pid" \ - >"$TEST_SKILL_DIR/collision.log" 2>&1 & - RELAY_PID=$! - local relay_status=0 - wait "$RELAY_PID" || relay_status=$? - [ "$relay_status" -ne 0 ] - RELAY_PID="" - [ ! -e "$marker" ] - [ ! -e "$TEST_SKILL_DIR/collision.pid" ] -} - -@test "codex desktop relay reaps a stubborn process group after the upstream leader crashes" { - skip_on_windows "POSIX process groups are required" - local fake log child_pid_file child_pid - fake="$TEST_SKILL_DIR/fake-relay-crash-tree" - child_pid_file="$TEST_SKILL_DIR/fake-relay-crash-child.pid" - cat > "$fake" <<'NODE' -#!/usr/bin/env node -"use strict"; -const fs = require("fs"); -const { spawn } = require("child_process"); -const child = spawn(process.execPath, ["-e", "process.on('SIGTERM',()=>{}); setInterval(()=>{},1000)"], { stdio: "ignore" }); -fs.writeFileSync(process.env.AGMSG_FAKE_CHILD_PID, `${child.pid}\n`); -setTimeout(() => process.exit(17), 50); -NODE - chmod +x "$fake" - log="$TEST_SKILL_DIR/upstream-crash-tree.log" - AGMSG_FAKE_CHILD_PID="$child_pid_file" \ - AGMSG_CODEX_DESKTOP_RELAY_SHUTDOWN_GRACE_MS=200 start_relay "$fake" "$log" - for _ in {1..100}; do [ -s "$child_pid_file" ] && break; sleep 0.05; done - [ -s "$child_pid_file" ] - child_pid="$(cat "$child_pid_file")" - local relay_status=0 - wait "$RELAY_PID" || relay_status=$? - [ "$relay_status" -ne 0 ] - RELAY_PID="" - for _ in {1..100}; do - kill -0 "$child_pid" 2>/dev/null || break - sleep 0.05 - done - run kill -0 "$child_pid" - [ "$status" -ne 0 ] -} - -@test "codex actas binds one exact visible thread through the authenticated relay" { - local fake log port project desktop_runner desktop_pid project_hash base bridge_pid token - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-actas.log" - AGMSG_FAKE_RELAY_PROCESS_EXIT=0 start_relay "$fake" "$log" - port="$(cat "$TEST_SKILL_DIR/relay.port")" - mkdir -p "$TEST_SKILL_DIR/run" - printf 'ws://127.0.0.1:%s/bridge/%s\n' "$port" "$BRIDGE_TOKEN" \ - > "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint" - chmod 600 "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint" - - project="$TEST_SKILL_DIR/actas-project" - mkdir -p "$project" - bash "$SCRIPTS/join.sh" team bob codex "$project" >/dev/null - bash "$SCRIPTS/delivery.sh" set monitor codex "$project" >/dev/null - cp "$TEST_SKILL_DIR/relay.health" "$TEST_SKILL_DIR/run/codex-desktop-relay.health" - run env CODEX_THREAD_ID=thread-visible-exact AGMSG_CODEX_BRIDGE_SUPERVISOR=direct \ - AGMSG_CODEX_ACTAS_READY_SECONDS=1 \ - bash "$TYPES/codex/actas-monitor.sh" "$project" codex bob thread-visible-exact - [ "$status" -eq 0 ] - [[ "$output" == *"status=visible_turn_only"* ]] - [[ "$output" == *"requested_mode=monitor"* ]] - [[ "$output" == *"effective_mode=turn"* ]] - run bash "$SCRIPTS/delivery.sh" status codex "$project" - [[ "$output" == *"mode: monitor"* ]] - - desktop_runner="$TEST_SKILL_DIR/relay-desktop-owner.js" - cat > "$desktop_runner" <<'NODE' -"use strict"; -const { WebSocketAppServerClient } = require(process.argv[2]); -const port = Number(process.argv[3]); -const token = process.argv[4]; -const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, - "ws://127.0.0.1/", - { connectTimeoutMs: 2000, requestTimeoutMs: 2000, requestPath: `/desktop/${token}` }, -); -client.start(); -(async () => { - await client.ready(); - await client.request("initialize", { clientInfo: { name: "desktop", title: "desktop", version: "1" }, capabilities: {} }); - client.notify("initialized"); - console.log("desktop-ready"); - setInterval(() => {}, 1000); -})().catch((error) => { console.error(error.stack || error); process.exit(1); }); -NODE - node "$desktop_runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ - > "$TEST_SKILL_DIR/desktop-owner.log" 2>&1 & - desktop_pid=$! - EXTRA_PIDS="$EXTRA_PIDS $desktop_pid" - for _ in {1..100}; do grep -q desktop-ready "$TEST_SKILL_DIR/desktop-owner.log" 2>/dev/null && break; sleep 0.05; done - grep -q desktop-ready "$TEST_SKILL_DIR/desktop-owner.log" - for _ in {1..100}; do grep -qx status=ready "$TEST_SKILL_DIR/relay.health" 2>/dev/null && break; sleep 0.05; done - cp "$TEST_SKILL_DIR/relay.health" "$TEST_SKILL_DIR/run/codex-desktop-relay.health" - - run env CODEX_THREAD_ID=thread-visible-exact AGMSG_CODEX_BRIDGE_SUPERVISOR=direct \ - AGMSG_CODEX_ACTAS_READY_SECONDS=3 \ - bash "$SCRIPTS/session-start.sh" codex "$project" " "$TEST_SKILL_DIR/run/codex-actas-restore.log" - ! grep -q "$BRIDGE_TOKEN" "$TEST_SKILL_DIR/run/codex-actas-restore.log" - - project_hash="$(printf '%s' "$(cd "$project" && pwd)" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" - base="$TEST_SKILL_DIR/run/codex-bridge.$project_hash.team.bob" - bridge_pid="$(cat "$base.pid")" - EXTRA_PIDS="$EXTRA_PIDS $bridge_pid" - kill -0 "$bridge_pid" - grep -qx 'thread=thread-visible-exact' "$base.meta" - grep -qx 'thread=thread-visible-exact' "$base.health" - grep -qx "app_server=ws://127.0.0.1:$port/" "$base.meta" - [ "$(stat -f '%Lp' "$base.appserver")" = "600" ] - token="$(cat "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint")" - ! grep -Fq "$token" "$base.meta" "$base.health" "$base.log" - grep -q $'^thread/resume\t' "$log" - ! grep -q $'^thread/start\t' "$log" - - sed -i.bak 's/^status=ready$/status=paused_ambiguous_wake/' "$base.health" - rm -f "$base.health.bak" - printf '%s\n' '{"durable":"sentinel"}' >"$base.wake.sentinel.json" - printf '%s\n' '{"relay":"sentinel"}' >"$base.relay-wake.json" - run env AGMSG_CODEX_ACTAS_THREAD=thread-visible-exact AGMSG_CODEX_BRIDGE_SUPERVISOR=direct \ - bash "$TYPES/codex/actas-monitor.sh" "$project" codex bob thread-visible-exact - [ "$status" -eq 0 ] - [[ "$output" == *"status=ok"* ]] - [[ "$output" == *"health=ready"* ]] - [ "$(cat "$base.pid")" != "$bridge_pid" ] - [ ! -f "$base.wake.sentinel.json" ] - [ ! -f "$base.relay-wake.json" ] - bridge_pid="$(cat "$base.pid")" - - bash "$SCRIPTS/delivery.sh" set turn codex "$project" >/dev/null - run kill -0 "$bridge_pid" - [ "$status" -ne 0 ] - kill -0 "$RELAY_PID" -} - -@test "codex desktop relay restart never blindly replays a durable dispatch" { - local fake log port runner project relay_status=0 - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-durable-restart.log" - project="$(cd "$TEST_SKILL_DIR" && pwd -P)" - write_bridge_binding "$ROLE_TOKEN" durable.team.alice thread-durable team alice "$project" - AGMSG_FAKE_RELAY_DELAY_TURN_MS=5000 start_relay "$fake" "$log" - port="$(cat "$TEST_SKILL_DIR/relay.port")" - runner="$TEST_SKILL_DIR/durable-restart-client.js" - cat >"$runner" <<'NODE' -const crypto = require("crypto"); -const { WebSocketAppServerClient } = require(process.argv[2]); -const port = Number(process.argv[3]); -const phase = process.argv[7]; -function make(path) { - const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, "durable-restart", - { requestPath: path, connectTimeoutMs: 2000, requestTimeoutMs: 800 }, - ); - client.start(); - return client; -} -async function init(client, name) { - await client.ready(); - await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); - client.notify("initialized"); -} -(async () => { - const desktop = make(`/desktop/${process.argv[4]}`); - await init(desktop, "desktop"); - const bridge = make(`/bridge/${process.argv[5]}/${process.argv[6]}`); - await init(bridge, "bridge"); - await bridge.request("thread/resume", { threadId: "thread-durable" }); - const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["durable.team.alice", "thread-durable", "41"].join("\0")).digest("hex")}`; - try { - await bridge.request("agmsg/wake/dispatch", { - threadId: "thread-durable", maxId: 41, clientUserMessageId, - dispatchMode: "start-or-reconcile", cwd: process.argv[8], runtimeWorkspaceRoots: [process.argv[8]], - }); - throw new Error(`${phase} unexpectedly accepted`); - } catch (error) { - const expected = phase === "first" ? /timed out/ : /still ambiguous/; - if (!expected.test(error.message)) throw error; - } finally { - bridge.stop(); desktop.stop(); - } -})().catch((error) => { console.error(error.stack || error); process.exit(1); }); -NODE - - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ - "$BRIDGE_TOKEN" "$ROLE_TOKEN" first "$project" - [ "$status" -eq 0 ] - [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] - [ -s "$TEST_SKILL_DIR/run/codex-bridge.durable.team.alice.relay-wake.json" ] - - kill "$RELAY_PID" - wait "$RELAY_PID" || relay_status=$? - [ "$relay_status" -eq 0 ] - RELAY_PID="" - rm -f "$TEST_SKILL_DIR/relay.port" - - start_relay "$fake" "$log" - port="$(cat "$TEST_SKILL_DIR/relay.port")" - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ - "$BRIDGE_TOKEN" "$ROLE_TOKEN" second "$project" - [ "$status" -eq 0 ] - [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] -} - -@test "codex desktop relay clears read-stage inflight state when a bridge socket closes" { - local fake log port runner project - fake="$(make_fake_app_server)" - log="$TEST_SKILL_DIR/upstream-read-disconnect.log" - project="$(cd "$TEST_SKILL_DIR" && pwd -P)" - write_bridge_binding "$ROLE_TOKEN" disconnect.team.alice thread-disconnect team alice "$project" - AGMSG_FAKE_RELAY_DELAY_READ_MS=400 start_relay "$fake" "$log" - port="$(cat "$TEST_SKILL_DIR/relay.port")" - runner="$TEST_SKILL_DIR/read-disconnect-client.js" - cat >"$runner" <<'NODE' -const crypto = require("crypto"); -const { WebSocketAppServerClient } = require(process.argv[2]); -const port = Number(process.argv[3]); -function make(path) { - const client = new WebSocketAppServerClient( - { host: "127.0.0.1", port }, "read-disconnect", - { requestPath: path, connectTimeoutMs: 2000, requestTimeoutMs: 3000 }, - ); - client.start(); - return client; -} -async function init(client, name) { - await client.ready(); - await client.request("initialize", { clientInfo: { name, title: name, version: "1" }, capabilities: {} }); - client.notify("initialized"); -} -(async () => { - const desktop = make(`/desktop/${process.argv[4]}`); - await init(desktop, "desktop"); - const clientUserMessageId = `agmsg-wake-v1-${crypto.createHash("sha256").update(["disconnect.team.alice", "thread-disconnect", "7"].join("\0")).digest("hex")}`; - const first = make(`/bridge/${process.argv[5]}/${process.argv[6]}`); - await init(first, "bridge-one"); - await first.request("thread/resume", { threadId: "thread-disconnect" }); - first.request("agmsg/wake/dispatch", { - threadId: "thread-disconnect", maxId: 7, clientUserMessageId, - dispatchMode: "start-or-reconcile", cwd: process.argv[7], runtimeWorkspaceRoots: [process.argv[7]], - }).catch(() => {}); - await new Promise((resolve) => setTimeout(resolve, 50)); - first.stop(); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const second = make(`/bridge/${process.argv[5]}/${process.argv[6]}`); - await init(second, "bridge-two"); - await second.request("thread/resume", { threadId: "thread-disconnect" }); - const result = await second.request("agmsg/wake/dispatch", { - threadId: "thread-disconnect", maxId: 7, clientUserMessageId, - dispatchMode: "start-or-reconcile", cwd: process.argv[7], runtimeWorkspaceRoots: [process.argv[7]], - }); - if (result.status !== "accepted") throw new Error(`wake was not retried safely: ${JSON.stringify(result)}`); - second.stop(); desktop.stop(); -})().catch((error) => { console.error(error.stack || error); process.exit(1); }); -NODE - - run node "$runner" "$TYPES/codex/codex-bridge.js" "$port" "$DESKTOP_TOKEN" \ - "$BRIDGE_TOKEN" "$ROLE_TOKEN" "$project" - [ "$status" -eq 0 ] - [ "$(grep -c $'^turn/start\t' "$log")" -eq 1 ] -} - -@test "codex desktop relay exits nonzero and reaps app-server when its runner disappears" { - skip_on_windows "POSIX parent liveness and process groups are required" - local fake parent_pid upstream_pid relay_status=0 - fake="$(make_fake_app_server)" - sleep 60 & - parent_pid=$! - EXTRA_PIDS="$EXTRA_PIDS $parent_pid" - AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="$TEST_SKILL_DIR/run" \ - node "$TYPES/codex/codex-desktop-relay.js" \ - --codex "$fake" --host 127.0.0.1 --port 0 \ - --desktop-token-file "$TEST_SKILL_DIR/desktop.token" \ - --bridge-token-file "$TEST_SKILL_DIR/bridge.token" \ - --health "$TEST_SKILL_DIR/parent.health" \ - --port-file "$TEST_SKILL_DIR/parent.port" \ - --pid-file "$TEST_SKILL_DIR/parent.pid" \ - --parent-pid "$parent_pid" >"$TEST_SKILL_DIR/parent-relay.log" 2>&1 & - RELAY_PID=$! - for _ in {1..100}; do [ -s "$TEST_SKILL_DIR/parent.health" ] && break; sleep 0.05; done - upstream_pid="$(sed -n 's/^upstream_pid=//p' "$TEST_SKILL_DIR/parent.health" | head -1)" - [ -n "$upstream_pid" ] - kill "$parent_pid" - wait "$parent_pid" 2>/dev/null || true - wait "$RELAY_PID" || relay_status=$? - [ "$relay_status" -ne 0 ] - RELAY_PID="" - for _ in {1..100}; do kill -0 "$upstream_pid" 2>/dev/null || break; sleep 0.05; done - run kill -0 "$upstream_pid" - [ "$status" -ne 0 ] -} - -@test "codex desktop relay rejects non-loopback listeners" { - run node "$TYPES/codex/codex-desktop-relay.js" --host 0.0.0.0 --port 0 --codex /bin/false - [ "$status" -ne 0 ] - [[ "$output" == *"--host must be loopback"* ]] -} diff --git a/tests/test_codex_desktop_relayctl.bats b/tests/test_codex_desktop_relayctl.bats deleted file mode 100644 index bdd4d329..00000000 --- a/tests/test_codex_desktop_relayctl.bats +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env bats - -bats_require_minimum_version 1.5.0 - -load test_helper - -setup() { - setup_test_env - export RELAY_RUN="$TEST_SKILL_DIR/relay-runtime" - export RELAY_PLISTS="$TEST_SKILL_DIR/LaunchAgents" - export FAKE_LAUNCHCTL_DIR="$TEST_SKILL_DIR/fake-launchctl-state" - export FAKE_RELAY_RUNNER="$TYPES/codex/codex-desktop-relay-run.sh" - export AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="$RELAY_RUN" - export AGMSG_CODEX_DESKTOP_RELAY_PLIST_DIR="$RELAY_PLISTS" - export AGMSG_CODEX_DESKTOP_RELAY_PORT="$((51000 + RANDOM % 1000))" - mkdir -p "$RELAY_RUN" "$RELAY_PLISTS" "$FAKE_LAUNCHCTL_DIR" "$TEST_SKILL_DIR/bin" - - local fake_codex="$TEST_SKILL_DIR/fake-relayctl-codex" - cat > "$fake_codex" <<'NODE' -#!/usr/bin/env node -"use strict"; -const readline = require("readline"); -readline.createInterface({ input: process.stdin }); -NODE - chmod +x "$fake_codex" - export AGMSG_CODEX_DESKTOP_RELAY_CODEX="$fake_codex" - - cat > "$TEST_SKILL_DIR/bin/uname" <<'SH' -#!/usr/bin/env bash -if [ "${1:-}" = "-s" ]; then echo Darwin; else /usr/bin/uname "$@"; fi -SH - cat > "$TEST_SKILL_DIR/bin/launchctl" <<'SH' -#!/usr/bin/env bash -set -euo pipefail -state="${FAKE_LAUNCHCTL_DIR:?}" -case "${1:-}" in - getenv) - cat "$state/env" 2>/dev/null || true - ;; - setenv) - printf '%s\n' "${3:-}" > "$state/env" - ;; - unsetenv) - rm -f "$state/env" - ;; - print) - pid="$(cat "$state/job.pid" 2>/dev/null || true)" - [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null - ;; - bootout) - pid="$(cat "$state/job.pid" 2>/dev/null || true)" - if [ -n "$pid" ]; then - kill "$pid" 2>/dev/null || true - for _ in $(seq 1 100); do kill -0 "$pid" 2>/dev/null || break; sleep 0.02; done - fi - rm -f "$state/job.pid" - ;; - bootstrap) - cp "${3:?plist}" "$state/bootstrap.plist" - [ "${FAKE_LAUNCHCTL_BOOTSTRAP_FAIL:-0}" != "1" ] || exit 19 - nohup "$FAKE_RELAY_RUNNER" > "$state/relay.log" 2>&1 & - printf '%s\n' "$!" > "$state/job.pid" - ;; - kickstart) - ;; - *) - echo "unexpected fake launchctl action: ${1:-}" >&2 - exit 2 - ;; -esac -SH - chmod +x "$TEST_SKILL_DIR/bin/uname" "$TEST_SKILL_DIR/bin/launchctl" - export PATH="$TEST_SKILL_DIR/bin:$PATH" -} - -teardown() { - launchctl bootout "gui/$(id -u)/com.agmsg.codex-desktop-relay" >/dev/null 2>&1 || true - teardown_test_env -} - -@test "relay runner stops cleanly after one permanent configuration failure" { - local run="$TEST_SKILL_DIR/runner-terminal" - mkdir -p "$run" - printf 'short\n' >"$run/codex-desktop-relay.desktop-token" - printf '%064d\n' 0 | tr '0' 'b' >"$run/codex-desktop-relay.bridge-token" - chmod 600 "$run"/*-token - - AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="$run" AGMSG_CODEX_DESKTOP_RELAY_PORT=0 \ - run bash "$TYPES/codex/codex-desktop-relay-run.sh" - - [ "$status" -eq 0 ] - [ "$(grep -c 'desktop capability must be 32 random bytes' <<<"$output")" -eq 1 ] -} - -@test "relay runner retries transient app-server crashes with delay" { - local run="$TEST_SKILL_DIR/runner-transient" fake="$TEST_SKILL_DIR/fake-crashing-codex" calls="$TEST_SKILL_DIR/crash-calls" - mkdir -p "$run" - printf '%064d\n' 0 | tr '0' 'a' >"$run/codex-desktop-relay.desktop-token" - printf '%064d\n' 0 | tr '0' 'b' >"$run/codex-desktop-relay.bridge-token" - chmod 600 "$run"/*-token - cat >"$fake" <>"$calls" -exit 1 -EOF - chmod +x "$fake" - - AGMSG_CODEX_DESKTOP_RELAY_RUN_DIR="$run" AGMSG_CODEX_DESKTOP_RELAY_PORT=0 \ - AGMSG_CODEX_DESKTOP_RELAY_CODEX="$fake" \ - bash "$TYPES/codex/codex-desktop-relay-run.sh" >"$TEST_SKILL_DIR/runner-transient.log" 2>&1 & - local pid=$! - for _ in {1..150}; do - [ "$(wc -l <"$calls" 2>/dev/null || echo 0)" -ge 2 ] && break - sleep 0.02 - done - [ "$(wc -l <"$calls")" -ge 2 ] - kill -TERM "$pid" - wait "$pid" 2>/dev/null || true -} - -@test "relayctl rotates exposed tokens, reuses private tokens, and redacts every durable surface" { - local desktop_token bridge_token output_text - printf '%064d\n' 0 | tr '0' 'a' > "$RELAY_RUN/codex-desktop-relay.desktop-token" - printf '%064d\n' 0 | tr '0' 'b' > "$RELAY_RUN/codex-desktop-relay.bridge-token" - chmod 644 "$RELAY_RUN/codex-desktop-relay.desktop-token" - chmod 600 "$RELAY_RUN/codex-desktop-relay.bridge-token" - - run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable - [ "$status" -eq 0 ] - [[ "$output" == *"app_server=ws://127.0.0.1:"*"/"* ]] - desktop_token="$(cat "$RELAY_RUN/codex-desktop-relay.desktop-token")" - bridge_token="$(cat "$RELAY_RUN/codex-desktop-relay.bridge-token")" - [ "$desktop_token" != "$(printf '%064d' 0 | tr '0' 'a')" ] - [ "$bridge_token" = "$(printf '%064d' 0 | tr '0' 'b')" ] - [ "$(stat -f '%Lp' "$RELAY_RUN/codex-desktop-relay.desktop-token")" = "600" ] - [ "$(stat -f '%Lp' "$RELAY_RUN/codex-desktop-relay.bridge-token")" = "600" ] - - output_text="$(bash "$TYPES/codex/codex-desktop-relayctl.sh" status)" - [[ "$output_text" != *"$desktop_token"* ]] - [[ "$output_text" != *"$bridge_token"* ]] - ! grep -q "$desktop_token\|$bridge_token" \ - "$FAKE_LAUNCHCTL_DIR/bootstrap.plist" "$RELAY_RUN/codex-desktop-relay.health" \ - "$FAKE_LAUNCHCTL_DIR/relay.log" - grep -A3 -q 'KeepAlive.*' "$FAKE_LAUNCHCTL_DIR/bootstrap.plist" - grep -A2 'KeepAlive' "$FAKE_LAUNCHCTL_DIR/bootstrap.plist" \ - | grep -q 'SuccessfulExit' - - # A second enable must retain capabilities that have remained private. - run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable - [ "$status" -eq 0 ] - [ "$(cat "$RELAY_RUN/codex-desktop-relay.desktop-token")" = "$desktop_token" ] - [ "$(cat "$RELAY_RUN/codex-desktop-relay.bridge-token")" = "$bridge_token" ] -} - -@test "relayctl bootstrap failure rolls back env, plist, runtime, and private endpoints" { - printf '%s\n' 'ws://127.0.0.1:49999/desktop/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ - > "$FAKE_LAUNCHCTL_DIR/env" - export FAKE_LAUNCHCTL_BOOTSTRAP_FAIL=1 - - run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable - [ "$status" -ne 0 ] - [ "$(cat "$FAKE_LAUNCHCTL_DIR/env")" = 'ws://127.0.0.1:49999/desktop/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ] - [ ! -e "$RELAY_PLISTS/com.agmsg.codex-desktop-relay.plist" ] - [ ! -e "$RELAY_RUN/codex-desktop-relay.desktop-token" ] - [ ! -e "$RELAY_RUN/codex-desktop-relay.bridge-token" ] - [ ! -e "$RELAY_RUN/codex-desktop-relay.desktop-endpoint" ] - [ ! -e "$RELAY_RUN/codex-desktop-relay.bridge-endpoint" ] - [ ! -e "$RELAY_RUN/codex-desktop-relay.pid" ] -} - -@test "relayctl disable removes its stale Desktop env even when the endpoint file is missing" { - local owned="ws://127.0.0.1:$AGMSG_CODEX_DESKTOP_RELAY_PORT/desktop/$(printf '%064d' 0 | tr '0' 'c')" - printf '%s\n' "$owned" > "$FAKE_LAUNCHCTL_DIR/env" - - run bash "$TYPES/codex/codex-desktop-relayctl.sh" disable - [ "$status" -eq 0 ] - [ ! -e "$FAKE_LAUNCHCTL_DIR/env" ] - [[ "$output" == *"/"* ]] - [[ "$output" != *"$(printf '%064d' 0 | tr '0' 'c')"* ]] -} - -@test "relayctl disable restores the Desktop endpoint that existed before enable" { - local prior='ws://127.0.0.1:49998/desktop/original-endpoint' - printf '%s\n' "$prior" > "$FAKE_LAUNCHCTL_DIR/env" - - run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable - [ "$status" -eq 0 ] - [ "$(cat "$RELAY_RUN/codex-desktop-relay.prior-desktop-endpoint")" = "$prior" ] - [ "$(stat -f '%Lp' "$RELAY_RUN/codex-desktop-relay.prior-desktop-endpoint")" = "600" ] - - run bash "$TYPES/codex/codex-desktop-relayctl.sh" disable - [ "$status" -eq 0 ] - [ "$(cat "$FAKE_LAUNCHCTL_DIR/env")" = "$prior" ] - [ ! -e "$RELAY_RUN/codex-desktop-relay.prior-desktop-endpoint" ] -} - -@test "relayctl rejects a port owned by another listener before partial installation" { - local holder="$TEST_SKILL_DIR/ctl-port-holder.js" port_file="$TEST_SKILL_DIR/ctl-port" holder_pid - cat > "$holder" <<'NODE' -const fs = require("fs"); -const net = require("net"); -const server = net.createServer(() => {}); -server.listen(0, "127.0.0.1", () => fs.writeFileSync(process.argv[2], `${server.address().port}\n`)); -NODE - node "$holder" "$port_file" & - holder_pid=$! - for _ in {1..100}; do [ -s "$port_file" ] && break; sleep 0.05; done - export AGMSG_CODEX_DESKTOP_RELAY_PORT="$(cat "$port_file")" - printf '%s\n' "$holder_pid" > "$RELAY_RUN/codex-desktop-relay.pid" - - run bash "$TYPES/codex/codex-desktop-relayctl.sh" enable - kill "$holder_pid" 2>/dev/null || true - wait "$holder_pid" 2>/dev/null || true - [ "$status" -ne 0 ] - [[ "$output" == *"owned by another listener"* ]] - [ ! -e "$RELAY_PLISTS/com.agmsg.codex-desktop-relay.plist" ] - [ ! -e "$RELAY_RUN/codex-desktop-relay.desktop-token" ] -} - -@test "relayctl status rejects a recycled pid with forged health" { - sleep 60 & - local unrelated_pid=$! - printf '%s\n' "$unrelated_pid" > "$RELAY_RUN/codex-desktop-relay.pid" - printf '%s\n' "$AGMSG_CODEX_DESKTOP_RELAY_PORT" > "$RELAY_RUN/codex-desktop-relay.port" - cat > "$RELAY_RUN/codex-desktop-relay.health" </dev/null || true - wait "$unrelated_pid" 2>/dev/null || true - [ "$status" -ne 0 ] - [[ "$output" == *"status=not_running"* ]] -} diff --git a/tests/test_codex_monitor_lease.bats b/tests/test_codex_monitor_lease.bats index 4d0bd15d..27f68b68 100644 --- a/tests/test_codex_monitor_lease.bats +++ b/tests/test_codex_monitor_lease.bats @@ -29,18 +29,6 @@ arm() { [[ "$output" == *"status=active"* ]] } -@test "enabling the Desktop monitor disarms a legacy scheduled lease" { - arm >/dev/null - - run bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" - [ "$status" -eq 0 ] - [[ "$output" == *"Disabled legacy Codex heartbeat/watchdog lease state"* ]] || return 1 - - run bash "$LEASE" heartbeat "$TEST_PROJECT" team alice thread-123 --ttl 60 - [ "$status" -eq 3 ] - [[ "$output" == *"status=inactive"* ]] || return 1 -} - @test "heartbeat renews an active lease and fallback stays dormant" { arm >/dev/null diff --git a/tests/test_codex_shim.bats b/tests/test_codex_shim.bats index e861bf6e..ce1eb336 100644 --- a/tests/test_codex_shim.bats +++ b/tests/test_codex_shim.bats @@ -35,21 +35,11 @@ teardown() { teardown_test_env } -@test "codex shim: monitor project passes through by default" { +@test "codex shim: monitor project routes resume through codex-monitor" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$TYPES/codex/codex-shim.sh" resume --last' - [ "$status" -eq 0 ] - grep -q "real-codex <--last>" "$CALL_LOG" - ! grep -q "^monitor" "$CALL_LOG" -} - -@test "codex shim: explicit legacy opt-in routes resume through codex-monitor" { - bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 bash "$TYPES/codex/codex-shim.sh" resume --last' - [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> <--last>" "$CALL_LOG" } @@ -57,7 +47,7 @@ teardown() { @test "codex shim: monitor project routes prompt launches through top-level codex" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 bash "$TYPES/codex/codex-shim.sh" "fix this"' + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$TYPES/codex/codex-shim.sh" "fix this"' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> " "$CALL_LOG" @@ -89,7 +79,6 @@ teardown() { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 \ run bash "$TYPES/codex/codex-shim.sh" --cd "$TEST_PROJECT" resume [ "$status" -eq 0 ] @@ -105,7 +94,6 @@ teardown() { [ "$status" -eq 0 ] [[ "$output" == *"codex() {"* ]] [[ "$output" == *"codex-shim.sh"* ]] - [[ "$output" == *"pass-through by default"* ]] [ ! -e "$HOME/.agents/bin/codex" ] } @@ -122,7 +110,6 @@ teardown() { chmod +x "$real_bin/codex" PATH="$HOME/.agents/bin:$real_bin:$PATH" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 \ run bash -c 'eval "$("$TYPES/codex/codex-shim-install.sh" function)"; cd "$TEST_PROJECT"; codex resume --last' [ "$status" -eq 0 ] @@ -138,7 +125,6 @@ teardown() { chmod +x "$HOME/.agents/bin/codex" PATH="$HOME/.agents/bin:$PATH" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 \ run bash -c 'eval "$("$TYPES/codex/codex-shim-install.sh" function)"; cd "$TEST_PROJECT"; codex resume --last' [ "$status" -eq 0 ] @@ -152,8 +138,7 @@ teardown() { bash "$TYPES/codex/codex-shim-install.sh" install >/dev/null [ -x "$HOME/.agents/bin/codex" ] - PATH="$HOME/.agents/bin:$PATH" AGMSG_CODEX_LEGACY_MONITOR_SHIM=1 \ - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" codex resume' + PATH="$HOME/.agents/bin:$PATH" run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" codex resume' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <-->" "$CALL_LOG" diff --git a/tests/test_codex_visible_monitor.bats b/tests/test_codex_visible_monitor.bats index 83fbea33..80b23221 100644 --- a/tests/test_codex_visible_monitor.bats +++ b/tests/test_codex_visible_monitor.bats @@ -33,22 +33,6 @@ join_codex_roles() { bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null } -install_fake_launchctl() { - local fakebin="$TEST_SKILL_DIR/fakebin" - export AGMSG_FAKE_LAUNCHCTL_LOG="$TEST_SKILL_DIR/fake-launchctl.log" - mkdir -p "$fakebin" - cat > "$fakebin/launchctl" <<'EOF' -#!/usr/bin/env bash -printf '%s\n' "$*" >> "$AGMSG_FAKE_LAUNCHCTL_LOG" -case "${1:-}" in - print) exit 1 ;; - *) exit 0 ;; -esac -EOF - chmod +x "$fakebin/launchctl" - export PATH="$fakebin:$PATH" -} - @test "codex actas in turn mode persists the selected role without a background receiver" { join_codex_roles bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null @@ -59,7 +43,7 @@ EOF [[ "$output" == *"status=visible_turn_only"* ]] [[ "$output" == *"name=bob"* ]] - local state="$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" + local state="$TEST_SKILL_DIR/run/codex-last-actas.$(project_hash).tsv" [ -f "$state" ] awk -F '\t' '$2 == "codex" && $3 == "team" && $4 == "bob" { found=1 } END { exit !found }' "$state" @@ -69,300 +53,34 @@ EOF run ! grep -q "session-start.sh" "$hooks" } -@test "codex actas resolves an allowed role name containing spaces" { - local name='codex receiver' - bash "$SCRIPTS/join.sh" team "$name" codex "$TEST_PROJECT" >/dev/null - bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null - - run env CODEX_THREAD_ID=thread-spaced \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex "$name" thread-spaced - - [ "$status" -eq 0 ] - [[ "$output" == *"status=visible_turn_only"* ]] - [[ "$output" == *"name=$name"* ]] -} - -@test "codex SessionStart rebinds only the same exact task and cannot move the role" { +@test "codex SessionStart rebinds the last actas role after restart" { join_codex_roles bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null env CODEX_THREAD_ID=thread-before \ bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-before >/dev/null - rm -f "$TEST_SKILL_DIR/run/codex-chat-visible.$(project_hash).team.bob.meta" + rm -f "$TEST_SKILL_DIR/run/codex-chat-visible.team.bob.meta" run env CODEX_THREAD_ID=thread-after \ bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" /dev/null - - env CODEX_THREAD_ID=thread-owner \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-owner >/dev/null - run env CODEX_THREAD_ID=thread-thief \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-thief - - [ "$status" -eq 5 ] - [[ "$output" == *"status=role_held"* ]] - [[ "$output" == *"owner_thread=thread-owner"* ]] - awk -F '\t' '$5 == "thread-owner" { found=1 } END { exit !found }' \ - "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" - awk -F '\t' '$5 == "thread-owner" { found=1 } END { exit !found }' \ - "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" - grep -q '^codex-seat:' "$TEST_SKILL_DIR/run/actas.team__bob.session" -} - -@test "codex actas refuses a role held by a live generic actas owner" { - join_codex_roles - bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null - setup_live_owner "$TEST_SKILL_DIR/run" "generic-owner" - printf 'generic-owner\n' > "$TEST_SKILL_DIR/run/actas.team__bob.session" - - run env CODEX_THREAD_ID=thread-codex \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-codex - - [ "$status" -eq 5 ] - [[ "$output" == *"status=role_held"* ]] - [ ! -e "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" ] - [ "$(cat "$TEST_SKILL_DIR/run/actas.team__bob.session")" = "generic-owner" ] -} - -@test "codex seats allow different roles in the same project to stay bound to different tasks" { - join_codex_roles - bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null - - env CODEX_THREAD_ID=thread-bob \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-bob >/dev/null - env CODEX_THREAD_ID=thread-alice \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex alice thread-alice >/dev/null - - awk -F '\t' '$4 == "bob" && $5 == "thread-bob" { found=1 } END { exit !found }' \ - "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" - awk -F '\t' '$4 == "alice" && $5 == "thread-alice" { found=1 } END { exit !found }' \ - "$TEST_SKILL_DIR/run/codex-seat.team.alice.tsv" - [ -e "$TEST_SKILL_DIR/run/codex-chat-visible.$(project_hash).team.bob.meta" ] - [ -e "$TEST_SKILL_DIR/run/codex-chat-visible.$(project_hash).team.alice.meta" ] -} - -@test "codex global role seat refuses the same team role from another project" { - join_codex_roles - bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null - local other="$TEST_SKILL_DIR/other-seat-project" - mkdir -p "$other" - bash "$SCRIPTS/join.sh" team bob codex "$other" >/dev/null - bash "$SCRIPTS/delivery.sh" set turn codex "$other" >/dev/null - env CODEX_THREAD_ID=thread-owner \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-owner >/dev/null - - run env CODEX_THREAD_ID=thread-other \ - bash "$TYPES/codex/actas-monitor.sh" "$other" codex bob thread-other - - [ "$status" -eq 5 ] - [[ "$output" == *"owner_thread=thread-owner"* ]] -} - -@test "codex fallback boots out the exact meta-less launchd job without changing monitor mode" { - join_codex_roles - bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - install_fake_launchctl - local state_key="$(project_hash).team.bob" - local base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" - mkdir -p "$TEST_SKILL_DIR/run" - : > "$base.plist" - - run env CODEX_THREAD_ID=thread-123 \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-123 - - [ "$status" -eq 0 ] - [[ "$output" == *"effective_mode=turn"* ]] - grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$state_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" - [ ! -e "$base.plist" ] - run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" - [[ "$output" == *"mode: monitor"* ]] -} - -@test "codex fallback ignores a forged launch label and unrelated recycled pid" { - skip_on_windows "process liveness assertion uses POSIX kill semantics" - join_codex_roles - bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null - install_fake_launchctl - local state_key="$(project_hash).team.bob" - local base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" unrelated_pid - mkdir -p "$TEST_SKILL_DIR/run" - sleep 60 & - unrelated_pid=$! - TEST_RECEIVER_PIDS="$TEST_RECEIVER_PIDS $unrelated_pid" - printf '%s\n' "$unrelated_pid" > "$base.pid" - cat > "$base.meta" < "$base.plist" <<'EOF' -Labelcom.example.other-unrelated-job -EOF - - run env CODEX_THREAD_ID=thread-forged \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-forged - - [ "$status" -eq 0 ] - kill -0 "$unrelated_pid" 2>/dev/null - ! grep -q 'com.example' "$AGMSG_FAKE_LAUNCHCTL_LOG" - grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$state_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" - [ ! -e "$base.pid" ] - [ ! -e "$base.meta" ] - [ ! -e "$base.plist" ] -} - -@test "codex SessionEnd releases the exact lease and meta-less launchd job" { - join_codex_roles - bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null - install_fake_launchctl - env CODEX_THREAD_ID=thread-ending \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-ending >/dev/null - local state_key="$(project_hash).team.bob" - local base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" - : > "$base.plist" - rm -f "$base.meta" "$base.pid" - - printf '%s\n' '{"session_id":"thread-ending"}' | \ - bash "$SCRIPTS/session-end.sh" codex "$TEST_PROJECT" - - grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$state_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" - [ ! -e "$base.plist" ] - [ ! -e "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" ] - [ ! -e "$TEST_SKILL_DIR/run/actas.team__bob.session" ] -} - -@test "codex reset removes only the target role's meta-less launchd job and lease" { - join_codex_roles - bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null - install_fake_launchctl - env CODEX_THREAD_ID=thread-drop \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-drop >/dev/null - local bob_key="$(project_hash).team.bob" - local alice_key="$(project_hash).team.alice" - : > "$TEST_SKILL_DIR/run/codex-bridge.$bob_key.plist" - : > "$TEST_SKILL_DIR/run/codex-bridge.$alice_key.plist" - - run bash "$SCRIPTS/reset.sh" "$TEST_PROJECT" codex bob - - [ "$status" -eq 0 ] - grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$bob_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" - ! grep -q "com.agmsg.codex-bridge.$alice_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" - [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.$bob_key.plist" ] - [ -e "$TEST_SKILL_DIR/run/codex-bridge.$alice_key.plist" ] - [ ! -e "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" ] - [ ! -e "$TEST_SKILL_DIR/run/actas.team__bob.session" ] -} - -@test "codex reset stops a bridge owned by a non-ASCII team and role" { - skip_on_windows "process ownership assertion uses POSIX command lines" - local team='日本チーム' name='受信係' thread='thread-japanese' - bash "$SCRIPTS/join.sh" "$team" "$name" codex "$TEST_PROJECT" >/dev/null - install_fake_launchctl - local safe_team safe_name state_key base bridge_pid - safe_team="$(SKILL_DIR="$TEST_SKILL_DIR" bash -c '. "$1/lib/actas-lock.sh"; _actas_lock_encode "$2"' _ "$SCRIPTS" "$team")" - safe_name="$(SKILL_DIR="$TEST_SKILL_DIR" bash -c '. "$1/lib/actas-lock.sh"; _actas_lock_encode "$2"' _ "$SCRIPTS" "$name")" - state_key="$(project_hash).$safe_team.$safe_name" - base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" - mkdir -p "$TEST_SKILL_DIR/run" - cat > "$TYPES/codex/codex-bridge.js" <<'EOF' -#!/usr/bin/env bash -trap 'exit 0' TERM INT -while :; do sleep 1; done -EOF - chmod +x "$TYPES/codex/codex-bridge.js" - "$TYPES/codex/codex-bridge.js" --project "$TEST_PROJECT" --type codex \ - --team "$team" --name "$name" --state-key "$state_key" \ - --app-server-file "$base.appserver" --thread "$thread" & - bridge_pid=$! - TEST_RECEIVER_PIDS="$bridge_pid" - printf '%s\n' "$bridge_pid" > "$base.pid" - cat > "$base.meta" < "$base.plist" - - run bash "$SCRIPTS/reset.sh" "$TEST_PROJECT" codex "$name" - - [ "$status" -eq 0 ] - run ! kill -0 "$bridge_pid" - [ ! -e "$base.pid" ] - [ ! -e "$base.meta" ] - [ ! -e "$base.plist" ] - TEST_RECEIVER_PIDS="" -} - -@test "codex delivery off removes only this project's meta-less jobs and preserves the global relay" { - join_codex_roles - bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - install_fake_launchctl - env CODEX_THREAD_ID=thread-off \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-off >/dev/null - local project_key="$(project_hash).team.bob" - local other_project="$TEST_SKILL_DIR/other-project" other_hash other_key relay_pid - mkdir -p "$other_project" - other_hash="$(printf '%s' "$(cd "$other_project" && pwd)" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" - other_key="$other_hash.team.bob" - : > "$TEST_SKILL_DIR/run/codex-bridge.$project_key.plist" - : > "$TEST_SKILL_DIR/run/codex-bridge.$other_key.plist" - sleep 60 & - relay_pid=$! - TEST_RECEIVER_PIDS="$TEST_RECEIVER_PIDS $relay_pid" - printf '%s\n' "$relay_pid" > "$TEST_SKILL_DIR/run/codex-desktop-relay.pid" - - run bash "$SCRIPTS/delivery.sh" set off codex "$TEST_PROJECT" - - [ "$status" -eq 0 ] - grep -qx "bootout gui/$(id -u)/com.agmsg.codex-bridge.$project_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" - ! grep -q "com.agmsg.codex-bridge.$other_key" "$AGMSG_FAKE_LAUNCHCTL_LOG" - [ ! -e "$TEST_SKILL_DIR/run/codex-bridge.$project_key.plist" ] - [ -e "$TEST_SKILL_DIR/run/codex-bridge.$other_key.plist" ] - [ ! -e "$TEST_SKILL_DIR/run/codex-seat.team.bob.tsv" ] - kill -0 "$relay_pid" -} - -@test "codex visible Stop hook exposes only pending metadata for the last actas role" { +@test "codex visible Stop hook reads the last actas inbox, not the first registration" { join_codex_roles bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null env CODEX_THREAD_ID=thread-123 \ bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex bob thread-123 >/dev/null bash "$SCRIPTS/send.sh" team alice bob "visible-bob-message" >/dev/null - run env CODEX_THREAD_ID=thread-123 bash "$SCRIPTS/check-inbox.sh" codex "$TEST_PROJECT" /dev/null - bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - local server port_file bridge_pid - server="$TEST_SKILL_DIR/stalled-relay.js" - port_file="$TEST_SKILL_DIR/stalled-relay.port" - cat > "$server" <<'NODE' -#!/usr/bin/env node -const crypto = require("crypto"); -const fs = require("fs"); -const net = require("net"); -const portFile = process.argv[2]; -const server = net.createServer((socket) => { - let buffer = Buffer.alloc(0); - socket.on("data", (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - const end = buffer.indexOf("\r\n\r\n"); - if (end === -1) return; - const header = buffer.slice(0, end).toString("utf8"); - const key = (/sec-websocket-key:\s*([^\r\n]+)/i.exec(header) || [])[1]; - if (!key) return socket.destroy(); - const accept = crypto.createHash("sha1").update(`${key.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64"); - socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); - socket.removeAllListeners("data"); - }); -}); -server.listen(0, "127.0.0.1", () => fs.writeFileSync(portFile, String(server.address().port))); -NODE - node "$server" "$port_file" & - local server_pid=$! - TEST_RECEIVER_PIDS="$server_pid" - for _ in {1..50}; do [ -s "$port_file" ] && break; sleep 0.05; done - [ -s "$port_file" ] - local port="$(cat "$port_file")" - mkdir -p "$TEST_SKILL_DIR/run" - printf 'ws://127.0.0.1:%s/bridge/%s\n' "$port" "$(printf '%064d' 0 | tr '0' 'b')" \ - > "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint" - chmod 600 "$TEST_SKILL_DIR/run/codex-desktop-relay.bridge-endpoint" - cat > "$TEST_SKILL_DIR/run/codex-desktop-relay.health" </dev/null" - [ "$status" -ne 0 ] run bash "$TYPES/codex/watch-once.sh" "$TEST_PROJECT" codex \ --team team --name bob --timeout 0 [ "$status" -eq 0 ] @@ -541,7 +195,7 @@ EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] - [[ "$output" == *"mode: monitor"* ]] + [[ "$output" == *"mode: turn"* ]] } @test "delivery turn stops an app monitor even when no bridge pidfile exists" { @@ -549,14 +203,7 @@ EOF bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - cat > "$TYPES/codex/codex-app-monitor.sh" <<'EOF' -#!/usr/bin/env bash -trap 'exit 0' TERM INT -while :; do sleep 1; done -EOF - chmod +x "$TYPES/codex/codex-app-monitor.sh" - "$TYPES/codex/codex-app-monitor.sh" \ - "$(cd "$TEST_PROJECT" && pwd -P)" codex team alice thread-123 & + sleep 60 & local app_pid=$! mkdir -p "$TEST_SKILL_DIR/run" printf '%s\n' "$app_pid" > "$TEST_SKILL_DIR/run/codex-app-monitor.team.alice.pid" @@ -593,44 +240,10 @@ EOF [[ "$output" == *"status=inactive"* ]] } -@test "delivery cleanup removes forged app-monitor state without signaling its pid or label" { - skip_on_windows "process liveness assertion uses POSIX kill semantics" - bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null - bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - install_fake_launchctl - - sleep 60 & - local unrelated_pid=$! - TEST_RECEIVER_PIDS="$TEST_RECEIVER_PIDS $unrelated_pid" - local base="$TEST_SKILL_DIR/run/codex-app-monitor.team.alice" - mkdir -p "$TEST_SKILL_DIR/run" - printf '%s\n' "$unrelated_pid" > "$base.pid" - cat > "$base.meta" < "$base.plist" <<'EOF' -Labelcom.example.other-unrelated-job -EOF - - run bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" - - [ "$status" -eq 0 ] - kill -0 "$unrelated_pid" 2>/dev/null - ! grep -q 'com.example' "$AGMSG_FAKE_LAUNCHCTL_LOG" - [ ! -e "$base.pid" ] - [ ! -e "$base.meta" ] - [ ! -e "$base.plist" ] -} - @test "dropping one codex role stops only that role's background receiver" { skip_on_windows "background receiver liveness uses POSIX signals" join_codex_roles - local sleeper="$TYPES/codex/codex-app-monitor.sh" bob_pid alice_pid + local sleeper="$TEST_SKILL_DIR/codex-app-monitor.sh" bob_pid alice_pid cat > "$sleeper" <<'EOF' #!/usr/bin/env bash trap 'exit 0' TERM INT @@ -640,9 +253,9 @@ done EOF chmod +x "$sleeper" - "$sleeper" "$TEST_PROJECT" codex team bob thread-bob & + "$sleeper" & bob_pid=$! - "$sleeper" "$TEST_PROJECT" codex team alice thread-alice & + "$sleeper" & alice_pid=$! TEST_RECEIVER_PIDS="$bob_pid $alice_pid" mkdir -p "$TEST_SKILL_DIR/run" @@ -718,14 +331,14 @@ EOF bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" "$sleeper" <<'EOF' #!/usr/bin/env bash trap 'exit 0' TERM INT while :; do sleep 1; done EOF chmod +x "$sleeper" - "$sleeper" "$TEST_PROJECT" codex team bob thread-ending & + "$sleeper" & receiver_pid=$! TEST_RECEIVER_PIDS="$receiver_pid" mkdir -p "$TEST_SKILL_DIR/run" @@ -748,14 +361,14 @@ EOF @test "codex SessionEnd stops only the receiver bound to that visible thread" { skip_on_windows "background receiver liveness uses POSIX signals" join_codex_roles - local sleeper="$TYPES/codex/codex-app-monitor.sh" receiver_pid + local sleeper="$TEST_SKILL_DIR/codex-app-monitor.sh" receiver_pid cat > "$sleeper" <<'EOF' #!/usr/bin/env bash trap 'exit 0' TERM INT while :; do sleep 1; done EOF chmod +x "$sleeper" - "$sleeper" "$TEST_PROJECT" codex team bob thread-ending & + "$sleeper" & receiver_pid=$! TEST_RECEIVER_PIDS="$receiver_pid" mkdir -p "$TEST_SKILL_DIR/run" @@ -786,13 +399,11 @@ EOF @test "codex monitor never falls back to hidden resume or a new task" { grep -q 'background-only handling is prohibited' "$TYPES/codex/codex-app-monitor.sh" - grep -q 'desktop_relay_endpoint_unavailable' "$TYPES/codex/actas-monitor.sh" + grep -q 'visible_app_server_unavailable' "$TYPES/codex/actas-monitor.sh" run grep -q 'start_codex_app_monitor "$THREAD_ID"' "$TYPES/codex/actas-monitor.sh" [ "$status" -ne 0 ] run grep -q 'start_bridge ""' "$TYPES/codex/actas-monitor.sh" [ "$status" -ne 0 ] run grep -q 'THREAD_ID="new"' "$TYPES/codex/actas-monitor.sh" [ "$status" -ne 0 ] - run grep -Eq 'codex-app-monitor|background-thread-resume|unix://|THREAD_ID="loaded"|resolve_thread_id|thread/start' "$TYPES/codex/actas-monitor.sh" - [ "$status" -ne 0 ] } diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index b9b66ea2..b3497cf4 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -693,10 +693,12 @@ JSON unset CLAUDE_CODE_SESSION_ID } -@test "session-start.sh for codex never infers an exact thread from a symlink-matched rollout" { +@test "session-start.sh for codex matches rollout cwd via a symlinked project path (#160)" { skip_on_windows "codex bridge launch and symlink path on Windows (#182)" - # A rollout can match the same project through a physical/symlink spelling, - # but it is not proof that this visible Codex task owns that thread. + # agmsg opens the project through a symlink (linkproj), but Codex records the + # canonical/physical cwd (realproj) in session_meta. A raw string compare + # misses the rollout, so the thread never resolves and the bridge never + # starts. With physical-path canonicalization the two reconcile. local realproj="$TEST_PROJECT/realproj" local linkproj="$TEST_PROJECT/linkproj" mkdir -p "$realproj" @@ -720,16 +722,22 @@ printf '%s\n' "$*" >> "$AGMSG_TEST_LOG" EOF chmod +x "$fake" - # Legacy launcher inputs are intentionally ignored as well: without an exact - # current thread and a matching saved actas binding, SessionStart is quiet. + # env -u CODEX_THREAD_ID forces the rollout-scan fallback that does the + # compare — without it, a CODEX_THREAD_ID inherited from the parent env (e.g. + # running the suite inside a Codex session) short-circuits the resolver and + # this test never exercises the path it's meant to cover. AGMSG_CODEX_BRIDGE_APP_SERVER="unix://$TEST_SKILL_DIR/run/codex-app-server.test.sock" \ AGMSG_CODEX_BRIDGE_CMD="$fake" \ AGMSG_TEST_LOG="$log" \ env -u CODEX_THREAD_ID bash "$SCRIPTS/session-start.sh" codex "$linkproj" >/dev/null - [ ! -f "$log" ] - run bash -c "compgen -G '$TEST_SKILL_DIR/run/codex-bridge.*.pid' >/dev/null" - [ "$status" -ne 0 ] + for _ in {1..20}; do + [ -f "$log" ] && break + sleep 0.1 + done + + [ -f "$log" ] + grep -q -- "--thread thread-sym" "$log" } # --- gemini agent tests --- @@ -1439,7 +1447,7 @@ JSON } # --- Codex monitor bridge (#41) --- -@test "session-start.sh for codex ignores legacy launcher env without a saved exact binding" { +@test "session-start.sh for codex starts bridge when monitor launcher env is present" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null local fake="$TEST_SKILL_DIR/fake-codex-bridge" local log="$TEST_SKILL_DIR/fake-codex-bridge.log" @@ -1456,12 +1464,20 @@ EOF CODEX_THREAD_ID="thread-123" \ bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null - [ ! -f "$log" ] - run bash -c "compgen -G '$TEST_SKILL_DIR/run/codex-bridge.*.pid' >/dev/null" + for _ in {1..20}; do + [ -f "$log" ] && break + sleep 0.1 + done + + [ -f "$log" ] + grep -q -- "--project $TEST_PROJECT" "$log" + grep -q -- "--thread thread-123" "$log" + grep -q -- "--app-server unix://$TEST_SKILL_DIR/run/codex-app-server.test.sock" "$log" + run grep -q -- "--inline-inbox" "$log" [ "$status" -ne 0 ] } -@test "session-start.sh for codex stays quiet without a saved exact actas binding" { +@test "session-start.sh for codex stays quiet without monitor launcher env" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null local fake="$TEST_SKILL_DIR/fake-codex-bridge" local log="$TEST_SKILL_DIR/fake-codex-bridge.log" @@ -1480,14 +1496,13 @@ EOF @test "delivery set monitor (codex): installs persistent SessionStart plus visible Stop fallback" { run bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" [ "$status" -eq 0 ] - [[ "$output" == *"Codex visible monitor beta is enabled"* ]] || return 1 - [[ "$output" == *"authenticated Desktop relay"* ]] || return 1 - [[ "$output" == *"intended visible Codex task"* ]] || return 1 - [[ "$output" == *"mail remains unread"* ]] || return 1 - [[ "$output" != *"codex-shim.sh"* ]] || return 1 - [[ "$output" != *"codex() {"* ]] || return 1 - [[ "$output" == *"For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md"* ]] || return 1 - [[ "$output" != *"Monitor tool"* ]] || return 1 + [[ "$output" == *"Codex monitor beta is enabled"* ]] + [[ "$output" == *"codex() {"* ]] + [[ "$output" == *"codex-shim.sh"* ]] + [[ "$output" == *"launch with codex"* ]] + [[ "$output" == *"Optional global PATH shim is still available"* ]] + [[ "$output" == *"For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md"* ]] + [[ "$output" != *"Monitor tool"* ]] [ ! -e "$HOME/.agents/bin/codex" ] local hook_file="$TEST_PROJECT/.codex/hooks.json" [ -f "$hook_file" ] @@ -1503,7 +1518,7 @@ EOF grep -q "check-inbox.sh" "$hook_file" } -@test "delivery status (codex): live bridge reports the visible exact-thread runtime" { +@test "delivery status (codex): live bridge reports alive and suppresses watch count" { skip_on_windows "codex bridge status liveness under Git Bash (#182)" bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null @@ -1520,21 +1535,20 @@ project=$TEST_PROJECT team=team name=alice type=codex -thread=thread-status EOF printf '%s\n' 99999999 > "$TEST_SKILL_DIR/run/watch.fake.pid" run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex visible bridge: team/alice active pid=$bpid thread=thread-status"* ]] || return 1 - [[ "$output" != *"watch processes:"* ]] || return 1 + [[ "$output" == *"Codex bridge: team/alice alive (pid $bpid)"* ]] + [[ "$output" != *"watch processes:"* ]] kill "$bpid" 2>/dev/null || true trap - EXIT } -@test "delivery status (codex): dead bridge metadata is reported as stale" { +@test "delivery status (codex): stale bridge pidfile is reported as stale" { skip_on_windows "codex bridge status liveness under Git Bash (#182)" bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null @@ -1551,17 +1565,16 @@ project=$TEST_PROJECT team=team name=alice type=codex -thread=thread-stale EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex visible bridge: team/alice stale thread=thread-stale"* ]] || return 1 - [[ "$output" != *"watch processes:"* ]] || return 1 + [[ "$output" == *"Codex bridge: team/alice stale pidfile (pid $dead_pid not running)"* ]] + [[ "$output" != *"watch processes:"* ]] } -@test "delivery status (codex): another project's bridge metadata is excluded" { +@test "delivery status (codex): bridge metadata mismatch is reported as stale" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null mkdir -p "$TEST_SKILL_DIR/run" @@ -1578,11 +1591,11 @@ EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex visible bridge: no role is currently bound for this project"* ]] || return 1 - [[ "$output" != *"watch processes:"* ]] || return 1 + [[ "$output" == *"Codex bridge: team/alice stale pidfile (metadata mismatch)"* ]] + [[ "$output" != *"watch processes:"* ]] } -@test "delivery status (codex): a pidfile without metadata is not reported as a bridge" { +@test "delivery status (codex): missing bridge metadata is reported as stale" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null mkdir -p "$TEST_SKILL_DIR/run" @@ -1592,22 +1605,22 @@ EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex visible bridge: no role is currently bound for this project"* ]] || return 1 - [[ "$output" != *"watch processes:"* ]] || return 1 + [[ "$output" == *"Codex bridge: team/alice stale pidfile (missing metadata)"* ]] + [[ "$output" != *"watch processes:"* ]] } -@test "delivery status (codex): identity without a runtime reports no bound role" { +@test "delivery status (codex): identity with no bridge reports not running" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex visible bridge: no role is currently bound for this project"* ]] || return 1 - [[ "$output" != *"watch processes:"* ]] || return 1 + [[ "$output" == *"Codex bridge: team/alice not running"* ]] + [[ "$output" != *"watch processes:"* ]] } -@test "delivery status (codex): reports only identities with runtime metadata" { +@test "delivery status (codex): multiple identities are enumerated independently" { skip_on_windows "codex bridge status liveness under Git Bash (#182)" bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null @@ -1625,37 +1638,30 @@ project=$TEST_PROJECT team=team name=alice type=codex -thread=thread-alice EOF run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] - [[ "$output" == *"Codex visible bridge: team/alice active pid=$bpid thread=thread-alice"* ]] || return 1 - [[ "$output" != *"team/bob"* ]] || return 1 - [[ "$output" != *"watch processes:"* ]] || return 1 + [[ "$output" == *"Codex bridge: team/alice alive (pid $bpid)"* ]] + [[ "$output" == *"Codex bridge: team/bob not running"* ]] + [[ "$output" != *"watch processes:"* ]] kill "$bpid" 2>/dev/null || true trap - EXIT } -@test "delivery status (codex): monitor mode with no bound role is explicit" { +@test "delivery status (codex): monitor mode with no identities is explicit" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" [ "$status" -eq 0 ] [[ "$output" == *"mode: monitor"* ]] - [[ "$output" == *"Codex visible bridge: no role is currently bound for this project"* ]] || return 1 - [[ "$output" != *"watch processes:"* ]] || return 1 + [[ "$output" == *"Codex bridge: no identities registered for this project"* ]] + [[ "$output" != *"watch processes:"* ]] } -@test "session-start.sh for codex preserves the saved exact thread when CODEX_THREAD_ID is unset" { +@test "session-start.sh for codex resolves thread id from rollout when CODEX_THREAD_ID is unset" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null - bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - env CODEX_THREAD_ID=thread-owner \ - bash "$TYPES/codex/actas-monitor.sh" "$TEST_PROJECT" codex alice thread-owner >/dev/null - local project_hash - project_hash="$(printf '%s' "$(cd "$TEST_PROJECT" && pwd)" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" - rm -f "$TEST_SKILL_DIR/run/codex-chat-visible.$project_hash.team.alice.meta" local fake="$TEST_SKILL_DIR/fake-codex-bridge" local log="$TEST_SKILL_DIR/fake-codex-bridge.log" cat >"$fake" <<'EOF' @@ -1664,8 +1670,8 @@ printf '%s\n' "$*" >> "$AGMSG_TEST_LOG" EOF chmod +x "$fake" - # A matching rollout is still ambiguous across visible tasks and must not - # replace the explicit thread-owner binding. + # Fresh/exec Codex sessions do not export CODEX_THREAD_ID; the hook must read + # the thread id from the newest rollout whose session_meta cwd matches (#41). local rollout_dir="$TEST_SKILL_DIR/home/.codex/sessions/2026/06/17" mkdir -p "$rollout_dir" printf '%s\n' "{\"type\":\"session_meta\",\"payload\":{\"id\":\"rollout-thread-999\",\"cwd\":\"$TEST_PROJECT\"}}" \ @@ -1678,11 +1684,9 @@ EOF AGMSG_TEST_LOG="$log" \ env -u CODEX_THREAD_ID bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null - [ ! -f "$log" ] - [ ! -e "$TEST_SKILL_DIR/run/codex-chat-visible.$project_hash.team.alice.meta" ] - grep -q '^codex-seat:' "$TEST_SKILL_DIR/run/actas.team__alice.session" - awk -F '\t' '$5 == "thread-owner" { found=1 } END { exit !found }' \ - "$TEST_SKILL_DIR/run/codex-seat.team.alice.tsv" + for _ in {1..20}; do [ -f "$log" ] && break; sleep 0.1; done + [ -f "$log" ] + grep -q -- "--thread rollout-thread-999" "$log" } @test "delivery set monitor (codex): warns loudly when Node is missing" { @@ -1695,50 +1699,33 @@ EOF [[ "$output" == *"monitor delivery will NOT start"* ]] } -@test "delivery set off (codex): stops the bridge and preserves the shared relay" { +@test "delivery set off (codex): stops the bridge, cleans run files, notes shell profile cleanup" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null mkdir -p "$TEST_SKILL_DIR/run" - source "$SCRIPTS/lib/hash.sh" - local h; h="$(printf '%s' "$TEST_PROJECT" | agmsg_sha1)" - local state_key="$h.team.alice" - local base="$TEST_SKILL_DIR/run/codex-bridge.$state_key" - local bridge="$TYPES/codex/codex-bridge.js" - cat > "$bridge" <<'EOF' -#!/usr/bin/env bash -trap 'exit 0' TERM INT -while :; do sleep 1; done -EOF - chmod +x "$bridge" - "$bridge" --project "$TEST_PROJECT" --type codex --team team --name alice \ - --state-key "$state_key" --app-server-file "$base.appserver" --thread thread-off & + # Stand in for a live bridge with a real process we can check kill -0 against. + sleep 60 & local bpid=$! - echo "$bpid" > "$base.pid" - cat > "$base.meta" < "$base.log" + echo "$bpid" > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" + echo "pid=$bpid" > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.meta" + : > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.log" # The launcher's stale-binding sidecar + the project's shared app-server record # must be torn down too. Use a non-codex pid for the server record so the # cmdline guard skips the kill — the record is still dropped. - : > "$base.appserver" + : > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.appserver" + source "$SCRIPTS/lib/hash.sh" + local h; h="$(printf '%s' "$TEST_PROJECT" | agmsg_sha1)" echo 2147483647 > "$TEST_SKILL_DIR/run/codex-app-server.$h.pid" : > "$TEST_SKILL_DIR/run/codex-app-server.$h.port" : > "$TEST_SKILL_DIR/run/codex-app-server.$h.version" run bash "$SCRIPTS/delivery.sh" set off codex "$TEST_PROJECT" [ "$status" -eq 0 ] - [[ "$output" == *"Stopped 1"*"Codex bridge"* ]] || return 1 - [[ "$output" == *"shared Codex Desktop relay remains installed"* ]] || return 1 - [[ "$output" != *"shim"* ]] || return 1 + [[ "$output" == *"Stopped 1 Codex bridge"* ]] + [[ "$output" == *"shim"* ]] ! kill -0 "$bpid" 2>/dev/null - [ ! -f "$base.pid" ] - [ ! -f "$base.meta" ] - [ ! -f "$base.appserver" ] + [ ! -f "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" ] + [ ! -f "$TEST_SKILL_DIR/run/codex-bridge.team.alice.meta" ] + [ ! -f "$TEST_SKILL_DIR/run/codex-bridge.team.alice.appserver" ] [ ! -f "$TEST_SKILL_DIR/run/codex-app-server.$h.pid" ] [ ! -f "$TEST_SKILL_DIR/run/codex-app-server.$h.port" ] [ ! -f "$TEST_SKILL_DIR/run/codex-app-server.$h.version" ] diff --git a/tests/test_uninstall_codex_monitor.bats b/tests/test_uninstall_codex_monitor.bats deleted file mode 100644 index 01eecc71..00000000 --- a/tests/test_uninstall_codex_monitor.bats +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bats - -setup() { - export HOME="$(mktemp -d)" - export INSTALLED="$HOME/.agents/skills/agmsg" - export TEST_LOG="$HOME/uninstall-actions.log" - mkdir -p "$INSTALLED/run" "$INSTALLED/scripts/drivers/types/codex" "$HOME/fakebin" - : > "$INSTALLED/.agmsg" - TEST_PID="" - - cat > "$HOME/fakebin/launchctl" <<'EOF' -#!/usr/bin/env bash -printf 'launchctl %s\n' "$*" >> "$TEST_LOG" -exit 0 -EOF - chmod +x "$HOME/fakebin/launchctl" - export PATH="$HOME/fakebin:$PATH" - - cat > "$INSTALLED/scripts/drivers/types/codex/codex-desktop-relayctl.sh" <<'EOF' -#!/usr/bin/env bash -printf 'relayctl %s\n' "$*" >> "$TEST_LOG" -EOF - chmod +x "$INSTALLED/scripts/drivers/types/codex/codex-desktop-relayctl.sh" -} - -teardown() { - if [ -n "${TEST_PID:-}" ]; then - kill "$TEST_PID" 2>/dev/null || true - wait "$TEST_PID" 2>/dev/null || true - fi - rm -rf "$HOME" -} - -@test "uninstall removes a meta-less role job and disables the global Desktop relay" { - local base="$INSTALLED/run/codex-bridge.deadbeef.team.bob" - cat > "$base.plist" <<'EOF' - - - Label - com.agmsg.codex-bridge.deadbeef.team.bob - -EOF - - run bash "$BATS_TEST_DIRNAME/../uninstall.sh" --yes --keep-data - - [ "$status" -eq 0 ] - grep -qx "launchctl bootout gui/$(id -u)/com.agmsg.codex-bridge.deadbeef.team.bob" "$TEST_LOG" - grep -qx 'relayctl disable' "$TEST_LOG" - [ ! -e "$base.plist" ] -} - -@test "uninstall derives the managed label and does not signal a forged pid" { - local project="$HOME/project" base="$INSTALLED/run/codex-bridge.deadbeef.team.bob" - mkdir -p "$project" - sleep 60 & - TEST_PID=$! - printf '%s\n' "$TEST_PID" > "$base.pid" - cat > "$base.meta" < "$base.plist" <<'EOF' - - - Label - com.example.other-unrelated-job - -EOF - - run bash "$BATS_TEST_DIRNAME/../uninstall.sh" --yes --keep-data - - [ "$status" -eq 0 ] - kill -0 "$TEST_PID" 2>/dev/null - grep -qx "launchctl bootout gui/$(id -u)/com.agmsg.codex-bridge.deadbeef.team.bob" "$TEST_LOG" - ! grep -q 'com.example' "$TEST_LOG" - [ ! -e "$base.pid" ] - [ ! -e "$base.meta" ] - [ ! -e "$base.plist" ] -} diff --git a/uninstall.sh b/uninstall.sh index 93ebd679..e206b7a9 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -64,99 +64,6 @@ echo "" REMOVED=false -codex_bridge_label_for_base() { - local base_name="${1##*/}" suffix - case "$base_name" in codex-bridge.*) suffix="${base_name#codex-bridge.}" ;; *) return 1 ;; esac - case "$suffix" in ""|*[!A-Za-z0-9._%-]*) return 1 ;; esac - printf 'com.agmsg.codex-bridge.%s' "$suffix" -} - -canonical_project_path() { - local project="$1" - if [ -d "$project" ]; then - (cd -P "$project" 2>/dev/null && pwd -P) - else - printf '%s' "$project" - fi -} - -installed_bridge_pid_matches() { - local pid="$1" base="$2" meta="$3" state_key project canonical_project type team name thread cmd expected prefix - [ -f "$meta" ] || return 1 - state_key="${base##*/codex-bridge.}" - case "$state_key" in ""|*[!A-Za-z0-9._%-]*) return 1 ;; esac - project="$(sed -n 's/^project=//p' "$meta" | head -1)" - type="$(sed -n 's/^type=//p' "$meta" | head -1)" - team="$(sed -n 's/^team=//p' "$meta" | head -1)" - name="$(sed -n 's/^name=//p' "$meta" | head -1)" - thread="$(sed -n 's/^thread=//p' "$meta" | head -1)" - canonical_project="$(canonical_project_path "$project")" - [ "$type" = "codex" ] && [ -n "$canonical_project" ] && [ -n "$team" ] \ - && [ -n "$name" ] && [ -n "$thread" ] || return 1 - case "$thread" in *[!A-Za-z0-9._-]*) return 1 ;; esac - cmd="$(ps -o args= -p "$pid" 2>/dev/null || true)" - expected="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --state-key $state_key --app-server-file $base.appserver --thread $thread" - case " $cmd " in *" $expected "*) return 0 ;; esac - # Pre-state-key bridge records are still removable, but signaling one also - # requires the full legacy project/role/thread argv contract. - prefix="$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js --project $project --type codex --team $team --name $name --app-server " - case " $cmd " in *" $prefix"*" --thread $thread "*) return 0 ;; esac - return 1 -} - -# --- 0. Stop Codex role bridges and remove the global Desktop relay --- -# This is the only automatic path that removes the shared relay itself. Normal -# delivery mode changes stop project-owned role bridges but keep the relay for -# other projects and future visible tasks. -for SKILL_DIR in "${SKILL_DIRS[@]}"; do - for plist in "$SKILL_DIR"/run/codex-bridge.*.plist; do - [ -f "$plist" ] || continue - base="${plist%.plist}" - label="$(codex_bridge_label_for_base "$base" 2>/dev/null || true)" - [ -n "$label" ] || continue - if command -v launchctl >/dev/null 2>&1; then - launchctl bootout "gui/$(id -u)/$label" >/dev/null 2>&1 || true - fi - pid="$(cat "$base.pid" 2>/dev/null || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - if installed_bridge_pid_matches "$pid" "$base" "$base.meta"; then - kill "$pid" 2>/dev/null || true - fi - fi - rm -f "$base.pid" "$base.meta" "$base.health" "$base.appserver" \ - "$base.plist" "$base.log" "$base.last-ids" "$base.binding" - rm -f "$base".wake.*.json "$base.relay-wake.json" - done - for meta in "$SKILL_DIR"/run/codex-bridge.*.meta; do - [ -f "$meta" ] || continue - base="${meta%.meta}" - label="$(codex_bridge_label_for_base "$base" 2>/dev/null || true)" - [ -n "$label" ] || continue - if command -v launchctl >/dev/null 2>&1; then - launchctl bootout "gui/$(id -u)/$label" >/dev/null 2>&1 || true - fi - pid="$(cat "$base.pid" 2>/dev/null || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - if installed_bridge_pid_matches "$pid" "$base" "$meta"; then - kill "$pid" 2>/dev/null || true - fi - fi - rm -f "$base.pid" "$base.meta" "$base.health" "$base.appserver" \ - "$base.plist" "$base.log" "$base.last-ids" "$base.binding" - rm -f "$base".wake.*.json "$base.relay-wake.json" - done - rm -f "$SKILL_DIR"/run/codex-seat.*.tsv - for lock in "$SKILL_DIR"/run/actas.*.session; do - [ -f "$lock" ] || continue - owner="$(head -1 "$lock" 2>/dev/null || true)" - case "$owner" in codex-seat:*) rm -f "$lock" ;; esac - done - relayctl="$SKILL_DIR/scripts/drivers/types/codex/codex-desktop-relayctl.sh" - if [ -x "$relayctl" ]; then - "$relayctl" disable >/dev/null 2>&1 || true - fi -done - # --- 1. Remove slash commands and hooks from joined projects --- for SKILL_DIR in "${SKILL_DIRS[@]}"; do TEAMS_DIR="$SKILL_DIR/teams" From 4c62e8e0632a866ba508291ded49be2ae38d62d1 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Tue, 14 Jul 2026 23:39:25 +0900 Subject: [PATCH 11/12] [auto] Permanently retire Codex Desktop relay --- install.sh | 48 +++++++++++++++++++++++++++++++++++++++++ tests/test_install.bats | 25 +++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/install.sh b/install.sh index f9ce147d..92b5f59b 100755 --- a/install.sh +++ b/install.sh @@ -150,6 +150,52 @@ install_windows_helpers() { fi } +retire_codex_desktop_relay() { + # The desktop relay made ChatGPT.app depend on a local WebSocket endpoint. + # A stale LaunchAgent or environment value then caused repeated app exits, so + # updates must remove the retired transport even though cp -R preserves files + # deleted from newer agmsg releases. + local relay_dir="$SKILL_DIR/scripts/drivers/types/codex" + local relay_pidfile="$SKILL_DIR/run/codex-desktop-relay.pid" + local relay_pid relay_command uid label + + if [ -f "$relay_pidfile" ]; then + relay_pid="$(cat "$relay_pidfile" 2>/dev/null || true)" + if [[ "$relay_pid" =~ ^[0-9]+$ ]]; then + relay_command="$(ps -p "$relay_pid" -o command= 2>/dev/null || true)" + case "$relay_command" in + *"$relay_dir/codex-desktop-relay.js"*|*"$relay_dir/codex-desktop-relay-run.sh"*) + kill -TERM "$relay_pid" 2>/dev/null || true + ;; + esac + fi + fi + + if [ "$(uname -s 2>/dev/null || true)" = "Darwin" ] && command -v launchctl >/dev/null 2>&1; then + uid="$(id -u)" + for label in com.agmsg.codex-desktop-relay com.agmsg.codex-chatgpt-restart-once; do + launchctl bootout "gui/$uid/$label" >/dev/null 2>&1 || true + done + launchctl unsetenv CODEX_APP_SERVER_WS_URL >/dev/null 2>&1 || true + fi + + rm -f \ + "$HOME/Library/LaunchAgents/com.agmsg.codex-desktop-relay.plist" \ + "$HOME/Library/LaunchAgents/com.agmsg.codex-chatgpt-restart-once.plist" \ + "$relay_dir/codex-desktop-relay-run.sh" \ + "$relay_dir/codex-desktop-relayctl.sh" \ + "$relay_dir/codex-desktop-relay.js" \ + "$SKILL_DIR/run/codex-desktop-relay.pid" \ + "$SKILL_DIR/run/codex-desktop-relay.port" \ + "$SKILL_DIR/run/codex-desktop-relay.health" \ + "$SKILL_DIR/run/codex-desktop-relay.log" \ + "$SKILL_DIR/run/codex-desktop-relay.desktop-token" \ + "$SKILL_DIR/run/codex-desktop-relay.bridge-token" \ + "$SKILL_DIR/run/codex-desktop-relay.desktop-endpoint" \ + "$SKILL_DIR/run/codex-desktop-relay.bridge-endpoint" \ + "$SKILL_DIR/run/codex-desktop-relay.prior-desktop-endpoint" +} + # --- Parse args --- while [[ $# -gt 0 ]]; do case "$1" in @@ -250,6 +296,7 @@ if [ "$UPDATE_ONLY" = true ]; then # ship without enumerating files. The agent-type manifests and per-type runtimes # live under scripts/drivers/types/ now, so this single copy carries them too. cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" + retire_codex_desktop_relay # Ship the external-plugin drop-in dir (just its README) so the location exists # post-install. A plain cp — not cp -R --delete — preserves any plugins the # user dropped in and their db/trusted-plugins opt-ins. @@ -357,6 +404,7 @@ sed "s/__SKILL_NAME__/$CMD_NAME/g" "$(agmsg_type_template_path "$TPL_TYPE")" > " # without enumerating files. The agent-type manifests and per-type runtimes live # under scripts/drivers/types/ now, so this single copy carries them too. cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" +retire_codex_desktop_relay # Ship the external-plugin drop-in dir (just its README) so the location exists # post-install. A plain cp — not cp -R --delete — preserves any plugins the user # dropped in and their db/trusted-plugins opt-ins. diff --git a/tests/test_install.bats b/tests/test_install.bats index 44fe58c3..3aee2791 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -45,6 +45,31 @@ teardown() { [ "$status" -eq 0 ] } +@test "install: --update retires stale Codex Desktop relay artifacts" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + local relay_dir="$SK/scripts/drivers/types/codex" + local launch_agents="$FAKE_HOME/Library/LaunchAgents" + mkdir -p "$relay_dir" "$SK/run" "$launch_agents" + touch \ + "$relay_dir/codex-desktop-relay-run.sh" \ + "$relay_dir/codex-desktop-relayctl.sh" \ + "$relay_dir/codex-desktop-relay.js" \ + "$SK/run/codex-desktop-relay.pid" \ + "$SK/run/codex-desktop-relay.desktop-token" \ + "$launch_agents/com.agmsg.codex-desktop-relay.plist" \ + "$launch_agents/com.agmsg.codex-chatgpt-restart-once.plist" + + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg --update + + [ ! -e "$relay_dir/codex-desktop-relay-run.sh" ] + [ ! -e "$relay_dir/codex-desktop-relayctl.sh" ] + [ ! -e "$relay_dir/codex-desktop-relay.js" ] + [ ! -e "$SK/run/codex-desktop-relay.pid" ] + [ ! -e "$SK/run/codex-desktop-relay.desktop-token" ] + [ ! -e "$launch_agents/com.agmsg.codex-desktop-relay.plist" ] + [ ! -e "$launch_agents/com.agmsg.codex-chatgpt-restart-once.plist" ] +} + @test "install: ships an executable uninstall.sh so npx/curl installs have one to run later" { # setup.sh's temp checkout is deleted right after install, so a copy inside # the skill dir is the only uninstaller npx/curl-installed users ever have. From 681efdbee10f5fa8c76b6529ec61530c51c26ab1 Mon Sep 17 00:00:00 2001 From: Ryo FUKUTANI Date: Wed, 15 Jul 2026 00:51:47 +0900 Subject: [PATCH 12/12] [auto] Add native Scheduled monitor workflow --- README.md | 19 ++- docs/codex-monitor-beta.md | 6 +- docs/codex-scheduled-monitor.md | 70 ++++++++++ scripts/drivers/types/codex/_delivery.sh | 32 ++++- .../types/codex/codex-scheduled-monitor.sh | 121 +++++++++++++++++- scripts/drivers/types/codex/template.md | 66 +++++++++- tests/test_codex_scheduled_monitor.bats | 50 ++++++++ tests/test_delivery.bats | 30 +++++ tests/test_install.bats | 8 ++ 9 files changed, 384 insertions(+), 18 deletions(-) create mode 100644 docs/codex-scheduled-monitor.md diff --git a/README.md b/README.md index bb2c7b9c..f2a271a1 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ How incoming messages reach your agent. Pick one at first join via the prompt, o | **`monitor`** (default on Claude Code) | SessionStart hook → Monitor tool → blocking SQLite stream | ~5s | Claude Code users wanting real-time push | | **`turn`** (default on Codex / Copilot CLI / OpenCode) | Stop hook fires `check-inbox.sh` between assistant turns | until your next interaction | Codex / Copilot CLI / OpenCode (no Monitor tool); Claude Code users on a quieter loop | | **`both`** | monitor primary, turn as per-session safety net | ~5s; falls back to turn-end on watcher failure | belt-and-suspenders | +| **`scheduled`** (Codex only) | native ChatGPT Scheduled task returning to the same task | 2 min, then 15 min, then 1 hour | unattended Codex follow-up without a relay | | **`off`** | no automatic delivery | manual `/agmsg` only | minimalists | ### Picking a mode @@ -256,6 +257,7 @@ How incoming messages reach your agent. Pick one at first join via the prompt, o /agmsg mode monitor — switch this project to real-time push (Claude Code) /agmsg mode turn — switch to between-turns checking /agmsg mode both — monitor with turn as a safety net +/agmsg mode scheduled — native ChatGPT Scheduled monitoring in this task /agmsg mode off — manual /agmsg only /agmsg mode — show current mode ``` @@ -300,11 +302,15 @@ The command updates `db/config.yaml`, rewrites the project's hook entries, and p $agmsg — or /skills → agmsg ``` -Codex supports `mode monitor` as a **beta** visible app-server receiver, plus `mode turn` and `mode off`. +Codex supports `mode scheduled` through a native ChatGPT Scheduled task, plus +`mode monitor` as a **beta** visible app-server receiver, `mode turn`, and +`mode off`. -> ⚠️ **Monitor is active only when a visible app-server bridge attaches to the persisted Codex task.** Background `codex exec resume` delivery is prohibited because a successful CLI turn can consume and answer mail without displaying the handling in Codex Desktop. When no visible bridge is available, agmsg keeps mail unread and downgrades the effective mode to `turn`. No heartbeat, cron, or scheduled polling task is created. +> ⚠️ **Monitor is active only when a visible app-server bridge attaches to the persisted Codex task.** Background `codex exec resume` delivery is prohibited because a successful CLI turn can consume and answer mail without displaying the handling in Codex Desktop. When no visible bridge is available, agmsg keeps mail unread and downgrades the effective mode to `turn`. The separate `scheduled` path uses only ChatGPT's native Scheduled-task feature; neither path creates a heartbeat, cron job, launchd job, Desktop relay, or app restart. -If you prefer a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). +Run `$agmsg scheduled start ` to create the native adaptive schedule in +the current task. See [docs/codex-scheduled-monitor.md](docs/codex-scheduled-monitor.md). +If you prefer the beta bridge and a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Bridge setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). ### GitHub Copilot CLI @@ -495,9 +501,10 @@ writable_roots = [ ] ``` -Codex only supports `mode turn` and `mode off`; it does not have Claude Code's -Monitor tool. The sandbox allowlist is still required for writes performed by -manual `$agmsg` commands and turn-end inbox checks. +Codex supports manual, `turn`, native `scheduled`, and beta visible-bridge +workflows. It does not have Claude Code's Monitor tool. The sandbox allowlist is +required for writes performed by manual commands, turn-end inbox checks, and +the native Scheduled state machine. Some Codex runtimes or automations may inject a managed permission profile for a single run. In that case, the run-specific writable roots must also include the diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 156d203c..f6826603 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -9,8 +9,10 @@ SessionStart after restart or compaction. > Monitor mode is active only after a visible app-server bridge attaches to the > selected thread. If no bridge is available, agmsg keeps mail unread and changes > the effective mode to `turn`. Background `codex exec resume`, cron, heartbeat, -> and scheduled polling are prohibited because they can process mail without -> showing the work to the human operator. +> and ad hoc scheduled polling are prohibited because they can process mail +> without showing the work to the human operator. The separate native ChatGPT +> Scheduled path returns to the same task and is documented in +> [codex-scheduled-monitor.md](codex-scheduled-monitor.md). ## Quick Start diff --git a/docs/codex-scheduled-monitor.md b/docs/codex-scheduled-monitor.md new file mode 100644 index 00000000..f6184551 --- /dev/null +++ b/docs/codex-scheduled-monitor.md @@ -0,0 +1,70 @@ +# Codex Native Scheduled Monitor + +The native Scheduled monitor returns to one existing ChatGPT task and checks +agmsg metadata without a Desktop relay or background receiver. + +It follows ChatGPT's documented scheduled-task model: a task scheduled from an +existing conversation returns to that conversation with its context, and local +project tasks require the ChatGPT desktop app and project directory to remain +available. + +Official reference: [Scheduled tasks](https://learn.chatgpt.com/docs/automations.md) + +## Cadence + +- Start through 30 minutes: every 2 minutes. +- After 30 minutes through 4 hours: every 15 minutes. +- After 4 hours through 24 hours: every hour. +- At 24 hours: pause the same Scheduled task. +- On a newly observed unread message for the selected role: reset to every 2 + minutes and begin a new 24-hour cycle. + +Empty, waiting, and not-due checks do not notify the user. The metadata check +does not read the message body or mark a message read. Only a wake result runs +the official `inbox.sh` command in the visible task. + +## Start + +From the Codex task that should receive the messages: + +```text +$agmsg scheduled start +``` + +The skill performs these steps: + +1. Turns off hook and bridge delivery for the project. +2. Prepares one idempotent monitor state for the selected team and role. +3. Creates or updates one native ChatGPT heartbeat automation targeting the + current task. +4. Starts it every two minutes in the local project directory. + +The setup fails closed. If native Scheduled-task creation is unavailable, the +prepared state is stopped and agmsg does not report the monitor as active. + +## Status and stop + +```text +$agmsg scheduled status +$agmsg scheduled stop +``` + +`scheduled stop` invalidates the local state and pauses the native task. `mode +off` invalidates every Scheduled monitor state for the project. If direct pause +is temporarily unavailable, the next run receives `status=inactive` and pauses +itself. + +## Safety boundaries + +The Scheduled path never uses: + +- Desktop relay or `CODEX_APP_SERVER_WS_URL` +- ChatGPT.app termination or restart +- launchd, cron, or a shell heartbeat +- a background receiver +- `codex exec resume` +- direct edits to ChatGPT automation files + +The task runs unattended with the user's default Codex sandbox settings. The +agmsg `db`, `teams`, and `run` directories must remain writable; `install.sh` +adds those writable roots when Codex configuration is present. diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh index d1653af2..54ba5459 100644 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -16,6 +16,13 @@ # is the safe path when no app-server can inject into the visible thread. agmsg_delivery_apply() { local type="$1" project="$2" mode="$3" + # Native Scheduled monitoring is mutually exclusive with hook/bridge modes. + # Invalidate its local state before enabling any of those modes; the next + # native run sees status=inactive and pauses itself. + if [ "$mode" != "off" ]; then + "$SKILL_DIR/scripts/drivers/types/codex/codex-scheduled-monitor.sh" \ + stop-project "$project" >/dev/null 2>&1 || true + fi if [ "$mode" = "monitor" ]; then agmsg_delivery_apply_default "$type" "$project" both else @@ -26,7 +33,22 @@ agmsg_delivery_apply() { # `monitor` is the user-facing mode name even though Codex installs both hook # types internally. Keep status stable for callers and existing automation. agmsg_delivery_status() { - agmsg_delivery_status_default "$@" | sed '1s/^mode: both$/mode: monitor/' + local type="$1" project="$2" hook_status scheduled_status + hook_status="$(agmsg_delivery_status_default "$type" "$project" | sed '1s/^mode: both$/mode: monitor/')" + scheduled_status="$("$SKILL_DIR/scripts/drivers/types/codex/codex-scheduled-monitor.sh" \ + status-project "$project" 2>/dev/null || true)" + if printf '%s\n' "$scheduled_status" | grep -q '^status=active '; then + if printf '%s\n' "$hook_status" | grep -q '^mode: off$'; then + printf '%s\n' "$hook_status" | sed '1s/^mode: off$/mode: scheduled/' + else + printf '%s\n' "$hook_status" + echo "WARNING: native Scheduled monitoring and Codex delivery hooks are both active." + fi + printf 'Codex Scheduled monitor: %s\n' \ + "$(printf '%s\n' "$scheduled_status" | tr '\n' ' ' | sed 's/[[:space:]]*$//')" + else + printf '%s\n' "$hook_status" + fi } agmsg_delivery_on_enable() { @@ -59,7 +81,7 @@ agmsg_delivery_on_enable() { agmsg_delivery_on_disable() { local project="$2" - local stopped lease_cleanup + local stopped lease_cleanup scheduled_cleanup stopped=$(stop_codex_bridge "$project") if [ "${stopped:-0}" -gt 0 ]; then echo "Stopped $stopped Codex bridge process(es) for this project and cleaned their run files." @@ -68,6 +90,12 @@ agmsg_delivery_on_disable() { lease_cleanup=$("$SKILL_DIR/scripts/drivers/types/codex/codex-monitor-lease.sh" \ disarm-project "$project" 2>/dev/null || true) [ -n "$lease_cleanup" ] && printf '%s\n' "$lease_cleanup" + # Turning delivery off must also invalidate native Scheduled runs. The next + # unattended run sees status=inactive and pauses itself; no local process is + # started to enforce that transition. + scheduled_cleanup=$("$SKILL_DIR/scripts/drivers/types/codex/codex-scheduled-monitor.sh" \ + stop-project "$project" 2>/dev/null || true) + [ -n "$scheduled_cleanup" ] && printf 'Codex Scheduled monitor: %s\n' "$scheduled_cleanup" echo "Note: shell profile functions are not changed automatically." echo " If you installed the optional global shim and no other project uses monitor mode, remove it:" echo " $SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh remove" diff --git a/scripts/drivers/types/codex/codex-scheduled-monitor.sh b/scripts/drivers/types/codex/codex-scheduled-monitor.sh index 5fda592d..fbb7bd10 100755 --- a/scripts/drivers/types/codex/codex-scheduled-monitor.sh +++ b/scripts/drivers/types/codex/codex-scheduled-monitor.sh @@ -16,6 +16,8 @@ WATCH_ONCE="${AGMSG_WATCH_ONCE:-$SCRIPT_DIR/watch-once.sh}" # shellcheck source=/dev/null source "$SCRIPT_DIR/../../../lib/hash.sh" # shellcheck source=/dev/null +source "$SCRIPT_DIR/../../../lib/compat.sh" +# shellcheck source=/dev/null source "$SCRIPT_DIR/../../../lib/resolve-project.sh" # shellcheck source=/dev/null source "$SCRIPT_DIR/../../../lib/validate.sh" @@ -31,6 +33,11 @@ RETRY_AFTER="${AGMSG_CODEX_SCHEDULED_RETRY_AFTER:-300}" usage() { cat >&2 <<'EOF' Usage: + codex-scheduled-monitor.sh prepare [--now ] + codex-scheduled-monitor.sh status-identity [--now ] + codex-scheduled-monitor.sh stop-identity + codex-scheduled-monitor.sh status-project + codex-scheduled-monitor.sh stop-project codex-scheduled-monitor.sh arm [--now ] codex-scheduled-monitor.sh check [--now ] [--force] codex-scheduled-monitor.sh delivered [--now ] @@ -209,6 +216,26 @@ rrule_for_interval() { esac } +active_state_for_identity() { + local file state_project state_team state_name state_status found="" + for file in "$RUN_DIR"/codex-scheduled-monitor.*.state; do + [ -f "$file" ] || continue + state_project="$(state_field project "$file")" + state_team="$(state_field team "$file")" + state_name="$(state_field name "$file")" + state_status="$(state_field status "$file")" + if [ "$state_project" = "$PROJECT" ] && [ "$state_team" = "$TEAM" ] \ + && [ "$state_name" = "$NAME" ] && [ "$state_status" = "active" ]; then + if [ -n "$found" ]; then + echo "agmsg: multiple active Scheduled monitor states exist for $TEAM/$NAME" >&2 + return 70 + fi + found="$file" + fi + done + printf '%s\n' "$found" +} + emit_runtime_status() { local now="$1" elapsed stage interval change next_due elapsed=$((now - CYCLE_STARTED_AT)) @@ -224,6 +251,97 @@ emit_runtime_status() { } case "$ACTION" in + prepare) + [ "$#" -ge 3 ] || usage + PROJECT="${1:?Missing project}" + TEAM="${2:?Missing team}" + NAME="${3:?Missing name}" + shift 3 + agmsg_validate_team_name "$TEAM" + agmsg_validate_agent_name "$NAME" + reject_line_breaks "$PROJECT" || { echo "agmsg: invalid project path" >&2; exit 64; } + PROJECT="$(agmsg_resolve_project "$PROJECT" codex)" + parse_now_options "$@" + + existing_state="$(active_state_for_identity)" || exit $? + if [ -n "$existing_state" ]; then + owner="$(state_field owner "$existing_state")" + [ -n "$owner" ] || { echo "agmsg: active Scheduled monitor state has no owner" >&2; exit 70; } + printf 'status=already_prepared owner=%s\n' "$owner" + else + owner="${AGMSG_CODEX_SCHEDULED_OWNER:-task-$(compat_uuidgen | tr '[:upper:]' '[:lower:]')}" + reject_line_breaks "$owner" || { echo "agmsg: invalid generated owner" >&2; exit 70; } + arm_output="$("$0" arm "$PROJECT" "$TEAM" "$NAME" "$owner" --now "$NOW")" + printf 'status=prepared owner=%s %s\n' "$owner" "$arm_output" + fi + echo "--- scheduled-task-prompt ---" + "$0" prompt "$PROJECT" "$TEAM" "$NAME" "$owner" + ;; + status-identity) + [ "$#" -ge 3 ] || usage + PROJECT="${1:?Missing project}" + TEAM="${2:?Missing team}" + NAME="${3:?Missing name}" + shift 3 + agmsg_validate_team_name "$TEAM" + agmsg_validate_agent_name "$NAME" + reject_line_breaks "$PROJECT" || { echo "agmsg: invalid project path" >&2; exit 64; } + PROJECT="$(agmsg_resolve_project "$PROJECT" codex)" + parse_now_options "$@" + existing_state="$(active_state_for_identity)" || exit $? + [ -n "$existing_state" ] || { echo "status=inactive"; exit 3; } + owner="$(state_field owner "$existing_state")" + "$0" status "$PROJECT" "$TEAM" "$NAME" "$owner" --now "$NOW" + ;; + stop-identity) + [ "$#" -eq 3 ] || usage + PROJECT="${1:?Missing project}" + TEAM="${2:?Missing team}" + NAME="${3:?Missing name}" + agmsg_validate_team_name "$TEAM" + agmsg_validate_agent_name "$NAME" + reject_line_breaks "$PROJECT" || { echo "agmsg: invalid project path" >&2; exit 64; } + PROJECT="$(agmsg_resolve_project "$PROJECT" codex)" + existing_state="$(active_state_for_identity)" || exit $? + [ -n "$existing_state" ] || { echo "status=inactive schedule_action=pause"; exit 0; } + owner="$(state_field owner "$existing_state")" + "$0" stop "$PROJECT" "$TEAM" "$NAME" "$owner" + ;; + status-project) + [ "$#" -eq 1 ] || usage + PROJECT="$(agmsg_resolve_project "${1:?Missing project}" codex)" + count=0 + for existing_state in "$RUN_DIR"/codex-scheduled-monitor.*.state; do + [ -f "$existing_state" ] || continue + [ "$(state_field project "$existing_state")" = "$PROJECT" ] || continue + [ "$(state_field status "$existing_state")" = "active" ] || continue + count=$((count + 1)) + printf 'identity=%s/%s\n' \ + "$(state_field team "$existing_state")" "$(state_field name "$existing_state")" + done + if [ "$count" -eq 0 ]; then + echo "status=inactive count=0" + exit 3 + fi + echo "status=active count=$count" + ;; + stop-project) + [ "$#" -eq 1 ] || usage + PROJECT="$(agmsg_resolve_project "${1:?Missing project}" codex)" + stopped=0 + for existing_state in "$RUN_DIR"/codex-scheduled-monitor.*.state; do + [ -f "$existing_state" ] || continue + [ "$(state_field project "$existing_state")" = "$PROJECT" ] || continue + [ "$(state_field status "$existing_state")" = "active" ] || continue + TEAM="$(state_field team "$existing_state")" + NAME="$(state_field name "$existing_state")" + owner="$(state_field owner "$existing_state")" + [ -n "$TEAM" ] && [ -n "$NAME" ] && [ -n "$owner" ] || continue + "$0" stop "$PROJECT" "$TEAM" "$NAME" "$owner" >/dev/null + stopped=$((stopped + 1)) + done + printf 'status=inactive stopped=%s schedule_action=pause\n' "$stopped" + ;; arm) [ "$#" -ge 4 ] || usage init_identity "$1" "$2" "$3" "$4" @@ -396,6 +514,7 @@ case "$ACTION" in scheduled_cmd="$scheduled_cmd $(printf '%q' "$OWNER") " cat < codex "$(pwd)"` + - For `4`, follow the `scheduled start` flow below instead of enabling a hook or bridge. - If monitor is chosen, immediately bind the newly joined role by running: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/actas-monitor.sh "$(pwd)" codex "${CODEX_THREAD_ID:-}"` - If monitor is chosen, report whether the visible app-server bridge attached. If it did not, say that the effective mode was downgraded to `turn`. @@ -97,7 +102,24 @@ the handling into the same visible Codex thread. 5. Background `codex exec resume` delivery is prohibited. 6. If a visible bridge cannot attach, keep mail unread, change the effective mode to `turn`, and fall back to the next visible turn. -7. Do not create cron, heartbeat, or scheduled polling jobs for Codex delivery. +7. Do not create cron, launchd, shell heartbeat, or background polling jobs for + Codex delivery. The only allowed schedule is the native ChatGPT Scheduled + task created by the `scheduled start` flow below. + +### Codex native Scheduled monitor invariant + +`scheduled start` may use only one native ChatGPT Scheduled task that returns +to the current task and runs in the local project directory. + +1. Start at two-minute intervals. The checked-in state machine changes the + cadence to 15 minutes after 30 minutes and one hour after four hours. +2. A new unread message addressed to the selected role resets the cycle to two + minutes. The metadata check must not read the body or mark it read. +3. Empty, waiting, and not-due runs produce no user notification. +4. At 24 hours, or after `scheduled stop` / `mode off`, pause the same Scheduled + task. Do not create a replacement task. +5. Never use Desktop relay, `CODEX_APP_SERVER_WS_URL`, app restart, launchd, + cron, a background receiver, or `codex exec resume`. **If no arguments provided (DEFAULT action — always do this when the command is invoked without arguments):** 1. **IMMEDIATELY** run inbox check for each TEAM: `~/.agents/skills/__SKILL_NAME__/scripts/inbox.sh $TEAM $AGENT` @@ -162,12 +184,42 @@ If argument is "mode" (no further args): 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh status codex "$(pwd)"` 2. Show the output to the user. +If argument starts with "scheduled start" (optionally followed by an agent name): +1. Resolve the selected Codex role and its team with `identities.sh`. Prefer the + active `actas` role. If multiple roles remain and none was specified, ask the + user which role to monitor. +2. Run `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set off codex "$(pwd)"` + so no hook or bridge remains active. +3. Run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-scheduled-monitor.sh prepare "$(pwd)" `. +4. Create or update one native ChatGPT automation with `kind=heartbeat` and + `targetThreadId` set to this current task. Use the emitted prompt verbatim, + the local project directory, and a two-minute recurrence. Do not use a + worktree. +5. If native Scheduled-task creation is unavailable, run `stop-identity` for + the same project/team/name and report the limitation. Do not claim that the + monitor is active. + +If argument starts with "scheduled status" (optionally followed by an agent name): +1. Resolve the role and team as in `scheduled start`. +2. Run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-scheduled-monitor.sh status-identity "$(pwd)" `. +3. Report the native Scheduled task's status too when that capability is available. + +If argument starts with "scheduled stop" (optionally followed by an agent name): +1. Resolve the role and team as in `scheduled start`. +2. Run `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-scheduled-monitor.sh stop-identity "$(pwd)" `. +3. Pause the same native Scheduled task. If direct pause is unavailable, the + next run will see `status=inactive` and pause itself. + If argument starts with "mode" followed by a mode name (e.g. "mode monitor"): -1. Parse the mode. Codex supports `monitor`, `turn`, `both`, and `off`. `monitor` already installs the visible turn fallback; `both` is an explicit equivalent for diagnostics. -2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` -3. If mode is `monitor` or `both` and an active role can be resolved, run `actas-monitor.sh` for that role and concrete thread id. -4. If mode is `turn` or `off`, confirm that the background receiver stopped. -5. If mode is `monitor` or `both`, report whether a visible app-server bridge attached. If it did not attach, report that the effective mode was downgraded to `turn`. Never describe visible-turn fallback as an active monitor. +1. Parse the mode. Codex supports `monitor`, `turn`, `both`, `scheduled`, and `off`. + `monitor` already installs the visible turn fallback; `both` is an explicit equivalent for diagnostics. +2. If the mode is `scheduled`, follow `scheduled start` above and stop this flow. +3. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` +4. If mode is `monitor` or `both` and an active role can be resolved, run `actas-monitor.sh` for that role and concrete thread id. +5. If mode is `turn` or `off`, confirm that the background receiver stopped. + Also pause the native Scheduled task when possible; switching to any + hook/bridge mode disarms its local state even if direct pause is unavailable. +6. If mode is `monitor` or `both`, report whether a visible app-server bridge attached. If it did not attach, report that the effective mode was downgraded to `turn`. Never describe visible-turn fallback as an active monitor. If argument is "hook on" (legacy alias): 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set turn codex "$(pwd)"` diff --git a/tests/test_codex_scheduled_monitor.bats b/tests/test_codex_scheduled_monitor.bats index e2add04c..8b6dd561 100644 --- a/tests/test_codex_scheduled_monitor.bats +++ b/tests/test_codex_scheduled_monitor.bats @@ -53,6 +53,53 @@ check_at() { [[ "$output" == *"next_rrule=FREQ=HOURLY;INTERVAL=1"* ]] } +@test "scheduled prepare is idempotent and emits one current-task prompt" { + export AGMSG_CODEX_SCHEDULED_OWNER="owner-prepared" + + run bash "$MONITOR" prepare "$TEST_PROJECT" team alice --now 1000 + [ "$status" -eq 0 ] + [[ "$output" == *"status=prepared owner=owner-prepared"* ]] + [[ "$output" == *"FREQ=MINUTELY;INTERVAL=2"* ]] + [[ "$output" == *"returns to this current task"* ]] + + run bash "$MONITOR" prepare "$TEST_PROJECT" team alice --now 1001 + [ "$status" -eq 0 ] + [[ "$output" == *"status=already_prepared owner=owner-prepared"* ]] + state_count="$(find "$AGMSG_RUN_PATH" -maxdepth 1 -name 'codex-scheduled-monitor.*.state' -type f | wc -l | tr -d ' ')" + [ "$state_count" -eq 1 ] + + run bash "$MONITOR" status-identity "$TEST_PROJECT" team alice --now 1001 + [ "$status" -eq 0 ] + [[ "$output" == *"status=active"* ]] + + run bash "$MONITOR" status-project "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" == *"identity=team/alice"* ]] + [[ "$output" == *"status=active count=1"* ]] + + run bash "$MONITOR" stop-identity "$TEST_PROJECT" team alice + [ "$status" -eq 0 ] + [[ "$output" == *"status=inactive schedule_action=pause"* ]] + + run bash "$MONITOR" stop-identity "$TEST_PROJECT" team alice + [ "$status" -eq 0 ] + [[ "$output" == *"status=inactive schedule_action=pause"* ]] +} + +@test "scheduled stop-project disarms every role in the project" { + AGMSG_CODEX_SCHEDULED_OWNER="owner-alice" bash "$MONITOR" prepare "$TEST_PROJECT" team alice --now 1000 >/dev/null + AGMSG_CODEX_SCHEDULED_OWNER="owner-bob" bash "$MONITOR" prepare "$TEST_PROJECT" team bob --now 1000 >/dev/null + + run bash "$MONITOR" stop-project "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" == *"stopped=2"* ]] + [[ "$output" == *"schedule_action=pause"* ]] + + run bash "$MONITOR" status-project "$TEST_PROJECT" + [ "$status" -eq 3 ] + [[ "$output" == *"status=inactive count=0"* ]] +} + @test "new unread mail resets a slow cycle to two minutes without consuming it" { arm >/dev/null bash "$MONITOR" scheduled "$TEST_PROJECT" team alice owner-1 3600 >/dev/null @@ -110,7 +157,10 @@ check_at() { [ "$status" -eq 0 ] [[ "$output" == *"do not notify the user"* ]] [[ "$output" == *"Do not create another task"* ]] + [[ "$output" == *"heartbeat automation"* ]] + [[ "$output" == *"targetThreadId is this current task"* ]] [[ "$output" == *"Never edit automation files directly"* ]] [[ "$output" == *"Never use Desktop relay"* ]] [[ "$output" == *"ChatGPT.app restart"* ]] + [[ "$output" == *"status is inactive"* ]] } diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index b3497cf4..5e1ea3bd 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -1518,6 +1518,36 @@ EOF grep -q "check-inbox.sh" "$hook_file" } +@test "delivery status (codex): native Scheduled state is reported without hooks" { + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + local scheduled="$TYPES/codex/codex-scheduled-monitor.sh" + AGMSG_CODEX_SCHEDULED_OWNER="owner-delivery" \ + bash "$scheduled" prepare "$TEST_PROJECT" team alice --now 1000 >/dev/null + + run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" == *"mode: scheduled"* ]] + [[ "$output" == *"identity=team/alice"* ]] + [[ "$output" == *"status=active count=1"* ]] + + run bash "$SCRIPTS/delivery.sh" set off codex "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" == *"stopped=1 schedule_action=pause"* ]] + + run bash "$SCRIPTS/delivery.sh" status codex "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" == *"mode: off"* ]] + [[ "$output" != *"mode: scheduled"* ]] + + AGMSG_CODEX_SCHEDULED_OWNER="owner-delivery-turn" \ + bash "$scheduled" prepare "$TEST_PROJECT" team alice --now 2000 >/dev/null + run bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" + [ "$status" -eq 0 ] + run bash "$scheduled" status-project "$TEST_PROJECT" + [ "$status" -eq 3 ] + [[ "$output" == *"status=inactive count=0"* ]] +} + @test "delivery status (codex): live bridge reports alive and suppresses watch count" { skip_on_windows "codex bridge status liveness under Git Bash (#182)" bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null diff --git a/tests/test_install.bats b/tests/test_install.bats index 3aee2791..6f1842bc 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -122,6 +122,14 @@ teardown() { [[ "$drop_block" == *"delivery.sh status"* ]] } +@test "install: Codex skill ships the native Scheduled monitor workflow" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + grep -q 'scheduled start' "$SK/SKILL.md" + grep -q 'kind=heartbeat' "$SK/SKILL.md" + grep -q 'targetThreadId' "$SK/SKILL.md" + grep -q 'Never use Desktop relay' "$SK/scripts/drivers/types/codex/codex-scheduled-monitor.sh" +} + @test "install: --update warns to re-register delivery hooks (#133)" { HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg run env HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg --update