Skip to content

fix(afk): stop the away daemon from self-injecting on claude+herdr - #3100

Open
NicholasACTran wants to merge 11 commits into
kunchenguid:mainfrom
NicholasACTran:fm/firstmate-afk-launch-fix-land-locally-v2
Open

fix(afk): stop the away daemon from self-injecting on claude+herdr#3100
NicholasACTran wants to merge 11 commits into
kunchenguid:mainfrom
NicholasACTran:fm/firstmate-afk-launch-fix-land-locally-v2

Conversation

@NicholasACTran

@NicholasACTran NicholasACTran commented Aug 26, 2026

Copy link
Copy Markdown

Intent

Fix a real bug where a Claude-primary + herdr away-mode setup silently never runs the away-mode daemon. The invariant: the daemon must never inject escalations into the very pane it is itself hosted in, on a claude primary under herdr - that self-injection is the wedge (a claude background shell renders a footer token herdr's claude-detection reads as 'the agent is working', so escalations defer forever). The fix (1) routes claude+herdr through the terminal-backed launcher in .agents/skills/afk/SKILL.md, (2) updates docs/herdr-backend.md to state the exception in its own voice, (3) enforces a guard in bin/fm-afk-launch.sh's start-native path refusing claude+herdr entirely (that path is always same-pane by definition), and (4) enforces a second guard in bin/fm-afk-start.sh's own direct entry point - since that script is also a documented direct entry point the launcher's guard alone does not cover - keyed on comparing this process's own raw pane (ignoring the FM_SUPERVISOR_TARGET override) against the actual injection target (which does honor that override): same pane on claude+herdr is refused, different panes (the launcher's own separate terminal, or an operator override pointing elsewhere) stay permitted. A direct claude+herdr entry that manually sets FM_SUPERVISOR_TARGET to a pane other than where it is actually running is deliberately PERMITTED - that is the case distinguishing this design from a simpler 'is FM_SUPERVISOR_TARGET set' signal, which review proved could be fooled in both directions. The guard decides before writing any lifecycle state (so a refusal leaves nothing to roll back) and stays silent when herdr is provably absent (only warns when herdr is confirmed present and the pane comparison itself cannot be completed). CI cannot run on this PR: GitHub holds workflow runs from a first-time fork contributor at action_required until a repo maintainer approves them, which is expected and accepted on this repo - the finish line is PR open, correct, and carrying the no-mistakes attestation over the actual current head, not green CI. PR 3100 is already open on this branch against https://github.com/kunchenguid/firstmate, pushed via the fork https://github.com/NicholasACTran/firstmate.git (captain-approved via no-mistakes init --fork-url, since this machine's GitHub account is pull-only on origin).

What Changed

  • Route claude on the herdr backend away from the harness-native in-pane daemon path: .agents/skills/afk/SKILL.md now sends that pair to the terminal-backed bin/fm-afk-launch.sh start, and docs/herdr-backend.md states the exception and its enforcement in its own voice. A claude background shell leaves a footer token that herdr's claude agent-detection ruleset reads as "the agent is working", so the away-mode busy guard defers every escalation for the life of the session.
  • Enforce that routing in code rather than in prose. bin/fm-afk-launch.sh start_native refuses the claude+herdr pair outright (that path is same-pane by definition), and bin/fm-afk-start.sh adds a second guard for its own direct entry point, keyed on comparing this process's raw pane (fm_afk_start_own_backend/fm_afk_start_own_pane, which ignore FM_SUPERVISOR_TARGET) against the real injection target from discover_supervisor_target (which honors it). Same pane on claude+herdr is refused; the launcher's separate pane and an override naming a different pane stay permitted. Both guards decide before any lifecycle state is written, and the start guard stays silent unless herdr is confirmed present but the pane comparison cannot complete.
  • Split lifecycle-flag writing into fm_afk_start_ensure_flag so the refresh and fresh-start branches each write it only after deciding to proceed, and extend tests/fm-afk-launch.test.sh with cases covering refusal-with-no-state-written, the refusal message naming start, claude on non-herdr, non-claude on herdr, launcher-hosted entry, and both override directions; tests/fm-afk-return.test.sh and tests/fm-daemon.test.sh pin backend/pane env at affected call sites so the new guards do not fire on ambient environment.

Risk Assessment

✅ Low: The change adds a narrowly-keyed same-pane refusal at the single exec chokepoint for the away daemon, correctly permits every launcher-hosted and non-claude/non-herdr path I traced, writes no lifecycle state on refusal, and is covered by behavior-executing tests whose ambient-environment pinning is load-bearing on the right variables; the three remaining findings are informational (dead diagnostic branches, an over-stated comment on the refresh carve-out, and a knob-honoring launcher guard whose residue is recoverable via stop).

Testing

I ran the three test files the change touches (fm-afk-launch, fm-afk-return, fm-daemon) and all pass, then proved the two new guard tests are real regressions by running the new test file against the base commit, where the claude+herdr refusal assertions fail exactly as the bug describes. For product-level evidence I drove the actual user scenario on a live herdr session: as a Claude captain in a real herdr pane, bin/fm-afk-launch.sh start-native and a direct bin/fm-afk-start.sh both refuse with the wedge explanation and point at bin/fm-afk-launch.sh start, leaving the state directory completely empty (nothing to roll back), while the prescribed start path succeeds and places the daemon in a separate non-visible workspace (workspaces 1 to 2, captain tab pane count unchanged at 1, daemon tab w2:t1 vs captain tab w1:t1), and running bin/fm-afk-start.sh from that daemon pane is permitted since it is a different pane from the injection target. stop restores the original topology and clears state. This is a shell/CLI surface with no rendered UI, so the evidence is a CLI transcript rather than a screenshot.

Evidence: Claude captain on a real herdr session: refusal at both entry points, then successful terminal-backed away-mode entry

Source: Claude captain on a real herdr session: refusal at both entry points, then successful terminal-backed away-mode entry

# Real herdr session: Claude captain enters away mode

Live herdr lab session `fm-lab-afk-evidence-17389`; captain pane `w1:p1` (workspace w1).
The shell below runs AS the captain: HERDR_ENV=1, HERDR_PANE_ID=w1:p1, CLAUDECODE=1.

## Step 1 - the old native two-step is now refused
`` `
$ bin/fm-afk-launch.sh start-native
fm-afk-launch: refusing start-native on claude+herdr: a claude background shell renders in the captain pane's footer, herdr reads that as the agent working, so the away daemon defers forever and away mode silently delivers nothing
fm-afk-launch: use 'bin/fm-afk-launch.sh start' instead (non-visible daemon terminal)
exit=1
$ ls -A state/
(empty - no lifecycle state written)
`` `

## Step 2 - the direct daemon entry point refuses the same self-injection
`` `
$ FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh
afk: refusing to host the away daemon in the same pane it injects into on claude+herdr: a claude background shell renders in that pane's footer, herdr reads that as the agent working, so the away daemon defers forever and away mode silently delivers nothing
afk: use 'bin/fm-afk-launch.sh start' instead (non-visible daemon terminal)
exit=1
$ ls -A state/
(still empty - away mode never half-entered)
`` `

## Step 3 - the prescribed path: bin/fm-afk-launch.sh start
`` `
$ bin/fm-afk-launch.sh start
fm-afk-launch: daemon launched in non-visible herdr workspace w2 (pane fm-lab-afk-evidence-17389:w2:p1), supervising fm-lab-afk-evidence-17389:w1:p1
exit=0
$ cat state/.afk-daemon-terminal
herdr	fm-lab-afk-evidence-17389:w2:p1	w2
$ ls -A state/
.afk
.afk-daemon-terminal
`` `

| observation | before | after start |
| --- | --- | --- |
| herdr workspaces in session | 1 | 2 |
| panes in the captain's tab | 1 | 1 |

Daemon terminal: `fm-lab-afk-evidence-17389:w2:p1` in tab `w2:t1`; captain tab is `w1:t1` - a different tab, so the daemon is NOT hosted in the pane it injects into.

## Step 4 - the daemon pane itself may run fm-afk-start.sh (different pane, permitted)
`` `
$ FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh   # run from the daemon pane
afk: starting supervise daemon in foreground; keep this command as a tracked background session
exit=0
`` `

## Step 5 - stop restores the topology
`` `
$ bin/fm-afk-launch.sh stop
fm-afk-launch: away mode stopped; daemon terminal torn down and .afk cleared
exit=0
`` `

After stop: workspaces=1 (was 1), captain tab panes=1 (was 1), state/ = 
Evidence: Guard assertions fail on base 07bf0c8, pass on target b1cd4ec
# base 07bf0c8 (pre-fix), new tests/fm-afk-launch.test.sh
not ok - native guard: claude+herdr native launch was accepted
not ok - native guard: refusal did not name the terminal-backed path
not ok - start guard: claude+herdr self-injection was accepted (afk: starting supervise daemon in foreground; ...)
not ok - start guard: refusal did not name the terminal-backed path
not ok - start guard: an operator-set override reopened the self-injection wedge

# target b1cd4ec
ok - native guard: claude+herdr is refused with no lifecycle state written
ok - native guard: refusal names the terminal-backed path
ok - native guard: claude on a non-herdr backend is still permitted
ok - native guard: a non-claude harness on herdr is still permitted
ok - start guard: claude+herdr self-injection is refused with no lifecycle state left behind
ok - start guard: refusal names the terminal-backed path
ok - start guard: claude+herdr in the launcher's own separate pane is permitted
ok - start guard: an override naming a different pane is permitted
ok - start guard: an override naming this process's own pane is still refused
ok - start guard: a non-claude harness in the same pane is permitted
ok - start guard: a refresh of a live daemon takes the refresh path, not the refusal
ok - start guard: herdr provably absent permits rather than blocks
ok - start guard: herdr provably absent permits without the could-not-check diagnostic

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 3 infos
  • ℹ️ bin/fm-afk-start.sh:117 - Both "cannot check itself" diagnostics in fm_afk_start_native_refused are unreachable, so the guard can never warn. Control reaches line 117 only when own_backend == herdr, which by fm_afk_start_own_backend (lines 81-91) requires TMUX_PANE empty AND HERDR_ENV=1 AND HERDR_PANE_ID non-empty. fm_afk_start_own_pane (lines 93-103) tests the identical two conditions, so it always takes its herdr branch and returns 0 - the line 118 message is dead. discover_supervisor_target under the same state returns 0 via the override branch or the HERDR_ENV+HERDR_PANE_ID compose branch, so the line 123 message is dead too (round 3 already noted this one as no-op). Net effect on the intent's phrase "only warns when herdr is confirmed present and the pane comparison itself cannot be completed": that warning never fires, and the one genuinely ambiguous state - HERDR_ENV=1 with HERDR_PANE_ID unset or empty - is classified by fm_afk_start_own_backend as "herdr provably absent" and permitted silently at line 114. Since own_backend and own_pane branch on identical predicates, fm_afk_start_own_backend plus the line 117-120 block could collapse into a single own_pane resolve. Noted so a future reader does not treat either message as a live failure mode; the user has already ruled twice on this diagnostic surface.
  • ℹ️ bin/fm-afk-start.sh:243 - The comment at line 243 asserts the refresh branch above it returned "a refresh of an already-live, already-safely-hosted daemon". The "already-safely-hosted" half is not established by anything the code checks: daemon_lock_held_by_live_daemon only proves a live daemon owns the lock, not where it is hosted. Concrete state - the exact one on the machine this fix targets - a claude+herdr captain that entered away mode before this change and is running the self-hosted, wedged in-pane daemon right now. Re-running /afk takes bin/fm-afk-launch.sh start, whose own refresh branch (bin/fm-afk-launch.sh:483-491) also returns 0 with "daemon already running; refreshed away-mode flag"; a bare bin/fm-afk-start.sh returns 0 at line 239. Neither guard fires, so the wedged daemon survives and away mode keeps silently delivering nothing until it dies. Refusing the refresh would not unwedge it either, so this is not obviously the wrong behavior - but the fix only takes effect on a genuinely fresh start, meaning verifying it on the reporting machine requires bin/fm-afk-launch.sh stop before bin/fm-afk-launch.sh start. Worth knowing before the fix is validated live; the comment's premise should not be read as a checked invariant.
  • ℹ️ bin/fm-afk-launch.sh:547 - fm_afk_launch_native_refused keys on discover_supervisor_backend, which honors the FM_SUPERVISOR_BACKEND override, while the sibling guard in bin/fm-afk-start.sh:114 deliberately reads the raw HERDR_ENV/HERDR_PANE_ID identity precisely because review proved an env-knob signal can be fooled in both directions. Concrete path on a real claude+herdr captain: FM_SUPERVISOR_BACKEND=tmux bin/fm-afk-launch.sh start-native resolves backend=tmux, is permitted, and proceeds to clear stale artifacts, write state/.afk, and write a none\t-\tnative terminal record - and tests/fm-afk-launch.test.sh:544-553 pins that permit as correct. The wedge itself stays unreachable because the second step (fm-afk-start.sh) ignores that override and refuses on the raw same-pane comparison, so this is not a hole in the invariant. The residue is that the operator is left holding prepared lifecycle state and a terminal record for a daemon that can never start, requiring bin/fm-afk-launch.sh stop to roll back, where a raw-identity keying in the launcher would have refused before writing anything. Requires an unusual manual override, so low practical exposure.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-afk-launch.test.sh (65 assertions, includes the new unit_native_refuses_claude_on_herdr and unit_start_refuses_self_injecting_claude_on_herdr, plus the real-herdr and real-tmux topology e2e blocks)
  • bash tests/fm-afk-return.test.sh (pinned-env re-entry gate call site)
  • bash tests/fm-daemon.test.sh (103 assertions; the two lock-reclamation call sites re-pinned with AFK_START_ENV)
  • Regression proof: exported base commit 07bf0c8 to a temp dir, copied in the new tests/fm-afk-launch.test.sh, and confirmed the guard assertions fail there (native guard: claude+herdr native launch was accepted, start guard: claude+herdr self-injection was accepted, start guard: an operator-set override reopened the self-injection wedge) and pass on b1cd4ec
  • Manual end-to-end on a live herdr lab session: created a captain workspace, ran bin/fm-afk-launch.sh start-native, bin/fm-afk-start.sh, bin/fm-afk-launch.sh start, bin/fm-afk-start.sh from the daemon pane, and bin/fm-afk-launch.sh stop as a CLAUDECODE=1 / HERDR_ENV=1 captain, capturing the transcript and herdr workspace/pane counts
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Reviews (3): Last reviewed commit: "fix(afk): decide the self-injection guar..." | Re-trigger Greptile

…cked path

A Claude Code background shell hosting the sub-supervisor daemon inside the
captain's own pane leaves a footer token that herdr's claude agent-detection
ruleset misreads as permanently "working", so the away-mode busy guard can
never read that pane idle and no escalation is ever delivered. Root cause
documented in data/firstmate-afk-daemon-wedged-investigation/report.md
(2026-08-26): 408 identical busy-guard deferrals over 95 minutes, versus a
clean 2h22m run on the terminal-backed FM_SUPERVISOR_TARGET path.

Route claude on the herdr backend through bin/fm-afk-launch.sh start (the
non-visible tracked terminal path) instead of start-native, and correct the
"it is fine" belief recorded in the afk skill and both launcher scripts'
header comments. Other harness/backend combinations are unaffected: tmux's
rendered busy-fallback regex does not match the background-shell footer
token, so only herdr's native agent-status path is implicated.

Deliberately not implemented: teaching the busy guard to subtract the
daemon's own footer contribution, since that couples firstmate to a private,
versioned herdr ruleset that already changes without notice.
docs/herdr-backend.md "Away-mode supervisor support" still described
native tracked-background execution as safe for every harness with the
capability, casting the terminal-backed launcher as the Pi-only fallback.
That contradicted the routing fix in .agents/skills/afk/SKILL.md and
bin/fm-afk-launch.sh: Claude on Herdr must not host the away daemon in
its own pane, because Herdr's Claude agent-detection ruleset reads the
daemon's own background-shell footer token as the agent working, at a
priority that beats the idle rule, which wedges the busy guard for the
life of the session.

State the same exception here, in this doc's own voice, so a session
that reads this file first gets the same answer as one that reads the
skill first.
The launcher's start-native guard only covers entry through
bin/fm-afk-launch.sh. This script is also a documented direct entry
point (the native two-step's second half, and a bare direct call), so
the same footer-wedge failure was still reachable by skipping the
launcher entirely.

Key the refusal on hosting mode, not harness+backend alone: the
launcher's terminal-backed path also execs this script but always
passes FM_SUPERVISOR_TARGET explicitly, so its presence marks an
already-safe, launcher-hosted invocation and must stay permitted.
@NicholasACTran
NicholasACTran force-pushed the fm/firstmate-afk-launch-fix-land-locally-v2 branch from c28edfe to 750b498 Compare August 26, 2026 08:33
@NicholasACTran NicholasACTran changed the title fix(bin): route claude+herdr away-mode daemon through the terminal-backed launcher fix(bin): route the away daemon off claude's in-pane background job on herdr Aug 26, 2026
…w its diagnostic

The guard must refuse before any lifecycle state is written so a
refusal leaves nothing to roll back - tracking that this invocation
wrote .afk and reverting it afterward was a repair, not the contract.
Reorder fm_afk_start_main so the daemon-lock refresh check and the
guard's decision both happen before fm_afk_start_ensure_flag runs.

Also narrow the guard's "cannot check myself" diagnostic: it fired
whenever either side was unresolvable, including when herdr is
provably absent (no TMUX_PANE, no HERDR_ENV+HERDR_PANE_ID) and there
is no real ambiguity to report. Resolve the backend first and permit
silently when it isn't herdr; only warn when herdr is confirmed
present and the pane comparison itself cannot be completed.
@NicholasACTran NicholasACTran changed the title fix(bin): route the away daemon off claude's in-pane background job on herdr fix(afk): stop the away daemon from self-injecting on claude+herdr Aug 26, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Reviewed HEAD b1cd4ecf71e9dde587fc61840b4b00f733fe39c5 vs main 07bf0c8556c70b5241516efad0e51f802484c748. Whole thread read (Greptile success). Full diff reviewed: .agents/skills/afk/SKILL.md, bin/fm-afk-launch.sh, bin/fm-afk-start.sh, docs/herdr-backend.md, tests/fm-afk-launch.test.sh, tests/fm-afk-return.test.sh, tests/fm-daemon.test.sh. No .github/workflows/*. Not disguised security.

Class: corrective. Claude+herdr hosting the away daemon in the pane it injects into leaves a footer token Herdr maps to working, so the busy guard never delivers. start-native refuses that pair before writing lifecycle state; fm-afk-start.sh refuses same-pane self-injection (raw own pane vs injection target, so an override pointing elsewhere stays permitted). Other harness/backend pairs unchanged.

This does not close #1956 or #3087. Those are watcher stale-hash / over-escalation bugs. No Fixes/Closes in the author body; the code is a launch-path self-injection guard, not those defects.

Sibling of #3090 (bounding escape) and overlapping #3089 (earlier docs/skill-only cause fix). #3089's author asked to hold that cause-fix with #3090. These three share AFK launch/start/skill files — not landing #3100 alone.

VISION (per rule, inspected evidence):

  • One captain, one interface — aligns. Restores away-mode escalation delivery instead of 95 minutes of identical busy deferrals.
  • Authority is explicit — aligns. No new autonomy; restores an existing away-mode path. Deliberately does not parse Herdr's private footer ruleset. Operator override to a different pane stays an explicit choice.
  • Scripts own the mechanics — aligns. Refusal is keyed on harness+backend+same-pane comparison in the launcher and start entry points; the skill records the same routing.
  • A restart is a non-event — aligns. Guard decides before writing .afk; a refusal leaves nothing to roll back. Refresh of a live daemon is untouched.
  • Delegation with a spine — aligns. Supervision delivery is the contract being repaired.
  • The fleet outlives any vendor — aligns. Avoids encoding Herdr's versioned claude.toml into the busy guard.
  • Scope — aligns. Command-layer AFK launch, not workshop/merge policy.

First-time fork CI: after that full-diff review I approved workflow runs 32952916298 (CI), 32952916311 (Require no-mistakes), and 32952954775 (Require no-mistakes) for this HEAD.

Not merging. Attestation matches this HEAD, but forge CI had not run yet, and the cause-fix stays paired with #3090 / #3089. Waiting on CI (and that pairing), not a captain decision.

Merge-eligible: NO. Captain-flag NOW: NO.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants