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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 101 additions & 11 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ user-invocable: true
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,
LLM for each one. Escalations still reach the captain, but as one pre-read,
batched digest rather than per-wake injections.

## What it does
Expand All @@ -18,14 +18,14 @@ batched digest rather than per-wake injections.
```sh
date '+%s' > state/.afk
```
This file survives a firstmate restart: recovery (§5) re-enters afk if the
This file survives a firstmate restart: recovery re-enters afk if the
flag is present.

2. **Ensure the sub-supervisor daemon is running.** Check the pid file; start
the daemon only if it is dead or absent:
```sh
if [ -f state/.supervise-daemon.pid ] && kill -0 "$(cat state/.supervise-daemon.pid)" 2>/dev/null; then
: # daemon already alive — it picks up the flag on its next cycle
: # daemon already alive - it picks up the flag on its next cycle
else
nohup bin/fm-supervise-daemon.sh >/dev/null 2>&1 &
fi
Expand All @@ -45,14 +45,14 @@ batched digest rather than per-wake injections.
No `/back` is needed. The first genuine message is the return signal:

- A message **without** the sentinel marker and **not** starting with `/afk`
→ the captain is back. Clear `state/.afk`, stop the daemon, flush one
-> the captain is back. Clear `state/.afk`, stop the daemon, 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 (arm `bin/fm-watch.sh`).
- A message **with** the sentinel marker (`FM_INJECT_MARK`, ASCII 0x1f) → it
responsiveness (arm `bin/fm-watch-arm.sh`).
- 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
- 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
Expand All @@ -63,12 +63,12 @@ a false exit is self-correcting (the captain re-runs `/afk`).
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.
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
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,
Expand All @@ -79,8 +79,8 @@ opencode, and pi).
The daemon never injects into an in-use pane. Two checks run before every
injection (shared with `fm-send.sh` via `bin/fm-tmux-lib.sh`):

- **`pane_is_busy`** — the harness shows a busy footer (agent mid-turn).
- **`pane_input_pending`** — the cursor line holds real unsubmitted text (a
- **`pane_is_busy`** - the harness shows a busy footer (agent mid-turn).
- **`pane_input_pending`** - the cursor line holds real unsubmitted text (a
human's half-typed line, or a previous injection whose Enter was swallowed).
The detector **strips the harness's composer box borders first**, so an idle
*bordered* composer (claude draws `│ > … │`) is correctly read as empty, not
Expand Down Expand Up @@ -116,3 +116,93 @@ 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.
Only captain-relevant events escalate to firstmate's context, and even then as
one pre-read, single-line, batched digest.

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.
- `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 both `pane_is_busy` (harness busy footer means agent mid-turn) and
`pane_input_pending` (real unsubmitted text on the cursor line means human
mid-typing or previous injection with swallowed Enter). Either condition
defers injection and preserves the buffer for retry. The daemon never merges
its digest into the captain's half-typed line.
- The composer detector, shared with `fm-send.sh` in `bin/fm-tmux-lib.sh`, drops
dim/faint ghost text, then strips harness composer box borders, so a ghost-only
or idle bordered composer such as claude's `│ > ... │` reads as empty, not
pending. Without these filters, idle bordered composers and dim ghost
suggestions can look like pending input and stall supervision. `FM_COMPOSER_IDLE_RE`
still overrides empty-composer matching after dim-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 empty composer. If that
cannot confirm a submit, it raises a loud, rate-limited wedge alarm: ERROR log,
durable `state/.subsuper-inject-wedged` marker, and a status-line flash. 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 via
`send-keys -l`, then submitted with Enter and verified. Enter is retried,
Enter only and never a retype, until the composer is confirmed empty. That
empty composer is the acknowledgement that the submit landed, using the same
dim-ghost-aware and border-aware detector so a ghost-only or bordered-empty
claude composer counts as submitted rather than a false swallowed Enter.
- **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 injection target
from `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE`, then a `firstmate:0` fallback
with a warning. The resolution source is logged at startup so a
wrong-but-resolving fallback is detectable.

## Reliability properties

These properties must hold:

- Nothing is lost. The durable queue plus `fm-wake-drain.sh` recover any missed
or crashed injection.
- Wedge detection is bounded-latency, not lossy.
- 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.

`FM_INJECT_SKIP` (default `heartbeat`) force-self-handles matching kinds,
overriding classification.
Use it sparingly.
112 changes: 112 additions & 0 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
name: harness-adapters
description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, and pi.
user-invocable: false
---

# harness-adapters

Use this reference before any harness-specific firstmate operation: spawn, recovery, trust-dialog handling, skill invocation, interrupt, exit, resume, or adapter verification.

Crewmates default to the same harness firstmate is running on unless `config/crew-harness` records an adapter name.
The captain may override that file at bootstrap or later; a per-task instruction such as "run this one on codex" overrides it for that dispatch only.
`default` means mirror firstmate's own harness.

Each adapter splits into mechanics and knowledge.
The mechanics, including launch command, autonomy flag, and turn-end hook, live in `bin/fm-spawn.sh`.
The supervision knowledge lives here: busy signature, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks.

Never dispatch a crewmate or secondmate on an unverified adapter.
If `config/crew-harness` names an unverified adapter, tell the captain and fall back to firstmate's own harness until that adapter is verified.
If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override, and the verified knowledge here.

## Detection

`bin/fm-harness.sh` prints firstmate's own harness, using verified env markers first and then process ancestry.
`bin/fm-harness.sh crew` resolves the effective crewmate harness from `config/crew-harness`.
On `unknown`, ask the captain instead of guessing.
A captain override always beats detection.
When verifying a new adapter, record its env marker and command name in `bin/fm-harness.sh`.

For stuck recovery, the target window's harness is recorded as `harness=` in `state/<id>.meta`.
Use that value for interrupt, exit, resume, and skill-invocation facts.

## no-mistakes skill invocation

Send the validation skill using the target harness's skill invocation form.
Natural language is acceptable if uncertain.

- claude: `/<skill>`, for example `/no-mistakes`.
- codex: `$<skill>`, for example `$no-mistakes`; `/<skill>` is claude-only and codex rejects it as "Unrecognized command".
- opencode: no separate verified skill invocation beyond normal slash-command behavior; use natural language if the exact skill command is uncertain.
- pi: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain.

## claude (VERIFIED)

| Fact | Value |
|---|---|
| Busy-pane signature | `esc to interrupt` |
| Exit command | `/exit` |
| Interrupt | single Escape |
| Skill invocation | `/<skill>` (e.g. `/no-mistakes`) |

First launch in a fresh worktree, or first ever on a machine, may show a trust or bypass-permissions confirmation.
After every spawn, peek the pane within about 20 seconds.
If such a dialog is showing, accept it with `bin/fm-send.sh <window> --key Enter`, or the choice the dialog requires, and verify the brief started processing.

Claude renders a predicted-next-prompt suggestion as dim/faint text inside an otherwise-empty composer after a turn completes.
A plain `tmux capture-pane` cannot tell that ghost text apart from typed text.
Firstmate launches every claude crewmate and secondmate with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`, scoped to firstmate-launched agents through `bin/fm-spawn.sh`, so it never touches the captain's global config.
The CLI's `--prompt-suggestions` flag is print/SDK-mode only and does not suppress the interactive composer ghost text, verified empirically on v2.1.186.
As defense in depth for any pane that flag cannot reach, including the captain's own firstmate composer that away-mode reads, the pane reader in `bin/fm-tmux-lib.sh` captures only the composer line with ANSI styling, drops dim/faint SGR 2 runs, and ignores them, so only normal-intensity typed text counts as pending input.
That styled capture is internal to the boolean detector only.
`fm-peek` and every other human or LLM-facing capture path stays plain `tmux capture-pane` with no escape codes.

## codex (VERIFIED 2026-06-11, codex-cli 0.139.0)

| Fact | Value |
|---|---|
| Busy-pane signature | `esc to interrupt` (shown as `• Working (Xs • esc to interrupt)`) |
| Exit command | `/quit` (slash popup needs about 1 second between text and Enter; `fm-send` handles it) |
| Interrupt | single Escape |
| Skill invocation | `$<skill>` (e.g. `$no-mistakes`); `/<skill>` is claude-only and codex rejects it as "Unrecognized command" |

Directory trust dialog on first run per repo root: "Do you trust the contents of this directory?"
Accept with Enter.
The decision persists for the repo, so later worktrees of the same project skip it.

Resume after exit with `codex resume <session-id>`.
The session id is printed on quit.

## opencode (VERIFIED 2026-06-11, v1.15.7-1.17.3)

| Fact | Value |
|---|---|
| Busy-pane signature | `esc interrupt` (dotted spinner footer; note no "to") |
| Exit command | `/exit` |
| Interrupt | double Escape; known flaky while a long shell command runs, so a wedged pane may need `/exit` and relaunch |

No trust dialog.
Opencode can auto-upgrade itself in the background and the running TUI can exit mid-task, observed live from 1.15.7 to 1.17.3.
If a pane shows the exit banner, relaunch with `--continue` to resume the session.
`--prompt` does not auto-submit alongside `--continue`, so send the next instruction via `fm-send` once the TUI is up.

## pi (VERIFIED 2026-06-11)

| Fact | Value |
|---|---|
| Busy-pane signature | `Working...` (braille spinner prefix; no `esc to interrupt` text) |
| Exit command | `/quit` |
| Interrupt | single Escape |

Pi has no permission system, so crewmates are always autonomous.
Keep the brief as one positional argument.
Multiple positional args become separate queued messages; `fm-spawn`'s template already does this correctly.

Project trust dialog can appear on the first pi run in any not-yet-trusted directory, observed even on clean worktrees.
Accept with Enter.
The decision persists per path in `~/.pi/agent/trust.json`, so later spawns in the same worktree slot skip it.

`fm-spawn` keeps the turn-end extension in `state/`, outside the worktree, because project-local extension files make the trust gate strictly worse and pollute the project.
The extension must listen for pi's `turn_end` event, not `agent_end`, so the watcher wakes after each completed turn instead of only when the whole agent run exits.
Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker.
Loading