diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index b296c56a2c6..7a84f1a7474 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -115,26 +115,14 @@ That doorbell is Firstmate's only when `open` verifies the record in this home, This is how firstmate tells a daemon escalation apart from a real message in the same pane. For other harnesses, the operational prefix travels with the message text; neither carrier relies on harness-level typed-vs-injected detection. -### Busy-guard and composer guard +### Injection guards -The daemon never injects into an in-use pane. Two 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): - -- **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. - 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. - -A busy primary pane, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. -In afk mode the composer guard is belt-and-suspenders (no human is typing), but it protects against the race window between the captain returning and their message landing, a dead shell, and the daemon's own previous injection sitting unsent. +[Composer and injection safety](../../../docs/herdr-backend.md#composer-and-injection-safety) owns the supervisor-pane guards, including Herdr's positive harness-process proof. +A deferred escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. +The guards protect against the race window between the captain returning and their message landing, a dead shell, and the daemon's own previous injection sitting unsent. **Max-defer escape (the daemon must never silently wedge).** -If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300), the daemon -attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. +If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300), the daemon attempts one normal flush under the same injection guards. The alarm is defense in depth rather than a substitute for keeping every genuinely idle supported composer injectable. If that submit cannot be confirmed, it raises a loud, rate-limited wedge alarm: an ERROR in the daemon log naming the last delivery failure, a durable @@ -200,23 +188,14 @@ The single-line format makes submission unambiguous across harnesses; the carrie - **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. - 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. +- **Supervisor-pane guards** - see [Injection guards](#injection-guards) and its operator-contract pointer. - 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`. Styled captures let that owner remove dim/faint and dark-TRUECOLOR ghost or placeholder text while shape detection uses the ANSI-stripped screen, so a dark border is not lost with ghost content. A ghost-only or idle bordered composer such as claude's `│ > ... │` therefore reads empty without allowing an unbordered shell prompt to do the same. `FM_COMPOSER_IDLE_RE` overrides the shared idle-placeholder regex, but a match alone never bypasses the classifier's shape-specific position and ANSI de-emphasis safety gates. `FM_BUSY_REGEX` overrides the rendered delivery guards plus Grok's isolated task-state fallback. A blank or otherwise unidentified input row carries no positive container proof and defers injection, so a modal dialog or a mid-redraw pane is never an injection target. -- **Max-defer escape** - the daemon must never silently wedge. If anything stays - buffered past `FM_MAX_DEFER_SECS` (default 300s), the daemon attempts one - normal flush, which still requires an idle pane and an affirmatively empty composer. If that - cannot confirm a submit, it raises a loud, rate-limited wedge alarm: ERROR log, - durable `state/.subsuper-inject-wedged` marker, a tmux status-line flash when - applicable, and a backend-independent active alert. A - composer false-positive surfaces as a visible stall, never an unbounded silent - no-op. +- **Max-defer escape** - see the max-defer policy under [Injection guards](#injection-guards). - **Verified type-once submit model** - the digest is typed once (`send-keys -l` on tmux, `pane send-text` on herdr), then submitted with Enter and verified. Enter is retried, Enter only and never a retype, until the backend submit diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 49f1b7c9429..cf8ed48ad42 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -77,6 +77,14 @@ # different, self-proving thing: real claude 2.x draws exactly # that (`─` rule, `❯`+NBSP, `─` rule), so the glyph inside the # pair carries the shape and no identity is needed. +# Claude writes a session's TITLE into that pair's top rule +# once the session has one (a resumed or backgrounded +# conversation: `──────── Firstmate operational input ─`, +# captured live through Herdr on claude 2.1.284). A titled rule +# opens a pair only for this self-proving form: the rows down to +# the next solid rule must hold an agent-glyph row, so a titled +# rule over anything else stays the ordinary text it was and can +# never promote a blank region into a composer. # # THE COMPOSER FOOTER ZONE (task firstmate-doorbell-vals-pending-p1): a # harness draws its own furniture BELOW the composer - a user statusLine, a @@ -762,6 +770,25 @@ _fm_composer_pi_separator_row() { # return 1 } +# _fm_composer_titled_rule_row: a `─` rule carrying a title - at least 8 +# leading `─` columns, one space-padded title holding no `─`, then a closing +# `─` run (see the separated shape in this file's header). Byte-exact literal +# tests only, so the answer is the same in every locale. +_fm_composer_titled_rule_row() { # + local row=$1 title + case "$row" in + ────────*─) ;; + *) return 1 ;; + esac + title="${row#"${row%%[!─]*}"}" + title="${title%"${title##*[!─]}"}" + case "$title" in + *─*) return 1 ;; + ' '*[!\ ]*' ') return 0 ;; + esac + return 1 +} + # Row-scan results are returned through FM_COMPOSER_SCAN_* globals (bash 3.2 # has no nameref); they are internal to this owner. _fm_composer_scan_screen() { # [extract-wrap] @@ -799,6 +826,7 @@ _fm_composer_scan_screen() { # [extract-wrap] FM_COMPOSER_SCAN_LEFTBAR_GLYPH_ROW=-1 FM_COMPOSER_SCAN_LEFTBAR_GLYPH= local leftbar_start=-1 pi_open=-1 pi_lines=0 pi_max + local titled_open=-1 titled_lines=0 titled_glyph_row=-1 titled_glyph='' local probe row_glyph row_glyph_row local box_glyph_row=-1 box_glyph='' pi_glyph_row=-1 pi_glyph='' pi_max=$FM_COMPOSER_PI_MAX_LINES @@ -844,9 +872,23 @@ _fm_composer_scan_screen() { # [extract-wrap] # Pi separator rows: a solid `─` rule at least 8 columns wide. A separator # closes the preceding candidate and immediately opens the next, so an # earlier transcript rule can never outrank the live bottom composer pair. + # A titled rule (claude's top rule once the session has a title) opens a + # pair only when an agent-glyph row proves the composer before the next + # solid rule closes it; unproven, it is ordinary text to the rules below. if _fm_composer_pi_separator_row "$trimmed"; then FM_COMPOSER_SCAN_PI_LAST_SEPARATOR=$row - if [ "$pi_open" -ge 0 ]; then + if [ "$titled_open" -ge 0 ] && [ "$titled_glyph_row" -ge 0 ]; then + FM_COMPOSER_SCAN_PI_PAIR_FOUND=1 + FM_COMPOSER_SCAN_PI_OPEN=$titled_open + FM_COMPOSER_SCAN_PI_CLOSE=$row + if [ "$titled_lines" -le "$pi_max" ]; then + FM_COMPOSER_SCAN_PI_PAIR_VALID=1 + else + FM_COMPOSER_SCAN_PI_PAIR_VALID=0 + fi + FM_COMPOSER_SCAN_PI_GLYPH_ROW=$titled_glyph_row + FM_COMPOSER_SCAN_PI_GLYPH=$titled_glyph + elif [ "$pi_open" -ge 0 ]; then FM_COMPOSER_SCAN_PI_PAIR_FOUND=1 FM_COMPOSER_SCAN_PI_OPEN=$pi_open FM_COMPOSER_SCAN_PI_CLOSE=$row @@ -862,7 +904,20 @@ _fm_composer_scan_screen() { # [extract-wrap] pi_lines=0 pi_glyph_row=-1 pi_glyph='' + titled_open=-1 else + if _fm_composer_titled_rule_row "$trimmed"; then + titled_open=$row + titled_lines=0 + titled_glyph_row=-1 + titled_glyph='' + elif [ "$titled_open" -ge 0 ]; then + titled_lines=$((titled_lines + 1)) + if [ "$titled_glyph_row" -lt 0 ] && [ "$row_glyph_row" -ge 0 ]; then + titled_glyph_row=$row_glyph_row + titled_glyph=$row_glyph + fi + fi if [ "$pi_open" -ge 0 ]; then pi_lines=$((pi_lines + 1)) if [ "$pi_glyph_row" -lt 0 ] && [ "$row_glyph_row" -ge 0 ]; then diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 7a7191807df..bee24d15fce 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -1410,7 +1410,7 @@ window_for_task() { # [state] # line, or a previous injection's unsent text), defer entirely - injecting # would merge with the human's text. inject_msg() { # [state] - local msg=$1 state target backend retries sleep_s verdict composer encoded bytes errf err='' body + local msg=$1 state target backend retries sleep_s verdict composer process_state encoded bytes errf err='' body state="${2:-$(_state_root)}" # (1) Presence-gate: inject ONLY when afk is active. When afk is off, the # daemon self-handles and stays quiet; firstmate drives the normal always-on @@ -1446,10 +1446,10 @@ inject_msg() { # [state] # composer. The shared classifier (fm_backend_composer_state -> # fm_composer_classify_content, bin/fm-composer-lib.sh) reports 'pending' # for real unsubmitted text (a human's half-typed line, or a swallowed - # prior injection) and 'unknown' for a bare dead-shell prompt (the agent - # exited to its login shell) or an unreadable pane. Neither is a safe - # target - typing the escalation into a shell could execute it - so defer - # on anything that is not affirmatively 'empty'. A deferred escalation + # prior injection) and 'unknown' for an unidentified prompt or an + # unreadable pane. Defer on anything that is not affirmatively 'empty'. + # A shell can share an agent's prompt glyph, so Herdr also needs the + # process proof below. A deferred escalation # stays buffered for the next cycle or the catch-up flush. composer=$(fm_backend_composer_state "$backend" "$target" 2>/dev/null) if [ "$composer" != empty ]; then @@ -1457,6 +1457,20 @@ inject_msg() { # [state] log "inject $INJECT_LAST_FAILURE" return 1 fi + # A rendered Claude glyph can survive over a shell after Claude exits. + # Require the shared process classifier's positive harness verdict, never a + # lingering Herdr registration or merely a non-shell foreground process. + if [ "$backend" = herdr ]; then + process_state=unreadable + if fm_backend_herdr_parse_target "$target"; then + process_state=$(fm_backend_herdr_pane_process_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" 2>/dev/null) + fi + if [ "$process_state" != agent ]; then + INJECT_LAST_FAILURE="deferred: supervisor harness not confirmed-live (process=${process_state:-unreadable})" + log "inject $INJECT_LAST_FAILURE" + return 1 + fi + fi # c) A primary that strips invisible characters from submitted prompts gets # the owner's record-backed doorbell instead of the typed envelope, so # the away-mode return check can still tell this escalation from the diff --git a/docs/architecture.md b/docs/architecture.md index ce2736a6b84..a5afe8df9ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -216,8 +216,7 @@ Pane existence, busy checks, composer checks, capture, and verified submit route The retries-exhausted queued-Enter decision is owned by `fm_composer_queued_enter_verdict` in `bin/fm-composer-lib.sh`; tmux and herdr provide only their backend-specific busy signals. Composer classification has one shared owner, `bin/fm-composer-lib.sh`: tmux, herdr, Zellij, Orca, and cmux contribute only a screen capture plus declarative styled, cursor, identity, and row capabilities, while the shared classifier owns every shape and the `empty`/`pending`/`pending-unproven`/`unknown` verdict. `fm-spawn.sh` also routes Kimi launch readiness through that classifier instead of carrying another shape copy. -The daemon injects only into an affirmatively `empty` composer, so every other or future verdict defers; positive container proof is required, and a blank unidentified row or bare dead-shell prompt cannot receive an escalation. -The current operator boundary is in [Composer and injection safety](herdr-backend.md#composer-and-injection-safety). +The supervisor-pane injection guards are owned by [Composer and injection safety](herdr-backend.md#composer-and-injection-safety). Unsupported supervisor backends refuse at daemon startup. Stalled escalation delivery writes `state/.subsuper-inject-wedged` and attempts a configured backend-independent active alert after `FM_MAX_DEFER_SECS` instead of silently deferring forever. On an unmarked return, `bin/fm-afk-return.sh` owns ordered shutdown, the record archive, durable catch-up evidence, the return brief, and the fail-closed gate that keeps ordinary work behind every live firstmate-actionable blocker the away session could not fix. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index dba4b5b6aee..222a491debe 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -630,6 +630,7 @@ It hands the visible pane's ANSI viewport plus Herdr's capability facts to the f - Bordered boxes. - Bare agent-glyph rows, including muse's `⟩`, which the adapter's retired local pattern silently omitted. +- Claude's titled composer, including resumed or backgrounded conversations. - opencode's left bar. - The Pi separator region this adapter pioneered, admitted only when native `agent get` identity is exactly Pi and state is idle or done. @@ -651,8 +652,13 @@ That safely defers injection and eventually raises the wedge alarm. ### Away-mode injection -A bare shell prompt is never an empty agent composer. -Away-mode injection proceeds only on an affirmative `empty` result, never on unknown. +Before typing, `inject_msg` in `bin/fm-supervise-daemon.sh` requires the supervisor pane to exist and pass the primary-pane busy guard. +That guard trusts Herdr native `busy` when available, otherwise matches rendered output against only the detected primary harness's signature; it never classifies a recorded worker task. +The composer guard requires the exact `empty` verdict from `fm_backend_composer_state`; every other or future verdict defers. +A shell can display Claude's `❯` glyph, so even an empty-looking composer does not prove an agent is alive. +Herdr additionally requires the exact `agent` verdict from `fm_backend_herdr_pane_process_state`, whose process proof is described under [Restart and liveness behavior](#restart-and-liveness-behavior). +A lingering native registration or a non-shell foreground process alone is insufficient; `shell`, `other`, `unreadable`, and every unrecognized process verdict defer before typing or publishing an operational-input record. +Deferred escalations remain buffered for retry. This prevents a dead agent pane from receiving and possibly executing an escalation as shell input. ### Operational input markers diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index b8d95dfd790..e3befd6065d 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -724,6 +724,24 @@ Cursor is deliberately outside this cursor-anchored empty-composer matrix becaus `zellij action dump-screen --pane-id --ansi` was verified at zellij 0.44.0 to preserve ANSI styling (real Claude Code rendered inside a zellij pane dumped `ESC[m` `❯` U+00A0 for its idle composer row), which is the capability the zellij composer classifier reads. +### 2026-10-02 claude 2.1.284 titled composer rule through Herdr + +Verified on 2026-10-02 on macOS arm64 (Darwin 25.6.0) against Claude Code 2.1.284 in an isolated `fm-lab-` session on Herdr 0.9.1, read through Herdr's ANSI viewport capture with its exact capability descriptor (`styled=1`, `cursor=0`, `identity=1`). +Once a Claude session carries a title, which a resumed or backgrounded conversation does, Claude writes that title into the composer's top rule: `──────── Firstmate operational input ─`, then the bare `❯` + U+00A0 row, then a solid `─` closing rule. +The titled rule is not a solid separator, so no pair formed, the closing rule read as an unpaired separator below the `❯` row, and the idle composer classified `unknown`. +A lab primary's away daemon reproduced the production log line for that screen on every tick: + +```text +inject deferred: supervisor composer not confirmed-empty (state=unknown: pending input, dead-shell prompt, or unreadable pane) +``` + +Pressing left opens Claude's agents view and moves the conversation to the background; Escape returns to it, and from then on the top rule carries the title. +The classifier now lets a titled rule open a pair only when an agent-glyph row sits between it and the next solid rule. +On the same live pane the unmodified library answered `unknown` and the fixed library answered `empty`, one escalation was then typed and submitted, a typed draft in the titled composer answered `pending`, and Claude's agents view (`❯ describe a task for a new session`) answered `pending` under this home's `dark-ansi` theme. + +`test_matrix_claude_titled_top_rule` in `tests/fm-composer-lib.test.sh` carries the captured rows and pins the three refusals the fix must keep: a typed draft, a dead shell prompt under or below a titled rule, and an unreadable or blank region. +For a shell that displays an agent glyph, `test_inject_msg_defers_on_shell_with_agent_glyph` and `test_inject_msg_herdr_requires_positive_process_proof` in `tests/fm-daemon.test.sh` pin the separate [injection safety boundary](../herdr-backend.md#away-mode-injection). + ### 2026-09-20 claude 2.1.236 statusLine footer through Herdr Verified on 2026-09-20 on macOS arm64 (Darwin 25.6.0) against Claude Code 2.1.236 running as Firstmate workers in Herdr 0.8.0 panes, read through Herdr's ANSI capture with its exact capability descriptor (`styled=1`, `cursor=0`, `identity=1`, `rows=20`). diff --git a/tests/fm-afk-inject-herdr-e2e.test.sh b/tests/fm-afk-inject-herdr-e2e.test.sh index 42e5a91210f..727fd212f03 100755 --- a/tests/fm-afk-inject-herdr-e2e.test.sh +++ b/tests/fm-afk-inject-herdr-e2e.test.sh @@ -229,7 +229,10 @@ done LOOP chmod +x "$LOOP_SCRIPT" -fm_backend_herdr_send_text_line "$SUPERVISOR_TARGET" "bash '$LOOP_SCRIPT' '$LOG_FILE'" \ +# The simulated composer also needs a harness-named foreground process for +# the injection guard; a plain bash loop is correctly refused as shell-only. +cp "$(command -v bash)" "$STATE_DIR/claude" +fm_backend_herdr_send_text_line "$SUPERVISOR_TARGET" "'$STATE_DIR/claude' '$LOOP_SCRIPT' '$LOG_FILE'" \ || fail "could not start the supervisor-loop script in the scratch herdr pane" sleep 1 # let the loop start and settle diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 0d1b87613d6..1fe1b0158f2 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -4101,6 +4101,12 @@ export default function (pi: ExtensionAPI): void { }, }); + pi.registerCommand("calm-expansion-e2e", { + description: "Report native tool expansion without changing it.", + handler: async (args, ctx) => { + ctx.ui.notify(`CALM_EXPANSION_E2E_${args.trim()}=${ctx.ui.getToolsExpanded()}`, "info"); + }, + }); pi.registerCommand("calm-diagnostic-e2e", { description: "Add the Calm transient diagnostic fixture.", handler: async (_args, ctx) => { @@ -4174,7 +4180,9 @@ TS {"type":"message","id":"a0000016","parentId":"a0000015","timestamp":"$now","message":{"role":"assistant","content":[{"type":"text","text":"The deterministic tool example is complete."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":2,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":3,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":16}} JSON - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ + # Keep the entire restored fixture visible: Pi's initial full-screen redraw + # does not guarantee scrollback for rows above a short viewport. + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 120 \ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" wait_for_text "$default_snapshot" "The deterministic tool example is complete." \ || fail "Pi calm E2E did not reach the restored session transcript" @@ -4187,10 +4195,12 @@ JSON assert_not_contains "$(cat "$default_snapshot")" 'Run `bin/fm-session-start.sh` now' \ "native session-start context unexpectedly rendered while Calm was off" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" C-o - wait_for_text "$expanded_snapshot" "escape to interrupt" \ + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-expansion-e2e initial" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + wait_for_text "$expanded_snapshot" "CALM_EXPANSION_E2E_initial=true" \ || fail "Ctrl+O did not retain Pi's ordinary startup and tool expansion behavior" - # The expansion redraw lands a frame or two after the footer hint, so wait for the - # tool output this block actually asserts instead of assuming one implies the other. + # Check the rendered output as well as native expansion state, without relying + # on Pi's transient startup hints or older tool-expansion status rows. wait_for_text "$expanded_snapshot" "CALM_E2E_OUTPUT" \ || fail "ordinary Ctrl+O expansion hid tool activity while calm mode was off" assert_contains "$(cat "$expanded_snapshot")" "CALM_E2E_OUTPUT" "ordinary Ctrl+O expansion hid tool activity while calm mode was off" @@ -4514,7 +4524,10 @@ JS assert_not_contains "$(cat "$restored_snapshot")" "Navigated to selected point" "second /calm added a navigation status row" assert_contains "$(cat "$restored_snapshot")" "Thinking..." "second /calm did not restore Pi's collapsed thinking labels" assert_contains "$(cat "$restored_snapshot")" "I will run one command." "second /calm did not restore the mid-turn assistant working note" - assert_contains "$(cat "$restored_snapshot")" "escape to interrupt" "/calm changed the active Ctrl+O expansion state" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-expansion-e2e restored" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + wait_for_text "$restored_snapshot" "CALM_EXPANSION_E2E_restored=true" \ + || fail "/calm changed the active Ctrl+O expansion state" hash_after=$(shasum -a 256 "$session_file" | awk '{print $1}') [ "$hash_before" = "$hash_after" ] || fail "/calm changed the persisted session or context data" @@ -4842,7 +4855,7 @@ JS active_screen_wait=$((active_screen_wait + 1)) done [ "$(cat "$home/config/calm")" = on ] || fail "Calm was not restored before the persistence restart" - tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 180 -y 44 + tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 180 -y 120 tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/quit" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s @@ -4850,7 +4863,7 @@ JS || fail "Pi did not exit cleanly before the Calm persistence restart" tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 120 \ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" wait_for_text "$restarted_snapshot" "CALM_WORKING_E2E_RESPONSE" \ || fail "Pi did not restore the persisted session after restart" diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index a9abd9e8ef6..4ce8989858d 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -219,6 +219,51 @@ test_matrix_claude_arrow_statusline_footer() { pass "matrix: claude's arrow statusline is footer furniture, not a composer holding text" } +test_matrix_claude_titled_top_rule() { + # Real claude 2.1.284 on herdr 0.9.1 (captured live 2026-10-02): once a + # session carries a title - a resumed or backgrounded conversation, which is + # what a long-lived primary is - claude writes that title into the composer's + # TOP rule. The rule stopped being a solid separator, no pair formed, the + # closing rule read as an unpaired separator below the `❯` row, and the idle + # composer answered `unknown`: the away daemon deferred every escalation for + # hours. The rows below are that capture, rules shortened to 46 columns. + local grey reset rule titled footer idle typed claude_idle out + claude_idle=$(printf 'claude\tidle') + grey="${ESC}[0m${ESC}[38;5;7m"; reset="${ESC}[0m" + rule="${grey}──────────────────────────────────────────────${reset}" + titled="${grey}──────────────── Firstmate operational input ─${reset}" + footer=" ${grey}Sonnet 5.5 | Context: 7% used${reset}"$'\n'" ${ESC}[0m${ESC}[38;5;11m⏵⏵ auto mode on${reset}${ESC}[38;5;7m · ← for agents · ${reset}${ESC}[38;5;14m1 shell${reset}" + idle="${grey}✻ Baked for 17s · done 6:05 AM · 1 shell still running${reset}"$'\n\n'"$titled"$'\n❯'"$NBSP"$'\n'"$rule"$'\n'"$footer" + assert_screen "titled claude idle on herdr" empty "$CAPS_STYLED" "$idle" '' "$claude_idle" + assert_screen "titled claude idle, identity probe absent" empty "$CAPS_STYLED" "$idle" '' probe-absent + assert_screen "titled claude idle on zellij" empty "$CAPS_STYLED_NOID" "$idle" + assert_screen "titled claude idle on cmux/orca" empty "$CAPS_PLAIN" "$idle" + # Guard 1: text really pending in that same titled composer still defers. + typed="$titled"$'\n❯'"$NBSP"$'half typed captain draft\n'"$rule"$'\n'"$footer" + assert_screen "titled claude with a typed draft" pending "$CAPS_STYLED" "$typed" '' "$claude_idle" + assert_screen "titled claude with a typed draft, plain capture" unknown "$CAPS_PLAIN" "$typed" + # Guard 2: a dead shell prompt under a titled rule proves nothing - a shell + # glyph is never the agent-glyph proof the titled pair requires. + for out in '$' '%' '#' '>'; do + assert_screen "dead shell '$out' under a titled rule" unknown "$CAPS_STYLED" \ + "$titled"$'\n'"$out "$'\n'"$rule" '' probe-absent + done + assert_screen "dead shell below a titled claude pair" unknown "$CAPS_STYLED" \ + "$titled"$'\n❯'"$NBSP"$'\n'"$rule"$'\nuser@host project\n$ ' '' "$claude_idle" + # Guard 3: an unreadable pane - no capture, or a titled rule with no glyph + # row and nothing else - stays unknown; the title alone opens no pair, so a + # blank region under it is still the strict blank-row rule's unknown. + assert_screen "empty capture" unknown "$CAPS_STYLED" '' '' probe-absent + assert_screen "titled rule over a blank region" unknown "$CAPS_STYLED" \ + "$titled"$'\n\n'"$rule" '' "$claude_idle" + assert_screen "titled rule over a blank region, pi identity" unknown "$CAPS_STYLED" \ + "$titled"$'\n\n'"$rule" '' "$(printf 'pi\tidle')" + # The title must be IN a rule: text that merely contains dashes is not one. + assert_screen "an untitled-looking text row is not a rule" unknown "$CAPS_STYLED" \ + $'see ──────── notes ─\n\n'"$rule" '' "$claude_idle" + pass "matrix: claude's titled composer top rule reads empty when idle and still defers on a draft, a dead shell, or an unreadable pane" +} + test_composer_footer_demotion_needs_a_proven_pair() { # The demotion is bounded in three directions, and each bound is a case # where a lower glyph row IS the live composer. @@ -969,6 +1014,7 @@ test_idle_placeholder_case_mode_is_explicit test_real_text_is_pending test_matrix_claude_bare_nbsp_row test_matrix_claude_arrow_statusline_footer +test_matrix_claude_titled_top_rule test_composer_footer_demotion_needs_a_proven_pair test_composer_footer_zone_is_shape_independent test_composer_footer_zone_refuses_rather_than_allows diff --git a/tests/fm-contributions.test.sh b/tests/fm-contributions.test.sh index 8dd43219be1..8e32cfdf711 100755 --- a/tests/fm-contributions.test.sh +++ b/tests/fm-contributions.test.sh @@ -1024,6 +1024,9 @@ test_arm_plumbs_a_configured_budget_into_the_check_shim() { wrap_forge "$home" mutate_record "$home" delivery '.records[0].checked_at="2026-09-15T08:00:00Z"' cp "$home/data/delivery/contributions.json" "$home/prior.json" + # Freeze poll time so crossing a wall-clock second cannot skip the read; + # the real watchdog still enforces the shim's one-second read timeout. + /bin/date +%s > "$home/forge/clock" printf 'hang\n' > "$home/forge/fault" if [ "$mode" = configured ]; then with_home "$home" env FM_CONTRIBUTIONS_BUDGET=1 "$ROOT/bin/fm-contributions.sh" arm >/dev/null \ diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index efc0e6bda52..f9f4fd8f50f 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -3092,6 +3092,8 @@ test_inject_msg_herdr_submits_through_backend_dispatch() { fm_backend_target_exists() { return 0; } pane_is_busy() { return 1; } fm_backend_composer_state() { printf 'empty'; } + fm_backend_herdr_parse_target() { FM_BACKEND_HERDR_SESSION=default; FM_BACKEND_HERDR_PANE=w1:p2; } + fm_backend_herdr_pane_process_state() { printf 'agent'; } fm_backend_send_text_submit() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected send_text_submit args: $1 $2" printf '%s\n' "$3" > "$dir/sent.log" @@ -3105,6 +3107,94 @@ test_inject_msg_herdr_submits_through_backend_dispatch() { pass "inject_msg: dispatches busy-guard/composer-guard/submit through the herdr backend and succeeds on a confirmed empty composer" } +# A real shell can draw Claude's prompt glyph after Claude exits. Its emitted +# prompt passes the composer classifier, but its process must block injection. +test_inject_msg_defers_on_shell_with_agent_glyph() { + local dir state + dir=$(make_supercase inject-shell-agent-glyph) + state="$dir/state" + afk_enter "$state" + ( + local fixture_shell_pid i=0 out + mkfifo "$dir/input" + bash -c 'printf "❯"; read -r line < "$1"' _ "$dir/input" > "$dir/prompt" & + fixture_shell_pid=$! + trap 'kill "$fixture_shell_pid" 2>/dev/null || true; wait "$fixture_shell_pid" 2>/dev/null || true' EXIT + while [ ! -s "$dir/prompt" ] && [ "$i" -lt 30 ]; do + sleep 0.1 + i=$((i + 1)) + done + fm_backend_source herdr || fail "could not load the Herdr adapter" + # shellcheck disable=SC2329 # Invoked indirectly by the sourced Herdr adapter. + fm_backend_herdr_cli() { + [ "$1 $2 $3" = 'default pane process-info' ] || fail "unexpected Herdr read: $*" + jq -n --argjson pid "$fixture_shell_pid" \ + --arg name "$(ps -p "$fixture_shell_pid" -o comm=)" \ + --arg args "$(ps -p "$fixture_shell_pid" -o args=)" '{result: {type: "pane_process_info", process_info: { + pane_id: "w1:p2", shell_pid: $pid, foreground_process_group_id: $pid, + foreground_processes: [{pid: $pid, name: $name, cmdline: $args, argv0: ($args | split(" ")[0])}] + }}}' + } + fm_backend_target_exists() { return 0; } + pane_is_busy() { return 1; } + fm_backend_composer_state() { fm_composer_classify_content 0 "$(cat "$dir/prompt")"; } + fm_backend_send_text_submit() { printf '%s\n' "$3" >> "$dir/sent.log"; printf 'empty'; } + out=$(fm_backend_herdr_pane_process_state default w1:p2) + [ "$out" = shell ] || fail "the real shell must classify shell, got '$out'" + out=$(fm_backend_composer_state herdr default:w1:p2) + [ "$out" = empty ] || fail "the emitted shell glyph must reproduce the empty-composer ambiguity, got '$out'" + escalate_add "$state" 'needs-decision: release approval' + if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET=default:w1:p2 escalate_flush "$state"; then + fail "a shell displaying Claude's glyph accepted the escalation" + fi + [ ! -s "$dir/sent.log" ] || fail "the escalation was typed into the shell" + [ "$INJECT_SUBMIT_ATTEMPTED" = 0 ] || fail "a submit was attempted into the shell" + [ -s "$state/.subsuper-escalations" ] || fail "the rejected escalation was lost" + [ ! -d "$state/operational-inbox" ] || fail "a doorbell was published before verifying a live harness" + + # Keep the same prompt and transport but give the portable fixture a + # harness process identity, as the real-Herdr composer simulator does. + kill "$fixture_shell_pid" 2>/dev/null || true + wait "$fixture_shell_pid" 2>/dev/null || true + cp "$(command -v bash)" "$dir/claude" + # shellcheck disable=SC2016 # $1 must expand in the child shell, not here + "$dir/claude" -c 'printf "❯"; read -r line < "$1"' _ "$dir/input" > "$dir/prompt" & + fixture_shell_pid=$! + out=$(fm_backend_herdr_pane_process_state default w1:p2) + [ "$out" = agent ] || fail "the harness-named fixture must classify agent, got '$out'" + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET=default:w1:p2 escalate_flush "$state" \ + || fail "a positive harness-process proof did not allow the queued escalation" + [ ! -s "$state/.subsuper-escalations" ] || fail "the delivered escalation remained buffered" + [ "$(wc -l < "$dir/sent.log" | tr -d ' ')" = 1 ] || fail "the escalation was not typed exactly once" + delivered_digest "$dir/sent.log" | grep -F 'release approval' >/dev/null \ + || fail "the delivered escalation lost its body" + ) || fail "shell-glyph injection regression failed" + pass "inject_msg: a real shell with Claude's glyph refuses injection and preserves the escalation" +} + +test_inject_msg_herdr_requires_positive_process_proof() { + local dir state process_state + dir=$(make_supercase inject-herdr-process-proof) + state="$dir/state" + afk_enter "$state" + for process_state in other unreadable '' future-state; do + ( + fm_backend_target_exists() { return 0; } + pane_is_busy() { return 1; } + fm_backend_composer_state() { printf 'empty'; } + fm_backend_herdr_parse_target() { FM_BACKEND_HERDR_SESSION=default; FM_BACKEND_HERDR_PANE=w1:p2; } + fm_backend_herdr_pane_process_state() { printf '%s' "$process_state"; } + fm_backend_send_text_submit() { printf 'sent\n' >> "$dir/sent.log"; printf 'empty'; } + if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET=default:w1:p2 inject_msg hello "$state"; then + fail "process state '$process_state' licensed injection without a live harness" + fi + [ ! -s "$dir/sent.log" ] || fail "process state '$process_state' allowed typing" + [ "$INJECT_SUBMIT_ATTEMPTED" = 0 ] || fail "process state '$process_state' attempted a submit" + ) || fail "Herdr process-proof regression failed" + done + pass "inject_msg: Herdr requires positive harness-process proof before typing" +} + # Safety-critical (task fm-composer-shellglyph-safety): the away-mode injector # must NEVER type an escalation into a dead-shell pane. A bare shell prompt # classifies `unknown` (not `pending`), and inject_msg now defers on anything @@ -3144,6 +3234,14 @@ test_inject_msg_defers_on_unrecognized_composer_state() { pass "inject_msg: unrecognized composer states defer by default" } +# Positional function names allow focused verification without a suite walk. +if [ "$#" -gt 0 ]; then + for selected_test in "$@"; do + "$selected_test" || exit 1 + done + exit 0 +fi + test_afk_start_refuses_when_flag_cannot_be_written test_afk_start_ignores_stale_pidfile_without_lock test_afk_start_reclaims_stale_daemon_lock_reused_pid @@ -3279,5 +3377,7 @@ test_inject_msg_herdr_busy_guard_defers test_inject_msg_herdr_composer_guard_defers test_inject_msg_herdr_pane_gone_defers test_inject_msg_herdr_submits_through_backend_dispatch +test_inject_msg_defers_on_shell_with_agent_glyph +test_inject_msg_herdr_requires_positive_process_proof test_inject_msg_defers_on_dead_shell_unknown test_inject_msg_defers_on_unrecognized_composer_state diff --git a/tests/fm-remote-secondmate-trace-context.test.sh b/tests/fm-remote-secondmate-trace-context.test.sh index 9b5573d5e85..fbde774b56e 100755 --- a/tests/fm-remote-secondmate-trace-context.test.sh +++ b/tests/fm-remote-secondmate-trace-context.test.sh @@ -96,7 +96,8 @@ git -C "$REMOTE_ROOT" init -q -b main git -C "$REMOTE_ROOT" config user.email test@example.com git -C "$REMOTE_ROOT" config user.name Test git -C "$REMOTE_ROOT" add . -git -C "$REMOTE_ROOT" commit -qm 'remote fixture root' +# Both seeds clone this object store; detached repacking must not race them. +git -C "$REMOTE_ROOT" -c maintenance.auto=false commit -qm 'remote fixture root' cat > "$FAKEBIN/fake-ssh" <<'SH' #!/usr/bin/env bash