Skip to content

docs: pin Herdr alt-screen scrollback bounds and capture viewport stability - #2396

Open
Kallas95 wants to merge 3 commits into
kunchenguid:mainfrom
Kallas95:fm/fm-herdr-altscreen-docs
Open

docs: pin Herdr alt-screen scrollback bounds and capture viewport stability#2396
Kallas95 wants to merge 3 commits into
kunchenguid:mainfrom
Kallas95:fm/fm-herdr-altscreen-docs

Conversation

@Kallas95

Copy link
Copy Markdown

Intent

Document that alternative-screen harnesses, including Claude Code, expose no scrollback history to Herdr, so any capture of such a pane is bounded to the visible window regardless of the requested --lines. Place this knowledge in docs/herdr-backend.md near the existing capture/peek sections (the 'Current transport behavior' paragraph about pane read --lines N and the 200-line floor), without duplicating what the doc already says. Add a non-regression test 'a firstmate capture never moves the pane viewport' to tests/fm-backend-herdr-smoke.test.sh, following the exact recipe in section 8 of the scout report data/fm-herdr-scroll-scout/report.md (the source of truth): seed real scrollback with seq 1 500, read .result.pane.scroll before and after the firstmate capture functions, and fail if the viewport moved. The test must pass locally and would fail if a capture moved the viewport. Follow repo style: one sentence per line in the docs, plain dash, shellcheck-clean test. Stay bounded: no doc refactor and no additional tests.

What Changed

  • docs/herdr-backend.md now states, next to the existing capture/transport section, that alternative-screen harnesses (including Claude Code) expose no scrollback history to Herdr, so any capture of such a pane is bounded to the visible window regardless of the requested --lines.
  • tests/fm-backend-herdr-smoke.test.sh gains a viewport-stability check: it creates a throwaway pane, seeds real scrollback with seq 1 500, records .result.pane.scroll before and after running the firstmate capture helpers (capture, capture_ansi, composer_state), and fails if the pane's scroll position changed.

Risk Assessment

✅ Low: Changement purement additif (2 lignes de doc + un bloc de smoke test auto-isolé) : tous les critères de l'intent sont vérifiés en source, les fixes des rounds précédents sont matériellement en place et corrects, et le nouveau code est shellcheck-clean.

Testing

Ran the full real-herdr smoke suite (the smallest unit containing the new check, which depends on the suite's isolated-session setup): all checks pass including the new viewport-stability one, with real seeded scrollback confirmed via .result.pane.scroll. Then demonstrated the test's failure mode end-to-end by fault injection: attaching a real herdr client and scrolling the pane in copy-mode between the before/after reads moved offset_from_bottom from 0 to 29 and made the test's exact assertion fire, so the test provably fails when a capture moves the viewport. Also verified the doc sentences sit in the intended 'Current transport behavior' spot without duplication. No browser/GUI surface exists for this docs+shell-test change; the end-user surfaces are the doc text and CLI test output, captured as text artifacts including the herdr client's terminal screen during the injected scroll. Shellcheck was not run here (lint phase owns it). No transient artifacts left in the worktree.

Evidence: Smoke test transcript (all pass, incl. new viewport-stability check)

ok - real herdr: current_path reads the pane's live cwd ok - real herdr: a firstmate capture never moves the pane viewport (no scroll for alternative-screen harnesses) ok - real herdr: kill removes the pane and is idempotent/best-effort

ok - real herdr: version_check accepts the installed binary's protocol
ok - real herdr: container_ensure starts the isolated session's server, creates the firstmate workspace (fm-lab-backend-smoke-75696:w1), and reports its seeded default tab id (w1:t1)
ok - real herdr: container_ensure is idempotent (reuses/adopts the existing firstmate workspace, reports no seeded default tab on adoption)
ok - real herdr: create_task prunes the freshly-created workspace's seeded default tab, leaving exactly one clean fm-<id> task tab
ok - real herdr: create_task refuses a same-labeled tab whose pane hosts a genuinely live registered agent (unchanged behavior)
ok - real herdr: create_task closes and replaces a same-labeled tab whose pane hosts no registered agent (the restored-husk shape), leaving the workspace intact
ok - real herdr: a secondmate-shaped home (.fm-secondmate-home) gets its OWN herdr workspace, distinct from the primary's, in the SAME session
ok - real herdr: the secondmate-shaped home's workspace is labeled 2ndmate-<secondmate-id> in herdr itself
ok - real herdr: a task spawned into the secondmate-shaped home lands as a tab inside the secondmate's OWN workspace
ok - real herdr: list_live stays scoped to each home's own workspace - neither home sees the other's tasks
ok - real herdr: BOTH workspace ids/labels AND both tasks' pane ids survive a session stop + fresh server restart (multi-workspace shape)
ok - real herdr: send_text_line runs a command atomically (pane run) and its output is capturable
ok - real herdr: send_literal + send_key Enter submit as two separate steps (verified: send-text does NOT auto-submit)
ok - real herdr: current_path reads the pane's live cwd
ok - real herdr: a firstmate capture never moves the pane viewport (no scroll for alternative-screen harnesses)
note: FM_HERDR_SMOKE_REAL_CLAUDE=1 not set; skipping the real-agent busy_state check
ok - real herdr: kill removes the pane and is idempotent/best-effort
ok - real herdr: list_live discovers a live task tab by fm-<id> label
Evidence: Fault-injection transcript: assertion fires when the viewport moves

scroll_before={"max_offset_from_bottom":481,"offset_from_bottom":0,"viewport_rows":23} scroll_after={"max_offset_from_bottom":473,"offset_from_bottom":29,"viewport_rows":31} not ok - a firstmate capture moved the pane viewport: {...offset_from_bottom:0...} -> {...offset_from_bottom:29...} DEMONSTRATED: the suite's viewport-stability assertion FAILS when the viewport moves

scroll_before={"max_offset_from_bottom":481,"offset_from_bottom":0,"viewport_rows":23}
scroll_after={"max_offset_from_bottom":473,"offset_from_bottom":29,"viewport_rows":31}
not ok - a firstmate capture moved the pane viewport: {"max_offset_from_bottom":481,"offset_from_bottom":0,"viewport_rows":23} -> {"max_offset_from_bottom":473,"offset_from_bottom":29,"viewport_rows":31}
DEMONSTRATED: the suite's viewport-stability assertion FAILS when the viewport moves
Evidence: Herdr client screen during the injected scroll (copy-mode, pane scrolled to lines 443-472)
 spaces                  │ fm-fault-scroll    +
                         │443                                                                                          ▕
 · firstmate             │444                                                                                          ▕
                         │445                                                                                          ▕
                         │446                                                                                          ▕
                         │447                                                                                          ▕
                         │448                                                                                          ▕
                         │449                                                                                          ▕
                         │450                                                                                          ▕
                         │451                                                                                          ▕
                         │452                                                                                          ▕
                         │453                                                                                          ▕
                         │454                                                                                          ▕
                         │455                                                                                          ▕
                         │456                                                                                          ▕
 new                 menu│457                                                                                          ▕
─────────────────────────│458                                                                                          ▕
 agents           grouped│459                                                                                          ▕
                         │460                                                                                          ▕
                         │461                                                                                          ▕
                         │462                                                                                          ▕
                         │463                                                                                          ▕
                         │464                                                                                          ▕
                         │465                                                                                          ▕
                         │466                                                                                          ▕
                         │467                                                                                          ▕
                         │468                                                                                          ▕
                         │469                                                                                          ▕
                         │470                                                                                          ▐
                         │471                                                                                          ▐
                         │472                                                                                          ▕
                        «│ COPY  h/j/k/l w/b/e { } move  / ? search  n/N repeat  v/space select  y/enter copy  q/esc exi
Evidence: Fault-injection script (reproducible demonstration)
#!/usr/bin/env bash
# Fault injection for the new viewport-stability check in
# tests/fm-backend-herdr-smoke.test.sh: reproduce the test's exact scenario
# (throwaway pane, seq 1 500, read .result.pane.scroll before/after), but
# deliberately MOVE the viewport between the two reads - the way a scrolling
# capture would - by attaching a real herdr client in a detached tmux session
# and scrolling up in copy-mode. If the test's assertion
# ([ "$scroll_before" = "$scroll_after" ]) fires on this moved viewport, the
# non-regression test demonstrably fails when a capture moves the viewport.
set -u

ROOT=${FM_ROOT:?set FM_ROOT to the firstmate worktree}
EVID=${FM_EVID:?set FM_EVID to the evidence dir}

# shellcheck source=/dev/null
. "$ROOT/tests/herdr-test-safety.sh"
herdr_forget_inherited_pane

SESSION="fm-lab-fault-scroll-$$"
export HERDR_SESSION="$SESSION"
TMUX_S="fm-fault-scroll-tmux-$$"

cleanup() {
  tmux kill-session -t "$TMUX_S" 2>/dev/null
  herdr_safe_stop_and_delete "$SESSION"
}
trap cleanup EXIT

fm_herdr_lab_prepare "$SESSION" || { echo "FATAL: lab prepare failed"; exit 2; }

# shellcheck source=/dev/null
. "$ROOT/bin/fm-backend.sh"
fm_backend_source herdr || { echo "FATAL: fm_backend_source herdr failed"; exit 2; }

CONTAINER_RAW=$(fm_backend_herdr_container_ensure /tmp) || { echo "FATAL: container_ensure failed"; exit 2; }
CONTAINER=${CONTAINER_RAW%%$'\t'*}
SEEDED=${CONTAINER_RAW#*$'\t'}
IDS=$(fm_backend_herdr_create_task "$CONTAINER" "fm-fault-scroll" /tmp "$SEEDED") || { echo "FATAL: create_task failed"; exit 2; }
read -r _TAB PANE <<EOF
$IDS
EOF
[ -n "$PANE" ] || { echo "FATAL: no pane id"; exit 2; }
TARGET="$SESSION:$PANE"

# Same seeding as the test.
fm_backend_herdr_send_text_line "$TARGET" 'seq 1 500' || { echo "FATAL: seeding failed"; exit 2; }
sleep 0.5

scroll_before=$(herdr pane get "$PANE" --session "$SESSION" | jq -c '.result.pane.scroll')
echo "scroll_before=$scroll_before"
{ [ -n "$scroll_before" ] && [ "$scroll_before" != null ]; } || { echo "FATAL: no scroll info"; exit 2; }

# THE FAULT: move the viewport, exactly what a scrolling capture would do.
tmux new-session -d -s "$TMUX_S" -x 120 -y 32 "herdr session attach $SESSION" || { echo "FATAL: tmux attach failed"; exit 2; }
sleep 2.5
tmux send-keys -t "$TMUX_S" C-b y
sleep 0.7
moved=0
for key in PPage PPage k Up C-u; do
  tmux send-keys -t "$TMUX_S" "$key"
  sleep 0.4
  cur=$(herdr pane get "$PANE" --session "$SESSION" | jq -c '.result.pane.scroll')
  if [ "$cur" != "$scroll_before" ]; then moved=1; break; fi
done
tmux capture-pane -pt "$TMUX_S" > "$EVID/fault-injection-herdr-client-screen.txt" 2>/dev/null || true

scroll_after=$(herdr pane get "$PANE" --session "$SESSION" | jq -c '.result.pane.scroll')
echo "scroll_after=$scroll_after"

# Leave copy mode so teardown is clean.
tmux send-keys -t "$TMUX_S" q 2>/dev/null
sleep 0.3

if [ "$moved" -ne 1 ] || [ "$scroll_before" = "$scroll_after" ]; then
  echo "FAULT INJECTION FAILED: could not move the viewport; detection not demonstrated"
  exit 2
fi

# Replay the test's exact assertion over the moved viewport.
if [ "$scroll_before" = "$scroll_after" ]; then
  echo "unexpected: assertion passed despite the moved viewport"
  exit 2
else
  echo "not ok - a firstmate capture moved the pane viewport: $scroll_before -> $scroll_after"
  echo "DEMONSTRATED: the suite's viewport-stability assertion FAILS when the viewport moves"
fi
Evidence: Doc excerpt: new sentences in 'Current transport behavior' after the 200-line-floor paragraph
It is the same semantic signal the native path uses and the same one the tmux submit core reads, so a pane already mid-turn before the text was typed still reports `pending` rather than borrowing another turn as proof of this delivery.
The composer verdict itself is deliberately unchanged: a right-aligned status token on the composer row stays content for every other caller, including the away-mode pre-injection guard.
The poll density bounds the residual possibility of an extremely fast complete turn; a missed transition can cause only a redundant Enter on an empty composer, never duplicate message text.

`pane read --lines N` can return empty output when N is below the viewport height.
The capture owner requests at least 200 lines from Herdr and trims locally to the caller's bound.
This generous floor is required for small composer and peek reads.
Alternative-screen harnesses, including Claude Code, expose no scrollback history to Herdr at all.
Any capture of such a pane is therefore bounded to the visible window regardless of the requested `--lines`.

Herdr's native agent state can read idle while a harness waits on its own long foreground tool.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed (2) ✅
  • ⚠️ tests/fm-backend-herdr-smoke.test.sh:308 - Le test de stabilité du viewport ne valide jamais que scroll_before est non vide et non null. .result.pane.scroll n'est référencé nulle part ailleurs dans le dépôt ; si herdr pane get échoue ou si le champ disparaît/est renommé dans une version future de Herdr, jq produit null (ou vide) avant ET après, la comparaison passe à vide et le test ne peut plus jamais échouer — ce qui contredit silencieusement sa raison d'être (« would fail if a capture moved the viewport »). Ajouter un garde-fou du type { [ -n &#34;$scroll_before&#34; ] &amp;&amp; [ &#34;$scroll_before&#34; != null ]; } || fail &#34;could not read .result.pane.scroll&#34; avant les captures.
  • ⚠️ tests/fm-backend-herdr-smoke.test.sh:309 - Les codes de retour des trois invocations de capture sont jetés (&gt;/dev/null sans || fail). fm_backend_herdr_capture et fm_backend_herdr_capture_ansi retournent 1 en cas d'échec CLI (bin/backends/herdr.sh:2596,2606) ; si une capture échoue, le scroll est trivialement inchangé et le test passe sans avoir exercé le comportement épinglé. Toutes les autres captures de cette suite sont gardées par || fail — appliquer le même garde ici (composer_state retourne toujours 0 et n'en a pas besoin, mais son verdict pourrait aussi être vérifié différent de unknown).

🔧 Fix: guard scroll read and capture failures in viewport test
2 issues (1 warning, 1 info) still open:

  • ⚠️ tests/fm-backend-herdr-smoke.test.sh:316 - L'assertion [ &#34;$cs&#34; != unknown ] ajoutée par le round de fix traite le verdict unknown comme un échec de capture, alors que c'est le verdict conçu et légitime du classificateur pour un écran sans composer : la règle stricte de bin/fm-composer-lib.sh (sélection cursorless, lignes 1261-1263, et le catalogue de formes) dit qu'un glyphe de prompt shell (&gt; $ % #) n'est jamais une preuve de conteneur. Le pane jetable du test héberge un shell brut affichant la sortie de seq 1 500 ; sur toute machine avec herdr installé et un prompt par défaut, composer_state retourne unknown et le test échoue systématiquement avec un message trompeur (« could not capture » alors que la capture ANSI a réussi). Il ne passe sur la machine de l'auteur que parce que son prompt starship coïncide avec le glyphe composer bare de claude. Correction : supprimer l'assertion et revenir à la forme du commit initial fm_backend_herdr_composer_state &#34;$SCROLL_TARGET&#34; &gt;/dev/null — le but de cet appel est d'exercer le chemin de capture pour la stabilité du viewport, et le succès de la capture est déjà garanti par les deux appels précédents gardés par || fail qui utilisent les mêmes primitives (capture et capture_ansi).
  • ℹ️ tests/fm-backend-herdr-smoke.test.sh:309 - Les deux findings du round 1 sont matériellement corrigés : le garde-fou non-vide/non-null sur scroll_before (lignes 309-310) élimine le passage à vide (un échec du pane get d'après ne peut plus produire de faux succès puisque scroll_before est garanti non-null), et les deux captures sont gardées par || fail (lignes 311-314). Seule la déclinaison composer_state du second fix introduit le défaut signalé séparément.

🔧 Fix: drop machine-dependent composer_state assertion in viewport test
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-backend-herdr-smoke.test.sh — full real-herdr smoke suite (isolated fm-lab session, herdr 0.8.0), all 18 checks pass including the new ok - real herdr: a firstmate capture never moves the pane viewport
  • Fault injection: replayed the test's exact scenario (throwaway pane, seq 1 500, read .result.pane.scroll before/after) but genuinely moved the viewport between reads via an attached herdr client in tmux copy-mode; offset_from_bottom went 0 → 29 and the test's exact assertion fired (not ok - a firstmate capture moved the pane viewport), proving the test would fail if a capture scrolled the view
  • Verified seeded scrollback is real before the captures (max_offset_from_bottom: 481 in .result.pane.scroll), so the pass is not vacuous
  • Manual doc check: the two new sentences sit directly after the existing pane read --lines N / 200-line-floor paragraph in 'Current transport behavior', one sentence per line, no duplication of existing content; diff bounded to 2 doc lines + 1 test block
  • Cleanup verified: no leftover fm-lab herdr sessions, no tmux sessions, git status --porcelain clean
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Document that alternative-screen harnesses, including Claude Code, expose
no scrollback history to Herdr, so any capture of such a pane is bounded
to the visible window regardless of the requested --lines.

Add a viewport-stability non-regression test to the real-herdr smoke suite
verifying that no firstmate capture moves the pane viewport.
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