Skip to content
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
8 changes: 6 additions & 2 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,18 @@ The operational prefix travels with the message text; it does not rely on harnes

### Busy-guard and composer guard

The daemon never injects into an in-use pane. Two checks run before every
The daemon never injects into an in-use pane. Three checks run before every
injection, dispatched through `bin/fm-backend.sh` for the supervisor's own
backend (tmux or herdr; see "Auto-discovered supervisor pane" below):

- **Harness ownership proof** - `inject_msg` first requires `fm_backend_pane_harness_state` to report that the detected primary harness owns the pane's terminal right now, from the foreground process group (plus Herdr's native agent name where it is verified).
A rendered glyph cannot prove this: a shell whose prompt theme draws a bare `❯` looks exactly like an idle Claude composer, and a digest typed there runs any command substitution a worker quoted.
Any other verdict logs `inject refused: supervisor pane is not owned by the primary harness`, types nothing, and leaves the escalation buffered for the max-defer wedge path below; a submit is also counted as delivered only while the same proof still holds afterwards.
- **Primary-pane busy guard** - `pane_is_busy` trusts Herdr native `busy` when available, otherwise matches rendered output against only the detected primary harness's signature.
This narrow delivery guard never classifies a recorded worker task and never uses a global union of vendor patterns.
- **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`pending-unproven`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`.
Every other or future verdict defers, including an unreadable pane, ambiguous geometry, a blank unidentified row, and a bare shell prompt left after the agent exits.
The daemon names its primary harness to the classifier, so for a harness whose composer is always framed (Claude) an unframed agent glyph row reads `unknown` too.
Each adapter contributes only capture and capability facts to the fleet-wide screen classifier in `bin/fm-composer-lib.sh`, which owns every shape and verdict.
It preserves proven idle composers as empty but requires a genuine container around shell glyphs; see `docs/herdr-backend.md` "Composer and injection safety" for the operator contract.
`pane_input_pending` is the tested fail-closed predicate for callers that need to know whether the composer is unsafe: it treats every result except exact `empty` as pending.
Expand Down Expand Up @@ -184,7 +188,7 @@ the operational prefix lets firstmate distinguish it from a real captain message
- **Single-line digest** - embedded newlines are collapsed to a literal
separator before injection, so submission is unambiguous regardless of
harness.
- **Busy and composer guards on the supervisor pane** - before injecting, the daemon runs the detected-primary-harness rendered busy guard and reads `fm_backend_composer_state` directly.
- **Ownership, busy, and composer guards on the supervisor pane** - before injecting, the daemon proves the detected primary harness owns the pane, runs that harness's rendered busy guard, and reads `fm_backend_composer_state` directly.
Only `empty` permits injection; `pending` protects half-typed or swallowed input, and `unknown` protects unreadable panes and bare dead-shell prompts.
Every other result preserves the buffer for retry, so the daemon never merges its digest into the captain's half-typed line or types it into a shell.
- The active backend passes its capture plus declarative styled, cursor, identity, and row capabilities to the shared screen classifier; all structural recognition and verdict logic remains in `bin/fm-composer-lib.sh`.
Expand Down
110 changes: 92 additions & 18 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2139,11 +2139,32 @@ fm_backend_herdr_pane_process_state() { # <session> <pane_id>
printf '%s' "$verdict"
}

# fm_backend_herdr_foreground_process_rows: the foreground processes of one
# already-validated `pane process-info` response, one per line as
# <pid>US<name>US<argv0>US<args> with US the ASCII unit separator (0x1f, not
# whitespace, so `read` keeps an empty field in place), where name is the kernel process name,
# argv0 the first argv element (or .argv0), and args the full command line
# (.cmdline, or argv joined by spaces). Fails when the response carries no
# foreground_processes array; an empty array prints nothing and succeeds,
# because that is the real exec-to-shell handoff shape, not an unreadable pane.
fm_backend_herdr_foreground_process_rows() { # <process-info-json>
printf '%s' "$1" | jq -e '.result.process_info.foreground_processes | type == "array"' \
>/dev/null 2>&1 || return 1
printf '%s' "$1" | jq -r '
.result.process_info.foreground_processes[]
| [ (.pid | if type == "number" then (floor | tostring) else "" end),
(.name // ""),
(((.argv // [])[0]) // .argv0 // ""),
(.cmdline // ((.argv // []) | join(" ")) // "") ]
| map(gsub("[\u001f\n]"; " "))
| join("\u001f")' 2>/dev/null
}

# fm_backend_herdr_pane_process_state_sample: one instantaneous observation
# for fm_backend_herdr_pane_process_state, which owns the verdict contract and
# the settle retry.
fm_backend_herdr_pane_process_state_sample() { # <session> <pane_id>
local session=$1 pane_id=$2 info shell_pid count i pid name argv0 args verdict
local session=$1 pane_id=$2 info shell_pid fg pid name argv0 args verdict
local others=0 ps_bin rows
info=$(fm_backend_herdr_cli "$session" pane process-info --pane "$pane_id" 2>/dev/null) \
|| { printf 'unreadable'; return 0; }
Expand All @@ -2154,29 +2175,18 @@ fm_backend_herdr_pane_process_state_sample() { # <session> <pane_id>
shell_pid=$(printf '%s' "$info" | jq -er \
'.result.process_info.shell_pid | select(type == "number" and . > 1) | floor' 2>/dev/null) \
|| { printf 'unreadable'; return 0; }
count=$(printf '%s' "$info" | jq -er \
'.result.process_info.foreground_processes | select(type == "array") | length' 2>/dev/null) \
|| { printf 'unreadable'; return 0; }
i=0
while [ "$i" -lt "$count" ]; do
pid=$(printf '%s' "$info" | jq -r --argjson i "$i" \
'.result.process_info.foreground_processes[$i].pid | select(type == "number") | floor' 2>/dev/null)
name=$(printf '%s' "$info" | jq -r --argjson i "$i" \
'.result.process_info.foreground_processes[$i].name // empty' 2>/dev/null)
argv0=$(printf '%s' "$info" | jq -r --argjson i "$i" '
.result.process_info.foreground_processes[$i] as $p
| (($p.argv // [])[0]) // $p.argv0 // empty' 2>/dev/null)
args=$(printf '%s' "$info" | jq -r --argjson i "$i" '
.result.process_info.foreground_processes[$i] as $p
| $p.cmdline // (($p.argv // []) | join(" ")) // empty' 2>/dev/null)
fg=$(fm_backend_herdr_foreground_process_rows "$info") || { printf 'unreadable'; return 0; }
while IFS=$'\037' read -r pid name argv0 args; do
[ -n "$pid$name$argv0$args" ] || continue
verdict=$(fm_agent_process_classify "$name" "$argv0" "$args" "$pid")
case "$verdict" in
agent) printf 'agent'; return 0 ;;
shell) ;;
*) others=$((others + 1)) ;;
esac
i=$((i + 1))
done
done <<EOF
$fg
EOF

# Nothing in the foreground is a harness. A foreground that is not purely
# shells is already `other`, whatever else the pane holds. Before calling a
Expand Down Expand Up @@ -3101,6 +3111,70 @@ fm_backend_herdr_agent_identity_raw() { # <session> <pane> -> <agent>\t<status>
printf '%s' "$out" | jq -r '[.result.agent.agent // "", .result.agent.agent_status // ""] | @tsv' 2>/dev/null
}

# fm_backend_herdr_native_agent_name: the `agent get` agent name Herdr reports
# for a harness family, for the families where that name is verified live
# (docs/verification/runtime-backends.md "Pane harness ownership"). A family
# with no verified name prints nothing, and fm_backend_herdr_pane_harness_state
# then rests on the process proof alone rather than guessing a name that would
# refuse every delivery if Herdr spells it differently.
fm_backend_herdr_native_agent_name() { # <family>
case "${1:-}" in
claude|pi|codex) printf '%s' "$1" ;;
esac
}

# fm_backend_herdr_pane_harness_state: owned|foreign|unreadable for <target>
# and <harness>, the herdr half of bin/fm-backend.sh's
# fm_backend_pane_harness_state. Two independent facts must both hold for
# `owned`:
# - Herdr's native `agent get` names the harness, where that name is verified
# (fm_backend_herdr_native_agent_name). A different or absent agent is
# `foreign`.
# - A process in the pane's FOREGROUND group is that harness
# (fm_agent_process_harness_family over `pane process-info`). This is the
# fact that settles ownership: Herdr keeps a registration after its agent
# exits to a shell (upstream issue #4115), and
# fm_backend_herdr_pane_process_state still says `agent` for a harness
# suspended under a shell that now owns the terminal, so neither of those
# can prove who will read the next keystroke. A foreground group holding
# only shells or other programs is `foreign`.
fm_backend_herdr_pane_harness_state() { # <target> <harness>
local target=$1 want native identity agent info fg pid name argv0 args family
want=$(fm_agent_harness_family "${2:-}") || { printf 'foreign'; return 0; }
fm_backend_herdr_parse_target "$target" || { printf 'unreadable'; return 0; }
native=$(fm_backend_herdr_native_agent_name "$want")
if [ -n "$native" ]; then
# A pane with no registered agent answers `agent get` with error code
# agent_not_found (a plain shell): that is proof of absence, not an
# unreadable pane.
if ! identity=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" agent get "$FM_BACKEND_HERDR_PANE" 2>&1); then
[ "$(printf '%s' "$identity" | jq -r '.error.code // empty' 2>/dev/null)" = agent_not_found ] \
&& { printf 'foreign'; return 0; }
printf 'unreadable'
return 0
fi
agent=$(printf '%s' "$identity" | jq -r '.result.agent.agent // empty' 2>/dev/null)
[ "$agent" = "$native" ] || { printf 'foreign'; return 0; }
fi
info=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane process-info --pane "$FM_BACKEND_HERDR_PANE" 2>/dev/null) \
|| { printf 'unreadable'; return 0; }
printf '%s' "$info" | jq -e --arg pane "$FM_BACKEND_HERDR_PANE" '
.result.type == "pane_process_info"
and .result.process_info.pane_id == $pane
' >/dev/null 2>&1 || { printf 'unreadable'; return 0; }
fg=$(fm_backend_herdr_foreground_process_rows "$info") || { printf 'unreadable'; return 0; }
while IFS=$'\037' read -r pid name argv0 args; do
[ -n "$pid$name$argv0$args" ] || continue
if family=$(fm_agent_process_harness_family "$name" "$argv0" "$args") && [ "$family" = "$want" ]; then
printf 'owned'
return 0
fi
done <<EOF
$fg
EOF
printf 'foreign'
}

# fm_backend_herdr_composer_identity: the native agent identity/state probe
# backing the shared classifier's separated (pi) shape - the genuine herdr
# primitive no other backend has natively.
Expand Down
71 changes: 71 additions & 0 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,77 @@ fm_backend_tmux_foreground_argv0s() { # <target>
done
}

# fm_backend_tmux_exact_pane_id: the `%N` id of the pane <target> names, only
# when tmux proves that pane really belongs to <target>. display-message
# resolves an absent `session:window` to the active window without an error,
# while list-panes refuses it, so the id display-message reports must also be
# one of the panes list-panes returns for the same target.
fm_backend_tmux_exact_pane_id() { # <target>
local target=$1 id panes
id=$(tmux display-message -p -t "$target" '#{pane_id}' 2>/dev/null) || return 1
case "$id" in %[0-9]*) ;; *) return 1 ;; esac
panes=$(tmux list-panes -t "$target" -F '#{pane_id}' 2>/dev/null) || return 1
printf '%s\n' "$panes" | grep -qxF -- "$id" || return 1
printf '%s' "$id"
}

# fm_backend_tmux_pane_harness_state: whether <harness> - and not a shell, or
# some other program - is what currently owns <target>'s terminal, as one of
# owned|foreign|unreadable. Only `owned` licenses typing into the pane; see
# bin/fm-backend.sh's fm_backend_pane_harness_state for the contract.
#
# Two foreground-derived surfaces are read, either one naming the harness's
# family (fm_agent_process_harness_family) enough for `owned`: tmux's own
# #{pane_current_command}, and every process in the pane tty's foreground
# process group by kernel name, argv[0], and command line. Both are needed
# because Claude Code rewrites its process title to its version on macOS, where
# #{pane_current_command} then says `2.1.220` while the foreground process's
# install path still says claude (fm_backend_tmux_foreground_comms above).
# Both are foreground-scoped, so a harness suspended or backgrounded under a
# shell that now owns the terminal is never `owned`.
# Every read goes through the exact pane id fm_backend_tmux_exact_pane_id
# resolves, because tmux answers a display-message for an absent window from
# the client's active window instead of failing, which could describe (and
# vouch for) a different pane entirely.
fm_backend_tmux_pane_harness_state() { # <target> <harness>
local target=$1 want pane current family tty rows pid pgid tpgid comm args argv0
want=$(fm_agent_harness_family "${2:-}") || { printf 'foreign'; return 0; }
pane=$(fm_backend_tmux_exact_pane_id "$target") || { printf 'unreadable'; return 0; }
target=$pane
current=$(tmux display-message -p -t "$target" '#{pane_current_command}' 2>/dev/null) \
|| { printf 'unreadable'; return 0; }
if family=$(fm_agent_process_harness_family "$current" '' '') && [ "$family" = "$want" ]; then
printf 'owned'
return 0
fi
tty=$(tmux display-message -p -t "$target" '#{pane_tty}' 2>/dev/null) || tty=
case "$tty" in
/dev/*) ;;
*)
# With no tty the foreground group cannot be read, so a current command
# that does not name the harness is the only evidence there is.
if [ -n "$current" ]; then printf 'foreign'; else printf 'unreadable'; fi
return 0
;;
esac
rows=$(LC_ALL=C ps -t "${tty#/dev/}" -o pid=,pgid=,tpgid=,comm= 2>/dev/null) \
|| { printf 'unreadable'; return 0; }
while read -r pid pgid tpgid comm; do
[ -n "$comm" ] || continue
[ "$pgid" = "$tpgid" ] || continue
args=$(LC_ALL=C ps -p "$pid" -o args= 2>/dev/null) || args=
args=${args#"${args%%[![:space:]]*}"}
argv0=${args%%[[:space:]]*}
if family=$(fm_agent_process_harness_family "$comm" "$argv0" "$args") && [ "$family" = "$want" ]; then
printf 'owned'
return 0
fi
done <<EOF
$rows
EOF
printf 'foreign'
}

# fm_backend_tmux_agent_state: recovery-grade harness-agent state for one
# recorded target. See bin/fm-backend.sh's fm_backend_agent_state for the
# shared state vocabulary and docs/tmux-backend.md "Agent liveness probe" for
Expand Down
14 changes: 8 additions & 6 deletions bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ fm_afk_launch_restore_backup() { # <backup> <had-afk>
# dedicated background workspace (--no-focus) holds exactly one tab/pane; it
# never touches the captain's active tab. Prints the record line on success.
fm_afk_launch_create_herdr() { # <captain-target> <captain-backend>
local captain_target=$1 captain_backend=$2 session out wsid pane entry cmd label recovered create_result
local captain_target=$1 captain_backend=$2 session out wsid pane entry cmd harness label recovered create_result
session=${captain_target%%:*}
if [ -z "$session" ] || [ "$session" = "$captain_target" ]; then
fm_afk_launch_log "cannot derive herdr session from captain target '$captain_target'"
Expand Down Expand Up @@ -497,8 +497,9 @@ fm_afk_launch_create_herdr() { # <captain-target> <captain-backend>
IFS=$'\t' read -r wsid pane <<< "$recovered"
fi
entry=$(fm_afk_launch_entry_cmd)
cmd=$(printf 'exec env FM_HOME=%q FM_SUPERVISOR_TARGET=%q FM_SUPERVISOR_BACKEND=%q %q' \
"$FM_HOME" "$captain_target" "$captain_backend" "$entry")
harness=$(fm_afk_launch_primary_harness)
cmd=$(printf 'exec env FM_HOME=%q FM_SUPERVISOR_TARGET=%q FM_SUPERVISOR_BACKEND=%q FM_DAEMON_PRIMARY_HARNESS=%q %q' \
"$FM_HOME" "$captain_target" "$captain_backend" "$harness" "$entry")
if ! fm_afk_launch_record_write herdr "$session:$pane" "$wsid"; then
fm_afk_launch_log "failed to persist herdr daemon terminal record; closing $session:$pane"
fm_afk_launch_close_terminal herdr "$session:$pane"
Expand All @@ -519,13 +520,14 @@ fm_afk_launch_create_herdr() { # <captain-target> <captain-backend>
# captain's window). tmux pane ids are server-global, so the daemon reaches the
# captain pane by its %id from this separate session.
fm_afk_launch_create_tmux() { # <captain-target> <captain-backend>
local captain_target=$1 captain_backend=$2 session entry cmd hash nonce
local captain_target=$1 captain_backend=$2 session entry cmd harness hash nonce
hash=$(printf '%s' "$FM_HOME" | cksum | cut -d' ' -f1)
nonce="$$-${RANDOM:-0}-$(date '+%s')"
session="fm-afk-daemon-$hash-$nonce"
entry=$(fm_afk_launch_entry_cmd)
cmd=$(printf 'exec env FM_HOME=%q FM_SUPERVISOR_TARGET=%q FM_SUPERVISOR_BACKEND=%q %q' \
"$FM_HOME" "$captain_target" "$captain_backend" "$entry")
harness=$(fm_afk_launch_primary_harness)
cmd=$(printf 'exec env FM_HOME=%q FM_SUPERVISOR_TARGET=%q FM_SUPERVISOR_BACKEND=%q FM_DAEMON_PRIMARY_HARNESS=%q %q' \
"$FM_HOME" "$captain_target" "$captain_backend" "$harness" "$entry")
if ! fm_afk_launch_record_write tmux "$session" ""; then
fm_afk_launch_log "failed to persist planned tmux daemon session '$session'"
return 1
Expand Down
53 changes: 53 additions & 0 deletions bin/fm-agent-process-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,56 @@ fm_agent_process_classify() { # <name> <argv0> <args> [pid] -> agent|shell|othe
printf 'other'
fi
}

# fm_agent_harness_family: the process family a harness name runs as. Both Pi
# identities run the same `pi` engine (the signed launcher is only a wrapper),
# so they share one family; every other primary harness is its own family. An
# unrecognized name prints nothing and returns 1, so no caller can prove that
# an unverified harness owns a process.
fm_agent_harness_family() { # <harness>
case "${1:-}" in
claude|codex|opencode|grok|kimi|omp|cursor) printf '%s' "$1" ;;
pi|pi-signed) printf 'pi' ;;
*) return 1 ;;
esac
}

# fm_agent_process_harness_family: WHICH verified harness one process is, from
# the same identity surfaces fm_agent_process_classify reads, printed as a
# fm_agent_harness_family value; nothing and return 1 when the process names no
# primary harness. fm_agent_process_classify answers "is this some agent";
# this answers "is it THIS agent", which a caller about to type into a pane
# needs, because a different harness in the foreground is not the recipient it
# vetted.
#
# The name patterns mirror fm_agent_process_classify_name, and an exact harness
# component of the name or argv[0] path (fm_harness_path_name) carries Claude
# Code's version-named executable, whose install path is the only surface that
# still says claude. The node-bundle interpreter inference is deliberately not
# used: any node process holding a harness-shaped path would match it.
fm_agent_process_harness_family() { # <name> <argv0> <args>
local name=${1:-} argv0=${2:-} args=${3:-} surface base found
if fm_cursor_process_matches "$name" "$args" "$argv0"; then
printf 'cursor'
return 0
fi
for surface in "$name" "$argv0"; do
[ -n "$surface" ] || continue
base=${surface##*/}
base=${base#-}
case "$base" in
*claude*) printf 'claude'; return 0 ;;
*codex*) printf 'codex'; return 0 ;;
*opencode*) printf 'opencode'; return 0 ;;
*grok*) printf 'grok'; return 0 ;;
*kimi*) printf 'kimi'; return 0 ;;
pi|pi-signed|pi-launcher|Pi) printf 'pi'; return 0 ;;
omp) printf 'omp'; return 0 ;;
esac
if found=$(fm_harness_path_name "$surface"); then
fm_agent_harness_family "$found"
return 0
fi
done
return 1
}
Loading
Loading