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
81 changes: 81 additions & 0 deletions docs/adr/0005-codex-invocation-scoped-app-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# ADR 0005: Codex invocation-scoped app-server lifecycle

**Status:** proposed
**Date:** 2026-08-30
**Deciders:** @fujibee

## Context

The Codex monitor normally reuses one app-server per project and replaces itself
with the TUI. Remote tool processes can therefore keep a disposable worktree as
their working directory after that TUI closes. A project hash, process name, or
Codex thread ID cannot prove which OS processes belong to one invocation when
several sessions share the server. This is the narrow managed-worktree case in
[#149](https://github.com/fujibee/agmsg/issues/149).

## Decision

Add opt-in `codex-monitor.sh --invocation-scope <token>` mode. The monitor
validates the token, hashes it with the canonical project path, acquires a lease
for that key, and starts a fresh app-server. It supervises the TUI, app-server,
and top-level bridge launcher as captured shell jobs. Normal TUI exit stops and
waits for the captured server and launcher, removes only matching records and
the keyed request, releases the lease, and returns the TUI status. Direct
`TERM` also reaps the captured TUI and returns 143.

The raw token is never used as a path. `AGMSG_CODEX_APP_SERVER_KEY` contains
only the derived 40-character hexadecimal key. Scope-less launches scrub that
key and any inherited bridge URL before deriving their project-shared records.
Malformed inherited keys fail closed before any request path or lock resource
is used.

Scoped SessionStart requests and dispatcher locks use the same app-server key.
The monitor clears a pre-existing keyed request immediately after acquiring the
lease and again during cleanup, so an old request cannot route a new invocation.
A scoped launcher accepts only a three-field request whose thread is nonempty
and whose app-server URL equals the URL captured at launch. Missing, malformed,
or mismatched requests leave messages unread.

Each scoped dispatcher selects only roles whose canonical project and recorded
thread match its request. The existing project-and-role child lock remains
global, so one role still has at most one bridge consumer across scopes. A
scoped child proves the project-and-thread match before acquiring that lock. If
the role seat moves, the old child uses the existing bridge lease and process
start-token checks to retire its exact bridge, then releases the lock for the
new matching scope. Bare bridge pidfiles are not signal authority.

Scope-less launches retain project server reuse, the project dispatcher key,
and TUI `exec` behavior.

## Alternatives considered

- Treat every child of a shared server as owned by the closing TUI. Rejected:
the server can host work from another session.
- Use only Codex thread IDs. Rejected: they do not enumerate the OS process
tree or establish signal authority.
- Add a daemon, reference counter, or durable scope field to role records.
Rejected: keyed requests, dispatchers, and the existing global role lock are
sufficient.
- Make every launch scoped. Rejected: existing users may depend on project-wide
reuse and its lower startup cost.

## Consequences

- Positive: disposable-worktree callers get a bounded, opt-in app-server
lifecycle without signaling foreign sessions.
- Positive: concurrent scopes route distinct role threads to their exact
servers while preserving one consumer per project role.
- Positive: no dependency, daemon, schema change, or second bridge cleanup
mechanism is introduced.
- Negative: every scoped invocation pays fresh app-server startup cost.
- Negative: `SIGKILL` cannot run traps and may leave a server, request, or lease.
Readiness must fail closed and recovery remains an explicit incident action.
- Neutral: the supervisor's signal authority is its captured shell jobs;
bridge replacement separately uses the existing lease and start-token proof.

## References

- [Issue #149](https://github.com/fujibee/agmsg/issues/149)
- `scripts/drivers/types/codex/codex-monitor.sh`
- `scripts/drivers/types/codex/codex-bridge-launcher.sh`
- `docs/codex-monitor-beta.md`
38 changes: 35 additions & 3 deletions docs/codex-monitor-beta.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ approximates the same experience by launching Codex through an app-server bridge
> 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).
> already-running session stays unmonitored until you restart it (#151). A
> scope-less launch retains the shared project app-server lifetime; callers that
> need a bounded disposable-worktree lifecycle can opt into invocation scope
> as described below (#149).

## Quick Start

Expand Down Expand Up @@ -110,6 +111,37 @@ codex logout
The shim also passes through when the current project is not in Codex monitor
mode.

## Invocation-scoped lifetime (opt-in)

The normal monitor keeps its existing behavior: it reuses a live app-server for
the project and `exec`s the Codex TUI. A caller that owns a disposable worktree
can instead request a lifecycle boundary for one invocation:

```bash
codex-monitor.sh --project "$PWD" --invocation-scope "$opaque_scope" --codex-command codex -- -C "$PWD"
```

`$opaque_scope` is a unique, non-secret token for that invocation. The monitor
validates it, combines it with the canonical project path, and exposes only the
derived record key to its children. Each scoped launch starts a fresh app-server,
so it pays app-server startup cost instead of reusing the project server.

The scoped monitor supervises its captured TUI, app-server, and top-level bridge
launcher. When the TUI exits normally, it stops and waits for the captured
processes, removes the matching scoped records, and returns the TUI status. A
direct `TERM` takes the same path and returns status `143`.

Concurrent scopes use separate request files and dispatchers. A role is routed
only when its recorded project and thread match that scope's request, while the
existing project-and-role lock still permits only one bridge consumer for the
role. Ending one scope therefore does not stop a peer scope's dispatcher or
delivery.

`SIGKILL` cannot run the supervisor's cleanup traps. It may leave a server or
lease behind, so callers must treat readiness as failed and handle recovery as
an explicit incident action. Invocation scope does not change the legacy,
scope-less lifecycle.

## Bridge Mechanics

`codex-monitor.sh` starts (or reuses) an agmsg-managed Codex app-server socket
Expand Down
26 changes: 19 additions & 7 deletions scripts/drivers/types/codex/_app-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,32 @@
# got nothing". The two are different answers and only the first may fall
# through to a weaker source.

# Echo the app-server record key for <project>, or fail for an invalid scoped key.
_agmsg_codex_app_server_record_key() {
local project="$1"
if [ -n "${AGMSG_CODEX_APP_SERVER_KEY:-}" ]; then
case "$AGMSG_CODEX_APP_SERVER_KEY" in ''|*[!0-9a-f]*) return 1 ;; esac
[ "${#AGMSG_CODEX_APP_SERVER_KEY}" -eq 40 ] || return 1
printf '%s' "$AGMSG_CODEX_APP_SERVER_KEY"
else
printf '%s' "$project" | agmsg_sha1
fi
}

# Echo the app-server URL for <project>, or nothing.
#
# The environment variable wins when present: it is the value monitor exported
# for this very process, and preferring it keeps every context that already
# worked on exactly the path it used before.
# A scoped key selects its exact record; only unscoped callers may prefer the
# inherited generic URL.
_agmsg_codex_app_server_url() {
local project="$1" port_file port
local project="$1" record_key port_file port
[ -n "$project" ] || return 0
if [ -n "${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" ]; then
if [ -z "${AGMSG_CODEX_APP_SERVER_KEY:-}" ] \
&& [ -n "${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" ]; then
printf '%s' "$AGMSG_CODEX_BRIDGE_APP_SERVER"
return 0
fi
command -v agmsg_sha1 >/dev/null 2>&1 || return 0
port_file="$SKILL_DIR/run/codex-app-server.$(printf '%s' "$project" | agmsg_sha1 2>/dev/null).port"
record_key="$(_agmsg_codex_app_server_record_key "$project")" || return 1
port_file="$SKILL_DIR/run/codex-app-server.$record_key.port"
port="$(cat "$port_file" 2>/dev/null || true)"
# Digits, and a port a TCP stack could have handed out. Digits alone are not
# enough on their own — a prefix of a real port (5 of 52962) is all digits and
Expand Down
47 changes: 31 additions & 16 deletions scripts/drivers/types/codex/_session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
# launcher start the bridge — a hook-launched bridge cannot connect to the unix
# socket from inside the Codex sandbox (#41).

# shellcheck source=_app-server.sh
source "$SKILL_DIR/scripts/drivers/types/codex/_app-server.sh"

# Newest-N rollout files under $sessions_dir, sorted by mtime descending.
# `ls -t "$dir"/*/*/*/rollout-*.jsonl` is unreliable on Windows/Git Bash --
# reported to intermittently return an empty/truncated list with no
Expand Down Expand Up @@ -120,28 +123,40 @@ agmsg_session_start() {
done <<< "$PAIRS"
PAIRS="$safe_pairs"
[ -n "$PAIRS" ] || exit 0
app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}"
if [ -z "$app_server" ]; then
agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true)
if [ -n "$agent_pid" ]; then
agent_cmd=$(compat_get_cmdline "$agent_pid" 2>/dev/null || true)
app_server=$(printf '%s\n' "$agent_cmd" \
| sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \
| head -1)
if [ -n "${AGMSG_CODEX_APP_SERVER_KEY:-}" ]; then
if ! app_server="$(_agmsg_codex_app_server_url "$PROJECT")"; then
exit 0
fi
fi
if [ -z "$app_server" ]; then
project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1)
socket_path="$RUN_DIR/codex-app-server.$project_hash.sock"
if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then
app_server="unix://$socket_path"
else
app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}"
if [ -z "$app_server" ]; then
agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true)
if [ -n "$agent_pid" ]; then
agent_cmd=$(compat_get_cmdline "$agent_pid" 2>/dev/null || true)
app_server=$(printf '%s\n' "$agent_cmd" \
| sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \
| head -1)
fi
fi
if [ -z "$app_server" ]; then
app_server="$(_agmsg_codex_app_server_url "$PROJECT")"
fi
if [ -z "$app_server" ]; then
project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1)
socket_path="$RUN_DIR/codex-app-server.$project_hash.sock"
if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then
app_server="unix://$socket_path"
fi
fi
fi
[ -n "$app_server" ] || exit 0

if [ "${AGMSG_CODEX_BRIDGE_LAUNCHER:-}" = "1" ]; then
project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1)
request_file="$RUN_DIR/codex-bridge-request.$project_hash"
request_key=""
if ! request_key="$(_agmsg_codex_app_server_record_key "$PROJECT")"; then
exit 0
fi
request_file="$RUN_DIR/codex-bridge-request.$request_key"
tmp_request="$request_file.$$"
mkdir -p "$RUN_DIR" 2>/dev/null || true
printf '%s\t%s\t%s\n' "$TYPE" "$thread_id" "$app_server" > "$tmp_request"
Expand Down
Loading
Loading