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
20 changes: 10 additions & 10 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ batched digest rather than per-wake injections.
No `/back` is needed. The first genuine message is the return signal:

- A message **without** the sentinel marker and **not** starting with `/afk` -> the captain is back.
Run `bin/fm-afk-launch.sh stop`: it stops the daemon in the correct order - it SIGTERMs the daemon so its shutdown flush runs **while `state/.afk` is still present** (clearing the flag first makes that flush a no-op via the daemon's presence gate, stranding undelivered escalations), then closes the daemon's own terminal by exact id, then clears `state/.afk` last.
Then flush one distilled "while you were out" catch-up (drain `state/.wake-queue`, summarize any pending escalations from `state/.subsuper-escalations` and any `state/.subsuper-inject-wedged` marker), and resume full per-wake responsiveness through the emitted primary-harness supervision protocol from session start.
- A message **with** the sentinel marker (`FM_INJECT_MARK`, ASCII 0x1f) -> it
is a daemon escalation; stay afk and process it.
Run `bin/fm-afk-return.sh` before acting on the message that brought the captain back.
That script owns correct-ordered daemon shutdown, durable wake draining, escalation and wedge evidence, and the return-catch-up gate.
If it reports a firstmate-actionable `blocked:` event, remediate it immediately through the normal lifecycle, or explicitly reclassify it with a durable reason and close its decision key with `resolved [key=...]`, then run `bin/fm-afk-return.sh check`.
Once the daemon stops, resume full per-wake responsiveness through the emitted primary-harness supervision protocol while blocker handling proceeds, so the gate never creates a blind wait.
Do not answer a Bearings request or perform any other ordinary captain work until the check exits successfully.
- A message **with** the sentinel marker (`FM_INJECT_MARK`, U+2063 INVISIBLE SEPARATOR) -> it is a daemon escalation; stay afk and process it.
- Re-invoking `/afk` while already away -> stay afk (refresh the flag); this
does **not** trigger an exit.

Expand All @@ -77,12 +79,9 @@ explicit word - the daemon just batches the notification.

## Sentinel marker contract

The daemon prefixes every injection with `FM_INJECT_MARK` (ASCII unit
separator, 0x1f), invisible and untypable. This is how firstmate tells a
daemon escalation apart from a real message in the same pane. The marker
travels with the message text; it does not rely on harness-level
typed-vs-injected detection (which is not portable across claude, codex,
opencode, pi, and grok).
The daemon prefixes every injection with `FM_INJECT_MARK` (U+2063 INVISIBLE SEPARATOR), which has no normal keyboard keystroke and survives terminal transport as UTF-8 text.
This is how firstmate tells a daemon escalation apart from a real message in the same pane.
The marker travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, and grok.

## Busy-guard and composer guard

Expand All @@ -103,6 +102,7 @@ In afk mode the composer guard is belt-and-suspenders (no human is typing), but
**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.
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, a durable
`state/.subsuper-inject-wedged` marker (surface it on the "while you were out"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ jobs:
bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh)
printf '%s\n' "$bearings_output"
bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ')
[ "$bearings_count" -eq 29 ] || {
echo "::error::expected 29 Bearings tests, got $bearings_count"
[ "$bearings_count" -eq 30 ] || {
echo "::error::expected 30 Bearings tests, got $bearings_count"
exit 1
}

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,11 +630,11 @@ Invoke the `/afk` skill when the captain says `/afk`, says they are going afk, `
The skill owns the full daemon procedure: classification policy, batching, injection hardening, max-defer, verified submit, marker stripping, portable lock, dedupe, target discovery, reliability properties, and `FM_INJECT_SKIP`.
Inline facts that must survive without a loaded skill:

- Every daemon injection is prefixed with `FM_INJECT_MARK`, ASCII unit separator `0x1f`, so internal escalations are distinguishable from a captain message.
- Every daemon injection is prefixed with `FM_INJECT_MARK`, U+2063 INVISIBLE SEPARATOR, so internal escalations survive terminal transport and remain distinguishable from a captain message.
- While `state/.afk` exists, the daemon owns the watcher; do not separately arm `fm-watch-arm.sh` or `fm-watch.sh`.
- If firstmate receives a marked message while afk is active, it is an internal escalation: stay afk and process it.
- If the message starts with `/afk`, stay afk and refresh the flag.
- Any other unmarked message means the captain is back: stop the daemon so its shutdown flush runs while `state/.afk` is still set and clear `state/.afk` last (the `/afk` skill owns this ordering, via `bin/fm-afk-launch.sh stop`; clearing the flag first would make the flush a no-op), flush catch-up from `state/.wake-queue`, `state/.subsuper-escalations`, and `state/.subsuper-inject-wedged`, then resume the emitted primary-harness supervision protocol.
- Any other unmarked message means the captain is back: load `/afk`, run `bin/fm-afk-return.sh`, and do not process that message as ordinary captain work until its durable catch-up gate clears; the script owns stop ordering, wake draining, and firstmate-actionable blocker precedence.
- Afk never changes approval authority; PR merges, ask-user findings, destructive actions, irreversible actions, and security-sensitive choices still require the same approval they required before.
- Bias ambiguous cases toward exit because a present captain beats token savings and a false exit is self-correcting.

Expand Down
129 changes: 125 additions & 4 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -668,9 +668,9 @@ fm_backend_herdr_strip_ansi() { # <text>
# fm_backend_herdr_composer_state: classify the composer's own row as
# empty|pending|unknown, scanning a generous tail-window capture of <target>.
# herdr's CLI exposes no cursor-row primitive (unlike tmux's #{cursor_y}), so
# this locates the composer row structurally, recognizing TWO row shapes and
# keeping whichever match comes LAST (scanning forward), so a shape earlier in
# scrollback/a popup can never outrank the real (bottom-anchored) composer row:
# this locates the composer structurally, recognizing THREE shapes and keeping
# whichever match comes LAST (scanning forward), so a shape earlier in
# scrollback/a popup can never outrank the real (bottom-anchored) composer:
#
# bordered - a boxed composer (verified grok 0.2.82): the row's TRIMMED
# content both STARTS and ENDS with the same border glyph (β”‚, ┃,
Expand All @@ -697,6 +697,15 @@ fm_backend_herdr_strip_ansi() { # <text>
# deliberately narrower than the bordered content classifier so a
# no-agent shell fallback prompt (`>`, `$`, `%`, or `#`) falls
# through to `unknown` instead of being misread as delivered.
# separated - Pi's composer is one or more content rows between two solid
# horizontal `─` separator rows, with no prompt glyph or side
# borders. This shape is accepted ONLY when Herdr's native
# `agent get` identifies the target as Pi and reports it idle,
# done, or blocked. A missing/stale/non-Pi agent identity, a
# working Pi, an over-tall candidate, or an incomplete separator
# pair remains unknown. This identity + structure conjunction is
# what makes a blank Pi row safe without weakening dead-shell or
# ambiguous-pane refusal.
#
# empty - blank, a bare prompt glyph, known ghost/placeholder text
# ("Type a message...", verified grok 0.2.82's empty-composer
Expand Down Expand Up @@ -731,16 +740,86 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'}
# (claude) and β€Ί (codex) only. Generic shell-style glyphs > $ % # are still
# recognized after a bordered composer row has already been structurally found.
FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'}
# Pi allows a multi-line composer between its horizontal separators. Bound the
# structural candidate so two unrelated transcript rules with an arbitrarily
# large region between them can never be promoted into a composer.
FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=${FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES:-8}

fm_backend_herdr_pi_separator_row() { # <plain-row>
local row=$1
row="${row#"${row%%[![:space:]]*}"}"
row="${row%"${row##*[![:space:]]}"}"
[ "${#row}" -ge 8 ] || return 1
[ -z "${row//─/}" ]
}

# Locate the content and closing-row position of the bottom-most complete pair
# of Pi separator rows. A separator closes the preceding candidate and
# immediately opens the next, so an earlier transcript rule can never outrank
# the live bottom composer pair. Globals let the caller compare this shape's
# screen position with generic bordered/bare candidates without losing empty
# composer content through command substitution.
fm_backend_herdr_pi_composer_find() { # <ansi-capture>
local cap=$1 line plain open=0 lines=0 candidate="" max row=0 open_row=0
max=$FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES
case "$max" in ''|*[!0-9]*|0) max=8 ;; esac
FM_BACKEND_HERDR_PI_PAIR_FOUND=0
FM_BACKEND_HERDR_PI_PAIR_VALID=0
FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=0
FM_BACKEND_HERDR_PI_PAIR_LINE=0
FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=0
FM_BACKEND_HERDR_PI_CONTENT=""
while IFS= read -r line; do
row=$((row + 1))
plain=$(fm_backend_herdr_strip_ansi "$line")
if fm_backend_herdr_pi_separator_row "$plain"; then
FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=$row
if [ "$open" -eq 1 ]; then
FM_BACKEND_HERDR_PI_PAIR_FOUND=1
FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=$open_row
FM_BACKEND_HERDR_PI_PAIR_LINE=$row
if [ "$lines" -le "$max" ]; then
FM_BACKEND_HERDR_PI_PAIR_VALID=1
FM_BACKEND_HERDR_PI_CONTENT=$candidate
else
FM_BACKEND_HERDR_PI_PAIR_VALID=0
FM_BACKEND_HERDR_PI_CONTENT=""
fi
fi
open=1
open_row=$row
lines=0
candidate=""
elif [ "$open" -eq 1 ]; then
[ -z "$candidate" ] || candidate="${candidate}"$'\n'
candidate="${candidate}${line}"
lines=$((lines + 1))
fi
done <<EOF
$cap
EOF
}

fm_backend_herdr_agent_identity_raw() { # <session> <pane> -> <agent>\t<status>
local out
out=$(fm_backend_herdr_cli "$1" agent get "$2" 2>/dev/null) || return 1
printf '%s' "$out" | jq -r '[.result.agent.agent // "", .result.agent.agent_status // ""] | @tsv' 2>/dev/null
}

fm_backend_herdr_composer_state() { # <target> -> empty|pending|unknown
local target=$1 cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped
local target=$1 session pane cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped
local identity agent agent_status row=0 generic_line=0
fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; }
session=$FM_BACKEND_HERDR_SESSION
pane=$FM_BACKEND_HERDR_PANE
cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES" 2>/dev/null \
|| fm_backend_herdr_capture "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES") || { printf 'unknown'; return 0; }
# Structural scan: locate the bottom-most composer row and remember its RAW
# (styled) bytes. Shape detection runs on the plain row (fm_backend_herdr_strip_ansi
# keeps ghost text so the border/prompt glyph is still visible); the raw row is
# kept for ANSI-aware content extraction after the scan.
while IFS= read -r line; do
row=$((row + 1))
trimmed=$(fm_backend_herdr_strip_ansi "$line")
trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
Expand All @@ -749,17 +828,54 @@ fm_backend_herdr_composer_state() { # <target> -> empty|pending|unknown
'β”‚'*'β”‚'|'┃'*'┃'|'|'*'|')
shape=bordered
raw_match=$line
generic_line=$row
found=1
;;
*)
if printf '%s' "$trimmed" | grep -qE "$FM_BACKEND_HERDR_BARE_PROMPT_RE"; then
shape=bare
raw_match=$line
generic_line=$row
found=1
fi
;;
esac
done < <(printf '%s\n' "$cap")
# Pi has no prompt glyph or side border. Compare its bottom-most complete
# separator pair with the last generic match so an earlier bordered transcript
# row can never suppress the live Pi composer. Identity is consulted only when
# a lower separator pair could change the verdict.
fm_backend_herdr_pi_composer_find "$cap"
if [ "$FM_BACKEND_HERDR_PI_PAIR_FOUND" -eq 1 ] \
&& [ "$FM_BACKEND_HERDR_PI_PAIR_LINE" -gt "$generic_line" ] \
&& [ "$generic_line" -lt "$FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE" ]; then
identity=$(fm_backend_herdr_agent_identity_raw "$session" "$pane" 2>/dev/null || true)
IFS=$'\t' read -r agent agent_status <<EOF
$identity
EOF
case "$agent:$agent_status" in
pi:idle|pi:done|pi:blocked)
if [ "$FM_BACKEND_HERDR_PI_PAIR_VALID" -eq 1 ]; then
shape=separated
raw_match=$FM_BACKEND_HERDR_PI_CONTENT
found=1
else
found=0
fi
;;
pi:*|:*)
# A working Pi or unreadable identity cannot authorize injection, and
# the lower separator pair proves any generic row above is not current.
found=0
;;
*) : ;; # A known non-Pi agent keeps its established generic verdict.
esac
elif [ "$FM_BACKEND_HERDR_PI_PAIR_FOUND" -eq 0 ] \
&& [ "$FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE" -gt "$generic_line" ]; then
# A lower unmatched separator proves the generic row is stale, but does
# not provide the complete Pi composer structure required for injection.
found=0
fi
[ "$found" -eq 1 ] || { printf 'unknown'; return 0; }
# Content: extract the real typed text from the raw row with the shared,
# fleet-wide ghost stripper (bin/fm-composer-lib.sh), which drops dim/faint AND
Expand All @@ -779,6 +895,11 @@ fm_backend_herdr_composer_state() { # <target> -> empty|pending|unknown
stripped=${stripped//|/}
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
stripped="${stripped%"${stripped##*[![:space:]]}"}"
elif [ "$shape" = separated ]; then
# The native Pi identity plus the complete separator pair is the genuine
# composer container, equivalent to a bordered box for shared content
# classification. ANSI stripping keeps real text and drops only styling.
bordered=1
fi
# Delegate the empty/pending/unknown decision to the shared owner. The bare
# shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE
Expand Down
8 changes: 8 additions & 0 deletions bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,10 @@ fm_afk_launch_create_tmux() { # <captain-target> <captain-backend>

fm_afk_launch_start() {
local captain_target captain_backend backup artifact had_afk=0 result
if [ -e "$FM_AFK_LAUNCH_STATE/.afk-return-catchup" ]; then
fm_afk_launch_log "return catch-up is still pending; run bin/fm-afk-return.sh check before re-entering away mode"
return 1
fi
# Capture the captain pane FIRST, before creating anything.
captain_target=$(discover_supervisor_target) || {
fm_afk_launch_log "could not resolve the captain supervisor pane (set FM_SUPERVISOR_TARGET)"; return 1; }
Expand Down Expand Up @@ -503,6 +507,10 @@ fm_afk_launch_start() {
fm_afk_launch_start_native() {
local backup artifact had_afk=0 result=0
mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1
if [ -e "$FM_AFK_LAUNCH_STATE/.afk-return-catchup" ]; then
fm_afk_launch_log "return catch-up is still pending; run bin/fm-afk-return.sh check before re-entering away mode"
return 1
fi
if daemon_lock_held_by_live_daemon; then
fm_afk_launch_record_validate_if_present || return 1
fm_afk_launch_flag_write || return 1
Expand Down
Loading