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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions docs/codex-monitor-beta.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 33 additions & 4 deletions scripts/check-inbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
42 changes: 32 additions & 10 deletions scripts/delivery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
$pairs
EOF
Expand Down
85 changes: 81 additions & 4 deletions scripts/drivers/types/codex/_delivery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,30 @@
# 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. 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.
# 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,
# 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 <mode> <type> <project>; on_disable <type> <project>.

# 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:"
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
41 changes: 34 additions & 7 deletions scripts/drivers/types/codex/_session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading