Skip to content
Closed
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
9 changes: 6 additions & 3 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
#!/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.
# Writes the PID that identifies this session, resolved by
# bin/fm-session-lock-lib.sh - which owns that decision, including why the
# harness's own declaration of its session process is preferred over the
# ancestry walk where one exists. Either way it is a process that 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.
# 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 Down
66 changes: 58 additions & 8 deletions bin/fm-session-lock-lib.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Shared session-lock harness identity.
#
# ONE owner of the "which verified-harness process holds this home's session
# lock, and does the current process descend from that same harness?" decision.
# ONE owner of the "which process identifies this session, which verified-harness
# process holds this home's session lock, and does the current process descend
# from that same harness?" decision.
# bin/fm-lock.sh uses it to acquire and inspect state/.lock;
# bin/fm-claude-stop-autoarm.sh uses it to prove a Stop hook fires inside the
# lock-owning primary session before it may arm or rewake.
Expand Down Expand Up @@ -125,15 +126,64 @@ fm_harness_ancestry_pids() {
[ "$printed" -eq 1 ]
}

# The harness's own statement of which process IS this session, or return 1.
#
# Ancestry alone cannot answer that question under a session-hosting daemon,
# because two different arrangements produce the identical process chain:
# - an async hook or tool call of session X, run for X inside a daemon-hosted
# worker (the pid to record is X, several hops up), and
# - a background session of its own, launched BY session X through that same
# daemon (the pid to record is the background session, several hops down).
# In both, every hop from the caller up to X is harness-named with no gap, so no
# process-table fact separates them. Claude Code does separate them: it exports
# CLAUDE_PID into the processes it spawns for a session, set to that session's
# own pid, overriding whatever value those processes inherited. A stale inherited
# value is therefore possible only where the harness did not spawn the process at
# all (a tmux server started from a session, say, and every pane below it), which
# is why the value is trusted only after the checks in the caller below.
fm_harness_declared_session_pid() {
local declared=${CLAUDE_PID:-}
case "$declared" in
''|*[!0-9]*) return 1 ;;
esac
printf '%s\n' "$declared"
}

# Print the one pid that identifies this session when the session lock is being
# WRITTEN: the outermost pid of the contiguous run. That is the pid that lives as
# long as the session - a Claude worker several levels in is reaped when its hook
# returns, and a lock naming it would look stale moments later while the session
# is still running. Every non-Claude harness reports a single pid, so this is its
# innermost match unchanged.
# WRITTEN.
#
# The harness's own declaration wins when it names a live harness process inside
# this contiguous run. Requiring membership is what makes an untrustworthy value
# harmless: an inherited pid from an unrelated session is not in this ancestry
# and is ignored, and a dead one cannot be recorded as a live owner.
#
# Otherwise fall back to the outermost pid of the contiguous run. That is the pid
# that lives as long as the session for every harness that declares nothing - a
# Claude worker several levels in is reaped when its hook returns, and a lock
# naming it would look stale moments later while the session is still running.
# Every non-Claude harness reports a single pid, so this is its innermost match
# unchanged.
#
# The fallback is also what the outermost pid costs: under a session-hosting
# daemon it reaches past this session's own processes onto the session that
# launched it, so the lock records a pid that is only transiently an ancestor.
# When the daemon between them exits, that pid stops being an ancestor while
# still naming a live harness, and fm_session_lock_owned_by_self below then
# reads this session's own lock as a competing session's - permanently, for the
# rest of the session. tests/fm-session-lock-ancestry.test.sh drives that shape.
fm_harness_ancestry_pid() {
local pids pid outermost=''
local pids pid outermost='' declared
pids=$(fm_harness_ancestry_pids) || return 1
if declared=$(fm_harness_declared_session_pid) && fm_harness_pid_alive "$declared"; then
while IFS= read -r pid; do
if [ "$pid" = "$declared" ]; then
printf '%s\n' "$declared"
return 0
fi
done <<EOF
$pids
EOF
fi
while IFS= read -r pid; do
[ -n "$pid" ] && outermost=$pid
done <<EOF
Expand Down
16 changes: 16 additions & 0 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ family_for_basename() {
fm-herdr-version-floor-live-e2e.test.sh|\
fm-opencode-primary-live-e2e.test.sh|fm-pi-branch-live-e2e.test.sh|\
fm-pi-primary-live-e2e.test.sh|\
fm-session-lock-declaration-live-e2e.test.sh|\
fm-sessionstart-hook-live-e2e.test.sh|fm-sessionstart-instruction-refresh-live-e2e.test.sh|\
fm-quota-array-dispatch-live-e2e.test.sh|fm-send-secondmate-marker-herdr-e2e.test.sh|\
fm-send-inbox-doorbell-live-e2e.test.sh|\
Expand Down Expand Up @@ -466,6 +467,7 @@ tests/fm-send-resolve-key.test.sh 13450
tests/fm-send-secondmate-marker-herdr-e2e.test.sh 45
tests/fm-send-secondmate-marker.test.sh 4439
tests/fm-session-lock-ancestry.test.sh 1205
tests/fm-session-lock-declaration-live-e2e.test.sh 21
tests/fm-session-start.test.sh 144836
tests/fm-sessionstart-hook-live-e2e.test.sh 21
tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh 21
Expand Down Expand Up @@ -966,6 +968,20 @@ families_for_changed_path() {
bin/fm-gate-refuse*|bin/fm-lock*|bin/fm-quota-axi-lib.sh)
printf '%s\n' session-bootstrap
;;
bin/fm-session-lock-lib.sh)
# Session-lock identity: the writer at session start (session-bootstrap),
# the Stop auto-arm's self-ownership check (watcher-wake-lock and its
# dedicated unclassified suite), the secondmate harness's own use of the
# ancestry pid (secondmate), and the harness-declared session pid the
# writer prefers, which only a real harness can prove (live-harness-optin).
# This must stay a superset of what the bin/*) reference scan below would
# select, per this map's over-select contract.
printf '%s\n' session-bootstrap
printf '%s\n' watcher-wake-lock
printf '%s\n' secondmate
printf '%s\n' unclassified
printf '%s\n' live-harness-optin
;;
bin/fm-sessionstart-run.sh|.claude/settings.json|.codex/hooks.json|\
.pi/extensions/fm-primary-turnend-guard.ts)
# The run tier's two harness-supplied facts (source vocabulary and
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` `@AGENTS.md` pointer, and the canonical self-governance section |
| `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and unhealthy supervision |
| `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks |
| `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm |
| `fm-session-lock-lib.sh` | Shared session-lock harness identity (which pid identifies this session, ancestry walk, and holder liveness) for fm-lock.sh and the Claude Stop auto-arm |
| `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) |
| `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) |
| `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard |
Expand Down
2 changes: 1 addition & 1 deletion docs/sessionstart-nudge.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The Guard Predicates section of [`turnend-guard.md`](turnend-guard.md#guard-pred
The nudge payload starts with U+2063 and the stable `FIRSTMATE_OP: ` label, carries the current `session-start` protocol kind, and retains exactly ``Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` as its body.
The Ahoy skill owns the rule that this marked operational input is never a captain-authored session boundary, including its narrow legacy compatibility cases, and its own step 0 helm check is the fallback that protects a nudge-tier harness whose first command is a skill.

Before printing, the nudge wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s ancestry walk (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which now walks up to sixteen parents and can extend past a claude-named match to a still-more-ancestral one) and of Pi's `lockOwnership()`.
Before printing, the nudge wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s own identity resolution (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which walks up to sixteen parents and owns which pid in that run is recorded) and of Pi's `lockOwnership()`.
If the lock names a live pid in that ancestry, session start already ran in this harness session and the wrapper stays silent.
Every path in both wrappers exits 0, including malformed state and adapter errors, because a Claude SessionStart exit 2 blocks session initialization.
A lock another session holds and a truncated digest therefore surface as digest text, while broken GitHub auth surfaces through the deferred network result inline or as a wake; none becomes a refusal to open the session.
Expand Down
1 change: 1 addition & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ alive
`#{pane_current_command}` and foreground `ps -o comm=` read different name fields, but which one preserves executable identity is platform-dependent.
On macOS the pane command reflected the rewritable title while the full install path could survive in `ps -o comm=`; in the Linux portable regression those roles reversed for the version-named native executable, with the identifying path retained in argv[0].
The classifier therefore accepts a harness basename first, then an exact harness path component in the full executable path, then the same component in argv[0], without depending on which field carries it on a given platform.
Process identity is not the same question as which process IS the session; [`supervision.md`](supervision.md) owns that evidence and its own opt-in drift guard.

The portable regression is CI-enforced, while the real-harness drift guard is opt-in under the policy in `.agents/skills/firstmate-coding-guidelines/SKILL.md`.
Run the live guard after any harness upgrade and before trusting or refreshing the table above:
Expand Down
24 changes: 24 additions & 0 deletions docs/verification/supervision.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,30 @@ The current Stop-owned main/secondmate inclusion and child-worktree exclusion ar
Session-lock ownership in `bin/fm-session-lock-lib.sh` is decided against a session's whole contiguous harness ancestry rather than one chosen pid, so the Stop auto-arm reaches its lock owner wherever that owner sits: the outermost pid of Claude Code's multi-level `bg-spare` hook worker chain, or an inner pid when a harness-named daemon parents the session.
Harness identity is read from the executable path and `argv[0]` as well as the command basename, because Claude Code's native installer names the per-session executable by its version (`.../share/claude/versions/2.1.220`): `ps -o comm=` reports that path on macOS and the bare version string on Linux, and neither basename names a harness.
`tests/fm-session-lock-ancestry.test.sh` pins both platforms' reporting semantics behind a deterministic process table and runs the real Stop auto-arm in version-named, daemon-parented, and combined real process trees.

Which pid the lock RECORDS is a separate decision from how ownership is checked, and under a session-hosting daemon the process table cannot settle it.
An async hook running for session X inside a daemon-hosted worker and a background session launched by X through that same daemon produce the identical chain: harness-named end to end, with no non-harness process between the caller and X.
Resolving identity as the outermost pid of that chain is right for the worker and wrong for the background session, which then records X and, once the daemon between them exits, can no longer recognize its own lock while X still names a live harness.
`bin/fm-session-lock-lib.sh` therefore prefers the harness's own declaration of its session process, accepted only when it names a live harness inside this contiguous run, and falls back to the outermost pid otherwise.
`tests/fm-session-lock-ancestry.test.sh` drives that three-level launcher/daemon/session shape with real processes, the real `bin/fm-lock.sh`, the real Stop auto-arm, and the real turn-end guard, alongside the live-owner, dead-owner, away-mode, and inherited-declaration cases.

Measured with Claude Code 2.1.241 on 2026-08-24, in both a print-mode session and a real background agent, each launched with a deliberately wrong `CLAUDE_PID=2147483646` in its environment.
Both reported their OWN process to their own `SessionStart` hook, so the declaration is authoritative rather than inherited, which is the property the preference depends on.
The background agent's hook sat two harness-named hops below the shared session-hosting daemon, whose pid the outermost-pid fallback would have recorded instead: a process every background session in the home shares and which outlives any one of them.
Run the opt-in guard after every Claude Code upgrade and before trusting this result:

```sh
FM_SESSION_LOCK_DECLARATION_DRIFT=1 bin/fm-test-run.sh tests/fm-session-lock-declaration-live-e2e.test.sh
```

```text
# claude: 2.1.241 (Claude Code)
# print-mode session: declared 45547, ancestry [45547 ], planted 2147483646
# background agent: declared 37066, ancestry [37066 36968 90256 ], planted 2147483646
# background agent: without the declaration the writer would have recorded 90256, the shared plumbing above this session
ok - session-lock: claude 2.1.241 (Claude Code) declares its own session process in both a print-mode and a daemon-hosted background session, and the writer records it
```

`tests/fm-watch-arm.test.sh` runs real watcher and arm cycles against durable on-disk state to verify that a delivered reason survives until post-handling acknowledgement and stops replaying after acknowledgement, while an unrelated queue append cannot make a watcher cycle that delivered nothing look successful.
The same suite ingests a keyed remote-secondmate parent reply through the real adapter, establishes the incremental OPEN DECISIONS cursor, interrupts supervision, and proves re-arm replays every unacknowledged queue row plus the still-open decision through the ordinary drain path.
It also covers decision-only recovery, interrupted handling, handling-window generation reuse, non-fatal moved-generation acknowledgement with sequence-bounded consumption, and a persistent successor remaining live after recovery is acknowledged.
Expand Down
1 change: 1 addition & 0 deletions docs/watcher-continuity.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Cursor's `.cursor/hooks.json` `stop` hook (`bin/fm-turnend-guard-cursor.sh`) own
Claude's `.claude/settings.json` Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns routine tokenless re-arm.
The hook fires on every Stop, and an eligible primary with supervision need admits one home-scoped owner that foregrounds `bin/fm-watch-arm.sh` inside the hook-owned process tree.
A numeric session-lock owner that fails the shared `fm_harness_pid_alive` predicate is reclaimed through `bin/fm-lock.sh` before auto-arm state changes, while a live owner, absent lock, or malformed lock keeps the competing hook inert.
That inertness is correct only because the recorded owner is this session's own process rather than a pid it merely descends from, which is what `bin/fm-session-lock-lib.sh` owns for a session running under a session-hosting daemon.
The stale-owner claim occurs only after the existing AFK and supervision-need gates pass.
After each non-actionable arm close, the hook rechecks the identity-matched watcher lock and fresh beacon before retrying a bounded number of times.
A cycle-end failure is benign when that live-watcher predicate is true, and the hook suppresses the arm output and continues silently.
Expand Down
Loading
Loading