diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 517e8ba029c..81683d65fc7 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -1,6 +1,6 @@ --- name: afk -description: Enter away-mode supervision. Use when the user invokes /afk (e.g. "/afk", "/afk back in an hour", "going afk"). Sets a durable away-mode flag so the sub-supervisor daemon can self-handle routine wakes and escalate captain-relevant events plus bounded declared-external-wait rechecks as batched digests, cutting supervision token cost during walk-away stretches. Exit is automatic; any real (unmarked) message returns to full per-wake responsiveness. +description: Enter away-mode supervision. Use when the user invokes /afk (e.g. "/afk", "/afk back in an hour", "going afk"). Sets a durable away-mode flag so the sub-supervisor daemon can self-handle routine wakes and escalate captain-relevant events plus bounded declared-external-wait rechecks as batched digests, cutting supervision token cost during walk-away stretches. Exit is automatic; any real user message returns to full per-wake responsiveness. user-invocable: true metadata: internal: true @@ -8,228 +8,108 @@ metadata: # afk -Away-mode supervision. When invoked, `/afk` makes the daemon's token-saving -tradeoff **consented** and **explicit**: the captain is stepping away, so the -sub-supervisor may triage routine wakes in bash instead of waking firstmate's -LLM for each one. Escalations still reach the captain, but as one pre-read, -batched digest rather than per-wake injections. - -## What it does - -1. **Enter the lifecycle through `bin/fm-afk-launch.sh`.** - This owns the durable state write, session-scoped stale-artifact clearing, - terminal record, and rollback. - The flag survives a firstmate restart, so recovery re-enters afk when it is present. - -2. **Ensure the sub-supervisor daemon is running as a tracked background process.** - Its hosting differs by harness. - Pick the right path: - - **Harness WITH a native in-pane tracked-background tool** (e.g. claude's - background bash, grok's background tool): first run - `bin/fm-afk-launch.sh start-native`, then run - `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that native tool. - This is a deliberate no-separate-terminal exception because the harness-hosted job creates no terminal or layout mutation, and a shell launcher cannot invoke a harness-native background tool. - The launcher still owns lifecycle state and records the no-terminal mode, while the daemon inherits and auto-discovers the captain pane. - If the native launch fails, run `bin/fm-afk-launch.sh stop` to roll back the prepared lifecycle. - Do not wrap it in `nohup ... &` (Codex/herdr can reap fire-and-forget shell children after a tool call returns). - - **Harness WITHOUT one** (e.g. pi): run `bin/fm-afk-launch.sh start`. It is - the single owner of the daemon terminal: it creates a NON-VISIBLE tracked - terminal for the current backend (a herdr dedicated `--no-focus` workspace, - a detached tmux session), records its exact id, and passes the captain pane - in as `FM_SUPERVISOR_TARGET` so the daemon injects into the captain, not its - own new pane. **Never manufacture a terminal by splitting the captain's - active pane** (`herdr pane split`): a split co-tenants the tab and visibly - shrinks the captain's pane (docs/herdr-backend.md "Away-mode daemon terminal - launch"). - Both paths share `bin/fm-afk-start.sh` as the daemon entry. - The native path tells it that the launcher already prepared lifecycle state; the terminal-backed path lets the entry perform its existing state setup inside the new terminal. - It exits immediately if the identity-backed daemon lock already names a live process, otherwise it execs `bin/fm-supervise-daemon.sh` in the foreground. - The daemon is **presence-gated**: it injects escalations only while - `state/.afk` exists, and stays quiet otherwise. - -3. **Do not separately arm `fm-watch.sh`.** The daemon manages the watcher as - its child; the singleton lock no-ops a stray arm harmlessly. - -4. **Acknowledge** to the captain that away-mode is active. - The daemon will self-handle routine wakes, escalate captain-relevant events and bounded declared-external-wait rechecks, and let the captain exit by sending any real message. - -## How to exit afk - -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. -- Re-invoking `/afk` while already away -> stay afk (refresh the flag); this - does **not** trigger an exit. - -Bias ambiguous cases toward exit: a present captain beats token savings, and -a false exit is self-correcting (the captain re-runs `/afk`). - -## Orthogonal to approval authority - -afk changes how aggressively firstmate surfaces things, **not who approves -what**. "Away" never means "approves more." A PR ready for merge, a -needs-decision finding, or anything destructive still waits for the captain's -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). - -## Busy-guard and composer guard - -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): - -- **`pane_is_busy`** - the harness shows a busy footer (agent mid-turn) on tmux (shared with `fm-send.sh` via `bin/fm-tmux-lib.sh`); on herdr, tries the native `agent.get`-backed busy state first, trusts only `busy` outright, and corroborates every non-`busy` verdict with the same regex-over-capture reader. -- **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`. - `pending` means real unsubmitted text, while `unknown` includes an unreadable pane and a bare shell prompt left after the agent exits, so both defer. - The shared `bin/fm-composer-lib.sh` owns the content decision after each backend captures and structurally identifies its own composer row. - It preserves idle bordered composers such as claude's `│ > … │` and bare agent glyphs as empty, but a bare shell glyph is unknown unless inside a genuine bordered composer box; see `docs/herdr-backend.md` "Composer-emptiness safety" for the complete contract. - `pane_input_pending` remains the tested predicate for callers that only need to know whether real unsubmitted text is present, but it is insufficient for an injection-safety decision because it cannot distinguish `empty` from `unknown`. - -Either condition, 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. - -**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 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" -catch-up if present), a tmux status-line flash when applicable, and a configurable backend-independent active alert. -`docs/wedge-alarm.md` owns the alert channel setup and verification record. -So a guard false-positive becomes a visible stall, never an unbounded silent no-op. - -## Submit model - -The digest is typed **once** (`send-keys -l` on tmux, `pane send-text` on -herdr - both literal, non-submitting sends), then submitted with Enter and -**verified** through the selected backend's submit primitive. -Enter is retried (Enter only, never a retype) until the backend confirms the -submit landed. -For tmux that confirmation is a cleared composer, using the same corrected, -border-aware detector as the composer guard. -For herdr, normal idle-baseline submits are confirmed by native agent-state showing a real turn started; the ANSI-aware composer classifier remains the affirmative-empty pre-injection guard and conservative fallback for non-idle or unreadable baselines. -A bordered-empty or ghost-only composer is recognized as empty where that backend uses composer confirmation, rather than mistaken for a swallowed Enter. -`fm-send.sh` uses the same primitive and exits non-zero -when a steer's Enter is positively swallowed, so firstmate learns an instruction -did not land instead of leaving it unsubmitted. - -## Classification policy - -The daemon wraps `fm-watch.sh`, runs the watcher as a child, classifies each -wake reason in bash, and self-handles the routine majority without consuming a -firstmate turn. -Captain-relevant events, plus a bounded recheck of a declared external wait that remains idle, escalate to firstmate's context as one pre-read, single-line, batched digest. -The classification predicates (the captain-relevant verb set, declared-pause vocabulary, signal/stale tests, and fleet-scan) live in the shared `bin/fm-classify-lib.sh`, the same library the always-on watcher uses for its own triage when afk is off, so the two modes apply one identical policy. -While `state/.afk` exists the daemon owns the watcher, so the watcher reverts to one-shot and lets the daemon do the triage - the two never run their triage at the same time. - -Classify each wake this way: - -- `signal` whose status content has no captain-relevant verb - (`done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged`) - -> self-handle. Captain-relevant verb -> escalate. -- `signal` or `stale` for a declared `paused:` external wait -> self-handle and track the pause rather than a wedge. - If it remains declared and idle past `FM_PAUSE_RESURFACE_SECS` (default 3600s), housekeeping sends one awaiting-external recheck and resets the pause window. -- `check` -> always escalate. Check scripts print only when firstmate should wake. -- `stale` with a terminal status -> escalate. Non-terminal stale is transient: - record a marker and self-handle. If the pane is still idle past - `FM_STALE_ESCALATE_SECS` (default 240s), housekeeping escalates it as a - possible wedge. This bounds wedge-detection latency to the threshold plus a - tick: a delay, never a loss. Healthy crewmates are autonomous and do not wait - on firstmate mid-task. -- `heartbeat` -> self-handle. The daemon runs its own cheap bash fleet scan - every `FM_HEARTBEAT_SCAN_SECS` (default 300s) as the catch-all for a - captain-relevant status line the per-wake classifier might miss. -- Unknown reason, or any uncertainty -> escalate fail-safe. - -Escalations are buffered up to `FM_ESCALATE_BATCH_SECS` (default 90s; 0 = -immediate) and flushed as one single-line digest prefixed with the sentinel -marker, carrying pre-read status summaries and a recommended action. -The single-line format makes the submission unambiguous across harnesses, and -the marker lets firstmate distinguish it from a real captain message. - -## Injection hardening - -- **Single-line digest** - embedded newlines are collapsed to a literal - separator before injection, so submission is unambiguous regardless of - harness. -- **Composer guard on the supervisor pane** - before injecting, the daemon checks `pane_is_busy` (harness busy footer means agent mid-turn) 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. -- The shared composer classifier receives a candidate row only after the active backend performs its own capture and structural row recognition. - tmux and herdr route their raw styled candidate rows through the shared `fm_composer_strip_ghost` extractor, which removes dim/faint and dark-TRUECOLOR ghost/placeholder text before classification. - They read the composer shape from a separately ANSI-stripped plain row because a dark TRUECOLOR border can be stripped 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` still overrides tmux empty-composer matching after shared ghost and border stripping, and `FM_BUSY_REGEX` overrides busy footers. -- **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. -- **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 - primitive reports `empty` as its caller-facing success verdict. - For tmux that verdict means the shared-ghost-aware and border-aware composer - cleared. - For herdr's normal idle-baseline path it means native agent-state observed a real turn start; herdr uses the ANSI-aware structural classifier for the pre-injection composer guard and fallback paths. - This lets ghost-only or bordered-empty composers count as empty where a composer read is the active confirmation signal. -- **Marker strip** - `strip_injection_marker` removes the sentinel prefix before - classification or relay, so the digest text firstmate sees is clean. -- **Portable singleton lock** - the daemon uses the repo's portable lock helper - (`fm-wake-lib.sh`) instead of `flock`, which is absent on macOS. -- **Dedupe across signal/stale/scan** - `classify_signal` and `classify_stale` - both check the seen-status marker before escalating, so a status escalated by - one path is not re-escalated by another in the same digest. -- **Auto-discovered supervisor pane** - the daemon resolves its own BACKEND - (tmux vs herdr) and TARGET independently, mirroring - `bin/fm-backend.sh`'s own runtime auto-detection. Backend: `FM_SUPERVISOR_BACKEND` - override, then `$TMUX_PANE` set (tmux), then `$HERDR_ENV=1` with - `$HERDR_PANE_ID` present (herdr), then a tmux fallback. Target: - `FM_SUPERVISOR_TARGET` override (a tmux target or a herdr - `":"` target), then `$TMUX_PANE`, then - `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr, then a - `firstmate:0` fallback with a warning. Both resolution sources are logged at - startup so a wrong-but-resolving fallback is detectable. Other runtime - backends, including zellij, orca, and cmux, are not yet supported as - supervisor backends; the daemon refuses loudly at startup instead of - misapplying tmux primitives to a pane that isn't one - (docs/herdr-backend.md "Away-mode daemon: herdr supervisor-pane support"). - -## Stale-artifact lifecycle - -Treat `state/.subsuper-escalations`, its `.since` sidecar, and `state/.subsuper-inject-wedged` as session-scoped delivery artifacts, not as the durable work record. -Always enter through `bin/fm-afk-launch.sh`, which clears prior-session artifacts only for a fresh entry and preserves the current session's buffer on refresh. -Always exit through `bin/fm-afk-launch.sh stop`, which keeps `state/.afk` present through the daemon's shutdown flush and clears it last. -`docs/herdr-backend.md` "Stale-artifact lifecycle fix" owns the mechanism and verification evidence. +Away mode is an explicit, captain-consented token-saving tradeoff. +The daemon triages routine wakes in bash instead of waking firstmate's LLM for each one. +Captain-relevant events still wake firstmate as one pre-read batch. + +## Enter away mode + +1. Run `bin/fm-afk-launch.sh start-native` when the current harness provides a native tracked-background tool, including Claude Code's background bash. +2. Run `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` as its own native tracked background task. +3. Run `bin/fm-afk-launch.sh start` only on a harness without a native tracked-background tool. +4. Do not wrap either daemon entry in `nohup`, shell `&`, or another fire-and-forget shell. +5. Do not separately arm `bin/fm-watch-arm.sh` or `bin/fm-watch.sh` while `state/.afk` exists. +6. Acknowledge that away mode is active and that any real captain message ends it. + +`bin/fm-afk-launch.sh` owns the durable flag, stale-artifact clearing, terminal record, rollback, and stop ordering. +The native launch record makes `bin/fm-afk-start.sh` select `reap-wake` delivery automatically. +The terminal-backed path remains a compatibility mode for harnesses without a native background completion notification. +Never manufacture a daemon terminal by splitting the captain's active pane. + +## Native reap-wake lifecycle + +On Claude, the tracked away daemon is the parked LLM's wake task. +The daemon keeps that task running across routine signal, stale, and heartbeat events while its bash classifier absorbs them. +When a batched captain-relevant escalation becomes due, the daemon prints one line beginning `afk-reap-wake:` and exits cleanly. +Claude Code's native background-task completion notification then wakes the parked LLM through the same primitive used by normal Claude supervision. +The native delivery path does not inspect a pane, type into the composer, use a busy guard, classify composer text, or call `send-keys`. + +Treat a completed tracked away task whose output includes a line beginning `afk-reap-wake:` as an internal escalation rather than a captain return message. +Stay in away mode, drain `state/.wake-queue`, process the distilled batch, and surface only captain-relevant outcomes. +If `state/.afk` still exists at the end of that turn, run `bin/fm-afk-launch.sh start-native` and start a fresh `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` native background task as the turn's final supervision action. +Restart the away daemon, not `fm-watch-arm.sh`, because the daemon continues to own and wrap the watcher. + +The durable wake queue is authoritative and lossless. +The `afk-reap-wake:` line is a pre-read reason that explains why the tracked task completed. +Routine queue records may be present beside the captain-relevant record because the daemon batches and triages before waking the LLM. + +## Terminal-backed compatibility delivery + +A harness without a native tracked-background tool uses `bin/fm-afk-launch.sh start` to create one non-visible tracked terminal. +That compatibility path passes `FM_SUPERVISOR_TARGET` and `FM_SUPERVISOR_BACKEND` explicitly and delivers through the existing verified pane submit path. +It retains the busy guard, affirmative-empty composer guard, type-once submit, `FM_MAX_DEFER_SECS` alarm, and `state/.subsuper-inject-wedged` marker. +The compatibility path prefixes its injected message with `FM_INJECT_MARK`, ASCII unit separator `0x1f`, so firstmate can distinguish it from a real captain message in the shared input channel. +The marker never appears on native reap-wake delivery because a background-task completion is not a user message. + +## Exit away mode + +No `/back` command is required. + +- A completed native task whose output includes an `afk-reap-wake:` line is internal, so stay away and follow the native reap-wake lifecycle above. +- A legacy message beginning with `FM_INJECT_MARK` is an internal terminal-backed escalation, so stay away and process it. +- A message beginning with `/afk` refreshes away mode rather than ending it. +- Any other real user message means the captain is back. + +On a real return message, run `bin/fm-afk-launch.sh stop` before clearing any state yourself. +The launcher stops the daemon while `state/.afk` still exists, closes any exact recorded terminal, and clears `state/.afk` last. +Native shutdown preserves any buffered escalation for catch-up, while terminal-backed compatibility shutdown may make its final guarded submit while the flag is still present. +Drain `state/.wake-queue`, summarize any pending `state/.subsuper-escalations`, surface any legacy `state/.subsuper-inject-wedged` marker, and resume the primary harness supervision protocol emitted at session start. +Bias ambiguous user-message cases toward exit because a present captain beats token savings and a false exit is self-correcting. + +## Approval authority + +Away mode changes wake frequency, not approval authority. +A PR ready for merge, an ask-user finding, a destructive action, an irreversible action, or a security-sensitive choice still requires the same approval it required before away mode. + +## Classification and batching + +The daemon wraps `bin/fm-watch.sh`, classifies each wake in bash, and self-handles the routine majority without consuming a firstmate turn. +The shared `bin/fm-classify-lib.sh` owns the captain-relevant verbs, declared-pause vocabulary, signal and stale decisions, and catch-all scan used by both normal and away supervision. +While `state/.afk` exists, the watcher reverts to one-shot enqueue-and-exit behavior and lets the daemon own triage. + +- A `signal` with `done:`, `needs-decision:`, `blocked:`, `failed:`, `PR ready`, `checks green`, `ready in branch`, or `merged` escalates. +- A routine `signal` self-handles. +- A declared `paused:` external wait self-handles and re-surfaces after `FM_PAUSE_RESURFACE_SECS`, which defaults to 3600 seconds. +- A `check` always escalates because check scripts print only when firstmate should wake. +- A terminal `stale` escalates immediately. +- A non-terminal `stale` rechecks after `FM_STALE_ESCALATE_SECS`, which defaults to 240 seconds, and escalates only if it remains a possible wedge. +- A `heartbeat` self-handles while the daemon's cheap fleet scan backs up missed captain-relevant statuses. +- An unknown or uncertain reason escalates fail-safe. + +Escalations batch for up to `FM_ESCALATE_BATCH_SECS`, which defaults to 90 seconds and accepts zero for immediate delivery. +Native delivery completes the tracked task at that batch deadline without any pane-dependent defer condition. +Legacy terminal-backed delivery retains `FM_MAX_DEFER_SECS` because its guarded submit can still defer. +`FM_INJECT_SKIP`, which defaults to `heartbeat`, force-self-handles matching reason prefixes and should be used sparingly. + +## State lifecycle + +`state/.wake-queue` is the durable work record and survives daemon crashes, notification loss, and firstmate restarts. +`state/.subsuper-escalations` and its `.since` sidecar are a transient batch cache. +`state/.subsuper-inject-wedged` is a legacy terminal-backed delivery alarm and is not created by native reap-wake delivery. +Always enter through `bin/fm-afk-launch.sh`, which clears prior-session transient artifacts only on a fresh away session and preserves the current session's buffer on refresh. +Always exit through `bin/fm-afk-launch.sh stop`, which preserves the flag until daemon shutdown completes and clears it last. ## Reliability properties -These properties must hold: +These properties must hold for native tracked delivery: -- Nothing is lost. The durable queue plus `fm-wake-drain.sh` recover any missed - or crashed injection. -- Wedge detection is bounded-latency, not lossy. -- Declared external waits are rechecked on a separate, bounded cadence rather than being mislabeled as wedges. +- Routine wakes remain bash-only and do not complete the tracked task. +- A due captain-relevant batch completes the tracked task regardless of busy footers, composer placeholders, pending composer state, or pane readability. +- Claude's native task-completion notification delivers the wake to the parked LLM. +- The durable queue recovers the event if the daemon or harness restarts before firstmate drains it. +- Declared external waits use their own bounded recheck cadence rather than being mislabeled as wedges. - The catch-all scan backs up the keyword classifier. -- The daemon preserves a single-instance portable lock, crash-loop backoff, - a pane-gone guard, and a signal-trapped shutdown that flushes buffered - escalations before exit. +- The daemon preserves a single-instance portable lock, watcher crash-loop backoff, and signal-trapped cleanup. -`FM_INJECT_SKIP` (default `heartbeat`) force-self-handles matching kinds, -overriding classification. -Use it sparingly. +The delivery guarantee assumes the daemon was started as the harness's native tracked background task as required above. +Do not substitute a detached shell process because its exit has no harness notification contract. diff --git a/AGENTS.md b/AGENTS.md index 0f9d6699eda..9e47e28cdaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ config/secondmate-account-pool optional Agent Fleet pool the PRIMARY uses for S config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend, herdr/zellij/cmux are experimental new-task spawn backends, and Orca is legacy-recovery-only (docs/tmux-backend.md, docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and Orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") -config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md +config/wedge-alarm optional active-alert directives for wedged terminal-backed away-mode compatibility delivery; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history @@ -108,7 +108,7 @@ state/ volatile runtime signals; gitignored x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) x-poll.error generated X-mode relay diagnostic dedupe marker .wake-queue durable queued wakes: epochseqkindkeypayload - .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) + .afk durable away-mode flag; present = sub-supervisor may deliver escalations (set by /afk, cleared on user return) .lock per-home session lock written by fm-lock.sh: harness PID on line 1, holder process start time on line 2; direct readers parse only the first-line PID, while acquire/status treat it as held only when that PID is live with a matching start time (skipped for a legacy one-line lock, which is held while its PID is a live harness) and is not a Codex app-server .watch.lock .wake-queue.lock watcher singleton and queue serialization locks .hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch @@ -641,15 +641,17 @@ The context-% shown in a peek is not actionable as crew health; ignore it and in ### Away-mode stub -Invoke the `/afk` skill when the captain says `/afk`, says they are going afk, `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. -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`. +Invoke the `/afk` skill when the captain says `/afk`, says they are going afk, `state/.afk` exists, a tracked away task completes with `afk-reap-wake:`, an incoming legacy message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. +The skill owns the full daemon procedure: classification policy, batching, native reap-wake delivery, terminal-backed compatibility delivery, portable lock, dedupe, 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. +- On a native background-notify harness such as Claude, run the away daemon as its own tracked background task so completing that task is the captain-relevant wake primitive. - 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 tracked away task completes with `afk-reap-wake:`, stay afk, drain the durable wake queue, process the batch, and restart the away daemon as a fresh native tracked task if the flag still exists. +- `FM_INJECT_MARK`, ASCII unit separator `0x1f`, identifies only legacy terminal-backed injections and is never used by native reap-wake delivery. +- If firstmate receives a legacy 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 real user message means the captain is back: stop the daemon through `bin/fm-afk-launch.sh stop`, which clears `state/.afk` last, flush catch-up from `state/.wake-queue` and `state/.subsuper-escalations` plus any legacy `state/.subsuper-inject-wedged`, then resume the emitted primary-harness supervision protocol. - 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. diff --git a/README.md b/README.md index 65ca3a883e0..064bd811845 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | Skill | What it does | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine wakes in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery wedges while you step away | +| `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine wakes in bash and wakes the parked first mate with a batched digest only for captain-relevant events and bounded declared-external-wait rechecks | | `/bearings` | Generate a "pick up where I left off" status report from the read-only fleet snapshot - backlog, per-task crew state, open PRs, scout reports, pending decisions, and date-gated queued work - written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | | `/reports` | Browse, search, open, or summarize the machine-global completion report stack, including visual evidence and follow-ups | | `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | @@ -191,7 +191,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/architecture.md](docs/architecture.md) - how the crew, supervision, worktrees, secondmates, account routing, completion reports, and project modes work. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend and Agent Fleet account routing, optional X mode, the files you set, and harness support. -- [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for a wedged away-mode escalation delivery. +- [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for wedged terminal-backed away-mode compatibility delivery. - [docs/tmux-backend.md](docs/tmux-backend.md) - setup guide for the tmux reference backend: prerequisites, attaching, and watching crew windows. - [docs/herdr-backend.md](docs/herdr-backend.md) - setup guide for the experimental herdr backend, plus its verification notes and known gaps. - [docs/zellij-backend.md](docs/zellij-backend.md) - setup guide for the experimental zellij backend, plus its verification notes and known gaps. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 7aa594d099f..ee467a7414a 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2809,11 +2809,11 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown # away-mode daemon. Root cause: composer-content submit confirmation was too # sensitive to harness rendering details. Real claude/codex use bare prompt # rows, and real codex adds dynamic idle suggestions after `›`; the later -# ANSI-aware composer classifier now handles the pre-injection guard for that -# Codex shape, but idle-baseline submit confirmation deliberately stays on -# native agent-state so delivery does not depend on composer text. Composer -# content is retained for other callers (the away-mode daemon's PRE-injection -# empty-box guard, still dispatched via fm_backend_composer_state / +# ANSI-aware composer classifier now handles the compatibility pre-injection +# guard for that Codex shape, but idle-baseline submit confirmation deliberately +# stays on native agent-state so delivery does not depend on composer text. +# Composer content is retained for other callers (the terminal-backed away-mode +# compatibility path's empty-box guard, still dispatched via fm_backend_composer_state / # fm_backend_herdr_composer_state) and for submit attempts whose pre-Enter # agent-state baseline is not legibly idle. # diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 5134f184379..3852cda9f51 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -14,8 +14,8 @@ # inline with these same send/current-path primitives. # # The verified composer/busy-detection and verify-and-retry-submit primitives -# already live in bin/fm-tmux-lib.sh, shared with the away-mode daemon -# (bin/fm-supervise-daemon.sh); this adapter sources that file and re-exports +# already live in bin/fm-tmux-lib.sh, shared with the terminal-backed away-mode +# compatibility path; this adapter sources that file and re-exports # its submit core under the backend's naming convention rather than # duplicating it, so the two consumers cannot drift apart. # shellcheck source=bin/fm-tmux-lib.sh diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 99d2a091663..3154dc16d21 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# fm-afk-launch.sh - the single owner of the away-mode daemon TERMINAL lifecycle: -# launch it in a NON-VISIBLE tracked terminal per backend, record its exact id, -# tear it down by that exact id, and reconcile a leaked one after a crash. +# fm-afk-launch.sh - the single owner of the away-mode daemon lifecycle and any +# compatibility terminal it needs. +# It records exact ownership, tears a terminal down by exact id, and reconciles +# a leaked one after a crash. # # Why this exists (docs/herdr-backend.md "Away-mode daemon terminal launch"): # bin/fm-afk-start.sh execs the supervise daemon in the FOREGROUND of whatever @@ -13,11 +14,10 @@ # workspace with --no-focus, or a detached tmux session) that never touches the # captain's active tab, and NEVER uses shell `&` (which herdr/codex can reap). # -# Correct supervisor targeting: the daemon finds the captain pane to inject into -# from its OWN inherited env (discover_supervisor_target). Running it in a -# separate terminal would make it discover its OWN pane, so this captures the -# captain pane FIRST (from the pane this script runs in) and passes it in as -# FM_SUPERVISOR_TARGET/FM_SUPERVISOR_BACKEND explicitly. +# Correct compatibility-targeting: a terminal-backed daemon would discover its +# own pane, so this captures the captain pane first and passes it in as +# FM_SUPERVISOR_TARGET/FM_SUPERVISOR_BACKEND for legacy injection delivery. +# Native tracked launches use task completion and never inspect a pane. # # Usage: # fm-afk-launch.sh start Capture the captain pane, then (unless the daemon @@ -29,10 +29,12 @@ # fm-afk-launch.sh start-native # Prepare lifecycle state for a harness-native # background job and record that no terminal exists. -# fm-afk-launch.sh stop Correct-ordered exit: SIGTERM the daemon so its -# cleanup flushes WHILE state/.afk is still present, -# wait for it, close the recorded terminal by exact -# id, then clear state/.afk last. +# fm-afk-launch.sh stop Correct-ordered exit: SIGTERM the daemon while +# state/.afk is still present, wait for it, close the +# recorded terminal by exact id, then clear +# state/.afk last. Native delivery preserves its +# buffer for catch-up; compatibility injection may +# perform its final guarded flush during cleanup. # fm-afk-launch.sh reconcile Close a recorded-but-dead daemon terminal by exact # id and drop the record (recovery after a crash). # @@ -1225,9 +1227,9 @@ fm_afk_launch_stop_locked() { fm_afk_launch_log "malformed daemon terminal record; refusing to stop away mode" return 1 fi - # (1) SIGTERM the daemon so its cleanup trap flushes buffered escalations - # WHILE state/.afk is still present (the exit-ordering fix: clearing .afk - # first would make that flush a no-op via inject_msg's presence gate). + # (1) SIGTERM the daemon while state/.afk is still present. + # Terminal-backed compatibility delivery may make its final guarded flush in + # cleanup, while native reap-wake preserves the buffer for firstmate's catch-up. pid="" pid_identity="" if daemon_lock_held_by_live_daemon; then diff --git a/bin/fm-afk-start.sh b/bin/fm-afk-start.sh index 7aa7a5fdbdf..6eb47d8b427 100755 --- a/bin/fm-afk-start.sh +++ b/bin/fm-afk-start.sh @@ -8,9 +8,11 @@ # - prints "afk: daemon already running pid=" then exits 0 when that # lock is held by a live daemon (a REFRESH: no stale-artifact clear); # - otherwise clears any prior away session's stale escalation artifacts -# (fm_afk_clear_stale_artifacts) for a direct, non-prepared start, then -# execs bin/fm-supervise-daemon.sh in the foreground. A prepared start was -# already cleared transactionally by bin/fm-afk-launch.sh. +# (fm_afk_clear_stale_artifacts) for a direct, non-prepared start, selects +# reap-wake delivery when the launch record identifies a native tracked +# background task, then execs bin/fm-supervise-daemon.sh in the foreground. +# A prepared start was already cleared transactionally by +# bin/fm-afk-launch.sh. # # This file is sourceable: its BASH_SOURCE guard keeps main from running, while # exposing the daemon-lock helpers and fm_afk_clear_stale_artifacts. Sourcing it @@ -20,8 +22,10 @@ # This is the COMMON daemon entry for every backend. HOW it becomes a tracked # background process differs by harness/backend and is owned elsewhere: # - Harnesses with a native in-pane tracked-background tool (e.g. claude, grok) -# run this directly via that tool, so the daemon inherits the captain pane's -# env and auto-discovers it. +# run this directly via that tool. +# The native launch record selects reap-wake delivery, so a captain-relevant +# escalation completes this process and lets the harness's own background-task +# notification wake the parked LLM without reading or typing into its pane. # - Harnesses with NO native background mechanism (e.g. pi) run this THROUGH # bin/fm-afk-launch.sh, which creates a non-visible tracked terminal per # backend (herdr tab/workspace, tmux detached session) and passes the @@ -40,6 +44,7 @@ FM_AFK_LOCK="$FM_AFK_STATE/.supervise-daemon.lock" FM_AFK_DAEMON="$FM_AFK_START_DIR/fm-supervise-daemon.sh" FM_AFK_NATIVE_PROCESS="$FM_AFK_STATE/.afk-native-process" FM_AFK_NATIVE_HANDOFF_LOCK="$FM_AFK_STATE/.afk-native-handoff.lock" +FM_AFK_LAUNCH_RECORD="$FM_AFK_STATE/.afk-daemon-terminal" FM_AFK_NATIVE_PROCESS_UNSAFE=0 FM_AFK_NATIVE_PROCESS_MAX_BYTES=4096 @@ -275,6 +280,21 @@ fm_afk_start_flag_write() { mv "$pending" "$destination" || { rm -f "$pending"; return 1; } } +fm_afk_start_select_delivery() { + local record + if [ "${FM_AFK_STATE_PREPARED:-0}" = 1 ]; then + record=$(fm_afk_safe_control_read "$FM_AFK_LAUNCH_RECORD" "$FM_AFK_NATIVE_PROCESS_MAX_BYTES" 2>/dev/null) || return 1 + case "$record" in + $'none\t-\tnative') FM_AFK_DELIVERY=reap-wake ;; + $'tmux\t'*|$'herdr\t'*) FM_AFK_DELIVERY=inject ;; + *) return 1 ;; + esac + else + : "${FM_AFK_DELIVERY:=inject}" + fi + export FM_AFK_DELIVERY +} + fm_afk_native_process_identity() { local pid=$1 out case "$pid" in @@ -390,6 +410,12 @@ fm_afk_start_main() { return 1 fi + if ! fm_afk_start_select_delivery; then + fm_lock_release "$FM_AFK_NATIVE_HANDOFF_LOCK" + echo "afk: prepared launch record is missing, unsafe, or malformed" >&2 + return 1 + fi + if ! fm_afk_native_process_write; then fm_lock_release "$FM_AFK_NATIVE_HANDOFF_LOCK" echo "afk: could not register native process" >&2 diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 437b8c68977..0b4334b78c2 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -8,11 +8,11 @@ # agent composer" decision, and the copies drifted. The dangerous drift: a BARE # shell prompt glyph (`>`, `$`, `%`, `#`) - what a pane shows once its agent has # exited to a plain login shell - was treated as an empty, ready-to-inject -# AGENT composer. The away-mode escalation injector (bin/fm-supervise-daemon.sh) -# reads composer-emptiness to decide whether a pane is a safe injection target, -# so a dead-shell pane misread as "empty" meant an escalation could be typed -# into (and, worst case, executed by) that shell. Consolidating the one decision -# here means the safety rule cannot silently drift across adapters again. +# AGENT composer. The terminal-backed away-mode compatibility injector reads +# composer-emptiness to decide whether a pane is a safe injection target, so a +# dead-shell pane misread as "empty" meant an escalation could be typed into (and, +# worst case, executed by) that shell. Consolidating the one decision here means +# the safety rule cannot silently drift across adapters again. # # THE SAFETY RULE this owner enforces: a bare shell prompt glyph is a genuine # empty agent composer ONLY when it appears INSIDE a real agent-composer @@ -26,9 +26,9 @@ # afk-herdr-false-pending): a harness fills an otherwise-empty composer with # de-emphasized ghost text - claude's rotating prompt suggestion, codex's idle # suggestion, grok's placeholder - which a plain capture cannot tell apart from -# text a human typed, so the away-mode injector reads the idle pane as "pending -# input" and defers every escalation (the overnight wedge that motivated this -# consolidation). fm_composer_strip_ghost is the ONE ANSI-aware extractor of +# text a human typed, so the compatibility injector reads the idle pane as +# "pending input" and defers every escalation (the overnight wedge that motivated +# this consolidation). fm_composer_strip_ghost is the ONE ANSI-aware extractor of # "real typed content": it drops every de-emphasized run - dim/faint (SGR 2, how # claude and codex render ghost text) AND a dark/muted TRUECOLOR foreground (how # grok renders placeholder/hint text) - and keeps only normal-intensity, diff --git a/bin/fm-marker-lib.sh b/bin/fm-marker-lib.sh index 2303f3a5748..42145a6547f 100644 --- a/bin/fm-marker-lib.sh +++ b/bin/fm-marker-lib.sh @@ -25,17 +25,17 @@ # travels with the live secondmate, and is summarized in AGENTS.md. # # Distinct from the afk daemon marker, on purpose. -# The away-mode daemon (bin/fm-supervise-daemon.sh) marks its daemon->firstmate -# escalations with a BARE leading unit separator (FM_INJECT_MARK, ASCII 0x1f). +# The terminal-backed away-mode compatibility path marks daemon->firstmate +# injections with a BARE leading unit separator (FM_INJECT_MARK, ASCII 0x1f). # The from-firstmate marker instead uses U+2063 INVISIBLE SEPARATOR after its # human-readable label. U+2063 has no normal keyboard keystroke but travels as # UTF-8 text rather than a terminal control byte. The original ASCII 0x1f # separator did not survive terminal input faithfully: on Herdr 0.7.3 feeding # it to a real Pi composer removed the preceding label, so Pi received only the # unmarked request (docs/herdr-backend.md records the incident and live proof). -# The afk contract keys on a LEADING 0x1f, while this marker begins with its -# label and contains no 0x1f, so the two cannot conflate. The visible label is -# what the secondmate's LLM reads; U+2063 remains invisible. +# The legacy afk injection contract keys on a LEADING 0x1f, while this marker +# begins with its label and contains no 0x1f, so the two cannot conflate. The +# visible label is what the secondmate's LLM reads; U+2063 remains invisible. # # Sourced by bin/fm-send.sh, bin/fm-brief.sh, and the tests. No side effects on # source. set -u / set -e safe. diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 13f8410c09b..8b4b67ef5d6 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -3,8 +3,11 @@ # # Wraps bin/fm-watch.sh: runs it as a child, classifies each wake reason, and # either SELF-HANDLES the routine majority in bash (no firstmate turn) or -# ESCALATES a batched, distilled digest to the supervisor pane on -# captain-relevant events plus bounded declared-pause rechecks. This is the +# ESCALATES a batched, distilled digest on captain-relevant events plus bounded +# declared-pause rechecks. A native tracked-background launch delivers that +# escalation by completing this daemon, which lets the harness's own task +# notification wake the parked LLM. A terminal-backed compatibility launch +# still delivers through the supervisor pane. This is the # token-efficient replacement for the prior always-inject daemon: routine # signal/stale/heartbeat wakes cost zero firstmate context; only done/ # needs-decision/blocked/failed/persistent-wedge/check-output events and a @@ -12,27 +15,27 @@ # batch window. # # PRESENCE-GATING (the /afk contract). The daemon is the away-mode engine: it -# injects ONLY when the durable away-mode flag state/.afk is present. Invoking -# the /afk skill sets that flag and starts this daemon; any real (unmarked) -# user message clears it and firstmate resumes full responsiveness. +# delivers ONLY when the durable away-mode flag state/.afk is present. Invoking +# the /afk skill sets that flag and starts this daemon; any real user message +# clears it and firstmate resumes full responsiveness. # When afk is off, normal fm-watch.sh always-on triage is the active mechanism. # Any buffered daemon escalations that remain while afk is off survive in # state/.subsuper-escalations and are flushed on the next "while you were out" # catch-up or when afk is re-entered. # -# IN-BAND SENTINEL MARKER. Every daemon injection is prefixed with +# LEGACY IN-BAND SENTINEL MARKER. Every terminal-backed daemon injection is prefixed with # FM_INJECT_MARK (ASCII unit separator, 0x1f) — a byte a human would never type # at the start of a message. Firstmate's contract: a message that starts with # the marker is an internal escalation (stay afk); a message without it means # the captain is back (exit afk, flush catch-up, resume per-wake responsiveness). -# The marker and the busy-guard solve the same problem — the daemon and the -# human share one input channel — so they live together under /afk. +# Native reap-wake delivery never shares the input channel and does not use this +# marker, a busy guard, or a composer classifier. # # Reliability model (see the /afk skill): # - Nothing is lost in away mode: while state/.afk exists, the watcher reverts # to daemon-owned one-shot behavior and enqueues every wake to # state/.wake-queue BEFORE advancing its suppression markers, so a -# crash/restart/missed injection is recovered on the next fm-wake-drain.sh. +# crash/restart/missed notification is recovered on the next fm-wake-drain.sh. # The daemon does not touch the queue; it only reads the watcher's stdout # reason. # - Fail-safe-to-escalate: any wake the classifier cannot confidently mark @@ -44,23 +47,25 @@ # gets its own longer PAUSE_RESURFACE_SECS recheck, never a wedge escalation. # Crewmates are autonomous, so a delayed stale response does not stall a # healthy crewmate's own progress. -# Buffered escalation delivery also has a max-defer alarm: if a digest stays -# undelivered past FM_MAX_DEFER_SECS, the daemon retries a normal flush and -# writes state/.subsuper-inject-wedged and attempts a configurable active -# alert if submit still cannot be confirmed. +# Native tracked delivery has no pane guard to defer indefinitely: a due +# digest completes this process and the harness notifies the parked LLM. +# The legacy terminal-backed delivery retains its max-defer alarm. # - Cheap heartbeat catch-all: every HEARTBEAT_SCAN_SECS the daemon greps all # state/*.status for a captain-relevant line the per-wake classifier might # have missed (e.g. a status verb outside CAPTAIN_RE) and escalates it. # # The robustness shell from the prior always-inject version is preserved: -# single-instance lock (portable helper, no flock dependency), crash-loop -# backoff, pane-gone guard, and a signal-trapped shutdown that flushes buffered -# escalations before exit. +# single-instance lock (portable helper, no flock dependency) and crash-loop +# backoff remain shared. The compatibility path retains its pane-gone guard and +# shutdown flush; native shutdown preserves buffered escalations for catch-up. # # Usage: fm-supervise-daemon.sh # Long-lived background loop. Normally started by the /afk skill, which # sets state/.afk first. Env knobs: -# FM_SUPERVISOR_TARGET supervisor pane target (override; otherwise +# FM_AFK_DELIVERY reap-wake|inject. Native launch records set +# reap-wake automatically; terminal-backed +# launches retain inject compatibility. +# FM_SUPERVISOR_TARGET legacy injection pane target (override; otherwise # auto-discovered per backend - $TMUX_PANE # under tmux, ":" from # $HERDR_PANE_ID under herdr - then @@ -74,10 +79,9 @@ # resolves the runtime firstmate itself is # executing inside - $TMUX_PANE selects tmux, # $HERDR_ENV=1 selects herdr - falling back to -# tmux). zellij, orca, and cmux are not yet -# supported as supervisor backends; the daemon -# refuses loudly at startup rather than trying -# tmux primitives against a non-tmux pane. +# tmux). Applies only to compatibility injection: +# zellij, orca, and cmux are not supported there, +# and that mode refuses loudly at startup. # FM_INJECT_SKIP |-prefixes force-self-handle bypassing # classification (default "heartbeat"); empty # disables. Use sparingly: it overrides the @@ -94,10 +98,10 @@ # FM_HOUSEKEEPING_TICK seconds between housekeeping passes while # the watcher is mid-cycle (default 15) # FM_BUSY_REGEX OR-ed busy signatures (mirrors fm-watch.sh) -# FM_COMPOSER_IDLE_RE empty-composer regex applied after dim-ghost +# FM_COMPOSER_IDLE_RE compatibility injection empty-composer regex applied after dim-ghost # and structural border stripping (default: # bare prompt glyphs plus busy footers) -# FM_MAX_DEFER_SECS max seconds a buffered escalation may sit +# FM_MAX_DEFER_SECS compatibility-only max seconds a buffered escalation may sit # undelivered before one normal flush attempt; # if that cannot confirm a submit, a wedge # alarm fires (default 300; 0 disables) @@ -107,7 +111,7 @@ # absent file/var means auto: on macOS that is # an OS-level notification, so the alarm is # never silent. See wedge_alarm_notify below -# and docs/configuration.md. +# and docs/configuration.md. Compatibility only. # FM_WEDGE_ALARM_EXEC notifier seam: when set, every notifier # channel routes through this command as # ` ` instead of @@ -134,9 +138,10 @@ # FM_STATE_OVERRIDE alternate state dir (testing) # Logs each wake to state/.supervise-daemon.log (size-capped). Single # instance via portable lock on state/.supervise-daemon.lock. Trapped -# SIGTERM/SIGINT shut down within ~1s, flush escalations, release the -# lock. A crashing fm-watch.sh is logged and restarted, never killing -# the daemon; a tight crash-restart spin is detected and backed off. +# SIGTERM/SIGINT shut down within ~1s and release the lock; compatibility +# injection flushes, while native delivery preserves its buffer. A +# crashing fm-watch.sh is logged and restarted, never killing the daemon; +# a tight crash-restart spin is detected and backed off. set -u FM_DAEMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -171,22 +176,23 @@ fm_refuse_if_gate_agent . "$FM_DAEMON_DIR/fm-supervisor-target-lib.sh" # --- tunables --------------------------------------------------------------- -# Supervisor backends this daemon knows how to inject into today. zellij, orca, +# Supervisor backends the compatibility path can inject into today. zellij, orca, # and cmux are real backends elsewhere in firstmate (bin/fm-backend.sh) but this -# daemon has no verified composer/busy primitives wired up for them yet - see +# compatibility path has no verified composer/busy primitives for them yet - see # docs/herdr-backend.md and AGENTS.md section 4's # harness-verification discipline. Selecting one refuses loudly at startup # instead of silently running tmux primitives against a pane that is not a tmux # pane. FM_SUPERVISOR_SUPPORTED_BACKENDS="tmux herdr" +FM_REAP_WAKE_PENDING=0 INJECT_SKIP_DEFAULT="heartbeat" STALE_ESCALATE_SECS_DEFAULT=240 ESCALATE_BATCH_SECS_DEFAULT=90 HEARTBEAT_SCAN_SECS_DEFAULT=300 HOUSEKEEPING_TICK_DEFAULT=15 -# Max time a buffered escalation may sit undelivered before the daemon retries -# the normal flush path and, if that cannot confirm a submit, raises a loud wedge -# alarm. The escape hatch makes a guard false-positive visible instead of silent. +# Compatibility-only max time a buffered escalation may sit undelivered before +# the daemon retries the injection path and, if that cannot confirm a submit, +# raises a loud wedge alarm. MAX_DEFER_SECS_DEFAULT=300 WEDGE_ALARM_TIMEOUT_SECS_DEFAULT=10 WEDGE_ALARM_LAST_EPOCH=0 @@ -602,19 +608,30 @@ escalate_add() { # printf '%s\n' "$item" >> "$buf" } -# Flush the escalation buffer as ONE batched, single-line digest to the -# supervisor pane. Returns 0 on successful inject (or empty buffer), non-zero on -# inject failure (buffer preserved for retry / catch-up). +# Deliver one batched, single-line digest. +# Native reap-wake delivery prints one completion reason and asks the main loop +# to exit cleanly, which completes the harness-tracked background task. +# Legacy terminal-backed delivery retains the pane injection compatibility path. +# Returns 0 on successful delivery (or an empty buffer) and non-zero when the +# buffer must be preserved for retry or catch-up. escalate_flush() { # - local state=$1 buf item n msg + local state=$1 buf n msg buf="$state/.subsuper-escalations" [ -s "$buf" ] || return 0 - n=$(wc -l < "$buf" 2>/dev/null || echo 0) + n=$(wc -l < "$buf" 2>/dev/null | tr -d '[:space:]' || echo 0) # Join buffered items with the literal " | " separator into one digest line. msg=$(awk 'NR>1{printf " | "} {printf "%s",$0} END{print ""}' "$buf" 2>/dev/null) - # Single-line wrapper: no embedded newlines (inject_msg also collapses as a - # safety net, but keeping the source single-line makes the intent explicit). - msg=$(printf 'Supervisor escalate (%s event(s)): %s (pre-read; re-arm not needed — watcher daemon-managed)' "$n" "$msg") + msg=$(printf 'Supervisor escalate (%s event(s)): %s (pre-read)' "$n" "$msg") + if [ "${FM_AFK_DELIVERY:-inject}" = reap-wake ]; then + afk_active "$state" || { log "reap-wake deferred: afk inactive"; return 1; } + msg=$(_collapse_newlines "$msg") + printf 'afk-reap-wake: %s; drain state/.wake-queue, handle the batch, then restart the away daemon as a native tracked background task\n' "$msg" + FM_REAP_WAKE_PENDING=1 + log "reap-wake delivery ready: native tracked background task will complete" + : > "$buf" + rm -f "${buf}.since" "$state/.subsuper-inject-wedged" + return 0 + fi if inject_msg "$msg" "$state"; then : > "$buf"; rm -f "${buf}.since" "$state/.subsuper-inject-wedged"; return 0; fi return 1 } @@ -936,7 +953,10 @@ housekeeping() { # fi fi - # (1b) max-defer escape. If anything is still buffered past MAX_DEFER_SECS, +# A native delivery asks the main loop to complete as soon as the batch flushes. + [ "$FM_REAP_WAKE_PENDING" -eq 0 ] || return 0 + + # (1b) legacy-injection max-defer escape. If anything is still buffered past MAX_DEFER_SECS, # retry the normal delivery path. If that still cannot confirm, raise a loud # wedge alarm while preserving the buffer. max_defer=${FM_MAX_DEFER_SECS:-$MAX_DEFER_SECS_DEFAULT} @@ -1249,7 +1269,7 @@ trim_log() { # ============================================================================ fm_super_main() { - local STATE + local STATE DELIVERY BACKEND TARGET backend_source target_source STATE="$(_state_root)" mkdir -p "$STATE" [ -d "$STATE" ] && [ ! -L "$STATE" ] || { echo "error: unsafe daemon state directory: $STATE" >&2; exit 1; } @@ -1269,6 +1289,11 @@ fm_super_main() { local CRASH_WINDOW=${FM_CRASH_WINDOW:-$CRASH_WINDOW_DEFAULT} local CRASH_BACKOFF=${FM_CRASH_BACKOFF:-$CRASH_BACKOFF_DEFAULT} local CRASH_NORMAL_SLEEP=${FM_CRASH_NORMAL_SLEEP:-$CRASH_NORMAL_SLEEP_DEFAULT} + DELIVERY=${FM_AFK_DELIVERY:-inject} + case "$DELIVERY" in + reap-wake|inject) ;; + *) echo "error: unsupported away-mode delivery '$DELIVERY' (supported: reap-wake, inject)" >&2; exit 1 ;; + esac for state_file in "$LOG" "$WATCH_ERR" "$PIDFILE"; do if [ -L "$state_file" ] || { [ -e "$state_file" ] && [ ! -f "$state_file" ]; }; then @@ -1299,33 +1324,39 @@ fm_super_main() { # into FM_SUPERVISOR_BACKEND makes inject_msg/pane_is_busy/pane_input_pending # (which read that env var) dispatch through the right backend without an # extra global thread-through. - local discovered_backend backend_source - backend_source="FM_SUPERVISOR_BACKEND" - if [ -z "${FM_SUPERVISOR_BACKEND:-}" ]; then - if [ -n "${TMUX_PANE:-}" ]; then - backend_source="TMUX_PANE" - elif [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ]; then - backend_source="HERDR_ENV" - else - backend_source="FALLBACK($FM_SUPERVISOR_BACKEND_DEFAULT)" + if [ "$DELIVERY" = reap-wake ]; then + BACKEND=native + TARGET=tracked-background-task + backend_source=FM_AFK_DELIVERY + target_source=FM_AFK_DELIVERY + else + local discovered_backend + backend_source="FM_SUPERVISOR_BACKEND" + if [ -z "${FM_SUPERVISOR_BACKEND:-}" ]; then + if [ -n "${TMUX_PANE:-}" ]; then + backend_source="TMUX_PANE" + elif [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ]; then + backend_source="HERDR_ENV" + else + backend_source="FALLBACK($FM_SUPERVISOR_BACKEND_DEFAULT)" + fi fi - fi - discovered_backend=$(discover_supervisor_backend) || true - FM_SUPERVISOR_BACKEND="$discovered_backend" - local BACKEND="$FM_SUPERVISOR_BACKEND" + discovered_backend=$(discover_supervisor_backend) || true + FM_SUPERVISOR_BACKEND="$discovered_backend" + BACKEND="$FM_SUPERVISOR_BACKEND" # --- refuse an unsupported supervisor backend loudly, before ever trying a # tmux/herdr-specific call against it (zellij, orca, and cmux have no verified # composer/busy primitives wired up for this daemon yet - AGENTS.md section 4 # harness-verification discipline). This is the clear refusal the task calls # for, instead of a confusing "does not resolve to a tmux pane" error. - if ! fm_backend_list_contains "$FM_SUPERVISOR_SUPPORTED_BACKENDS" "$BACKEND"; then - echo "error: away-mode daemon does not support supervisor backend '$BACKEND' yet (supported: $FM_SUPERVISOR_SUPPORTED_BACKENDS); set FM_SUPERVISOR_BACKEND=tmux|herdr and FM_SUPERVISOR_TARGET to run firstmate's own pane under a supported backend" >&2 - log "startup failed: unsupported supervisor backend '$BACKEND' (source=$backend_source)" - fm_lock_release "$LOCK" 2>/dev/null || true - rm -f "$PIDFILE" 2>/dev/null || true - exit 1 - fi + if ! fm_backend_list_contains "$FM_SUPERVISOR_SUPPORTED_BACKENDS" "$BACKEND"; then + echo "error: away-mode daemon does not support supervisor backend '$BACKEND' yet (supported: $FM_SUPERVISOR_SUPPORTED_BACKENDS); set FM_SUPERVISOR_BACKEND=tmux|herdr and FM_SUPERVISOR_TARGET to run firstmate's own pane under a supported backend" >&2 + log "startup failed: unsupported supervisor backend '$BACKEND' (source=$backend_source)" + fm_lock_release "$LOCK" 2>/dev/null || true + rm -f "$PIDFILE" 2>/dev/null || true + exit 1 + fi # --- auto-discover the supervisor target (the pane running firstmate) ----- # Priority: FM_SUPERVISOR_TARGET override > $TMUX_PANE (tmux; inherited from @@ -1333,49 +1364,52 @@ fm_super_main() { # $HERDR_PANE_ID (herdr, composed into ":") > firstmate:0 # fallback. Exporting the result into FM_SUPERVISOR_TARGET makes inject_msg # (which reads that env var) use the discovered pane without an extra global. - local discovered target_source - target_source="FM_SUPERVISOR_TARGET" - if [ -z "${FM_SUPERVISOR_TARGET:-}" ]; then - if [ -n "${TMUX_PANE:-}" ]; then - target_source="TMUX_PANE" - elif [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ]; then - target_source="HERDR_ENV(HERDR_PANE_ID)" + local discovered + target_source="FM_SUPERVISOR_TARGET" + if [ -z "${FM_SUPERVISOR_TARGET:-}" ]; then + if [ -n "${TMUX_PANE:-}" ]; then + target_source="TMUX_PANE" + elif [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ]; then + target_source="HERDR_ENV(HERDR_PANE_ID)" + else + target_source="FALLBACK(firstmate:0)" + fi + fi + if discovered=$(discover_supervisor_target); then + : # resolved cleanly else - target_source="FALLBACK(firstmate:0)" + echo "warn: could not auto-discover supervisor pane (no FM_SUPERVISOR_TARGET, TMUX_PANE, or HERDR_ENV/HERDR_PANE_ID); falling back to '$discovered' - verify this is firstmate's pane" >&2 fi - fi - if discovered=$(discover_supervisor_target); then - : # resolved cleanly - else - echo "warn: could not auto-discover supervisor pane (no FM_SUPERVISOR_TARGET, TMUX_PANE, or HERDR_ENV/HERDR_PANE_ID); falling back to '$discovered' — verify this is firstmate's pane" >&2 - fi - FM_SUPERVISOR_TARGET="$discovered" - local TARGET="$FM_SUPERVISOR_TARGET" + FM_SUPERVISOR_TARGET="$discovered" + TARGET="$FM_SUPERVISOR_TARGET" # --- validate supervisor target at startup (a missing target is a typo) --- # Dispatches through bin/fm-backend.sh instead of a raw `tmux display-message` # probe, so a herdr supervisor pane is checked via the herdr adapter; for # backend=tmux this runs the exact same `tmux display-message -p -t "$TARGET" # '#{pane_id}'` call as before. - if ! fm_backend_target_exists "$BACKEND" "$TARGET"; then - echo "error: supervisor target '$TARGET' does not resolve to a $BACKEND pane; set FM_SUPERVISOR_TARGET" >&2 - log "startup failed: target '$TARGET' not found (backend=$BACKEND)" - fm_lock_release "$LOCK" 2>/dev/null || true - rm -f "$PIDFILE" 2>/dev/null || true - exit 1 + if ! fm_backend_target_exists "$BACKEND" "$TARGET"; then + echo "error: supervisor target '$TARGET' does not resolve to a $BACKEND pane; set FM_SUPERVISOR_TARGET" >&2 + log "startup failed: target '$TARGET' not found (backend=$BACKEND)" + fm_lock_release "$LOCK" 2>/dev/null || true + rm -f "$PIDFILE" 2>/dev/null || true + exit 1 + fi fi local afk_status="off" afk_active "$STATE" && afk_status="on" - log "daemon starting (pid $$); target=$TARGET; target_source=$target_source; backend=$BACKEND; backend_source=$backend_source; afk=$afk_status; inject_skip='${FM_INJECT_SKIP:-$INJECT_SKIP_DEFAULT}'; stale_escalate=${FM_STALE_ESCALATE_SECS:-$STALE_ESCALATE_SECS_DEFAULT}s; batch=${FM_ESCALATE_BATCH_SECS:-$ESCALATE_BATCH_SECS_DEFAULT}s" + log "daemon starting (pid $$); delivery=$DELIVERY; target=$TARGET; target_source=$target_source; backend=$BACKEND; backend_source=$backend_source; afk=$afk_status; inject_skip='${FM_INJECT_SKIP:-$INJECT_SKIP_DEFAULT}'; stale_escalate=${FM_STALE_ESCALATE_SECS:-$STALE_ESCALATE_SECS_DEFAULT}s; batch=${FM_ESCALATE_BATCH_SECS:-$ESCALATE_BATCH_SECS_DEFAULT}s" migrate_watcher_pause_markers "$STATE" - # --- shutdown: flush buffered escalations, reap child, release lock ------- + # --- shutdown: compatibility flush or native preserve, reap child, unlock - local WATCHER_PID="" CUR_TMP="" cleanup() { trap - TERM INT wedge_alarm_stop_active_notifier - escalate_flush "$STATE" 2>/dev/null || true + if [ "$DELIVERY" = inject ]; then + escalate_flush "$STATE" 2>/dev/null || true + fi if [ -n "${WATCHER_PID:-}" ]; then kill "$WATCHER_PID" 2>/dev/null || true wait "$WATCHER_PID" 2>/dev/null || true @@ -1385,6 +1419,7 @@ fm_super_main() { fi fm_lock_release "$LOCK" 2>/dev/null || true rm -f "$PIDFILE" 2>/dev/null || true + [ "$DELIVERY" != reap-wake ] || rm -f "$STATE/.afk-native-process" 2>/dev/null || true log "daemon shutting down" exit 0 } @@ -1425,7 +1460,7 @@ fm_super_main() { # has nowhere to go, and firstmate itself is the consumer of escalations. # Catch-up signals persist in state/*.status and flow on the next run, so # this delays rather than loses work. - if ! fm_backend_target_exists "$BACKEND" "$TARGET"; then + if [ "$DELIVERY" = inject ] && ! fm_backend_target_exists "$BACKEND" "$TARGET"; then log "warn: supervisor target '$TARGET' gone; backing off ${INJECT_FAIL_SLEEP}s, will retry" # Flush is pointless with no pane; preserve any buffered escalations. sleep "$INJECT_FAIL_SLEEP" @@ -1465,6 +1500,7 @@ fm_super_main() { log "wake: $reason" handle_wake "$reason" "$STATE" trim_log + [ "$FM_REAP_WAKE_PENDING" -eq 0 ] || cleanup fi start_watcher || continue fi @@ -1478,6 +1514,7 @@ fm_super_main() { if [ "$(_file_age "$STATE/.subsuper-last-housekeep")" -ge "${FM_HOUSEKEEPING_TICK:-$HOUSEKEEPING_TICK_DEFAULT}" ]; then _now > "$STATE/.subsuper-last-housekeep" housekeeping "$STATE" + [ "$FM_REAP_WAKE_PENDING" -eq 0 ] || cleanup fi done } diff --git a/bin/fm-supervisor-target-lib.sh b/bin/fm-supervisor-target-lib.sh index 7f613db2028..233f144ba79 100755 --- a/bin/fm-supervisor-target-lib.sh +++ b/bin/fm-supervisor-target-lib.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # fm-supervisor-target-lib.sh - the single owner of supervisor-pane discovery. # -# The away-mode daemon (bin/fm-supervise-daemon.sh) must know which pane runs -# firstmate itself, both to inject escalations into it and, for the daemon, to -# validate that target at startup. The script-owned away launcher +# The terminal-backed away-mode compatibility path must know which pane runs +# firstmate itself, both to inject escalations into it and to validate that +# target at startup. Native tracked delivery never resolves a pane. The launcher # (bin/fm-afk-launch.sh) must resolve the SAME captain pane BEFORE it creates a # separate, non-visible terminal for the daemon, so it can pass that pane in as # FM_SUPERVISOR_TARGET (otherwise the daemon, running in its own terminal, would diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 1dfabd89d56..a8cab6fc31a 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -2,9 +2,9 @@ # fm-tmux-lib.sh — shared tmux pane primitives for firstmate. # # ONE source of truth for: busy detection, composer-empty (pending-input) -# detection, and a verify-and-retry-Enter submit. Sourced by both the away-mode -# daemon (bin/fm-supervise-daemon.sh) and bin/fm-send.sh so the composer/submit -# logic cannot drift between the two. +# detection, and a verify-and-retry-Enter submit. Sourced by both the terminal- +# backed away-mode compatibility path and bin/fm-send.sh so composer/submit logic +# cannot drift between the two. # # Why this exists (incident afk-invx-i5): the daemon's old composer check only # recognized a BARE prompt glyph ("> ") as an empty composer. claude draws its diff --git a/docs/architecture.md b/docs/architecture.md index 2ee1ed7a6e1..165837a1a4f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,13 +48,17 @@ The guard covers the main primary and genuinely marked secondmate homes, exempts A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-external-wait vocabulary, and status-scan primitives. The always-on watcher also uses that library's absorb classification on no-verb signals and first-sighting stale panes before status-log terminality is trusted, while the daemon maintains distinct wedge and declared-pause recheck cadences. -The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause that remains idle, as one batched, single-line digest prefixed with an in-band sentinel marker so firstmate can tell daemon injections apart from real messages. -Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend. -Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native busy state, native agent-state submit confirmation on idle baselines, and its ANSI-aware structural composer classifier for pending-input guards and submit fallback. +The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause that remains idle, as one batched, single-line digest. +On a native background-notify harness such as Claude, the daemon itself is the tracked background task and completes with an `afk-reap-wake:` reason when a batch becomes due. +That native completion path never reads or types into the primary pane, and `state/.wake-queue` remains the lossless backlog the woken LLM drains before restarting the away daemon. +A terminal-backed compatibility path remains for harnesses without a native tracked-background tool and prefixes its injected message with `FM_INJECT_MARK`. +The compatibility injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend. +Pane existence, busy checks, composer checks, capture, and verified submit on that compatibility path route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native busy state, native agent-state submit confirmation on idle baselines, and its ANSI-aware structural composer classifier for pending-input guards and submit fallback. Composer-content classification has one shared owner, `bin/fm-composer-lib.sh`, used by tmux, herdr, Orca, and cmux after each adapter performs its own capture and composer-row recognition. -The daemon injects only into an affirmatively `empty` composer, so both `pending` and `unknown` defer and a bare dead-shell prompt cannot receive an escalation; the complete policy is in [Composer-emptiness safety](herdr-backend.md#composer-emptiness-safety-2026-07-10-fleet-wide-across-all-four-backends). -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. +The compatibility path injects only into an affirmatively `empty` composer, so both `pending` and `unknown` defer and a bare dead-shell prompt cannot receive an escalation; the complete policy is in [Composer-emptiness safety](herdr-backend.md#composer-emptiness-safety-2026-07-10-fleet-wide-across-all-four-backends). +Unsupported supervisor backends refuse only on the compatibility path because native reap-wake delivery has no supervisor-pane backend. +Stalled compatibility delivery writes `state/.subsuper-inject-wedged` and attempts a configured backend-independent active alert after `FM_MAX_DEFER_SECS` instead of silently deferring forever. +Native tracked delivery has no max-defer guard because a due batch completes the task directly without a pane-dependent defer condition. `fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using metadata-routed target `harness=` values, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. ## Runtime session backends diff --git a/docs/configuration.md b/docs/configuration.md index 7a0f319ae61..d27ddb2ef31 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,22 +68,24 @@ The `config/backend` file is not inherited by secondmate homes. ## Away-mode supervisor backend (FM_SUPERVISOR_BACKEND / FM_SUPERVISOR_TARGET) -The `/afk` sub-supervisor injects escalation digests into firstmate's own pane independently of where new task endpoints are spawned. -It currently supports only `tmux` and `herdr` supervisor panes. +These settings apply only to the terminal-backed compatibility path used by a harness without a native tracked-background completion notification. +Native tracked delivery completes the away daemon task and does not resolve or inspect a supervisor pane. +The compatibility path injects escalation digests into firstmate's own pane independently of where new task endpoints are spawned and currently supports only `tmux` and `herdr` supervisor panes. Set `FM_SUPERVISOR_BACKEND=tmux|herdr` and `FM_SUPERVISOR_TARGET=` to override both axes explicitly; for herdr the target is `":"`. Without overrides, backend detection uses `$TMUX_PANE` first, then `HERDR_ENV=1` with `HERDR_PANE_ID`, then falls back to `tmux`. That keeps a tmux pane nested inside herdr on the tmux transport, matching the runtime backend's innermost-first rule. Target detection uses `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE`, then `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr, then the legacy `firstmate:0` tmux fallback with a warning. -Selecting any other supervisor backend, including `zellij`, `orca`, or `cmux`, refuses at daemon startup instead of trying tmux injection primitives against a non-tmux pane. +Selecting any other supervisor backend, including `zellij`, `orca`, or `cmux`, refuses compatibility delivery at daemon startup instead of trying tmux injection primitives against a non-tmux pane. ## Away-mode wedge alarm channels (config/wedge-alarm) -When away-mode injection wedges past `FM_MAX_DEFER_SECS`, the sub-supervisor raises a loud, rate-limited alarm. +When terminal-backed compatibility injection wedges past `FM_MAX_DEFER_SECS`, the sub-supervisor raises a loud, rate-limited alarm. Beyond the durable `state/.subsuper-inject-wedged` marker and the tmux status-line flash, it attempts a configured backend-independent active alert that can reach the captain even when every pane and its backend status-line is unreadable. `config/wedge-alarm` (local, gitignored) lists channel directives, one per non-empty, non-comment line; every listed non-`off` channel fires, best-effort. `FM_WEDGE_ALARM_CHANNEL` overrides the file with a single directive. Directives are `off` (a position-independent kill switch that disables every active alert), `auto`/`default`, `osascript` (macOS Notification Center banner), `herdr` (herdr UI notification), and `command:` (run `` via `sh -c`, summary on `$1` and stdin). -An absent file means `auto`, i.e. default-on on macOS: the alarm exists precisely so a wedged away-mode primary is never silent, and it fires at most once per max-defer window after a genuine wedge. +An absent file means `auto`, i.e. default-on on macOS: the alarm exists precisely so a wedged compatibility delivery is never silent, and it fires at most once per max-defer window after a genuine wedge. +Native tracked delivery does not use these channels because a due batch completes the task directly without a pane-dependent defer condition. A missing or failing channel logs and falls through to the next, never crashing the daemon. See [`wedge-alarm.md`](wedge-alarm.md) for the channel reference and macOS verification evidence, and [`examples/wedge-alarm`](examples/wedge-alarm) for a copyable config. @@ -468,17 +470,17 @@ FM_SEND_RETRIES=3 # fm-send Enter-retry attempts after typing the line onc FM_SEND_SLEEP=0.4 # seconds between fm-send submit checks FM_SEND_SETTLE=1 # seconds fm-send waits after a successful text submit; 0 disables # sub-supervisor (bin/fm-supervise-daemon.sh); presence-gated via /afk -FM_SUPERVISOR_BACKEND= # optional supervisor pane backend override; tmux/herdr only, otherwise detects $TMUX_PANE then HERDR_ENV/HERDR_PANE_ID before tmux fallback -FM_SUPERVISOR_TARGET= # optional supervisor pane target override; tmux target or herdr :, otherwise auto-detected +FM_SUPERVISOR_BACKEND= # terminal-backed compatibility only: optional supervisor pane backend override; tmux/herdr only, otherwise detects $TMUX_PANE then HERDR_ENV/HERDR_PANE_ID before tmux fallback +FM_SUPERVISOR_TARGET= # terminal-backed compatibility only: optional supervisor pane target override; tmux target or herdr :, otherwise auto-detected FM_INJECT_SKIP=heartbeat # |-prefixes force-self-handled bypassing classification; empty disables FM_ESCALATE_BATCH_SECS=90 # buffer window for batched escalation digests; 0 = flush immediately -FM_MAX_DEFER_SECS=300 # max buffered escalation age before retry plus wedge alarm; 0 disables -FM_WEDGE_ALARM_CHANNEL= # override config/wedge-alarm with one active-alert directive for the wedge alarm; off|auto|osascript|herdr|command:; absent = auto (macOS -> an OS notification) -FM_WEDGE_ALARM_EXEC= # notifier seam: route every channel (osascript, herdr, command:) through this command as ` `; "discard" fires nothing; unset in production; the daemon defaults it to "discard" when sourced so no test posts a real notification (docs/wedge-alarm.md) -FM_WEDGE_ALARM_TIMEOUT_SECS=10 # maximum seconds for each osascript, herdr, override, or command: notifier before its watchdog terminates it and continues to the next channel; invalid or zero values use 10 -FM_INJECT_FAIL_SLEEP=30 # seconds to back off when the supervisor pane is unavailable -FM_INJECT_CONFIRM_RETRIES=3 # daemon Enter-retry attempts after typing a digest once -FM_INJECT_CONFIRM_SLEEP=0.5 # seconds between daemon submit checks +FM_MAX_DEFER_SECS=300 # terminal-backed compatibility only: max buffered escalation age before retry plus wedge alarm; 0 disables +FM_WEDGE_ALARM_CHANNEL= # terminal-backed compatibility only: override config/wedge-alarm with one active-alert directive for the wedge alarm; off|auto|osascript|herdr|command:; absent = auto (macOS -> an OS notification) +FM_WEDGE_ALARM_EXEC= # terminal-backed compatibility only: notifier seam routing every channel through ` `; "discard" fires nothing; unset in production; sourced tests default to "discard" (docs/wedge-alarm.md) +FM_WEDGE_ALARM_TIMEOUT_SECS=10 # terminal-backed compatibility only: maximum seconds per notifier before its watchdog terminates it and continues; invalid or zero values use 10 +FM_INJECT_FAIL_SLEEP=30 # terminal-backed compatibility only: seconds to back off when the supervisor pane is unavailable +FM_INJECT_CONFIRM_RETRIES=3 # terminal-backed compatibility only: Enter-retry attempts after typing a digest once +FM_INJECT_CONFIRM_SLEEP=0.5 # terminal-backed compatibility only: seconds between submit checks FM_HEARTBEAT_SCAN_SECS=300 # cadence of the catch-all status scan for missed captain verbs FM_HOUSEKEEPING_TICK=15 # seconds between batch-flush, stale/pause-recheck, and scan passes FM_CRASH_THRESHOLD=10 # watcher crashes allowed inside FM_CRASH_WINDOW before daemon backoff diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 7e96213072a..d4a782acefe 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -184,7 +184,7 @@ Herdr tasks additionally record: | Send literal (unsubmitted) | `herdr pane send-text ` | Does NOT auto-submit, contrary to the original design addendum's guess. Verified directly: a unique marker sent this way sits unexecuted in the composer until a separate Enter. Behaves exactly like tmux's `send-keys -l`. | | Send + submit atomically | `herdr pane run ` | Runs and submits a command in one call; used for the two fixed spawn-time commands (`treehouse get`, the `GOTMPDIR` export) exactly where tmux used one `send-keys ... Enter` call. | | Send key | `herdr pane send-keys ` | Verified names: `enter`, `escape` (alias `esc`), `ctrl+c` (aliases `C-c`, `c-c`). `ctrl+c` verified to interrupt a running foreground process immediately. | -| Submit confirmation (idle baseline) | `herdr agent get ` -> `.result.agent.agent_status` after Enter | `fm_backend_herdr_send_text_submit` records the pre-Enter status and, when it is idle/done, confirms delivery by polling for `working`/`blocked` across the Enter attempt's confirmation budget. Composer-state reads remain the affirmative-empty pre-injection guard and the conservative fallback for preexisting submit-active or unreadable baselines; see "Native agent-state submit confirmation". | +| Submit confirmation (idle baseline) | `herdr agent get ` -> `.result.agent.agent_status` after Enter | `fm_backend_herdr_send_text_submit` records the pre-Enter status and, when it is idle/done, confirms delivery by polling for `working`/`blocked` across the Enter attempt's confirmation budget. Composer-state reads remain the terminal-backed compatibility path's affirmative-empty pre-injection guard and the conservative fallback for preexisting submit-active or unreadable baselines; see "Native agent-state submit confirmation". | | Bounded capture | `herdr pane read --source recent --lines N` | See "Verified bug" below - N is never passed through directly. | | ANSI capture | `herdr pane read --source recent --lines N --format ansi` | Herdr 0.7.3 preserves composer de-emphasis styling, letting the shared `fm_composer_strip_ghost` extractor treat dim/faint and dark-TRUECOLOR ghost/placeholder text as empty while retaining real typed input. The same small-`--lines` workaround applies. | | Busy state | `herdr agent get ` -> `.result.agent.agent_status` | Verified live against an interactive `claude` session: reports `working` while generating, `done` once idle. Mapped: `working` -> busy; `idle`/`done` -> idle; `blocked` -> idle (surfaced like a stale pane, not suppressed as busy - a blocked agent is stuck waiting on the human, not grinding); anything else -> unknown (the cue for the shared tail-regex fallback). | @@ -382,7 +382,7 @@ The herdr adapter no longer diffs raw pane content before/after Enter (see the i It keeps `fm_backend_herdr_composer_state` as a structural classifier for the composer's own row - located as the bottom-most bordered composer row or verified bare prompt row described above - and reports `empty`, `pending`, or `unknown`. When ANSI capture is available, the classifier keeps the raw styled row long enough to route it through the shared `fm_composer_strip_ghost` extractor before classification. The 2026-07-10 incident below records the supported dim/faint and dark-TRUECOLOR ghost/placeholder styling. -That classifier is still the away-mode daemon's affirmative-empty pre-injection guard and the conservative fallback when `fm_backend_herdr_send_text_submit` cannot use an idle/done native agent-state baseline. +That classifier is still the terminal-backed away-mode compatibility path's affirmative-empty pre-injection guard and the conservative fallback when `fm_backend_herdr_send_text_submit` cannot use an idle/done native agent-state baseline. Normal idle-baseline submit confirmation now uses herdr's native agent-state instead; see "Native agent-state submit confirmation" for the current submit path. A dedicated composer-state or cursor-row/style primitive is still a candidate upstream Herdr feature request; it would let the guard/fallback classifier eventually reach tmux's cursor-row precision instead of relying on a structural approximation over captured tail rows and ANSI style. @@ -488,12 +488,14 @@ As with every other real-herdr test in this document, the default session's own `bin/fm-supervise-daemon.sh` (the `/afk` sub-supervisor) was tmux-only through 2026-07-03: it discovered its own injection target from `$TMUX_PANE`, and injected via raw `tmux display-message`/`tmux capture-pane`/`tmux send-keys` calls with no backend indirection. On a herdr-based fleet (firstmate itself running with `HERDR_ENV=1`, no `$TMUX_PANE`), this failed outright at startup: `TMUX_PANE` is unset, so discovery fell through to the legacy `firstmate:0` fallback, which then failed the tmux pane-exists probe and refused to start. -The fix is transport-layer only - discovery, injection, and the busy/composer guards now dispatch through the SAME `bin/fm-backend.sh` primitives every other backend-aware script already uses (`fm_backend_target_exists`, `fm_backend_busy_state`, `fm_backend_capture`, `fm_backend_send_text_submit`, and the new `fm_backend_composer_state` dispatcher added alongside this work). -Classification policy, batching, the max-defer escape, the `FM_INJECT_MARK` sentinel contract, locks, and wake-queue handling are all unchanged. +This section records the terminal-backed compatibility transport, which native tracked-background launches now bypass through reap-wake completion. +The compatibility fix is transport-layer only - discovery, injection, and the busy/composer guards dispatch through the same `bin/fm-backend.sh` primitives every other backend-aware script already uses (`fm_backend_target_exists`, `fm_backend_busy_state`, `fm_backend_capture`, `fm_backend_send_text_submit`, and `fm_backend_composer_state`). +Classification policy, batching, locks, and wake-queue handling are shared with native reap-wake delivery. +The max-defer escape and `FM_INJECT_MARK` sentinel remain compatibility-only contracts. **Discovery.** `FM_SUPERVISOR_TARGET` remains the explicit override, now accepting either a tmux target or a herdr `":"` target. A new `FM_SUPERVISOR_BACKEND` override (`tmux`|`herdr`) resolves independently, mirroring `bin/fm-backend.sh`'s own `fm_backend_detect`: `$TMUX_PANE` set selects tmux (even nested inside herdr, matching the innermost-first rule); `$HERDR_ENV=1` with `$HERDR_PANE_ID` present selects herdr, composing the target as `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"`; absent both, the daemon falls back to tmux/`firstmate:0`, byte-identical to its pre-herdr-support behavior. -Other runtime backends, including zellij, orca, and cmux, are not yet supported as supervisor backends - the daemon refuses loudly at startup (`FM_SUPERVISOR_SUPPORTED_BACKENDS="tmux herdr"`) rather than misapplying tmux primitives to a pane that isn't a tmux pane. +Other runtime backends, including zellij, orca, and cmux, are not yet supported as compatibility supervisor backends - compatibility delivery refuses loudly at startup (`FM_SUPERVISOR_SUPPORTED_BACKENDS="tmux herdr"`) rather than misapplying tmux primitives to a pane that isn't a tmux pane. **Injection dispatch.** `inject_msg`'s pane-exists probe, busy-guard (`pane_is_busy`), composer-guard (a direct `fm_backend_composer_state` read; see the composer-safety note below), and verified submit all take an optional `` argument (defaulting to `tmux` when omitted, so every pre-existing caller/test is unaffected) and route through the generic dispatchers instead of calling `tmux` directly. For `backend=tmux` every dispatch resolves to the exact same underlying call as before (`fm_backend_capture`'s tmux arm runs the identical `tmux capture-pane -p -t -S -40`; `fm_backend_tmux_send_text_submit` re-exports `fm_tmux_submit_core` verbatim), so tmux behavior is unchanged byte-for-byte. @@ -641,12 +643,12 @@ The unit regression coverage is `tests/fm-backend-herdr.test.sh`'s `test_compose `fm_backend_herdr_send_text_submit` now records a pre-Enter native agent-state baseline before choosing the confirmation signal. When that baseline is legibly idle or done, it confirms a submit by polling herdr's own semantic agent-state (`agent get`) for a submit-active transition (`working` or `blocked`), via the new `fm_backend_herdr_wait_for_working` helper. -Composer content (`fm_backend_herdr_composer_state`) is still used for the pre-injection empty-box guard (`bin/fm-supervise-daemon.sh`'s `inject_msg`, which reads `fm_backend_composer_state` directly and requires an affirmatively-`empty` verdict; see "Composer-emptiness safety" below). +Composer content (`fm_backend_herdr_composer_state`) is still used for the terminal-backed compatibility path's pre-injection empty-box guard (`bin/fm-supervise-daemon.sh`'s `inject_msg`, which reads `fm_backend_composer_state` directly and requires an affirmatively-`empty` verdict; see "Composer-emptiness safety" below). It is also the conservative fallback for submit attempts whose pre-Enter baseline is already submit-active or unreadable, because a preexisting `working`/`blocked` status cannot prove that this Enter landed. This makes the normal idle-baseline confirmation path cross-agent: it no longer depends on what a harness's idle composer happens to display. This originally fixed the practical submit-confirmation effect of the Codex idle-tip gap left open by the 2026-07-07 incident above. -The 2026-07-08 follow-up fixed the pre-injection composer guard itself by using herdr's ANSI capture to ignore faint Codex ghost suggestions. +The 2026-07-08 follow-up fixed the compatibility pre-injection composer guard itself by using herdr's ANSI capture to ignore faint Codex ghost suggestions. The submit-confirmation path still deliberately uses native agent-state on idle baselines, so it remains independent of composer rendering. ### Design: two failure directions, both guarded @@ -694,7 +696,7 @@ Additional scenarios verified directly against the real binaries: `tests/fm-backend-herdr.test.sh`'s "wait_for_working" and "send_text_submit" sections cover both failure directions (a slow transition caught mid-window, an unreadable target that never retries), endpoint-spread timing with no final trailing sleep, the submit-specific `blocked` mapping, the popup-placeholder-fill case using the new mechanism, the already-submit-active baseline fallback, and `test_send_text_submit_confirms_despite_codex_idle_tip_composer`, which asserts a confirmed `empty` verdict AND that `pane read` is never called on an idle baseline. The composer-guard regression for the 2026-07-08 AFK delivery bug lives in `test_composer_state_codex_dynamic_idle_tip_reads_empty_when_faint`. -`test_composer_state_guard_still_refuses_real_pending_text_after_submit_confirmation_change` is a regression guard for the pre-injection empty-box guard itself, confirming it still refuses genuine pending composer text after this change. +`test_composer_state_guard_still_refuses_real_pending_text_after_submit_confirmation_change` is a regression guard for the compatibility pre-injection empty-box guard itself, confirming it still refuses genuine pending composer text after this change. `tests/fm-afk-inject-herdr-e2e.test.sh`'s synthetic supervisor-pane fixture was updated alongside this fix: since confirmation is no longer composer-content-based, a bash script that only DRAWS composer text without being a registered herdr agent would read `agent_not_found` forever and never confirm a submission - discovered when the pre-existing (composer-only) fixture version of that test regressed against the new confirmation code (Scenario B: 0 digests instead of exactly 1, since the daemon treated every injection as unconfirmed and kept retyping it every housekeeping tick, which is exactly the duplicate-send failure mode this design change exists to prevent). The fix: the fixture now registers itself as a real herdr agent via `herdr pane report-agent --source --agent