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 new file mode 100755 index 00000000..74a1f2f3 --- /dev/null +++ b/scripts/drivers/types/codex/actas-monitor.sh @@ -0,0 +1,491 @@ +#!/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 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}" +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="$(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 + +# 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 + 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 + bootout_label_and_wait "gui/$(id -u)" "$label" + 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" +} + +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/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_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") + 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 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" + 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" + 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" ] && [ "$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" + 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 "$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-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 +} + +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 + if [ "${AGMSG_CODEX_ALLOW_HEADLESS_APP_MONITOR:-}" != "1" ]; then + start_chat_visible_turn_delivery "$THREAD_ID" + fi + 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..a11585f4 --- /dev/null +++ b/scripts/drivers/types/codex/codex-app-monitor.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 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 +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + 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}" +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/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-$$.$$" + +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" +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 +} +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: +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 + } + +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 + ;; + *) + 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 + ;; + esac + + if ! inbox_text="$(read_unread_for_prompt)"; then + continue + fi + + 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 + 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 + 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 6fccb208..054dfd35 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. @@ -1027,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}:`, @@ -1035,6 +1054,8 @@ 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} `, + "", + visibleUiRequirement, ].join("\n"); } return [ @@ -1042,11 +1063,25 @@ 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} `, + "", + visibleUiRequirement, ].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 +1096,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/template.md b/scripts/drivers/types/codex/template.md index 8dcf1195..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) - 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 — 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 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. 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. @@ -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. @@ -142,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/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 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" {