Skip to content

fix: deliver away-mode escalations through titled composers - #2385

Open
unhexquadium wants to merge 9 commits into
kunchenguid:mainfrom
unhexquadium:fm/afk-wedge
Open

fix: deliver away-mode escalations through titled composers#2385
unhexquadium wants to merge 9 commits into
kunchenguid:mainfrom
unhexquadium:fm/afk-wedge

Conversation

@unhexquadium

@unhexquadium unhexquadium commented Aug 14, 2026

Copy link
Copy Markdown

Intent

Fix the away-mode escalation delivery wedge in firstmate. While the captain was away, the sub-supervisor daemon buffered captain-relevant escalations and injected them into firstmate's own pane, but every injection deferred indefinitely - one episode sat 9.7 hours (34973s) undelivered, with 2845 defers and zero successful injections - so decision gates stalled overnight and delivery only happened via the return catch-up.

ROOT CAUSE, reproduced live on the running fleet: Claude Code 2.1.232 draws the session's agent name as a reverse-video title inside the TOP rule of its own bare composer. bin/fm-composer-lib.sh's _fm_composer_pi_separator_row only counted a row consisting of nothing but the rule glyph, so the titled top rule stopped counting as a rule, which left the composer's untitled BOTTOM rule unpaired. _fm_composer_select_cursorless treats an unpaired rule below the best candidate as proof that the candidate is stale scrollback, so it abandoned selection and every idle claude pane classified unknown. The away-mode injector requires an affirmative empty before typing, so it deferred forever: correct fail-closed behaviour applied to a wrong verdict. Steers to worker panes through fm-send.sh were unaffected the whole time, because the herdr submit primitive confirms delivery from native agent-state (agent get) and never reads the composer; the daemon's pre-injection composer guard is the only delivery check that consults the classifier.

THE FIX accepts a rule carrying an embedded title, bounded exactly as _fm_composer_titled_bottom_ok already bounds a titled bottom BORDER: rule glyphs at both ends and an ASCII-printable interior. That bounded shape was chosen deliberately over a looser match so ordinary transcript prose can never be promoted into a composer boundary, and the new tests pin that boundary from both sides (prose ending in a rule, prose following a rule, and a non-ASCII title must all still fail to qualify). Verified live: the supervisor pane flips unknown -> empty, typed text still reads pending, and all 12 relevant suites pass.

DELIBERATE DECISIONS a reviewer reading only the diff would not know:

  1. A secondary "bare-composer wrap-extension overreach" fix was in the originally accepted scope and was deliberately REMOVED after implementation disproved it. The proposed variant ("do not extend the wrap region when the pane has no cursor capability") is literally "delete the extension", because _fm_composer_select_cursorless only ever runs cursorless. Doing that turns the existing deliberate assertion in tests/fm-composer-lib.test.sh ("cursorless activity below bare row on herdr") from pending to empty - a composer holding real text classified as safe to inject into, on the exact backend the injector uses. The alternative variant required a regex over a user-configured statusLine, which cannot be built from verified evidence. The captain decided to drop it and file it as its own backlog item. Do not re-add it or any variant of it in this change.

  2. The bin/fm-supervise-daemon.sh change is deliberate diagnostic hardening, not part of the classifier fix. The wedge produced thousands of identical defer lines and no evidence, and the defer reason names three possible causes it cannot separate. The wedge marker now records the composer verdict and the captured screen once per max-defer window, alongside the existing alarm. The verdict's meaning stays owned by bin/fm-composer-lib.sh; the daemon only reports it.

CONSTRAINTS AND KNOWN GAPS: ShellCheck was absent on this machine, so the repo-pinned 0.11.0 static binary was installed to ~/.local/bin and bin/fm-lint.sh now runs clean - lint is not being skipped. tests/fm-afk-inject-herdr-e2e.test.sh fails identically with and without this change ("the supervisor pane's shell did not become ready"); it needs a real spawned herdr pane, and is pre-existing and environmental, not a regression. tests/fm-composer-matrix-live-e2e.test.sh is the live guard that would have caught this regression, but it requires tmux, which this herdr-backed machine does not have; that is noted as follow-up and is deliberately out of scope here.

  1. The branch already carries five pipeline fix commits from a previous run of this same change, each one a captain-directed outcome of an earlier review round. Do not re-litigate them: (a) the wedge alarm fires BEFORE the new backend evidence probes, so an unbounded probe cannot block the alarm path; (b) the diagnostic verdict and the diagnostic screen come from ONE capture via fm_backend_composer_observation, so a redraw between two reads cannot make them disagree - this is why the diff reaches into bin/fm-backend.sh, bin/backends/herdr.sh and bin/fm-tmux-lib.sh, which were not in the original scope; (c) ANSI is stripped from the captured evidence at the output boundary so the durable marker stays plain text; (d) the titled-rule interior is validated as ASCII-printable AFTER the file's standard Unicode-whitespace normalization, and the function comment, SHAPE CATALOGUE entry and test names all state that boundary in those terms - the captain explicitly chose to correct the documented wording rather than move validation to the pre-normalization row, because a pre-normalization check would be the only such check in the file and would violate the locale-safety invariant issue Away mode never delivers: Claude's composer renders as ❯+U+00A0, which the classifier reads as pending instead of empty #1988 exists to protect; (e) the ShellCheck suppressions are per-assignment with reasons, matching the existing convention at bin/fm-backend.sh:527, bin/fm-classify-lib.sh:70, bin/fm-wake-lib.sh:124 and tests/fm-supervision-events.test.sh:65 - SC2034 on FM_BACKEND_COMPOSER_SCREEN is a false positive because it is a genuine cross-file output global, and SC2329 covers three intentional test stubs, two of which are tripwires that must stay uncalled.

DELIVERY: this account has no write access to the upstream repo, so per the captain's decision the branch pushes to the fork unhexquadium/firstmate and the PR opens against kunchenguid/firstmate, matching how every recent PR on this repo ships. The fork is a PR staging vehicle only; nothing is ever merged into the fork itself.

What Changed

  • Recognize bounded, two-ended titled composer rules so Claude's idle composer classifies as empty while one-ended prose and non-ASCII titles remain rejected.
  • Record a secured, ANSI-stripped composer verdict and matching screen capture in away-mode wedge markers using bounded, alert-first evidence collection for tmux and Herdr.
  • Add regression coverage and documentation for titled-rule classification, wedge evidence handling, and live backend behavior.

Risk Assessment

✅ Low: The private-marker fix establishes mode 0600 before writing sensitive content, preserves alarm ordering and single-snapshot diagnostics, and leaves the durable classifier fix intact.

Testing

The focused classifier, daemon, and Herdr adapter suites passed; a base-versus-target reproduction using the captured Claude 2.1.232 pane proved unknown became empty, the production away-mode guard submitted exactly once while unsafe shapes still deferred, and the durable diagnostic preserved the buffer with a mode-0600 plain-text paired verdict and screen. A live spawned-pane run was not attempted because tmux is unavailable and the supplied intent identifies the real-Herdr shell-readiness failure as environmental.

Evidence: Away-mode end-to-end transcript

same_claude_capture_baseline_verdict=unknown same_claude_capture_target_adapter_verdict=empty typed_draft_target_adapter_verdict=pending one_ended_prose_target_adapter_verdict=unknown non_ascii_title_target_adapter_verdict=unknown away_mode_injection_result=delivered away_mode_submit_calls=1

AFK WEDGE END-TO-END EVIDENCE
base_commit=6789876442d0fb6da9f70d86399a2930c5073ae2
target_commit=4f28e5e4fff82d12559a49711db154b3ab90b9b3
same_claude_capture_baseline_verdict=unknown
same_claude_capture_target_adapter_verdict=empty
typed_draft_target_adapter_verdict=pending
one_ended_prose_target_adapter_verdict=unknown
non_ascii_title_target_adapter_verdict=unknown
away_mode_injection_result=delivered
away_mode_injection_kind=away-supervisor
away_mode_injection_body=needs-decision: choose safe option
away_mode_submit_calls=1
wedge_marker_verdict=empty
wedge_marker_contains_captured_title_and_status=yes
wedge_marker_is_plain_text=yes
wedge_marker_buffer_preserved=yes
Evidence: Plain-text durable wedge marker

composer verdict: empty captured screen: ──────────────────────── orchestrator ── ❯ ────────────────────────────────────────

fm away-mode inject WEDGED: 34973s undelivered as of 2026-08-14T10:15:55-0700
The supervisor pane could not accept an escalation. Buffered items:
needs-decision: choose safe option

--- supervisor pane default:w1:p2 (herdr) ---
composer verdict: empty
captured screen:
transcript line
──────────────────────── orchestrator ──
❯ 
────────────────────────────────────────
  kalvira@host: /home/kalvira/firstmate (main) [ctx:15%]
  ⏵⏵ auto mode on
Evidence: Reproducible evidence harness
#!/usr/bin/env bash
set -euo pipefail

ROOT=/home/kalvira/.no-mistakes/worktrees/29769f6dfb27/01M00KEYQHEKASD825G1EXZHAK
BASE=6789876442d0fb6da9f70d86399a2930c5073ae2
EVIDENCE_DIR=/tmp/no-mistakes-evidence/01M00KEYQHEKASD825G1EXZHAK
ESC=$(printf '\033')
NBSP=$(printf '\302\240')
CAPS=$'styled=1\ncursor=0\nidentity=1\nrows=20'

# Reproduces the Claude Code 2.1.232 pane shape that triggered the wedge:
# a reverse-video title embedded in the top rule, a bare idle composer,
# an untitled closing rule, and active status rows below it.
CLAUDE_CAPTURE=$'transcript line\n'\
"${ESC}[0m${ESC}[38;2;136;136;136m────────────────────────${ESC}[0m${ESC}[38;2;0;0;0m${ESC}[48;2;136;136;136m orchestrator ${ESC}[0m${ESC}[38;2;136;136;136m──${ESC}[0m"$'\n'\
"${ESC}[0m${ESC}[38;2;153;153;153m❯${NBSP}${ESC}[0m"$'\n'\
"${ESC}[0m${ESC}[38;2;136;136;136m────────────────────────────────────────${ESC}[0m"$'\n'\
"  ${ESC}[1m${ESC}[38;5;2mkalvira@host${ESC}[0m${ESC}[38;2;153;153;153m:${ESC}[0m /home/kalvira/firstmate (main) [ctx:15%]"$'\n'\
"  ${ESC}[38;2;255;193;7m⏵⏵ auto mode on${ESC}[0m"

baseline_verdict=$(
  bash -c '. "$1"; fm_composer_classify_screen "$2" "$3" "" probe-absent' _ \
    <(git -C "$ROOT" show "$BASE:bin/fm-composer-lib.sh") "$CAPS" "$CLAUDE_CAPTURE"
)

# Load the target's real classifier, backend adapter, and away-mode injector.
. "$ROOT/bin/fm-supervise-daemon.sh"
fm_backend_source herdr

ACTIVE_CAPTURE=$CLAUDE_CAPTURE
fm_backend_herdr_capture_ansi() {
  printf '%s\n' "$ACTIVE_CAPTURE"
}
fm_backend_herdr_capture() {
  return 1
}

idle_verdict=$(fm_backend_composer_state herdr default:w1:p2)

ACTIVE_CAPTURE=${CLAUDE_CAPTURE/"❯${NBSP}"/"❯ answer the parked decision"}
typed_verdict=$(fm_backend_composer_state herdr default:w1:p2)

ACTIVE_CAPTURE=$'transcript\nrunning the build ──────────\n❯\n────────────────────────'
one_ended_prose_verdict=$(fm_backend_composer_state herdr default:w1:p2)

ACTIVE_CAPTURE=$'transcript\n──────────── ✻ пример ──\n❯\n────────────────────────'
non_ascii_title_verdict=$(fm_backend_composer_state herdr default:w1:p2)

RUNTIME_DIR=$(mktemp -d "$EVIDENCE_DIR/runtime.XXXXXX")
STATE_DIR="$RUNTIME_DIR/state"
DELIVERY_FILE="$RUNTIME_DIR/delivered"
CALLS_FILE="$RUNTIME_DIR/submit-calls"
LOG="$RUNTIME_DIR/daemon.log"
mkdir -p "$STATE_DIR"
trap 'rm -rf "$RUNTIME_DIR"' EXIT
afk_enter "$STATE_DIR"

ACTIVE_CAPTURE=$CLAUDE_CAPTURE
FM_SUPERVISOR_BACKEND=herdr
FM_SUPERVISOR_TARGET=default:w1:p2
fm_backend_target_exists() {
  [ "$1" = herdr ] && [ "$2" = default:w1:p2 ]
}
pane_is_busy() {
  return 1
}
fm_backend_send_text_submit() {
  [ "$1" = herdr ] && [ "$2" = default:w1:p2 ]
  printf 'x' >> "$CALLS_FILE"
  printf '%s' "$3" > "$DELIVERY_FILE"
  printf 'empty'
}

inject_msg 'needs-decision: choose safe option' "$STATE_DIR"
delivered=$(cat "$DELIVERY_FILE")
fm_operational_input_kind "$delivered" delivered_kind
fm_operational_input_body "$delivered" delivered_body
submit_calls=$(wc -c < "$CALLS_FILE" | tr -d '[:space:]')

# Exercise the durable operator diagnostic with the same captured screen.
escalate_add "$STATE_DIR" 'needs-decision: choose safe option'
WEDGE_ALARM_LAST_EPOCH=0
FM_WEDGE_ALARM_CHANNEL=off
inject_wedge_alarm "$STATE_DIR" 34973
WEDGE_MARKER="$STATE_DIR/.subsuper-inject-wedged"
cp "$WEDGE_MARKER" "$EVIDENCE_DIR/wedge-marker.txt"
wedge_verdict=$(sed -n 's/^composer verdict: //p' "$WEDGE_MARKER")
grep -F 'orchestrator' "$WEDGE_MARKER" >/dev/null
grep -F 'kalvira@host' "$WEDGE_MARKER" >/dev/null
case "$(cat "$WEDGE_MARKER")" in
  *"$ESC"*) wedge_marker_plain=no ;;
  *) wedge_marker_plain=yes ;;
esac

printf 'AFK WEDGE END-TO-END EVIDENCE\n'
printf 'base_commit=%s\n' "$BASE"
printf 'target_commit=%s\n' "$(git -C "$ROOT" rev-parse HEAD)"
printf 'same_claude_capture_baseline_verdict=%s\n' "$baseline_verdict"
printf 'same_claude_capture_target_adapter_verdict=%s\n' "$idle_verdict"
printf 'typed_draft_target_adapter_verdict=%s\n' "$typed_verdict"
printf 'one_ended_prose_target_adapter_verdict=%s\n' "$one_ended_prose_verdict"
printf 'non_ascii_title_target_adapter_verdict=%s\n' "$non_ascii_title_verdict"
printf 'away_mode_injection_result=delivered\n'
printf 'away_mode_injection_kind=%s\n' "$delivered_kind"
printf 'away_mode_injection_body=%s\n' "$delivered_body"
printf 'away_mode_submit_calls=%s\n' "$submit_calls"
printf 'wedge_marker_verdict=%s\n' "$wedge_verdict"
printf 'wedge_marker_contains_captured_title_and_status=yes\n'
printf 'wedge_marker_is_plain_text=%s\n' "$wedge_marker_plain"
printf 'wedge_marker_buffer_preserved=%s\n' "$([ -s "$STATE_DIR/.subsuper-escalations" ] && printf yes || printf no)"

[ "$baseline_verdict" = unknown ]
[ "$idle_verdict" = empty ]
[ "$typed_verdict" = pending ]
[ "$one_ended_prose_verdict" = unknown ]
[ "$non_ascii_title_verdict" = unknown ]
[ "$delivered_kind" = away-supervisor ]
[ "$delivered_body" = 'needs-decision: choose safe option' ]
[ "$submit_calls" = 1 ]
[ "$wedge_verdict" = empty ]
[ "$wedge_marker_plain" = yes ]
[ -s "$STATE_DIR/.subsuper-escalations" ]
Evidence: Targeted test timing metadata
{
  "families": [
    {
      "count": 1,
      "duration_ms": 11089,
      "failed": 0,
      "name": "backend-dispatch"
    },
    {
      "count": 1,
      "duration_ms": 2774,
      "failed": 0,
      "name": "pure-contract-unit"
    },
    {
      "count": 1,
      "duration_ms": 15731,
      "failed": 0,
      "name": "watcher-wake-lock"
    }
  ],
  "finished_at": "2026-08-14T17:13:29Z",
  "run_id": "fm-test-run-1786727579834-1199520",
  "scripts": [
    {
      "duration_ms": 2774,
      "exit": 0,
      "expected_gate_skip": "none",
      "family": "pure-contract-unit",
      "gate_skip": false,
      "path": "tests/fm-composer-lib.test.sh"
    },
    {
      "duration_ms": 15731,
      "exit": 0,
      "expected_gate_skip": "none",
      "family": "watcher-wake-lock",
      "gate_skip": false,
      "path": "tests/fm-daemon.test.sh"
    },
    {
      "duration_ms": 11089,
      "exit": 0,
      "expected_gate_skip": "none",
      "family": "backend-dispatch",
      "gate_skip": false,
      "path": "tests/fm-backend-herdr.test.sh"
    }
  ],
  "selection": "scripts",
  "started_at": "2026-08-14T17:12:59Z",
  "summary": {
    "duration_ms": 29650,
    "failed": 0,
    "skipped_gate": 0,
    "total": 3
  }
}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ bin/fm-supervise-daemon.sh:938 - The new pane capture is appended to a marker created with ordinary shell redirection and never given restrictive permissions. With umask 022 and a traversable FM_HOME, the marker is 0644, allowing other local users to read terminal contents that may include credentials. Ensure the marker is mode 0600 before appending diagnostic evidence.

🔧 Fix: Secure away-mode wedge evidence marker
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh --list --changed --base 6789876442d0fb6da9f70d86399a2930c5073ae2
  • bin/fm-test-run.sh --json /tmp/no-mistakes-evidence/01M00KEYQHEKASD825G1EXZHAK/targeted-test-timings.json tests/fm-composer-lib.test.sh tests/fm-daemon.test.sh tests/fm-backend-herdr.test.sh
  • bash -o pipefail -c 'bash "$1" | tee "$2"' _ /tmp/no-mistakes-evidence/01M00KEYQHEKASD825G1EXZHAK/afk-wedge-e2e.sh /tmp/no-mistakes-evidence/01M00KEYQHEKASD825G1EXZHAK/afk-wedge-e2e.txt
  • Inspected wedge-marker.txt and verified mode 0600, preserved buffer, paired verdict and captured screen, and absence of ANSI escape bytes.
  • Verified the working tree remained clean and testing left no transient project artifacts.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.


Adjacent issue deliberately left out of scope

The review round that produced the 0600 fix on state/.subsuper-inject-wedged raised a
class of problem, not a single instance.
state/.subsuper-escalations, written by the same daemon, holds buffered captain escalation
text and is created the same way, so under umask 022 it has the same world-readable
weakness.

That one is pre-existing - it is not introduced or worsened by this branch - so it was
deliberately not fixed here rather than widening a validated change during its own pipeline
run.
It is worth its own item.

Claude 2.1.232 draws the session's agent name as a reverse-video title inside
the top rule of its own bare composer.
Only a solid `─` row counted as a rule, so that title left the composer's
untitled bottom rule unpaired, an unpaired rule below the candidate read as
proof the candidate was stale, and cursorless selection was abandoned.
Every idle claude pane classified `unknown`.

The away-mode injector requires an affirmative `empty`, so it deferred every
escalation while the supervisor pane sat idle; one episode buffered for 9.7
hours before the return catch-up delivered it.
Steers to worker panes were unaffected because the herdr submit primitive
confirms delivery from native agent-state and never reads the composer.

Accept a rule that carries an embedded title, bounded exactly as
_fm_composer_titled_bottom_ok already bounds a titled bottom border: rule
glyphs at both ends and an ASCII-printable interior.
A wedge episode produced thousands of identical defer lines and no evidence.
The defer reason names three possible causes and cannot separate them, so
"the captain half-typed a line" and "this pane's rendering is not in the shape
catalogue" are indistinguishable from durable state.

Record the composer verdict and the captured screen in the wedge marker, once
per max-defer window alongside the existing alarm, so the rendering that
wedged is preserved rather than needing a live re-capture that may no longer
show it.
The verdict's meaning stays owned by bin/fm-composer-lib.sh.
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.

1 participant