diff --git a/.agents/skills/harness-adapters/references/common/dispatch.md b/.agents/skills/harness-adapters/references/common/dispatch.md index 96db331b557..4b60fae0648 100644 --- a/.agents/skills/harness-adapters/references/common/dispatch.md +++ b/.agents/skills/harness-adapters/references/common/dispatch.md @@ -27,6 +27,7 @@ Tool references record empirical knowledge for those executable owners. For an approved new adapter check, use the spawn owner's raw-launch escape hatch only for a trivial supervised task. Verify detection in `../../../bin/fm-harness.sh`, launch in `../../../bin/fm-spawn.sh`, busy state in `../../../bin/fm-busy-lib.sh`, shared composer behavior in `../../../bin/fm-composer-lib.sh`, lifecycle in `../../../bin/fm-control-lib.sh`, and tmux liveness in `../../../bin/backends/tmux.sh` when secondmate use is supported. +Also observe, in a real child of a real session of that harness, whether it exports a launch marker naming its own session pid: `FM_SESSION_LAUNCH_MARKERS` in `../../../bin/fm-session-lock-lib.sh` has a verified row for Claude alone, and every other harness decides session-lock ownership by process ancestry until a row is verified for it, which `../../../docs/verification/runtime-backends.md#session-lock-identity-and-the-suspended-holder` owns. Also verify primary integration through `references/common/primary-hooks.md`, model discovery through `references/common/model-and-effort.md`, and one tool record. A value remains unreachable until its executable owner, portable regression, applicable credentialed live guard, and verification record land together. `../firstmate-coding-guidelines/SKILL.md` owns harness-dependent proof. diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index 5f8b2d14a75..f778e6be137 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -117,18 +117,19 @@ fm_hook_payload_is_foreign_host "$PAYLOAD" && exit 0 fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 # --- identity: only the lock-owning session's hooks may arm ------------------ -# A prior session may have died after leaving its numeric harness pid in .lock. -# Use the shared liveness predicate to recognize only that stale-owner case. +# A prior session may have left its numeric harness pid in .lock after dying or +# being suspended. The shared holder predicate recognizes exactly those +# recoverable cases and keeps a genuinely competing session inert. # Defer the mutating claim until after the unchanged AFK and need gates, so an # idle or away home remains byte-for-byte inert. Missing or malformed locks are -# uncertainty rather than stale-owner evidence and remain inert. +# uncertainty rather than recoverable-owner evidence and remain inert. RECOVER_SESSION_LOCK=0 if ! fm_session_lock_owned_by_self "$STATE"; then LOCK_PID=$(cat "$STATE/.lock" 2>/dev/null || true) case "$LOCK_PID" in ''|*[!0-9]*) exit 0 ;; esac - fm_harness_pid_alive "$LOCK_PID" && exit 0 + fm_session_lock_holder_competes "$LOCK_PID" && exit 0 RECOVER_SESSION_LOCK=1 fi diff --git a/bin/fm-context.sh b/bin/fm-context.sh new file mode 100755 index 00000000000..0b19251401c --- /dev/null +++ b/bin/fm-context.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# fm-context.sh - how much context window this session and each crew has left. +# +# Why this exists: a session that runs out of context compacts, and a crew that +# compacts mid-task can lose a decision it was told once and then repeat work or +# quietly drop a constraint. The percentage a harness renders in its own status +# line is visible only to whoever is looking at that pane, so it cannot be acted +# on by supervision and it cannot be read for THIS session at all. +# +# The authoritative source is the session transcript, not rendered output. Claude +# Code records exact token usage on every assistant record, so the current +# context is the last such record's input_tokens + cache_creation_input_tokens + +# cache_read_input_tokens. That is a structural fact rather than a vendor string, +# so it does not break when a status line is restyled. +# +# Usage: fm-context.sh [--json] [--window ] [--threshold ] [...] +# No task ids reports this session plus every task with a state/.meta. +# --window context window to measure against (default 1000000). +# --threshold exit 1 if any reported session has less than this percent of the +# window remaining, so a guard can gate on it. Default 0, never fails. +# --json one JSON object per line instead of the table. +# +# Both numbers are always printed - used and remaining - because a bare +# percentage is read as either one depending on who is reading it. +# +# LIMITATION, stated rather than hidden: the window is a parameter, not a +# measurement. The transcript records what was consumed, never the model's +# capacity, and it does not record the harness's auto-compact headroom. The +# model name is printed so a wrong --window is visible instead of silent. +set -euo pipefail + +FM_HOME="${FM_HOME:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)}" +PROJECTS_ROOT="${CLAUDE_PROJECTS_ROOT:-$HOME/.claude/projects}" + +window=1000000 +threshold=0 +as_json=0 +declare -a wanted=() + +die() { printf 'fm-context.sh: %s\n' "$1" >&2; exit 2; } + +while [ $# -gt 0 ]; do + case "$1" in + --json) as_json=1; shift ;; + --window) [ $# -ge 2 ] || die "--window needs a value"; window="$2"; shift 2 ;; + --threshold) [ $# -ge 2 ] || die "--threshold needs a value"; threshold="$2"; shift 2 ;; + -h|--help) sed -n '2,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) die "unknown option: $1" ;; + *) wanted+=("$1"); shift ;; + esac +done + +command -v jq >/dev/null 2>&1 || die "jq is required" +[ "$window" -gt 0 ] 2>/dev/null || die "--window must be a positive integer" + +# A transcript lives under a directory named after its working directory with +# every '/' and '.' replaced by '-', so the mapping is derivable rather than +# needing a lookup table. +transcript_dir() { + printf '%s/%s' "$PROJECTS_ROOT" "$(printf '%s' "$1" | tr './' '--')" +} + +# Streams the file and keeps the newest assistant usage, so a large transcript is +# not slurped into memory. Prints " " or nothing. +read_usage() { + local file="$1" + [ -r "$file" ] || return 0 + jq -rn ' + reduce inputs as $r ([0,"?"]; + if $r.type == "assistant" and ($r.message.usage != null) + then [ (($r.message.usage.input_tokens // 0) + + ($r.message.usage.cache_creation_input_tokens // 0) + + ($r.message.usage.cache_read_input_tokens // 0)), + ($r.message.model // "?") ] + else . end) + | select(.[0] > 0) | "\(.[0]) \(.[1])" + ' "$file" 2>/dev/null || true +} + +# The newest transcript in a directory, used when no session id is known. +newest_transcript() { + local dir="$1" + [ -d "$dir" ] || return 0 + find "$dir" -maxdepth 1 -name '*.jsonl' -printf '%T@ %p\n' 2>/dev/null | + sort -rn | head -1 | cut -d' ' -f2- +} + +emit() { + local label="$1" tokens="$2" model="$3" + local used_pct left left_pct + used_pct=$(( tokens * 100 / window )) + left=$(( window - tokens )) + [ "$left" -lt 0 ] && left=0 + left_pct=$(( left * 100 / window )) + if [ "$as_json" = 1 ]; then + jq -cn --arg label "$label" --arg model "$model" \ + --argjson used "$tokens" --argjson left "$left" \ + --argjson used_pct "$used_pct" --argjson left_pct "$left_pct" \ + --argjson window "$window" \ + '{label:$label, model:$model, window:$window, used:$used, + used_percent:$used_pct, remaining:$left, remaining_percent:$left_pct}' + else + printf '%-30s %9d used (%3d%%) %9d left (%3d%%) %s\n' \ + "$label" "$tokens" "$used_pct" "$left" "$left_pct" "$model" + fi + if [ "$threshold" -gt 0 ] && [ "$left_pct" -lt "$threshold" ]; then + return 1 + fi + return 0 +} + +breached=0 + +report_one() { + local label="$1" file="$2" + local line + line=$(read_usage "$file") + [ -n "$line" ] || return 0 + emit "$label" "${line%% *}" "${line##* }" || breached=1 +} + +if [ "$as_json" = 0 ]; then + printf '%-30s %-22s %-22s %s\n' "session" "context" "remaining" "model" +fi + +# This session, when its id is known; otherwise the newest transcript for this home. +if [ ${#wanted[@]} -eq 0 ]; then + self_dir=$(transcript_dir "$FM_HOME") + self_file="" + if [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && [ -r "$self_dir/$CLAUDE_CODE_SESSION_ID.jsonl" ]; then + self_file="$self_dir/$CLAUDE_CODE_SESSION_ID.jsonl" + else + self_file=$(newest_transcript "$self_dir") + fi + [ -n "$self_file" ] && report_one "this session" "$self_file" +fi + +# Crew: each task's worktree is recorded in its metadata, and the worktree maps +# to a transcript directory by the same rule. +shopt -s nullglob +for meta in "$FM_HOME"/state/*.meta; do + id=$(basename "$meta" .meta) + if [ ${#wanted[@]} -gt 0 ]; then + match=0 + for w in "${wanted[@]}"; do [ "$w" = "$id" ] && match=1; done + [ "$match" = 1 ] || continue + fi + worktree=$(sed -n 's/^worktree=//p' "$meta" | head -1) + [ -n "$worktree" ] || continue + report_one "$id" "$(newest_transcript "$(transcript_dir "$worktree")")" +done +shopt -u nullglob + +exit "$breached" diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 52d7c8aee4b..9249bd8385b 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -3,6 +3,15 @@ # 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. +# +# Acquisition yields ONLY to a genuinely competing session, decided by +# fm_session_lock_holder_competes in bin/fm-session-lock-lib.sh: a recorded +# holder that is this same session in another process tree, or a durably +# suspended one, does not block. Every acquisition then converges the lock onto +# the acquiring session's own pid, so repeated runs are idempotent, and any live +# holder it converged onto, took over from, or could not identify is named on +# stdout rather than being silent. +# # Usage: fm-lock.sh acquire; exit 1 unless ownership is verified # fm-lock.sh status print holder and liveness; always exits 0 set -u @@ -29,7 +38,13 @@ if [ "${1:-}" = "status" ]; then 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 + if ! fm_harness_pid_alive "$old"; then + echo "lock: stale (pid $old dead or not a harness)" + elif fm_harness_pid_suspended "$old"; then + echo "lock: held by SUSPENDED harness pid $old (reclaimable: a stopped session is not holding this home)" + else + echo "lock: held by live harness pid $old" + fi exit 0 fi @@ -55,13 +70,17 @@ release_claim_lock() { trap release_claim_lock EXIT trap 'exit 1' HUP INT TERM +# Why the yield reason is captured rather than printed here: the fast path +# below is only a pre-check, and the authoritative decision is retaken under the +# claim lock. Printing it once, at the end, keeps one acquisition to one line. +YIELDED= if [ -f "$LOCK" ] && [ ! -L "$LOCK" ]; then old=$(cat "$LOCK" 2>/dev/null || true) if [ "$old" = "$me" ]; then echo "lock acquired: harness pid $me" exit 0 fi - if fm_harness_pid_alive "$old"; then + if fm_session_lock_holder_competes "$old"; then echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 exit 1 fi @@ -86,9 +105,22 @@ 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 + if [ "$old" != "$me" ]; then + if fm_session_lock_holder_competes "$old"; then + echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 + exit 1 + fi + # Reclaiming an owner that is gone has always been silent and stays that + # way. The three holders that are alive and still yield are the ones worth + # naming - this session's own holder in another tree, a durably suspended + # session, and a live process the identity rules cannot type as a harness - + # so name them rather than moving the lock out from under a visible process + # quietly. None of the three is exceptional; the last is the ordinary + # reading of a stale lock whose pid an unrelated process now occupies. + # The predicate supplies the whole clause and leaves it empty for the silent + # case, so this is one assignment rather than a second liveness question that + # could disagree with the classification that just ran. + YIELDED=$FM_SESSION_HOLDER_YIELD_REASON fi fi if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then @@ -104,4 +136,8 @@ if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then exit 1 fi release_claim_lock -echo "lock acquired: harness pid $me" +if [ -n "$YIELDED" ]; then + echo "lock acquired: harness pid $me ($YIELDED)" +else + echo "lock acquired: harness pid $me" +fi diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index c2a117b0b84..61b03e35634 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -2,11 +2,36 @@ # 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. +# lock, and is the current process part of that same session?" 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. +# bin/fm-claude-stop-autoarm.sh and bin/fm-turnend-guard-cursor.sh use it to +# prove a turn-end hook fires inside the lock-owning primary session before it +# may arm or rewake. # This file is sourced by scripts and has no side effects on source. +# +# Two independent kinds of same-session evidence are accepted, because the +# process tree alone is not the session: +# +# Ancestry (below): the lock names a process in this one's contiguous +# verified-harness ancestry. This is the original evidence and still the +# primary one. +# +# Session cohort (further below): the lock names a live harness process tied to +# this one by a launch relationship in EITHER direction - it started this +# session, or this session started it - and co-located with it. A harness can +# put a session's work in a process tree that never reaches the pid holding the +# lock - a background session rehosted under its own pty reparents to init - +# and ancestry then reports one genuine session as two competing ones. +# +# Neither kind vetoes the other: each is a positive proof on its own, and the +# absence of cohort evidence leaves the ancestry verdict exactly as it was. +# Within the cohort proof the signals are AND-ed, not OR-ed, because the one +# property that must survive is that a genuinely separate concurrent session is +# still refused. +# +# The cohort proof is per-harness and currently reaches only Claude; every other +# adapter is decided by ancestry alone. FM_SESSION_LAUNCH_MARKERS below owns that +# scope limit, why it is safe, and what extending it requires. # Cursor process identity is NOT expressible as a command-name pattern and is # deliberately not added to the tables below: Cursor's installed names are @@ -47,6 +72,107 @@ fm_harness_path_name() { # return 1 } +# Print the exact harness name when path $1's BASENAME is exactly a verified +# harness name, or return 1. +# +# The strictest of this file's rules, and the single owner of the exact-equality +# test. Two callers reach it, and they hand it different things. The `MainThread` +# branch below passes a SCRIPT PATH from argv, its only token of evidence, so it +# takes the strictest reading of it; the sibling interpreter branch reads the same +# token positions under the same stop-at-the-first-flag rule but by whole path +# component, because the only shape it identifies at all is a node-hosted bin +# entry that is NOT named after the harness. fm_harness_comm_name below passes a +# REPORTED COMMAND NAME, and normalizes the reporter's own artifacts out of it +# first; that normalization is stated there and deliberately does not reach the +# script-path caller, which sees neither of those artifacts. +# +# Exact equality is deliberate, and under `MainThread` it is the ONLY evidence +# there is: the command path is literally `MainThread` and argv[0] is the +# interpreter, so neither the command-path nor the argv[0] rule above can see the +# script at all. A harness whose script basename is not exactly the harness name +# - a version-suffixed name, or a `.js` bin entry - is therefore deliberately not +# identified in that branch. Teaching it such a shape is a verification task +# against a real release, the same as extending FM_SESSION_LAUNCH_MARKERS, and +# not a reason to loosen this rule. +fm_harness_basename_name() { # + local path=$1 base name + [ -n "$path" ] || return 1 + base=${path##*/} + for name in "${FM_HARNESS_NAMES[@]}"; do + if [ "$base" = "$name" ]; then + printf '%s' "$name" + return 0 + fi + done + return 1 +} + +# Print the exact harness name that COMMAND NAME $1 reports, or return 1. +# +# The same exact-equality test as fm_harness_basename_name, applied to a command +# name after two reporting artifacts are undone. Both are properties of how the +# name reaches us rather than of the program, so leaving them in place refuses a +# harness its own home while it is the only session alive: +# +# 1. A command name is not always one word, and it is truncated to 15 +# characters. Linux reports the kernel task name, which node and Bun +# harnesses rename to label a worker role, so Claude Code's background pty +# worker `claude bg-pty-host` arrives as `claude bg-pty-h` - one executable +# word, one role label, and a cut that lands mid-word. The executable is the +# FIRST word, so only that word is compared and the cut cannot reach it: no +# verified harness name is longer than 15 characters. +# 2. Claude Code's installed executable is literally named `claude.exe` on +# every platform, because it is a single-file Bun build, so a process that +# execs it reports that name verbatim. `.exe` is the only executable suffix +# any verified harness carries. +# +# Neither step loosens the comparison itself, which is still exact equality +# against FM_HARNESS_NAMES, and that is what keeps this from becoming a prefix +# rule: `claude-code` has neither a space nor a suffix, so it is still not a +# harness, and neither are `claudette` or `claude_code`. Path components are +# untouched here, so `pi` keeps the basename anchoring that stops an interior +# `/home/pi` component from naming a harness. +fm_harness_comm_name() { # + local base=${1##*/} + base=${base%% *} + base=${base%.exe} + fm_harness_basename_name "$base" +} + +# Print the exact harness name carried by an ARGV token $1, or return 1. +# +# A whole path component, as fm_harness_path_name reads it, except that `pi` and +# `pi-signed` must be the token's own basename. FM_HARNESS_RE anchors those two +# names as `^pi$` and `^pi-signed$` because they are too short and too ordinary +# to survive an unanchored reading, and a two-character interior component is +# exactly where that bites: `/home/pi` is the default home directory on Raspberry +# Pi OS, so `node /home/pi/app.js` would otherwise be a verified Pi harness, and +# as a recycled recorded holder it would refuse a real session its own home. +# The command path and argv[0] rule above reads those names as components too and +# is deliberately left alone here; this anchoring covers only the argv tokens, +# which the rule this replaced could never match for these two names at all. +fm_harness_argv_path_name() { # + local path=$1 base name + [ -n "$path" ] || return 1 + base=${path##*/} + for name in "${FM_HARNESS_NAMES[@]}"; do + case "$name" in + pi|pi-signed) + if [ "$base" = "$name" ]; then + printf '%s' "$name" + return 0 + fi + ;; + *) + case "/$path/" in + */"$name"/*) printf '%s' "$name"; return 0 ;; + esac + ;; + esac + done + return 1 +} + # True when the process described by command name $1 and full argument string $2 # is a verified harness. Sets FM_HARNESS_IS_CLAUDE for the ancestry walk. # @@ -57,11 +183,27 @@ fm_harness_path_name() { # # argv[0] in `ps -o comm=`, while procps on Linux reports the kernel exec # name and ignores argv[0] entirely, so a version-named Claude Code binary # is identified by its install path on macOS and by argv[0] on Linux. -# 3. a bare interpreter (node, python) running a harness script path. -# 4. Cursor's own structural identity, owned by bin/fm-cursor-lib.sh. +# 3. a bare interpreter (node, python) running a harness script path, taken +# from the argv tokens before the first flag by whole path component. +# 4. node's own `MainThread` exec name, resolved from the one argv token after +# the interpreter by exact basename only, and refused outright when that +# token is a flag. +# 5. Cursor's own structural identity, owned by bin/fm-cursor-lib.sh. +# +# Rules 3 and 4 read an ARGUMENT LIST, which is data the process was handed rather +# than the program it is running, so they identify a recorded pid for the ancestry +# walk and the liveness predicate and they carry no weight in the cohort's +# acceptance decision. fm_harness_exec_kind below is that stricter reading, and +# the difference between the two is stated there. +# +# This answers WHETHER, not WHICH. FM_HARNESS_IS_CLAUDE is the one thing it also +# reports, because the ancestry walk needs it to know when to keep climbing a +# nested worker chain; it is a global every call clobbers. Which harness a +# particular pid IS belongs to fm_harness_exec_kind below, which reads only +# executable identity and is the only naming the cohort proof may act on. FM_HARNESS_IS_CLAUDE=0 fm_harness_process_matches() { # - local comm=$1 args=$2 base argv0 name + local comm=$1 args=$2 base argv0 name script rest FM_HARNESS_IS_CLAUDE=0 base=$(basename -- "$comm") if printf '%s' "$base" | grep -qE "$FM_HARNESS_RE"; then @@ -73,23 +215,175 @@ fm_harness_process_matches() { # case "$name" in claude) FM_HARNESS_IS_CLAUDE=1 ;; esac return 0 fi - # Bare interpreter (e.g. node): match the harness name in its script path. + # Bare interpreter (e.g. node, python) that reports its OWN name as the exec + # name: identity comes from the interpreter's script path in argv, read under + # the same discipline the MainThread branch below uses. The tokens after the + # interpreter are read in order and the scan STOPS at the first one beginning + # with `-`, because from there on a path-shaped token may be an interpreter flag + # or the VALUE of one, and a flag's value can be named anything at all. A token + # before that boundary carries a verdict only when a whole path component of it + # is exactly a harness name. + # + # Reading the whole argument string is what this rule used to do, and it + # identified shapes that are not harnesses at all, because that test was an + # unanchored regex with no path-component requirement in it: an unrelated + # service carrying any ordinary `~/.claude/...` hook, settings, log or + # transcript argument reported itself as Claude, as did + # `node --require /opt/hooks/claude/instrument.js /srv/app/server.js` and + # `python3 /srv/app.py --config /etc/claude/x.toml`. None of the three is + # identified now. + # + # Stopping at the first flag gives up the inferred + # `node --experimental-foo /path/to/claude/cli.js` shape on purpose, which is + # the same trade the MainThread branch takes: the alternative is an allowlist of + # value-taking interpreter flags that would rot silently every time a vendor + # adds one, and silently stale recorded state is the failure this whole + # mechanism exists to remove. Both outputs are derived from the token that + # matched and never from the whole argv, so a harness name sitting elsewhere in + # the arguments can neither name the kind nor raise the Claude flag. + # + # Two limits of this rule are known and stated rather than left implied. It + # reads EVERY token before that flag rather than the script token alone, so a + # positional path argument can decide the verdict and this rule and the + # MainThread branch disagree on identical argv: `node /srv/app/server.js + # /opt/claude/agent.json` is Claude here and is nothing there. And the component + # it needs is an exact one, so a real npm layout whose package directory is + # `claude-code` or `opencode-ai` rather than the bare harness name is not + # identified at all. Both bound identification for the ancestry walk and the + # liveness predicate only; neither can reach the cohort's acceptance decision, + # which fm_harness_exec_kind below settles from executable identity alone. + # + # The npm-layout limit runs in BOTH directions, and the second direction is + # stated here because this lands on a running fleet by in-place update. The + # liveness predicate also judges a RECORDED holder, so a session lock an older + # firstmate wrote for a still-live session of one of those unidentified shapes + # now reads as not-a-harness and is reclaimed while that session is still + # running. It is bounded to that adoption window rather than being a standing + # hole, because this rule refuses to record such a holder in the first place, + # and it is not closed. + # + # That shape is one INSTANCE of a wider condition rather than its cause. Any + # live process occupying the recorded pid that these rules cannot type reads + # the same way, and the ordinary trigger is an unrelated process that inherited + # a recycled pid from a lock nobody released. fm_harness_pid_state's header + # below is the single owner of that condition rather than this block restating + # it. The mitigation covers all of it: fm_session_lock_holder_competes + # classifies a live-but-unidentified holder apart from a gone one, so the + # reclaim announces itself rather than moving the lock out from under a visible + # process in silence. case "$comm" in *node*|*python*) - if printf '%s' "$args" | grep -qE "$FM_HARNESS_RE"; then - case "$args" in *claude*) FM_HARNESS_IS_CLAUDE=1 ;; esac - return 0 + rest=${args#* } + if [ "$rest" != "$args" ]; then + while [ -n "$rest" ]; do + script=${rest%% *} + case "$rest" in + *' '*) rest=${rest#* } ;; + *) rest='' ;; + esac + [ -n "$script" ] || continue + case "$script" in + -*) break ;; + esac + if name=$(fm_harness_argv_path_name "$script"); then + case "$name" in claude) FM_HARNESS_IS_CLAUDE=1 ;; esac + return 0 + fi + done fi ;; esac + # Node renames its own main thread, so an npm-installed harness can report + # `MainThread` as its exec name and carry no interpreter name for the case + # above to catch: codex-cli 0.139.0 under nvm on Linux reports comm + # `MainThread` with argv `node .../bin/codex`, and without this it is not a + # harness at all, which leaves a codex primary unable to acquire its own home. + # + # Identity then has to come from the interpreter's SCRIPT PATH, and the ONLY + # candidate is the single token immediately after the interpreter, matched by + # the strictest rule this file has, exact basename. `MainThread` is a name any + # node program can present, and the rest of an interpreter's argv is not the + # script: it also carries the values of the interpreter's own flags, and those + # values are paths that can be named anything, `/opt/vendor/claude` included. + # + # So the branch REFUSES to identify anything as soon as that first token is a + # flag, rather than trying to work out where the flags end. An interpreter that + # reports its own name is decided by the rule above, which stops at the first + # flag in the same way but reads every token up to it, by whole path component + # rather than by basename. Neither reading reaches the cohort's acceptance + # decision, which fm_harness_exec_kind settles from executable identity alone. + # + # The refusal gives up the inferred `node --enable-source-maps .../bin/codex` + # shape on purpose: the alternative is an allowlist of value-taking interpreter + # flags, which would rot silently every time a vendor adds one, and silently + # stale recorded state is the exact failure this whole mechanism exists to + # remove. Refusing to guess is the intended behaviour, and only the plain + # `node