diff --git a/README.ja.md b/README.ja.md index 2bb4e11b..799d62c7 100644 --- a/README.ja.md +++ b/README.ja.md @@ -292,6 +292,8 @@ Codexは `mode monitor` をapp-serverブリッジ経由でサポートし、加 > ⚠️ **monitorモードはCodexの起動方法を変える — それを承知した上で有効化すること。** CodexにはMonitorツールがないため、`mode monitor` はインタラクティブシェル内で `codex` をagmsgのmonitorシム経由にルーティングするシェル関数を表示する。monitorモードのプロジェクトでは、このシムがインタラクティブな起動を、受信したagmsgメッセージを現在のCodexスレッドのターンに変換するブリッジ経由にルーティングする。`codex exec` とmonitor対象外のプロジェクトは実物のCodexにそのまま通る。これはCodex app-serverの挙動に依存しており、既知の制限がある(TUIを閉じるとオーファンが残る — #149)。 +monitorモードのプロジェクトでは、素の `codex` は新しいスレッドを開くのではなく**そのロールに記録されたスレッド**(`actas` が記録するseat)を再開する。これで、目の前の会話がブリッジの配信先そのものになる。再開するのは曖昧さがない場合だけ — プロジェクトに登録されたCodexロールが1つで、seatがこのプロジェクトのもので、rolloutがディスクに残っているとき。それ以外は理由を表示して新規スレッドで起動する。明示的な `codex resume ` は常にそのまま尊重される。 + グローバルな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)。 ### GitHub Copilot CLI diff --git a/README.md b/README.md index b775aedf..b8331166 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,8 @@ Codex supports `mode monitor` through an app-server bridge, plus `mode turn` and > ⚠️ **Monitor mode changes how Codex starts — enable it knowing that.** 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 Codex app-server behavior and has a known limitation (orphans on TUI close — #149). +In a monitor-mode project, a plain `codex` **resumes the role's recorded thread** (the seat `actas` records) rather than opening a fresh one, so the conversation you see is the one the bridge delivers to. It does so only when that is unambiguous — one Codex role registered for the project, seated here, with its rollout still on disk; otherwise it starts fresh and says why. An explicit `codex resume ` is always honored as given. + If you prefer a global PATH shim, run `~/.agents/skills//scripts/drivers/types/codex/codex-shim-install.sh install` and put `~/.agents/bin` before the real Codex binary on PATH. You can also launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup notes and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). ### GitHub Copilot CLI diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 4fa3ba1e..8f979f9e 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -117,8 +117,37 @@ under `~/.agents/skills//run/`, starts the out-of-sandbox bridge launcher, and then connects the Codex TUI to that socket with `--remote`. Codex fires the SessionStart hook on the session's **first turn** (the first -message you send), not the moment the TUI opens — so the bridge does not exist -until you interact once after a restart. +message you send), not the moment the TUI opens — so for a role that has no +recorded seat yet, the bridge does not exist until you interact once after a +restart. + +### A plain `codex` resumes the role's seat + +Once a role has a recorded seat (the role→thread record `actas` / +`codex-record-session.sh` writes), a plain monitored `codex` in that project +opens the TUI **on that thread** — `codex resume --remote ` — not +on a fresh one. The bridge is bound to the recorded thread (#350), and a seat +that exists is never replaced by inference (#579), so a fresh TUI thread could +never become the seat again: the bridge would keep delivering to the recorded +thread while you looked at a different one, with every turn marked read where +you could not see it. + +The resume happens only when it is unambiguous and safe: exactly one Codex role +is registered for the project, its seat is recorded for this project, and the +thread's rollout still exists (`codex resume ` refuses to start, so +a stale seat falls back to a fresh launch, like `spawn`). With several roles, +`codex-monitor.sh` says so and starts fresh; pick one with +`codex resume `. An explicit `codex resume ...` is never rewritten. + +The bridge, for its part, **attaches** to the thread the TUI loaded +(`--wait-for-tui-thread`: it polls `thread/loaded/list` until the recorded +thread is there) instead of issuing a `thread/resume` of its own. The TUI is +the thread's writer, and `turn/start` needs only the thread id (the +no-rollout fallback has relied on that since #276), so a bridge-side resume is +at best redundant and at worst a competing writer (Codex Desktop: `already has +an active writer`, #906). If the TUI has not loaded the thread within +`--loaded-timeout`, the bridge exits rather than deliver into a thread no one +is watching, and the launcher relaunches it once the TUI is there. The SessionStart hook is designed to **not** start the bridge directly — a hook-launched process was observed to run inside the Codex sandbox and fail to @@ -136,8 +165,10 @@ connect to the unix socket (EPERM). Instead: 2. `codex-bridge-launcher.sh`, started by `codex-monitor.sh` **outside** the sandbox, reads the request file and starts `codex-bridge.js`. 3. The bridge connects to the same app-server over **WebSocket-over-UDS**, - resumes the thread, and arms `watch-once.sh` via the app-server `process/spawn` - API (which polls the agmsg DB for unread rows, `read_at IS NULL`). + attaches to the thread the TUI has loaded (or resumes it, when launched + without `--wait-for-tui-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. diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh index f61beb9c..e8cdc83c 100644 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -29,8 +29,9 @@ 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 "Restart your Codex session (quit and relaunch \`codex\`). A role with a recorded" + echo " seat is resumed on that thread and bridged right away; a new role records its" + echo " seat on your first message, and the bridge starts from there." echo " Already-running sessions stay unmonitored until they restart." echo "For more info: $CODEX_MONITOR_DOC_URL" } diff --git a/scripts/drivers/types/codex/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh index 98fb410e..75457bcb 100755 --- a/scripts/drivers/types/codex/codex-bridge-launcher.sh +++ b/scripts/drivers/types/codex/codex-bridge-launcher.sh @@ -757,6 +757,14 @@ EOF # the new ones, so no gate can bail out between the wipe and the rewrite. rm -f "$pidfile" "$appserver_file" "$thread_file" + # --wait-for-tui-thread: the thread is this role's recorded seat, and + # codex-monitor.sh opens the TUI on it. The bridge attaches once the TUI has + # it loaded rather than resuming it on its own: the TUI is the writer, a + # turn/start needs only the id, and a bridge-side resume is the "already has + # an active writer" collision against Codex Desktop (#906). A bridge that + # never sees the TUI on its thread exits instead of delivering into a thread + # no one is looking at (#350). A legacy request-file thread takes the same + # path: it too names a thread a live TUI has opened. nohup "${bridge_run[@]}" \ --project "$PROJECT" \ --workspace-root "$STORAGE_DIR" \ @@ -765,6 +773,7 @@ EOF --type "$TYPE" \ "${bridge_pairs[@]}" \ --thread "$thread_id" \ + --wait-for-tui-thread \ --app-server "$req_app_server" \ --inline-inbox \ >>"$log" 2>&1 3>&- 4>&- & diff --git a/scripts/drivers/types/codex/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js index 9cda16b3..eba20056 100755 --- a/scripts/drivers/types/codex/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -58,6 +58,10 @@ Options: CODEX_THREAD_ID; "loaded" discovers the live TUI thread via thread/loaded/list (codex 0.141+, see #170). --loaded-timeout Max wait for a loaded thread to appear (default: 30000). + --wait-for-tui-thread With an explicit --thread: wait (up to --loaded-timeout) + until the live TUI has that thread loaded, then attach + to it WITHOUT a thread/resume of our own. The TUI owns + the thread; the bridge only starts turns on it. --turn-timeout Idle watchdog: assume a turn ended after this many seconds with no app-server activity for it at all (default: 60; 0 disables). Re-armed on any @@ -199,6 +203,7 @@ function parseArgs(argv) { pairs: [], workspaceRoots: [], turnTimeout: Number(process.env.AGMSG_CODEX_BRIDGE_TURN_TIMEOUT || 60), + waitForTuiThread: false, }; for (let i = 0; i < argv.length; i += 1) { @@ -245,6 +250,8 @@ function parseArgs(argv) { opts.threadId = argv[++i]; } else if (arg === "--loaded-timeout") { opts.loadedTimeout = Number(argv[++i]); + } else if (arg === "--wait-for-tui-thread") { + opts.waitForTuiThread = true; } else if (arg === "--inline-inbox") { opts.inlineInbox = true; } else { @@ -1207,10 +1214,46 @@ class CodexBridge { } } + // Attach to a thread the live TUI has loaded, without resuming it ourselves. + // + // The launcher binds this bridge to the role's recorded thread, and + // codex-monitor.sh now opens the TUI on that same thread. Two clients on one + // thread is the point -- the operator sees the turns the bridge starts. The + // TUI is that thread's writer, and the bridge needs no resume of its own: + // turn/start takes only the id, which is what the no-rollout fallback below + // has relied on since #276. A bridge-side thread/resume is therefore at best + // redundant and at worst a competing writer ("already has an active writer" + // against Codex Desktop, #906). So: ask thread/loaded/list until the TUI's + // load shows up, then proceed exactly as that fallback does (idle, no + // resume). Give up when the TUI has not loaded it in time -- a bridge that + // cannot see its TUI on the thread must not deliver into a thread no one is + // looking at (#350's failure, by another road), and the launcher relaunches + // it once the TUI is there. + async waitForTuiThread() { + const deadline = Date.now() + (this.opts.loadedTimeout || 30000); + for (;;) { + const response = await this.client.request("thread/loaded/list", {}); + const ids = response && Array.isArray(response.data) ? response.data : []; + if (ids.includes(this.threadId)) { + console.error(`codex-bridge: TUI loaded thread ${this.threadId}; attaching without a competing resume`); + this.threadIdle = true; + this.turnActive = false; + return; + } + if (Date.now() >= deadline) { + die(`TUI did not load recorded thread ${this.threadId} within the configured timeout`); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + async ensureThread() { if (this.threadId === "loaded") { this.threadId = await this.resolveLoadedThread(); console.error(`codex-bridge: discovered loaded thread ${this.threadId}`); + } else if (this.threadId && this.opts.waitForTuiThread) { + await this.waitForTuiThread(); + return; } if (this.threadId) { let response; diff --git a/scripts/drivers/types/codex/codex-monitor.sh b/scripts/drivers/types/codex/codex-monitor.sh index 8f580c91..92c9680d 100755 --- a/scripts/drivers/types/codex/codex-monitor.sh +++ b/scripts/drivers/types/codex/codex-monitor.sh @@ -230,6 +230,68 @@ SOCKET_URL="ws://127.0.0.1:$PORT" "$SCRIPT_DIR/../../../delivery.sh" set monitor codex "$PROJECT" >/dev/null +# A plain monitored launch attaches the TUI to the role's RECORDED thread. +# +# The bridge launcher binds each bridge to its role's role-session record +# (#350), and codex-record-session.sh never lets an inference replace a seat +# that already exists (#579). Both are right on their own, but together they +# mean a plain `codex` -- which `--remote` starts as a FRESH thread -- can never +# become the seat again: the bridge keeps delivering to the recorded thread, +# and the conversation the operator is looking at is a different one. The +# turns land, and are marked read, where no one can see them. +# +# So the TUI has to make the same choice the launcher makes. One role with a +# seat in this project, whose rollout still exists, is resumed on that thread +# (`codex resume --remote `); everything else keeps the fresh +# launch it has today. An explicit `codex resume ...` already carries the +# operator's choice and is left alone. agmsg_role_resume_uuid is the same +# fail-open gate spawn/resurrect use (record + transcript, else fresh), so a +# seat whose rollout is gone -- which `codex resume ` refuses -- +# cannot take the TUI down with it. +TUI_ROLE="" +TUI_THREAD="" +resolve_tui_thread() { + [ "$CODEX_COMMAND" = "codex" ] || return 0 + local ids count team name _rest uuid rec_project rec_project_phys project_phys + ids="$("$SCRIPT_DIR/../../../identities.sh" "$PROJECT" codex 2>/dev/null || true)" + count="$(printf '%s\n' "$ids" | grep -c . || true)" + case "$count" in + 0) return 0 ;; + 1) ;; + *) + echo "agmsg: $count Codex roles are registered for this project; a plain launch cannot choose which one to show, so this is a fresh thread. Use 'codex resume ' to pick one." >&2 + return 0 + ;; + esac + IFS=$'\t' read -r team name _rest </dev/null || true)" + [ -n "$uuid" ] || return 0 + # The launcher compares the record's project with its own the same way; a + # seat in another project is that role's live conversation elsewhere, and + # resuming it here would show the wrong one. + rec_project="$(agmsg_role_session_get "$team" "$name" project 2>/dev/null || true)" + project_phys="$(agmsg_canonical_path "$PROJECT" 2>/dev/null || printf '%s' "$PROJECT")" + rec_project_phys="$(agmsg_canonical_path "$rec_project" 2>/dev/null || printf '%s' "$rec_project")" + if [ "$rec_project_phys" != "$project_phys" ]; then + echo "agmsg: the recorded thread for $team/$name belongs to another project; starting a fresh thread instead of resuming the wrong conversation." >&2 + return 0 + fi + TUI_ROLE="$team/$name" + TUI_THREAD="$uuid" +} +resolve_tui_thread + export AGMSG_CODEX_BRIDGE=1 export AGMSG_CODEX_BRIDGE_APP_SERVER="$SOCKET_URL" export AGMSG_CODEX_BRIDGE_LAUNCHER=1 @@ -244,6 +306,10 @@ cd "$PROJECT" # empty array errors with "unbound variable" (a no-arg `codex`/`codex resume`). case "$CODEX_COMMAND" in codex) + if [ -n "$TUI_THREAD" ]; then + echo "agmsg: attaching Codex TUI to $TUI_ROLE (thread $TUI_THREAD)" >&2 + exec "$REAL_CODEX" resume --remote "$SOCKET_URL" "$TUI_THREAD" ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} + fi exec "$REAL_CODEX" --remote "$SOCKET_URL" ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} ;; resume) diff --git a/tests/test_codex_bridge.bats b/tests/test_codex_bridge.bats index a87106d0..1fbf5aa2 100644 --- a/tests/test_codex_bridge.bats +++ b/tests/test_codex_bridge.bats @@ -952,6 +952,116 @@ EOF [[ "$output" =~ "no loaded codex thread" ]] } +@test "codex-bridge: --wait-for-tui-thread attaches once the TUI has the thread loaded, with no resume of its own" { + run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' + if [ "$status" -ne 0 ]; then + skip "node child_process.spawn is not available in this sandbox" + fi + + # The TUI is still opening the thread when the bridge connects: the first two + # thread/loaded/list answers do not carry it, the third does. The bridge must + # wait for that, and then NOT thread/resume -- the TUI is the thread's writer. + local fake="$TEST_SKILL_DIR/fake-app-server-tui-thread.js" + local log="$TEST_SKILL_DIR/fake-app-server-tui-thread.log" + cat >"$fake" <<'EOF' +const fs = require("fs"); +const readline = require("readline"); +const log = process.argv[2]; +const rl = readline.createInterface({ input: process.stdin }); +let lists = 0; + +function send(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + fs.appendFileSync(log, `${message.method}\n`); + if (message.method === "initialize") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } else if (message.method === "thread/loaded/list") { + lists += 1; + const data = lists >= 3 ? ["other-thread", "seated-thread-1"] : ["other-thread"]; + send({ jsonrpc: "2.0", id: message.id, result: { data } }); + } else if (message.method === "thread/resume") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { thread: { id: message.params.threadId, status: { type: "idle" } } }, + }); + } else if (message.method === "process/spawn") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + setTimeout(() => process.exit(0), 10); + } else if (message.method === "process/kill") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } +}); +EOF + + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ + --project "$PROJ" --team team --name alice --thread seated-thread-1 --wait-for-tui-thread \ + --loaded-timeout 5000 --timeout 20 + + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qF "TUI loaded thread seated-thread-1; attaching without a competing resume" + # Polled until the TUI's load showed up... + [ "$(grep -c "thread/loaded/list" "$log")" -ge 3 ] + # ...then armed the watch on it, without ever resuming or starting a thread. + grep -q "process/spawn" "$log" + refute grep -q "thread/resume" "$log" + refute grep -q "thread/start" "$log" +} + +@test "codex-bridge: --wait-for-tui-thread gives up when the TUI never loads the thread" { + run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' + if [ "$status" -ne 0 ]; then + skip "node child_process.spawn is not available in this sandbox" + fi + + # A bridge that cannot see its TUI on the thread must not fall back to a + # resume of its own: that is exactly the bridge-on-one-copy, operator-on- + # another shape this flag exists to prevent. Exit, and let the launcher retry. + local fake="$TEST_SKILL_DIR/fake-app-server-tui-thread-absent.js" + local log="$TEST_SKILL_DIR/fake-app-server-tui-thread-absent.log" + cat >"$fake" <<'EOF' +const fs = require("fs"); +const readline = require("readline"); +const log = process.argv[2]; +const rl = readline.createInterface({ input: process.stdin }); + +function send(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + fs.appendFileSync(log, `${message.method}\n`); + if (message.method === "initialize") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } else if (message.method === "thread/loaded/list") { + send({ jsonrpc: "2.0", id: message.id, result: { data: ["other-thread"] } }); + } else if (message.method === "thread/resume") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { thread: { id: message.params.threadId, status: { type: "idle" } } }, + }); + } else if (message.method === "process/spawn") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } +}); +EOF + + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ + --project "$PROJ" --team team --name alice --thread seated-thread-1 --wait-for-tui-thread \ + --loaded-timeout 1500 --timeout 20 + + [ "$status" -ne 0 ] + printf '%s\n' "$output" | grep -qF "TUI did not load recorded thread seated-thread-1" + refute grep -q "thread/resume" "$log" + refute grep -q "process/spawn" "$log" +} + @test "codex-bridge: inline-inbox includes unread message text in turn input" { run node -e 'const r = require("child_process").spawnSync("/bin/sh", ["-c", "true"]); if (r.error) { console.error(r.error.message); process.exit(1); }' if [ "$status" -ne 0 ]; then diff --git a/tests/test_codex_bridge_launcher.bats b/tests/test_codex_bridge_launcher.bats index 0e8de039..052bd809 100644 --- a/tests/test_codex_bridge_launcher.bats +++ b/tests/test_codex_bridge_launcher.bats @@ -173,6 +173,17 @@ run_launcher() { ! grep -q -- "--thread loaded" "$CAPTURE" } +@test "launcher: the bridge attaches to the TUI's load of the recorded thread, not a resume of its own" { + # codex-monitor.sh opens the TUI on the recorded thread; the bridge must wait + # for that load and attach to it rather than thread/resume the thread itself + # (the bridge-side resume is how bridge and operator end up on different + # copies of one conversation, #350, and the "active writer" collision, #906). + put_record team alice rec-thread-1 "$PROJ" codex + run_launcher + [ -f "$CAPTURE" ] + grep -q -- "--thread rec-thread-1 --wait-for-tui-thread" "$CAPTURE" +} + @test "launcher: passes the active storage override as a workspace root" { export AGMSG_STORAGE_PATH="$TEST_SKILL_DIR/custom-store" put_record team alice rec-thread-1 "$PROJ" codex diff --git a/tests/test_codex_monitor.bats b/tests/test_codex_monitor.bats index ebf4be66..f11d8678 100644 --- a/tests/test_codex_monitor.bats +++ b/tests/test_codex_monitor.bats @@ -394,3 +394,115 @@ EOF # No truncating redirect to the published path. ! grep -qE '>[[:space:]]*"\$PORT_FILE"' "$src" } + +# --- a plain launch resumes the role's recorded seat --- +# +# The bridge is bound to the role's recorded thread (#350) and a recorded seat +# is never replaced by inference (#579), so a plain `codex` -- which --remote +# opens as a FRESH thread -- could never become the seat again: the bridge kept +# delivering to the recorded thread while the operator looked at another one. +# codex-monitor.sh now opens the TUI on the recorded thread when that is +# unambiguous, and leaves every other launch exactly as it was. + +# A role-session record (team, agent) -> thread, as actas/record-session write it. +put_seat() { + SKILL_DIR="$TEST_SKILL_DIR" bash -c \ + 'source "$1/lib/role-session.sh"; agmsg_role_session_record "$2" "$3" "$4" "$5" codex' \ + _ "$SCRIPTS" "$@" +} + +# A codex rollout for : what `codex resume ` needs to exist. +put_rollout() { + local uuid="$1" dir="$HOME/.codex/sessions/2026/08/10" + mkdir -p "$dir" + printf '{"type":"session_meta","payload":{"id":"%s","cwd":"%s"}}\n' "$uuid" "$TEST_PROJECT" \ + > "$dir/rollout-2026-08-10T13-54-57-$uuid.jsonl" +} + +# The launcher is not under test here; a no-op keeps it from spawning a real +# bridge against the fake app-server. +run_monitor() { + run env AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_BRIDGE_LAUNCHER_CMD=/bin/true \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" "$@" +} + +@test "codex-monitor: a plain launch resumes the role's recorded thread" { + skip_on_windows "spawns a python socket listener; flaky on the Windows runner" + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + put_seat team alice seated-thread-1 "$TEST_PROJECT" + put_rollout seated-thread-1 + + run_monitor --codex-command codex -- --foo + [ "$status" -eq 0 ] + # The TUI is opened ON the seat: `resume --remote `, with the + # operator's own args still after it. + grep -q '^plain-codex <--remote> <--foo>$' "$CALL_LOG" + printf '%s\n' "$output" | grep -qF 'attaching Codex TUI to team/alice (thread seated-thread-1)' +} + +@test "codex-monitor: a plain launch stays fresh when the role has no seat" { + skip_on_windows "spawns a python socket listener; flaky on the Windows runner" + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + + run_monitor --codex-command codex -- + [ "$status" -eq 0 ] + grep -q '^plain-codex <--remote> $' "$CALL_LOG" + refute grep -q '' "$CALL_LOG" +} + +@test "codex-monitor: a seat whose rollout is gone falls back to a fresh launch" { + skip_on_windows "spawns a python socket listener; flaky on the Windows runner" + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + put_seat team alice seated-thread-1 "$TEST_PROJECT" + # No rollout: `codex resume ` would refuse to start ("no rollout + # found"), so the seat must not be handed to the TUI. Same gate as spawn. + + run_monitor --codex-command codex -- + [ "$status" -eq 0 ] + grep -q '^plain-codex <--remote> $' "$CALL_LOG" + refute grep -q '' "$CALL_LOG" +} + +@test "codex-monitor: a seat recorded for another project is not resumed here" { + skip_on_windows "spawns a python socket listener; flaky on the Windows runner" + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + local elsewhere="$TEST_SKILL_DIR/elsewhere"; mkdir -p "$elsewhere" + put_seat team alice seated-thread-1 "$elsewhere" + put_rollout seated-thread-1 + + run_monitor --codex-command codex -- + [ "$status" -eq 0 ] + grep -q '^plain-codex <--remote> $' "$CALL_LOG" + refute grep -q '' "$CALL_LOG" + printf '%s\n' "$output" | grep -qF 'belongs to another project' +} + +@test "codex-monitor: several roles in one project start fresh and say so" { + skip_on_windows "spawns a python socket listener; flaky on the Windows runner" + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + bash "$SCRIPTS/join.sh" team bob codex "$TEST_PROJECT" >/dev/null + put_seat team alice seated-thread-1 "$TEST_PROJECT" + put_seat team bob seated-thread-2 "$TEST_PROJECT" + put_rollout seated-thread-1 + put_rollout seated-thread-2 + + run_monitor --codex-command codex -- + [ "$status" -eq 0 ] + # A plain launch cannot choose between two seats; it must not guess. + grep -q '^plain-codex <--remote> $' "$CALL_LOG" + refute grep -q '' "$CALL_LOG" + printf '%s\n' "$output" | grep -qF '2 Codex roles are registered' +} + +@test "codex-monitor: an explicit resume is passed through as given, seat or not" { + skip_on_windows "spawns a python socket listener; flaky on the Windows runner" + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + put_seat team alice seated-thread-1 "$TEST_PROJECT" + put_rollout seated-thread-1 + + run_monitor --codex-command resume -- other-thread-9 + [ "$status" -eq 0 ] + # The operator's choice wins; the seat is never substituted for it. + grep -q '^plain-codex <--remote> $' "$CALL_LOG" + refute grep -q 'seated-thread-1' "$CALL_LOG" +}