Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.
Merged
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
13 changes: 7 additions & 6 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,13 @@ The decision persists for the repo, so later worktrees of the same project skip
Resume after exit with `codex resume <session-id>`.
The session id is printed on quit.

**Primary-session guard fact (verified 2026-07-08, codex-cli 0.142.1).**
The firstmate PRIMARY's own `.codex/hooks.json` registers a Stop hook that pipes Codex's Stop payload to `bin/fm-turnend-guard.sh`.
Codex Stop hooks block on exit 2 and expose `stop_hook_active` for the same one-block loop safety Claude uses.
Codex's Stop payload includes `cwd`, but the tracked primary hook does not use it to choose the guard executable.
Verified on 2026-07-08: Codex runs the Stop hook command with process PWD set to the hook-loaded project root, and no `CODEX_PROJECT_DIR`, `CODEX_WORKSPACE_ROOT`, or `CODEX_CWD` root variable is set.
The tracked hook anchors to `pwd -P`, verifies that root is firstmate-shaped and hook-bearing, and then invokes `bin/fm-turnend-guard.sh` with the original payload.
**Primary-session hook facts.**
The firstmate PRIMARY's own `.codex/hooks.json` registers the bounded
`SessionStart` and `SessionEnd` lock hooks described in
`docs/configuration.md`, plus the Bash `PreToolUse` primary-directory guard.
These tracked hooks anchor to the hook-loaded project's `pwd -P`.
JT does not auto-wire `bin/fm-turnend-guard.sh` into a Codex Stop hook in
Phase B; the guard remains callable as documented in `docs/turnend-guard.md`.
Codex's primary watcher protocol is `bin/fm-watch-checkpoint.sh --seconds "${FM_CODEX_WATCH_CHECKPOINT:-180}"`, not `bin/fm-watch-arm.sh`.
The checkpoint is deliberately foreground and bounded so Codex regains control regularly to process user messages and queued wakes.

Expand Down
22 changes: 22 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash -lc 'payload=$(cat 2>/dev/null || true); [ -n \"$payload\" ] || exit 0; command -v node >/dev/null 2>&1 || exit 0; root=$(pwd -P) || exit 0; [ -x \"$root/bin/fm-codex-session-lock-hook.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; node -e \"const fs=require(\\\"fs\\\");const event=process.argv[1];try{const doc=JSON.parse(fs.readFileSync(process.argv[2],\\\"utf8\\\"));const entries=doc?.hooks?.[event];if(!Array.isArray(entries)||!entries.some(group=>Array.isArray(group?.hooks)&&group.hooks.some(hook=>typeof hook?.command===\\\"string\\\"&&hook.command.includes(\\\"fm-codex-session-lock-hook.sh\\\"))))process.exit(1)}catch{process.exit(1)}\" SessionStart \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | exec \"$root/bin/fm-codex-session-lock-hook.sh\"'",
"timeout": 3
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
Expand All @@ -11,6 +22,17 @@
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "bash -lc 'payload=$(cat 2>/dev/null || true); [ -n \"$payload\" ] || exit 0; command -v node >/dev/null 2>&1 || exit 0; root=$(pwd -P) || exit 0; [ -x \"$root/bin/fm-codex-session-lock-hook.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; node -e \"const fs=require(\\\"fs\\\");const event=process.argv[1];try{const doc=JSON.parse(fs.readFileSync(process.argv[2],\\\"utf8\\\"));const entries=doc?.hooks?.[event];if(!Array.isArray(entries)||!entries.some(group=>Array.isArray(group?.hooks)&&group.hooks.some(hook=>typeof hook?.command===\\\"string\\\"&&hook.command.includes(\\\"fm-codex-session-lock-hook.sh\\\"))))process.exit(1)}catch{process.exit(1)}\" SessionEnd \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | exec \"$root/bin/fm-codex-session-lock-hook.sh\"'",
"timeout": 3
}
]
}
]
}
}
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ Bootstrap is detect, then consent, then install.
Never install anything the captain has not approved in this session.

Run `bin/fm-session-start.sh` once at every session start.
It acquires the session lock, performs locked stale Herdr projection cleanup, runs `bin/fm-bootstrap.sh`, drains the wake queue, and prints the recovery digest in that order.
It verifies or acquires the session lock, performs locked stale Herdr projection cleanup, runs `bin/fm-bootstrap.sh`, drains the wake queue, and prints the recovery digest in that order.
For Codex, the tracked `SessionStart` hook may already have claimed the lock for this thread before the script runs; `bin/fm-session-start.sh` reuses that owner.
The matching lifecycle and compatibility rules are owned by `docs/configuration.md` under "Codex session lock lifecycle."
Do not run `bin/fm-lock.sh` and `bin/fm-bootstrap.sh` separately as the normal startup path.
Before checking the toolchain, bootstrap adds existing `$HOME/.nvm/versions/node/*/bin` and `$HOME/.local/bin` directories to `PATH` without moving them ahead of an explicit caller path.
The same shared normalization runs before tool lookup in spawn, teardown, and the read-only supervision model, so clean non-interactive shells can find HOME-installed Axi tools consistently.
Expand Down Expand Up @@ -303,7 +305,7 @@ Load `harness-adapters` before any spawn, recovery, trust-dialog handling, harne
You may have been restarted mid-flight.
Reconcile reality with your records before doing anything else:

1. Use the lock result printed by `bin/fm-session-start.sh`; it records the harness process PID, which is session-stable.
1. Use the lock result printed by `bin/fm-session-start.sh`; it records the verified per-home session owner.
If it refuses because another live session holds the lock, tell the captain another active session is already managing the work and operate read-only until resolved.
2. Keep the wake records drained and printed by `bin/fm-session-start.sh` as the first work queue for this recovery turn.
3. Read `data/backlog.md`, `data/secondmates.md` if present, every `state/*.meta`, and every `state/*.status`.
Expand Down
79 changes: 79 additions & 0 deletions bin/fm-codex-session-lock-hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Codex SessionStart/SessionEnd adapter for the per-home session lock.
# Usage: <Codex hook JSON> | fm-codex-session-lock-hook.sh
#
# SessionStart claims the lock before the first model turn so a visible Codex
# ancestry PID is retained when available. A later PID-isolated tool call from
# the same thread preserves that owner instead of replacing it with a transient
# fallback PID. Failure to claim stays silent because fm-session-start.sh owns
# the complete read-only diagnostic.
#
# SessionEnd removes only a regular lock whose Codex thread marker exactly
# matches the ending session. The comparison and removal run under the same
# acquisition lock as fm-lock.sh. A missing, malformed, unreadable, symlinked,
# differently owned, or concurrently busy lock is left untouched. Codex allows
# at most three seconds for SessionEnd hooks, so this adapter never waits for a
# busy acquisition lock and never delays a clean /quit.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
LOCK="$STATE/.lock"
PAYLOAD=$(cat 2>/dev/null || true)

command -v node >/dev/null 2>&1 || exit 0
PARSED=$(printf '%s' "$PAYLOAD" | node -e '
let payload = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", chunk => payload += chunk);
process.stdin.on("end", () => {
try {
const value = JSON.parse(payload);
if (!value || Array.isArray(value)
|| typeof value.hook_event_name !== "string"
|| typeof value.session_id !== "string") process.exit(1);
process.stdout.write(value.hook_event_name + "\n" + value.session_id);
} catch {
process.exit(1);
}
});
' 2>/dev/null) || exit 0
case "$PARSED" in *$'\n'*) ;; *) exit 0 ;; esac
EVENT=${PARSED%%$'\n'*}
SESSION_ID=${PARSED#*$'\n'}
[ -n "$SESSION_ID" ] || exit 0
case "$SESSION_ID" in *[!A-Za-z0-9._:-]*) exit 0 ;; esac
if [ -n "${CODEX_THREAD_ID:-}" ] && [ "$CODEX_THREAD_ID" != "$SESSION_ID" ]; then
exit 0
fi
export CODEX_THREAD_ID="$SESSION_ID"

if [ "$EVENT" = SessionStart ]; then
"$SCRIPT_DIR/fm-lock.sh" >/dev/null 2>&1 || true
exit 0
fi
[ "$EVENT" = SessionEnd ] || exit 0
[ -e "$LOCK" ] || [ -L "$LOCK" ] || exit 0

# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"
CLAIM_LOCK="$STATE/.lock.acquire"
if ! fm_lock_try_acquire "$CLAIM_LOCK"; then
exit 0
fi
release_claim_lock() {
fm_lock_release "$CLAIM_LOCK"
}
trap release_claim_lock EXIT
trap 'exit 0' HUP INT TERM

[ -f "$LOCK" ] && [ ! -L "$LOCK" ] || exit 0
OWNER=$(cat "$LOCK" 2>/dev/null) || exit 0
# shellcheck source=bin/fm-session-lock-lib.sh
. "$SCRIPT_DIR/fm-session-lock-lib.sh"
MARKER=$(fm_codex_owner_marker "$OWNER" 2>/dev/null || true)
[ -n "$(fm_codex_owner_kind "$OWNER" 2>/dev/null || true)" ] || exit 0
[ "$MARKER" = "$SESSION_ID" ] || exit 0
rm -f "$LOCK" 2>/dev/null || true
3 changes: 3 additions & 0 deletions bin/fm-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ detect_own() {
# It does NOT set CLAUDECODE despite being Claude-Code-compatible, so this marker
# is unambiguous when firstmate runs natively on grok.
[ "${GROK_AGENT:-}" = "1" ] && { echo grok; return; }
# Codex exposes a stable per-session marker to tool processes. Grok must stay
# ahead of this check because its child environment may inherit that marker.
[ -n "${CODEX_THREAD_ID:-}" ] && { echo codex; return; }
# Layer 2: walk the parent chain and match the command name.
local pid=$$ comm args
for _ in 1 2 3 4 5 6 7 8; do
Expand Down
66 changes: 46 additions & 20 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
#!/usr/bin/env bash
# Acquire or inspect the per-home firstmate session lock.
# Writes the harness (agent) process PID found by walking the shell's ancestry,
# which lives as long as the firstmate session - unlike the transient subshell
# PID of any one tool call, which is dead moments after it is written.
# Acquire or inspect the per-home Firstmate session lock.
# Writes a verified harness PID. Codex adds its stable thread marker and whether
# the PID came from verified ancestry or a PID-isolated fallback.
# Usage: fm-lock.sh acquire; exit 1 unless ownership is verified
# fm-lock.sh status print holder and liveness; always exits 0
set -u
Expand All @@ -17,23 +16,30 @@ mkdir -p "$STATE" 2>/dev/null || {
exit 1
}

# Harness identity (FM_HARNESS_RE, ancestry walk, holder liveness) is owned by
# the shared session-lock lib so the Claude Stop auto-arm applies the exact
# same identity contract.
# shellcheck source=bin/fm-session-lock-lib.sh
. "$SCRIPT_DIR/fm-session-lock-lib.sh"

if [ "${1:-}" = "status" ]; then
if [ "${1:-}" = status ]; then
if [ ! -f "$LOCK" ]; then echo "lock: free"; exit 0; fi
old=$(cat "$LOCK" 2>/dev/null) || {
echo "lock: unreadable"
exit 0
}
if fm_harness_pid_alive "$old"; then echo "lock: held by live harness pid $old"; else echo "lock: stale (pid $old dead or not a harness)"; fi
old=$(cat "$LOCK" 2>/dev/null) || { echo "lock: unreadable"; exit 0; }
fm_session_lock_holder_state "$old"
holder_status=$?
case "$holder_status" in
0) case "$old" in
*'|codex:'*) echo "lock: held by live Codex session owner $old" ;;
*) echo "lock: held by live harness pid $old" ;;
esac ;;
1) echo "lock: stale (owner $old dead or not a harness)" ;;
2) echo "lock: held by unverifiable Codex session owner $old" ;;
*) echo "lock: invalid owner record; manual inspection required" ;;
esac
exit 0
fi

me=$(fm_harness_ancestry_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; }
owner=$(fm_session_lock_owner) || {
echo "error: cannot locate harness process in ancestry" >&2
exit 1
}
probe=$(mktemp "$STATE/.lock-write.XXXXXX" 2>/dev/null) || {
echo "error: cannot write session lock; operate read-only until resolved" >&2
exit 1
Expand Down Expand Up @@ -66,22 +72,42 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then
echo "error: session lock is unreadable; operate read-only until resolved" >&2
exit 1
}
if [ "$old" != "$me" ] && fm_harness_pid_alive "$old"; then
echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2
exit 1
old_marker=$(fm_codex_owner_marker "$old" 2>/dev/null || true)
owner_marker=$(fm_codex_owner_marker "$owner" 2>/dev/null || true)
if [ -n "$old_marker" ] && [ "$old_marker" = "$owner_marker" ]; then
owner=$old
elif [ "$old" = "${owner%%|*}" ] && [ -n "$owner_marker" ]; then
:
elif [ "$old" != "$owner" ]; then
fm_session_lock_holder_state "$old"
holder_status=$?
case "$holder_status" in
0)
echo "error: another live firstmate session holds the lock (owner $old); operate read-only until resolved" >&2
exit 1 ;;
2)
echo "error: cannot verify whether another Codex session holds the lock (owner $old); operate read-only until resolved" >&2
exit 1 ;;
3)
echo "error: session lock has an invalid owner record; operate read-only until resolved" >&2
exit 1 ;;
esac
fi
fi
if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then
if ! { printf '%s\n' "$owner" > "$LOCK"; } 2>/dev/null; then
echo "error: cannot write session lock; operate read-only until resolved" >&2
exit 1
fi
written=$(cat "$LOCK" 2>/dev/null) || {
echo "error: cannot verify session lock ownership; operate read-only until resolved" >&2
exit 1
}
if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then
if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$owner" ]; then
echo "error: session lock ownership verification failed; operate read-only until resolved" >&2
exit 1
fi
release_claim_lock
echo "lock acquired: harness pid $me"
case "$owner" in
*'|codex:'*) echo "lock acquired: Codex session owner $owner" ;;
*) echo "lock acquired: harness pid $owner" ;;
esac
Loading
Loading