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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <thread-id>` は常にそのまま尊重される。

グローバルなPATHシムを好むなら、`~/.agents/skills/<cmd>/scripts/drivers/types/codex/codex-shim-install.sh install` を実行し、`~/.agents/bin` を実物のCodexバイナリより前にPATHに置く。`~/.agents/skills/<cmd>/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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <thread-id>` is always honored as given.

If you prefer a global PATH shim, run `~/.agents/skills/<cmd>/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/<cmd>/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
Expand Down
39 changes: 35 additions & 4 deletions docs/codex-monitor-beta.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,37 @@ under `~/.agents/skills/<cmd>/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 <url> <thread>` — 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 <gone-uuid>` 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 <thread-id>`. 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
Expand All @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions scripts/drivers/types/codex/_delivery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
9 changes: 9 additions & 0 deletions scripts/drivers/types/codex/codex-bridge-launcher.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand All @@ -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>&- &
Expand Down
43 changes: 43 additions & 0 deletions scripts/drivers/types/codex/codex-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ms> 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 <sec> 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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions scripts/drivers/types/codex/codex-monitor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> <thread>`); 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 <gone-uuid>` 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 <thread-id>' to pick one." >&2
return 0
;;
esac
IFS=$'\t' read -r team name _rest <<EOF
$ids
EOF
{ [ -n "$team" ] && [ -n "$name" ]; } || return 0
# shellcheck source=../../../lib/type-registry.sh
source "$SCRIPT_DIR/../../../lib/type-registry.sh"
# shellcheck source=../../../lib/role-session.sh
source "$SCRIPT_DIR/../../../lib/role-session.sh"
# shellcheck source=../../../lib/resolve-project.sh
source "$SCRIPT_DIR/../../../lib/resolve-project.sh"
# shellcheck source=../../../lib/boot-command.sh
source "$SCRIPT_DIR/../../../lib/boot-command.sh"
uuid="$(agmsg_role_resume_uuid codex "$team" "$name" "$PROJECT" 2>/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
Expand All @@ -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)
Expand Down
Loading
Loading