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 5c86155a..f2a271a1 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). @@ -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** app-server bridge, 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`. -> ⚠️ **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). +> ⚠️ **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 6eef241f..f6826603 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -1,22 +1,18 @@ # 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. +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 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 -> 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 -> 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). +> 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 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 @@ -28,10 +24,18 @@ 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. 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. +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: ```text @@ -60,6 +64,15 @@ 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 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 +scheduled polling automation is created for this path. + ## Optional PATH Shim If you prefer the previous global PATH shim setup, install it explicitly: @@ -138,9 +151,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 @@ -172,10 +187,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 ``` @@ -204,12 +220,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=) @@ -217,27 +231,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: @@ -263,7 +263,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/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/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/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..d3a18799 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -320,28 +320,102 @@ 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. +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 + 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 [ -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 + 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 # 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" + 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" + 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 + 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" "$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" \ + "$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" + # 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 + 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() { + 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() { - echo "Codex monitor beta is enabled." + 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." @@ -29,25 +74,53 @@ 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" } agmsg_delivery_on_disable() { local project="$2" - local stopped + 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." 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" + # 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" 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 + # 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") + 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 +} + agmsg_delivery_runtime_status() { local type="$1" project="$2" local pairs found=0 @@ -64,13 +137,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-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 diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 560fbd6a..53030457 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -60,8 +60,40 @@ INNER_EOF } 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 + + # 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 +111,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 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) @@ -124,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 new file mode 100755 index 00000000..ec54b319 --- /dev/null +++ b/scripts/drivers/types/codex/actas-monitor.sh @@ -0,0 +1,541 @@ +#!/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 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}" +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/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 +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 + +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 + 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 + + 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_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" + 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 requested_mode=$requested_mode effective_mode=$MODE reason=$reason" + exit 0 +} + +THREAD_ID="${AGMSG_CODEX_ACTAS_THREAD:-}" +if [ -z "$THREAD_ID" ]; then + THREAD_ID="$(resolve_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" + (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null +} + +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" + +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_chat_visible_turn_delivery "$THREAD_ID" "visible_app_server_unavailable" +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" + ) + 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. 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}" +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 + ;; +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..763e3549 --- /dev/null +++ b/scripts/drivers/types/codex/codex-app-monitor.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 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 <<'EOF' +Usage: codex-app-monitor.sh + +This legacy background receiver is disabled. +Use a visible app-server bridge, or use turn delivery when no bridge is +available. +EOF +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +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-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 6fccb208..11d1df7e 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,23 @@ 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 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 [ `agmsg delivered the following unread messages for ${this.identity.team}/${this.identity.name}:`, @@ -1035,18 +1063,38 @@ 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"); } 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 +1109,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/codex-monitor-lease.sh b/scripts/drivers/types/codex/codex-monitor-lease.sh new file mode 100755 index 00000000..e16e5b4f --- /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 <&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 ] + 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 +} + +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)) + [ "$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 + 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" + 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 < 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" + - 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`. 6. Then check inbox for the newly joined team. @@ -81,10 +87,45 @@ Four possible outputs: **Only use scripts in `~/.agents/skills/__SKILL_NAME__/scripts/` — do not read or modify files under `teams/` or `db/` directly.** +### 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. + +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, 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` -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,12 +153,14 @@ 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 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 ``." 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. +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." @@ -141,10 +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` (beta bridge), `turn`, and `off` — reject `both` with: "Codex bridge beta supports `monitor`, `turn`, or `off`; `both` is not supported yet." -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" +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/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/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 bc863630..5a0444f3 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" +# 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 + # 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 @@ -87,4 +184,13 @@ 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 + stop_codex_thread_receivers + "$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_bridge.bats b/tests/test_codex_bridge.bats index 4ce91eee..d7979133 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({ @@ -795,6 +803,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('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({ @@ -814,6 +830,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_monitor_lease.bats b/tests/test_codex_monitor_lease.bats new file mode 100644 index 00000000..27f68b68 --- /dev/null +++ b/tests/test_codex_monitor_lease.bats @@ -0,0 +1,169 @@ +#!/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" == *"--fallback-after 0"* ]] + [[ "$output" == *"low-cost independent agmsg collector"* ]] + [[ "$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_scheduled_monitor.bats b/tests/test_codex_scheduled_monitor.bats new file mode 100644 index 00000000..8b6dd561 --- /dev/null +++ b/tests/test_codex_scheduled_monitor.bats @@ -0,0 +1,166 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env + export TEST_PROJECT="$(mktemp -d)" + export MONITOR="$TYPES/codex/codex-scheduled-monitor.sh" + export AGMSG_RUN_PATH="$TEST_SKILL_DIR/run" + 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 "$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 "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 + 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" == *"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_codex_visible_monitor.bats b/tests/test_codex_visible_monitor.bats new file mode 100644 index 00000000..80b23221 --- /dev/null +++ b/tests/test_codex_visible_monitor.bats @@ -0,0 +1,409 @@ +#!/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" +} + +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 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 + [ "$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 "check-inbox.sh" "$hooks" + [[ "$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" + + run env CODEX_THREAD_ID=thread-after \ + bash "$SCRIPTS/session-start.sh" 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" "$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 "legacy background opt-in cannot resume a Codex thread or consume unread mail" { + join_codex_roles + 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" + prompt="$TEST_SKILL_DIR/fake-codex.prompt" + + run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + 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 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 + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] +} + +@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 "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 CODEX_THREAD_ID=thread-123 \ + AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + AGMSG_FAKE_CODEX_LOG="$log" \ + AGMSG_FAKE_CODEX_PROMPT="$prompt" \ + AGMSG_FAKE_CODEX_INBOX="$SCRIPTS/inbox.sh" \ + 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 + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] +} + +@test "repeated monitor requests without a visible app-server never start a receiver" { + join_codex_roles + 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" == *"effective_mode=turn"* ]] + + 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" == *"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" { + 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 + + 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" < "$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 \ + "$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"* ]] + 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 "legacy monitor opt-in cannot create a second hidden receiver" { + join_codex_roles + 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_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 + [ "$status" -eq 64 ] + + run env AGMSG_CODEX_ALLOW_BACKGROUND_THREAD_RESUME=1 \ + AGMSG_CODEX_APP_MONITOR_CODEX="$fake" \ + 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 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" { + 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 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" + [ "$status" -ne 0 ] +} diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index df4d2fb0..5e1ea3bd 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" { @@ -1492,7 +1493,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 +1507,45 @@ 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): 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" { diff --git a/tests/test_install.bats b/tests/test_install.bats index 44fe58c3..6f1842bc 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. @@ -97,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 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" ]]