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
6 changes: 6 additions & 0 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ First launch in a fresh worktree, or first ever on a machine, may show a trust o
After every spawn, peek the pane within about 20 seconds.
If such a dialog is showing, accept it from an active firstmate session using `FM_HOME=<this-firstmate-home> bin/fm-send.sh <window> --key Enter`, or the choice the dialog requires, unless `FM_HOME` is already set to the active firstmate home; verify the brief started processing.

**Autonomy is a launch-time grant and cannot be restored from the keyboard (verified 2026-07-31, Claude Code 2.1.220).**
`bypassPermissions` is only available to a session that was started with `--dangerously-skip-permissions`, or with `--allow-dangerously-skip-permissions`, whose own help states it "enable[s] bypassing all permission checks as an option, without it being enabled by default".
A process started with neither does not offer that mode at all, so no interrupt, keystroke, or mode-cycle can put it back into bypass; `shift+tab` cycles only the modes the running process already has.
This is the durable reason a restored pane cannot be repaired remotely with a keystroke, independent of whether any given session provider transmits that key.
The only repair is to exit the agent and relaunch it with the original launch command, which is what `bin/fm-crew-relaunch.sh` replays; `stuck-crewmate-recovery` owns the procedure.

Claude renders a predicted-next-prompt suggestion as dim/faint text inside an otherwise-empty composer after a turn completes.
A plain `tmux capture-pane` cannot tell that ghost text apart from typed text.
Firstmate launches every claude crewmate and secondmate with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`, scoped to firstmate-launched agents through `bin/fm-spawn.sh`, so it never touches the captain's global config.
Expand Down
26 changes: 25 additions & 1 deletion .agents/skills/stuck-crewmate-recovery/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: stuck-crewmate-recovery
description: >-
Agent-only playbook for stuck or missing ordinary Firstmate direct reports.
Use when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer.
Use when the session-start digest reports an ordinary direct report's endpoint dead, reports a live endpoint as unfit, or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer.
Reconciles recorded work before escalating from targeted inspection through safe relaunch or failure.
user-invocable: false
metadata:
Expand All @@ -12,10 +12,33 @@ metadata:
# stuck-crewmate-recovery

Use this playbook when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or when a direct report is stale, looping, repeatedly confused, asking a question its brief already answers, unresponsive, or when a steer failed to land.
Also use it when the digest reports a live endpoint as `fitness: unfit`.

Load `harness-adapters` before sending an interrupt, exit command, resume command, or harness-specific skill invocation.
The target window's harness is recorded as `harness=` in `state/<id>.meta`.

## Alive but unable to work

A live endpoint does not prove the worker can still act.
After a session-provider restart, a pane can come back with full conversation context, no error, and a live endpoint, yet have lost the autonomy flag it was launched with and be sitting outside its task worktree.
Such a worker stalls forever on its first tool call, waiting for an approval nobody is watching for.
`bin/fm-crew-fitness.sh <id>` is the owner of that verdict, and the session-start digest runs it for every live endpoint.
`bin/fm-crew-fitness.sh --all` sweeps this home when a restart is suspected mid-session.

Repair an `unfit` worker in this order.

1. Exit the agent with its adapter's exit command from `harness-adapters`.
The pane must fall back to its shell first, because the repair refuses to relaunch over a live agent.
2. Run `bin/fm-crew-relaunch.sh <id>`, which replays the exact launch command recorded at spawn time in the task's own worktree.
Do not hand-assemble the relaunch: the recorded command already carries the resolved autonomy flag, model, effort, env prefixes, and encoded brief.
3. Confirm with `bin/fm-crew-fitness.sh <id>` that the pane reads `fit` again.

A refusal from that script is a stop-and-investigate result.
It refuses when an agent still owns the task, when the recorded worktree is gone or is no longer its own checkout, and when no launch command was recorded, and it never allocates a replacement worktree.
It also refuses when the recorded endpoint is gone entirely rather than merely idle, because there is then no pane to relaunch into: that case is a respawn, which the dead-direct-report procedure below owns, and it refuses before writing anything so a refusal never leaves a half-applied repair behind.
`unknown` is not `unfit`: it means the check could not establish an answer, so inspect the pane before acting rather than relaunching on it.
Interrupting the harness cannot restore autonomy that was never granted at launch, so never try to fix an `unfit` pane with a keystroke; `harness-adapters` owns why.

## Session-start reconciliation for a dead ordinary direct report

This procedure covers ordinary `kind=ship` and `kind=scout` direct reports.
Expand Down Expand Up @@ -43,6 +66,7 @@ Escalate in order:
3. If the crewmate is confused or looping, interrupt with the adapter's interrupt key, then redirect with one corrective line.
For example, for a single-Escape adapter: `FM_HOME=<this-firstmate-home> bin/fm-send.sh <window> --key Escape`.
4. If the crewmate is genuinely wedged after redirection, exit the agent with the adapter's exit command and relaunch with the same brief plus a `progress so far` note appended to it.
Use `bin/fm-crew-relaunch.sh <id> --note '<progress so far>'` for that relaunch whenever the task recorded a `launch=` command: it replays the resolved launch in the recorded worktree and appends the note itself, so nothing has to be reassembled by hand.
Genuine wedging means looping, unresponsive, repeating the same obstacle, or truly dead.
A low context reading is not wedging; modern harnesses auto-compact and keep going.
The worktree and commits persist, so relaunch is cheap.
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ state/ volatile runtime signals; gitignored
<id>.turn-ended touched by turn-end hooks
<id>.grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown
<id>.kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=, launch=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
<id>.herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces"
<id>.check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution
<id>.check-trust private content binding created by fm-check-register.sh for an intentional custom check
Expand Down Expand Up @@ -147,6 +147,8 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai
A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use the firstmate repo's built-in defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.).
5. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/<id>.meta`; a bounded tail of each task's `state/<id>.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint.
That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh <id>` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded.
Every alive endpoint additionally prints one `fitness:` line from `bin/fm-crew-fitness.sh`, because a pane that came back from a session-provider restart without its autonomy grant or outside its worktree still reads as alive while being unable to work.
`unfit` routes to `stuck-crewmate-recovery` (section 5), while `unknown` only means the check could not establish an answer, so inspect that pane rather than repairing it.
6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness.
The closing reminder points back to that emitted block and preserves only the lock, afk, X-mode, and read-once reminders.
The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism.
Expand Down Expand Up @@ -191,7 +193,7 @@ Honor lock-refused read-only mode exactly as section 3 requires.
Treat digest status tails as wake-event history and use targeted current-state reconciliation when the live state matters.

Reconcile only this home's recorded direct reports and their recorded backend inventory; never sweep a shared endpoint namespace for matching names or claim another home's work.
For an ordinary direct report whose endpoint is dead or metadata has no window, load `stuck-crewmate-recovery` and preserve the recorded worktree and unlanded work while reconciling ownership.
For an ordinary direct report whose endpoint is dead, whose live endpoint reports `fitness: unfit`, or whose metadata has no window, load `stuck-crewmate-recovery` and preserve the recorded worktree and unlanded work while reconciling ownership.
For a dead secondmate direct report, load `secondmate-provisioning` and reconcile only that secondmate, never its whole child tree from the main home.
Each secondmate reconciles work already in its own home and then idles; recovery never authorizes it to invent work.

Expand Down Expand Up @@ -494,7 +496,7 @@ These skills are not captain-invocable; load them only at their precise triggers
- `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata.
- `project-management` - load before adding, creating, removing, or initializing a project.
Cloning or registering a project is add intake and uses the same trigger.
- `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer.
- `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead, reports a live endpoint as `fitness: unfit`, or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer.
- `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`.
- `decision-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer.
- `fmx-respond` - load on an `x-mention <request_id>` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on.
Expand Down
27 changes: 27 additions & 0 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1890,6 +1890,33 @@ fm_backend_herdr_current_path() { # <target>
| jq -r '.result.pane.foreground_cwd // empty' 2>/dev/null
}

# fm_backend_herdr_pane_argv_has: 0 if any argv element of the pane's FOREGROUND
# process group is EXACTLY <element>, 1 if it is confidently absent, 2 if the
# answer cannot be established. See fm-backend.sh's fm_backend_pane_argv_has for
# the shared contract.
#
# `pane process-info` reports `.result.process_info.foreground_processes[].argv`
# as a real JSON array, so element boundaries survive intact and jq can compare
# whole elements. An absent or empty foreground_processes list is an
# undeterminable read (2), never proof of absence: a pane whose agent has not
# been attributed yet must not be reported as having lost its autonomy flag.
fm_backend_herdr_pane_argv_has() { # <target> <element>
local target=$1 want=$2 info n
fm_backend_herdr_target_ready "$target" || return 2
info=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane process-info \
--pane "$FM_BACKEND_HERDR_PANE" 2>/dev/null) || return 2
[ -n "$info" ] || return 2
n=$(printf '%s' "$info" \
| jq -r '[.result.process_info.foreground_processes[]? | select(.argv != null)] | length' 2>/dev/null)
case "$n" in ''|*[!0-9]*) return 2 ;; esac
[ "$n" -gt 0 ] || return 2
if printf '%s' "$info" | jq -e --arg want "$want" \
'any(.result.process_info.foreground_processes[]?.argv[]?; . == $want)' >/dev/null 2>&1; then
return 0
fi
return 1
}

# fm_backend_herdr_send_text_line: send one line of TEXT then submit,
# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. Used for the fixed
# spawn-time commands (treehouse get, the GOTMPDIR export). `pane run` types
Expand Down
78 changes: 78 additions & 0 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,84 @@ fm_backend_tmux_current_path() { # <target>
tmux display-message -p -t "$1" '#{pane_current_path}' 2>/dev/null
}

# fm_backend_tmux_foreground_pids: the pids of <target>'s FOREGROUND process
# group, one per line, or nonzero when that group cannot be resolved at all.
# tmux exposes no process formatter beyond the pane's controlling tty, so the
# tty's foreground process-group id is the only handle on "what is actually
# running in this pane right now". Shared by the argv and environment readers,
# which ask different questions about exactly the same processes.
fm_backend_tmux_foreground_pids() { # <target>
local target=$1 tty tpgid pids
tty=$(tmux display-message -p -t "$target" '#{pane_tty}' 2>/dev/null) || return 1
[ -n "$tty" ] || return 1
tpgid=$(ps -t "$tty" -o tpgid= 2>/dev/null | tr -d ' ' | grep -E '^[0-9]+$' | head -1)
case "$tpgid" in ''|*[!0-9]*) return 1 ;; esac
pids=$(ps -t "$tty" -o pgid=,pid= 2>/dev/null | awk -v g="$tpgid" '$1 == g { print $2 }')
[ -n "$pids" ] || return 1
printf '%s\n' "$pids"
}

# fm_backend_tmux_pane_argv_has: 0 if any argv element of the pane's FOREGROUND
# process group is EXACTLY <element>, 1 if it is confidently absent, 2 if the
# answer cannot be established. See fm-backend.sh's fm_backend_pane_argv_has for
# the shared contract and why exact-element (never substring) matching is
# mandatory.
#
# The element boundaries come from the NUL-separated /proc/<pid>/cmdline of
# every process in the pane's foreground group. NUL separation is what makes
# exact-element matching possible; a space-joined `ps -o args=` fallback could
# not tell a real flag apart from a brief that merely mentions it, so a host
# without a readable /proc returns 2 (undeterminable) rather than guessing.
fm_backend_tmux_pane_argv_has() { # <target> <element>
local want=$2 pid found=1 pids
[ -r /proc/self/cmdline ] || return 2
pids=$(fm_backend_tmux_foreground_pids "$1") || return 2
for pid in $pids; do
[ -r "/proc/$pid/cmdline" ] || continue
found=0
if tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null | grep -qxF -- "$want"; then
return 0
fi
done
# found stays 1 when no foreground process was readable at all: that is an
# undeterminable read, not proof the element is absent.
[ "$found" -eq 0 ] || return 2
return 1
}

# fm_backend_tmux_pane_env_has: 0 if any environment entry of the pane's
# FOREGROUND process group is EXACTLY <entry> (a whole NAME=value string), 1 if
# it is confidently absent, 2 if the answer cannot be established. See
# fm-backend.sh's fm_backend_pane_env_has for the shared contract.
#
# /proc/<pid>/environ is NUL-separated exactly like cmdline, so whole entries
# survive and can be compared without any parsing of the value. It holds the
# environment the process was EXECed with, which is precisely the question:
# whether the env prefix its recorded launch command specified is still in force
# or was dropped by a restart that rebuilt the command.
#
# Verified readable for a same-uid process on Linux 6.18 (WSL2) with
# kernel.yama.ptrace_scope=1 and no hidepid mount option, against a live agent
# pane the reader did not start (docs/verification/runtime-backends.md,
# "Foreground argv and environment"). Readability is NOT universal: a host with
# no /proc at all (macOS), a hidepid=2 mount, and a process owned by another
# user each make the read fail, and every one of them returns 2 rather than
# reporting a grant absent that was merely unreadable.
fm_backend_tmux_pane_env_has() { # <target> <entry>
local want=$2 pid found=1 pids
[ -r /proc/self/environ ] || return 2
pids=$(fm_backend_tmux_foreground_pids "$1") || return 2
for pid in $pids; do
[ -r "/proc/$pid/environ" ] || continue
found=0
if tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | grep -qxF -- "$want"; then
return 0
fi
done
[ "$found" -eq 0 ] || return 2
return 1
}

# fm_backend_tmux_send_text_line: send one line of TEXT then Enter, with no
# composer verification - used for the fixed spawn-time commands
# (`treehouse get`, the GOTMPDIR export) that already ran this exact sequence
Expand Down
Loading
Loading