Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,14 @@ jobs:
set -eu
npm install -g tasks-axi
tasks-axi --version
# Pinned to the Pi release the fleet runs and these tests were last green
# on. Unpinned, CI picked up Pi 0.99.1, whose changed stock rendering fails
# fm-calm-pi-extension and fm-pi-branch-extension; checking compatibility
# with the latest Pi is the filed follow-up (backlog: firstmate-pi-099-tests).
- name: Install the Pi package for the Pi extension tests
run: |
set -eu
npm install -g @earendil-works/pi-coding-agent
npm install -g @earendil-works/pi-coding-agent@0.87.1
npm ls -g --depth 0 @earendil-works/pi-coding-agent
- name: Run portable parallel shard 1
run: |
Expand Down Expand Up @@ -216,10 +220,14 @@ jobs:
# The Pi extension tests read the installed Pi package's own types and
# runtime, so without it they gate-skip and pass silently. It is a public
# npm package and needs no credential, so CI can hold the real thing.
# Pinned to the Pi release the fleet runs and these tests were last green
# on. Unpinned, CI picked up Pi 0.99.1, whose changed stock rendering fails
# fm-calm-pi-extension and fm-pi-branch-extension; checking compatibility
# with the latest Pi is the filed follow-up (backlog: firstmate-pi-099-tests).
- name: Install the Pi package for the Pi extension tests
run: |
set -eu
npm install -g @earendil-works/pi-coding-agent
npm install -g @earendil-works/pi-coding-agent@0.87.1
npm ls -g --depth 0 @earendil-works/pi-coding-agent
- name: Run portable serial shard ${{ matrix.shard }}
env:
Expand Down
21 changes: 21 additions & 0 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3082,6 +3082,27 @@ fm_backend_herdr_send_literal() { # <target> <text>
return "$rc"
}

# fm_backend_herdr_launch_line: the short line fm-spawn.sh types to start a
# staged launch file in a Herdr pane. Other backends type `. '<file>'`; Herdr
# instead sources the file inside a /bin/sh that has job control on (set -m),
# so the agent command becomes its own process group and takes the terminal
# foreground the moment it starts, whatever the pane shell is.
# Herdr registers an agent only by probing the pane's foreground process group,
# and it re-probes an agent-free pane only when that group changes or in a
# short window after the screen has been still for a moment. A pane shell
# without job control for sourced commands (fish, or a shell with monitor mode
# off) runs the agent inside its own group, so no group change happens, and an
# agent that keeps redrawing never leaves the screen still: the pane stays
# agent-free and every hook report is held back
# (docs/herdr-backend.md "Agent registration at launch").
# The file path is the single positional operand, quoted for any pane shell.
fm_backend_herdr_launch_line() { # <launch-file>
local quoted
quoted=$(printf '%s' "$1" | sed "s/'/'\\\\''/g")
# shellcheck disable=SC2016 # $0 is expanded by the job-control sh, not here
printf '%s' "/bin/sh -c 'set -m; . \"\$0\"' '$quoted'"
}

# fm_backend_herdr_normalize_key: map firstmate's key vocabulary (Enter,
# Escape, C-c, as used by fm-send.sh --key and stuck-crewmate-recovery) onto
# herdr's `pane send-keys` names. Verified empirically: enter, escape/esc, and
Expand Down
15 changes: 12 additions & 3 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,11 @@
# Launch delivery:
# Every harness and backend receives its complete launch command from a
# never-reused 0600 file in a 0700 home-scoped task namespace under /tmp, while
# the pane receives only a short source line.
# the pane receives only a short line that sources it. On Herdr that line
# sources the file inside a job-controlled /bin/sh so the agent takes the
# terminal foreground as its own process group, which is what Herdr's agent
# registration waits for (bin/backends/herdr.sh fm_backend_herdr_launch_line);
# the launch command therefore runs under /bin/sh there, not the pane shell.
# This keeps commands beyond the terminal's roughly 1,024-byte input boundary
# intact, prevents a delayed source line from being rebound by a relaunch, and
# prevents equal task ids in different Firstmate homes from sharing a file.
Expand Down Expand Up @@ -300,7 +304,8 @@
# even on a host that never had it set.
# An enabled task trace also retains TRACEPARENT. Explicit Firstmate launch
# assignments still apply inside the filtered environment. Raw commands must
# be POSIX sh compatible under this opt-in; the absent-file path is unchanged.
# be POSIX sh compatible under this opt-in, and on Herdr always; elsewhere the
# absent-file path is unchanged.
# This is an exec environment boundary, not a sandbox for the pane's startup
# shell, credential files, same-user processes, or later shell initialization.
# See docs/configuration.md for provider/Git setup and supported limits.
Expand Down Expand Up @@ -5325,7 +5330,11 @@ if ! (umask 077 && printf '%s\n' "$LAUNCH" >"$LAUNCH_STAGE" &&
fi
sleep 0.3
SPAWN_LAUNCH_SENT=1
spawn_send_literal "$T" ". $(shell_quote "$LAUNCH_FILE")"
if [ "$BACKEND" = herdr ]; then
spawn_send_literal "$T" "$(fm_backend_herdr_launch_line "$LAUNCH_FILE")"
else
spawn_send_literal "$T" ". $(shell_quote "$LAUNCH_FILE")"
fi
sleep 0.3
if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then
HERDR_PROJECTION_ABORT_CLEANUP=0
Expand Down
4 changes: 3 additions & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ family_for_basename() {
fm-launch-prompt-signals-live-e2e.test.sh|\
fm-herdr-version-floor-live-e2e.test.sh|\
fm-herdr-pi-stale-registration-live-e2e.test.sh|\
fm-herdr-pi-launch-registration-live-e2e.test.sh|\
fm-worker-account-live-e2e.test.sh|\
fm-opencode-primary-live-e2e.test.sh|fm-pi-branch-live-e2e.test.sh|\
fm-pi-branch-responsiveness-live-e2e.test.sh|\
Expand All @@ -374,7 +375,8 @@ family_for_basename() {
fm-herdr-submit-confirm-live-e2e.test.sh)
printf '%s\n' live-harness-optin
;;
fm-backend-herdr.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\
fm-backend-herdr.test.sh|fm-backend-herdr-launch-line.test.sh|\
fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\
fm-tmux-agent-liveness.test.sh|\
fm-control.test.sh|fm-control-relaunch.test.sh|\
fm-herdr-session-cleanup.test.sh|fm-send-resolve-key.test.sh|fm-send-strict.test.sh|\
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,7 @@ Choose the minimum additions for the authentication method actually in use:

Verify the selected provider login and Git transport after opting in; Firstmate does not infer credentials from model names or install a secret manager.
Raw launch commands run under noninteractive POSIX `sh` with this option and must use compatible syntax.
On the Herdr backend they always run under POSIX `sh` ([Herdr backend](herdr-backend.md#agent-registration-at-launch)).

The filter runs at the worker command boundary, after the terminal daemon and pane shell have started; it does not scrub either of those processes.
This is not a sandbox: it cannot revoke same-user access to credential files, prevent tools or later shells from loading credentials again, or isolate processes from the same user's other processes.
Expand Down
24 changes: 24 additions & 0 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Herdr provides the terminal session while Treehouse continues to provide task wo
| Why a seeded default tab is or is not closed | [Default-tab prune safety](#default-tab-prune-safety) |
| What task metadata records for a Herdr endpoint | [Endpoint metadata](#endpoint-metadata) |
| How text and keys reach a worker and how delivery is confirmed | [Current transport behavior](#current-transport-behavior) and [Composer and injection safety](#composer-and-injection-safety) |
| Why a new worker registers as an agent, or reads as no agent | [Agent registration at launch](#agent-registration-at-launch) |
| What happens after a Herdr server restart and how liveness is judged | [Restart and liveness behavior](#restart-and-liveness-behavior) |
| How blocked transitions arrive and what happens without protocol 16 | [Push events and polling fallback](#push-events-and-polling-fallback) |
| Where the away daemon runs and how it stops | [Away-mode supervisor support](#away-mode-supervisor-support) |
Expand Down Expand Up @@ -657,6 +658,27 @@ Claude Code itself then removes it from the submitted prompt, so a Claude Code p
`bin/fm-operational-input.sh` owns current operational construction and parsing, and the AFK skill owns legacy away-input compatibility.
No Herdr-specific copy of that protocol exists.

## Agent registration at launch

Herdr registers an agent in a pane only by probing that pane's foreground process group.
It probes an agent-free pane again only when that group changes, or during a short window that opens when the screen changes after being still (8 seconds after 2 still seconds in the Herdr 0.9.2 source).
Until a probe finds the agent, Herdr also holds back the Pi integration's lifecycle reports, so the pane reads no agent and `agent_status` `unknown`.

A pane shell that runs sourced commands inside its own process group therefore hides a new agent from that probe.
fish does this for every sourced command, and so does any shell with monitor mode off.
When the launch runs past the probe window and the agent then keeps redrawing, as a Pi working on its brief does, the pane stays agent-free for as long as the agent works.
Steering then treats the live worker as exited and rings no doorbell, and liveness reads misjudge the pane.

So on Herdr, `bin/fm-spawn.sh` does not type `. '<launch file>'`.
It types the line that `fm_backend_herdr_launch_line` in `bin/backends/herdr.sh` builds, which sources the same staged file inside a `/bin/sh` with job control on.
Each command in the file, the agent included, then runs as its own process group and takes the terminal foreground as it starts, so Herdr probes it at once whatever the pane shell is.
The terminal returns to the pane shell when the agent exits, and the line returns the agent's exit status.
This covers every fresh spawn and relaunch of every harness on Herdr.
The launch command therefore runs under POSIX `sh` on Herdr rather than the pane shell, so a raw launch command must use POSIX `sh` syntax there.

`tests/fm-backend-herdr-launch-line.test.sh` pins the process-group guarantee in a real pseudo-terminal without Herdr.
`tests/fm-herdr-pi-launch-registration-live-e2e.test.sh` proves the registration against the real Herdr and Pi, and [verification](verification/runtime-backends.md#agent-registration-at-launch) records the measurement.

## Restart and liveness behavior

### Husks after a server restart
Expand Down Expand Up @@ -845,7 +867,9 @@ tests/fm-backend-herdr-workspace-per-home-e2e.test.sh
tests/fm-backend-herdr-launcher-workspace-e2e.test.sh
tests/fm-backend-herdr-presentation-e2e.test.sh
tests/fm-backend-herdr-agent-exit-shell-e2e.test.sh
tests/fm-backend-herdr-launch-line.test.sh
tests/fm-herdr-pi-stale-registration-live-e2e.test.sh
tests/fm-herdr-pi-launch-registration-live-e2e.test.sh
tests/fm-backend-herdr-eventwait-smoke.test.sh
tests/fm-control-herdr-smoke.test.sh
tests/fm-herdr-session-cleanup.test.sh
Expand Down
42 changes: 42 additions & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -1734,6 +1734,48 @@ poll 8: {"agent_status":"working","session":".../2026-09-21T14-10-08-776Z_01a0c4

The read that supplies the reference is `bin/backends/herdr.sh`'s `fm_backend_herdr_pane_agent_session_ref`, the per-harness rule is `bin/fm-control-lib.sh`'s `fm_control_relaunch_resume_flag`, and the launch argument is composed by `relaunch_resume_args` in `bin/fm-spawn.sh`; `docs/herdr-backend.md` "Agent status authority and relaunch" owns the contract. Nothing here changes `resume` as a control verb, and only a relaunch asks for it.

### Agent registration at launch

Measured 2026-09-29 on Linux x86_64 against Herdr 0.9.2 and Pi 0.87.1, in an isolated `fm-lab-` session (`bin/fm-herdr-lab.sh`), with the Herdr Pi integration version 9 installed.

A freshly launched Pi stays unregistered when the pane shell sources the launch without job control and the Pi keeps redrawing.
The staged launch keeps the screen busy for 10 seconds (past Herdr's acquisition window), then starts Pi with no prompt and an extension that sets a status line every 150 ms, which stands in for a Pi working on its brief:

```sh
# /tmp/fm-hpaf/launch-bug.sh
export COMPACT_ADVISER_DISABLE=1; sh -c 'i=0; while [ $i -lt 50 ]; do printf .; sleep 0.2; i=$((i+1)); done; echo'; env -u CURSOR_AGENT FM_PI_HARNESS=pi "$PI" --no-session --no-context-files -e /tmp/fm-hpaf/churn.ts
```

Each pane below first ran a nested shell (`fish`, or `bash --norc --noprofile` followed by `set +m`), then the typed line, then was read every second with `herdr pane get <pane> --session "$LAB" | jq -c '.result.pane | {agent, agent_status}'` and `herdr pane process-info --pane <pane> --session "$LAB"`:

| Nested shell | Typed line | Registration | Foreground group |
| --- | --- | --- | --- |
| fish | `. /tmp/fm-hpaf/launch-bug.sh` | `{"agent":null,"agent_status":"unknown"}` for 35 s | `fish`, `pi` |
| bash, `set +m` | `. /tmp/fm-hpaf/launch-bug.sh` | `{"agent":null,"agent_status":"unknown"}` for 20 s | `bash`, `pi` |
| bash, job control on | `. /tmp/fm-hpaf/launch-bug.sh` | `{"agent":"pi","agent_status":"idle"}` from 12 s | `pi` |
| fish | `/bin/sh -c 'set -m; . "$0"' '/tmp/fm-hpaf/launch-bug.sh'` | `{"agent":"pi","agent_status":"idle"}` from 12 s | `pi` |
| bash, `set +m` | `/bin/sh -c 'set -m; . "$0"' '/tmp/fm-hpaf/launch-bug.sh'` | `{"agent":"pi","agent_status":"idle"}` from 12 s | `pi` |

After `/quit` in a fixed pane, the foreground returned to the nested shell and a typed `echo alive-$?` printed `alive-0`.
Typing `sh /tmp/fm-hpaf/launch-bug.sh` from fish did not register Pi either: that gives the whole launch one new group when it starts, and the busy preamble spends Herdr's window before Pi appears inside the same group.

The live guard that refreshes this record runs by default wherever Herdr and Pi are installed, spends no model token, and fails naming both versions:

```sh
tests/fm-herdr-pi-launch-registration-live-e2e.test.sh
```

Observed 2026-09-29:

```text
# pi 0.87.1 under herdr 0.9.2: launch-line pane registered pi, foreground [{"name":"pi","argv0":null}]
ok - real herdr 0.9.2 + pi 0.87.1: a Pi started through the Herdr launch line registers as an agent even when the pane shell has no job control
# herdr 0.9.2 still leaves a sourced, continuously redrawing Pi unregistered under a shell without job control (foreground [{"name":"bash","argv0":null},{"name":"pi","argv0":null}]): the launch line is what registers it
```

`tests/fm-backend-herdr-launch-line.test.sh` pins the portable half in a real pseudo-terminal without Herdr: from a bash with job control off, the typed line gives a stand-in agent its own process group holding the terminal foreground, returns the terminal and the agent's exit status to the shell, and the plain source line from the same shell keeps the agent in the shell's group.
`docs/herdr-backend.md` "Agent registration at launch" owns the contract.

### Away-mode transport

The away daemon is no longer launched on Pi; the away posture there is the record `bin/fm-afk-contract.sh` owns.
Expand Down
Loading
Loading