From 5b935073d49c7e969d3a50638cf56a262e9b7f76 Mon Sep 17 00:00:00 2001 From: jdr Date: Sun, 30 Aug 2026 14:35:32 -0400 Subject: [PATCH] fix(bin): stand down the Claude Stop auto-arm on pi-code-delivered payloads pi-code loads the tracked Claude settings but has no asyncRewake support: it awaits every Stop hook, so the auto-arm's foreground monitoring holds Pi's turn open for the declared multi-hour timeout (observed live 2026-08-30). Stand down when the payload's transcript_path carries a .pi/ component, the stamp pi-code puts on every payload and a Claude transcript never has. Fail direction is unchanged: missing payload, jq, or transcript_path runs. Fixes #3343. --- bin/fm-claude-stop-autoarm.sh | 17 +++++++++++ tests/fm-claude-stop-autoarm.test.sh | 42 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index 762b1a3dcf4..447b41ba4d8 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -107,6 +107,23 @@ PAYLOAD=$(cat 2>/dev/null || true) # its turn boundary, so stand down on a Cursor-delivered payload. fm_hook_payload_is_foreign_host "$PAYLOAD" && exit 0 +# pi-code (Pi's Claude-hook compatibility extension, verified through 1.0.14) +# also loads the tracked Claude settings and has no asyncRewake: it awaits every +# Stop hook, so this arm runs SYNCHRONOUSLY inside Pi's turn end and holds that +# turn open for the declared multi-hour timeout - the same wedge as Cursor +# above, observed live 2026-08-30 (issue #3343). Pi's native Firstmate +# extensions own Pi supervision, so stand down on a Pi-delivered payload. The +# signal is again the PAYLOAD: pi-code stamps every hook payload's +# transcript_path with Pi's own session file under .pi/, while a Claude +# transcript never has a .pi path component. Fail direction matches the guard +# above: no payload, no jq, or no transcript_path means the hook RUNS, because +# a skipped run under a genuine Claude primary is the worse failure. +if [ -n "$PAYLOAD" ] && command -v jq >/dev/null 2>&1; then + printf '%s' "$PAYLOAD" | jq -e ' + (.transcript_path // "") | contains("/.pi/") + ' >/dev/null 2>&1 && exit 0 +fi + # --- scope: genuine primary checkout only ----------------------------------- fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index 042d04ba947..32cdda08325 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -305,6 +305,46 @@ test_inert_when_afk() { pass "auto-arm: inert while AFK owns supervision" } +# pi-code (Pi's Claude-hook compatibility extension) delivers a Claude-shaped +# Stop payload but awaits the hook with no asyncRewake support, so the hook +# must stand down or it wedges Pi's turn for the declared timeout (issue #3343). +# The discriminator is the payload's transcript_path: pi-code stamps Pi's own +# session file under .pi/, which a Claude transcript path never contains. +test_stands_down_on_pi_code_delivered_payload() { + local dir out status + dir=$(make_primary_dir "$TMP_ROOT/picode") + : > "$dir/state/task.meta" + write_arm_fixture "$dir" actionable + out=$(printf '%s\n' '{"session_id":"sess-pi","stop_hook_active":false,"transcript_path":"/home/user/.pi/agent/sessions/--tmp--/2026-08-30T00-00-00-000Z_x.jsonl"}' \ + | FM_HOME="$dir" "$FAKE_CLAUDE" -c ' + printf "%s\n" "$$" > "$FM_HOME/state/.lock" + "$FM_HOME/bin/fm-claude-stop-autoarm.sh" + ' 2>&1); status=$? + expect_code 0 "$status" "hook must stand down silently on a pi-code-delivered payload" + [ -z "$out" ] || fail "pi-code stand-down printed output: $out" + [ ! -e "$dir/state/arm-ran" ] || fail "hook armed on a pi-code-delivered payload" + [ ! -e "$dir/state/.claude-autoarm-epoch" ] || fail "hook wrote an epoch on a pi-code-delivered payload" + pass "auto-arm: stands down on a pi-code-delivered payload (transcript under .pi/)" +} + +# The stand-down must not overmatch: a genuine Claude transcript_path keeps the +# full arm-and-rewake path, so losing the discriminator can only fail toward +# running (the documented fail direction). +test_claude_transcript_path_still_arms() { + local dir out status + dir=$(make_primary_dir "$TMP_ROOT/claude-transcript") + : > "$dir/state/task.meta" + write_arm_fixture "$dir" actionable + out=$(printf '%s\n' '{"session_id":"sess-claude","stop_hook_active":false,"transcript_path":"/home/user/.claude/projects/-home-user-proj/uuid.jsonl"}' \ + | FM_HOME="$dir" "$FAKE_CLAUDE" -c ' + printf "%s\n" "$$" > "$FM_HOME/state/.lock" + "$FM_HOME/bin/fm-claude-stop-autoarm.sh" + ' 2>&1); status=$? + expect_code 2 "$status" "actionable close must still rewake when the payload carries a Claude transcript_path" + [ -e "$dir/state/arm-ran" ] || fail "hook did not arm with a Claude transcript_path present" + pass "auto-arm: a Claude transcript_path still arms and rewakes" +} + test_stale_lock_recovery_preserves_afk_and_need_gates() { local afk_dir idle_dir out status afk_dir=$(make_primary_dir "$TMP_ROOT/stale-afk") @@ -1154,6 +1194,8 @@ test_inert_without_session_lock test_reclaims_stale_session_lock_before_arming test_inert_when_lock_held_by_other_harness test_inert_when_afk +test_stands_down_on_pi_code_delivered_payload +test_claude_transcript_path_still_arms test_stale_lock_recovery_preserves_afk_and_need_gates test_resolves_outermost_claude_pid_in_nested_bgspare_chain test_inert_when_fleet_idle