Skip to content
Closed
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
11 changes: 11 additions & 0 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ 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.

On a **confirmed** flush (a successful inject, never a routine self-handled
wake), `escalate_flush` also fires a best-effort native OS notification through
`bin/fm-notify.sh "Firstmate" "<id-free count-and-class summary>" --focus`, so
the captain is pinged out of band while away from the pane. This is the issue
#106 notifier hook: the summary carries no task ids or internal vocabulary
(it can surface on a lock screen), the `FM_NOTIFY` toggle silences it
(`FM_NOTIFY=off`), and a missing or failing notifier never affects escalation
delivery. `FM_NOTIFY_CMD` overrides the notifier path for tests; the hook checks
`fm_notify_enabled` before running anything, so `FM_NOTIFY=off` silences even a
custom `FM_NOTIFY_CMD` that would not consult the toggle itself.

## Injection hardening

- **Single-line digest** - embedded newlines are collapsed to a literal
Expand Down
35 changes: 34 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ state/ volatile runtime signals; gitignored
.wake-queue durable queued wakes: epoch<TAB>seq<TAB>kind<TAB>key<TAB>payload
.afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return)
.watch.lock .wake-queue.lock watcher singleton and queue serialization locks
.hash-* .count-* .stale-* .stale-since-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.hash-* .count-* .stale-* .stale-since-* .seen-* .hb-surfaced-* .notify-seen-* .last-* .heartbeat-streak watcher internals; never touch
.watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete
.last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); fm-guard.sh reads it
.fm-tmux-pane firstmate's own recorded tmux pane id; written by fm-notify.sh install, read by fm-focus.sh so a notification click lands on the exact pane (section 8)
.subsuper-* .supervise-daemon.* sub-supervisor internals; never touch
.no-mistakes/ local validation state and evidence; gitignored
```
Expand Down Expand Up @@ -563,6 +564,38 @@ Inline facts that must survive without a loaded skill:
- 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.

### Out-of-band notifier

`bin/fm-notify.sh` fires a native OS notification so the captain gets pinged off the pane the moment captain-relevant work needs them.
It is the first backend of the pluggable notifier in issue #106, and it fires in BOTH supervision modes.
In normal operation the always-on watcher (`bin/fm-watch.sh`) fires it from its signal path the moment a signal wake carries a captain-relevant verb (`done`/`needs-decision`/`blocked`/`failed`/`merged`/`checks green`/`PR ready`/`ready in branch`), never on `working:`/turn-end/heartbeat wakes.
In away mode the sub-supervisor calls it only when it actually delivers a captain-relevant escalation digest, never on routine self-handled wakes (and the watcher hook stands down while afk, so the captain is never double-pinged).
Both paths share the same filtering and emit the same id-free count-and-class summary (a "needs your decision" vs "ready for review" class), dedupe per task by content so the same status is never toasted twice, and are fully error-isolated and backgrounded so a missing, failing, or slow notifier never changes supervision.
Usage is `bin/fm-notify.sh "<title>" "<message>" [--focus|--no-focus] [--open <url>]`; it detects the platform and routes to the right native notifier, is dependency-light and non-blocking, and exits quietly when no notifier is reachable.
Every notification pops, sounds, and persists by default, and click-to-focus is on by default - a bare `bin/fm-notify.sh "T" "M"`, the daemon hook, and the watcher hook all produce the same full notification (the captain hit a silent drop when a toast fired without the focus action, so focus-on is now the default; `--no-focus` exists only for tests).
On WSL/Windows the toast is a raw WinRT toast via `powershell.exe` (no modules): `scenario="reminder"` (ToastGeneric) so it pops as a banner and stays until acted on instead of dropping silently into the notification center, with an explicit `<audio>` so it always sounds (a quiet-dropped toast otherwise makes no sound), and a "Go to firstmate" click-to-focus action.
The two platforms below mirror that behavior as closely as each robustly allows; they were implemented and unit-tested for dispatch and argument-building but NOT end-to-end verified, since firstmate develops on WSL.
On macOS the notifier uses `terminal-notifier` when present (a persistent alert - which requires the captain to set terminal-notifier's notification style to "Alerts" in System Settings - plus a sound and an `-execute` click-to-focus that runs `bin/fm-focus.sh`); without it, it falls back to `osascript display notification` WITH a sound, which sounds but cannot persist or click-to-focus.
On Linux the notifier uses `notify-send --urgency=critical` for persistence, a best-effort sound via `canberra-gtk-play`/`paplay`/`aplay` when one is installed (guarded, never fatal), and `notify-send --action` click-to-focus where the desktop's notification daemon supports actions (GNOME-style libnotify does; many do not), degrading to persistent+sound where it does not.
What each non-Windows path delivers depends on installed tools: macOS click-to-focus needs `terminal-notifier`, Linux click-to-focus needs an action-capable `notify-send` daemon plus `wmctrl` or `xdotool` to raise the window, and a sound needs a player; the tmux pane select still lands even when no window-raise tool is present.
The `FM_NOTIFY` env toggle gates it: it is on by default, and `FM_NOTIFY=off` (also `0`/`false`/`no`/`disabled`) silences every notification.
That gate is enforced wherever the notifier is fired so a custom notifier cannot become a bypass: the script self-gates, and both the daemon's escalation hook (`notify_escalation`) and the watcher's signal hook (`notify_signal_statuses`) check `fm_notify_enabled` before running anything, so `FM_NOTIFY=off` silences the toast even when `FM_NOTIFY_CMD` points at an override that would not consult the toggle itself.

Focus is click-to-focus, never auto-focus (the notification never steals the foreground on its own, which was rejected as invasive); it is on by default and `--no-focus` opts out (tests only).
The click action is wired per platform: on Windows it carries a "Go to firstmate" action (and a body launch) wired to the registered `firstmate:` URL protocol; on macOS it is `terminal-notifier -execute`; on Linux it is `notify-send --action`.
All three run `bin/fm-focus.sh`, which raises the host terminal (Windows Terminal via `powershell.exe`, the macOS Terminal/iTerm via `osascript`, or an X window via `wmctrl`/`xdotool`) and then selects the firstmate tmux window AND pane.
That window+pane resolution is shared across all platforms in `bin/fm-focus-lib.sh` (so the notifier and the handler cannot drift): it is resolved dynamically at click time with nothing hardcoded - no session name, window index, or pane literal - so it survives a session rename, a window move, and a firstmate restart.
The primary signal is the pane id firstmate records to `state/.fm-tmux-pane` (under the firstmate home, derived from `$FM_HOME`); if that pane is gone, `fm-focus.sh` falls back to the claude pane whose cwd is the firstmate home.
On Windows the protocol points at a hidden `wscript` VBS launcher (window style 0, so no console flashes) that runs `bin/fm-focus.sh`; run `bin/fm-notify.sh install` once to arm it: it records the pane id and, on WSL/Windows only, registers the `firstmate:` protocol under `HKCU` and writes the launcher pointing at the installed `bin/fm-focus.sh`; it is idempotent (safe to re-run) and a clean no-op off WSL/Windows (macOS/Linux carry the focus command inline on each notification, so they need no install step).
Focusing a Windows Terminal tab by session is out of scope (unsupported by Windows Terminal; window+pane focus is the captain's chosen design).

When a done-state escalation carries a PR/MR URL, the notification also gets a second "Open PR" button via `--open <url>`, so the captain can jump straight to the PR.
Both supervision hooks add it automatically: when a captain-relevant `done:` status carries a PR URL (a GitHub `/pull/<n>` or GitLab `/merge_requests/<n>`) and the task's project is not `local-only` (read from the task's `mode=` in `state/<id>.meta`), the watcher and the daemon pass `--open <url>`; `local-only` tasks have no PR and get no button, and the URL rides only the flag so the id-free summary text is unchanged.
The button is reserved for review/done-state notifications: a wake classified as a decision (any buffered `needs-decision`/`blocked`/`failed` item wins) carries no `--open` even when a `done:` PR line sits alongside it, so the action stays consistent with the notification class.
On WSL/Windows it is a real second toast button (`<action content="Open PR" activationType="protocol" arguments="<url>"/>`, the URL XML-attribute-escaped so it cannot break the toast markup) that opens the default browser; on macOS it is best-effort via `terminal-notifier -open <url>` (the single click action, so it replaces the focus `-execute` when a URL is present); on Linux it is best-effort via a second `notify-send --action` that runs `xdg-open <url>` where the daemon supports actions.

The script keeps a marked seam for later out-of-band backends (a phone push via ntfy or Pushover for the captain's iPhone), so adding one does not touch the native path.

### Stuck-crewmate recovery

On `stale`, looping, repeated confusion, an answered-by-brief question, an unresponsive pane, or a failed steer, load `stuck-crewmate-recovery`.
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ shellcheck bin/*.sh tests/*.sh # lint the toolbelt and behavior tests
for test_script in tests/*.test.sh; do bash "$test_script"; done # behavior tests, matching CI and no-mistakes commands.test
tests/fm-wake-queue.test.sh # durable wake queue losslessness, catch-up, double-drain, duplicate-collapse, and drain liveness guard tests
tests/fm-watcher-lock.test.sh # watcher singleton, lock-race, watch-arm liveness, and guard-warning tests
tests/fm-watch-triage.test.sh # always-on watcher triage: benign absorb, actionable surface, stale wedge threshold, heartbeat backstop, and afk one-shot coherence
tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, max-defer, composer, and fm-send submit tests
tests/fm-watch-triage.test.sh # always-on watcher triage: benign absorb, actionable surface, stale wedge threshold, heartbeat backstop, afk one-shot coherence, the PR-URL/local-only classifier helpers, and the normal-mode outbound notifier hook (captain-relevant fire, FM_NOTIFY gate, content dedup, error isolation, afk skip, Open-PR --open wiring)
tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, max-defer, composer, fm-send submit, escalation notifier-hook (including the done-state Open-PR --open pass), and FM_NOTIFY=off-with-FM_NOTIFY_CMD gate tests
tests/fm-notify.test.sh # out-of-band notifier: platform detection, FM_NOTIFY=off short-circuit, the always-on persistent+sounding+click-to-focus Windows toast (bare-call default and --no-focus opt-out), the --open "Open PR" action (Windows toast button + XML-escape, macOS -open, Linux --action), macOS terminal-notifier (sound + -execute) and osascript-with-sound fallback, Linux critical + --action click-to-focus, pane-id recording, firstmate: protocol install, and base64 injection-safety
tests/fm-focus.test.sh # click-to-focus handler: dynamic window+pane resolution in a non-firstmate session and the cwd-of-home fallback, the WSL launcher's positional-$1 home taking precedence over FM_HOME (an env wrapper does not survive WScript.Shell.Run), plus the per-platform terminal raise on macOS (osascript) and Linux (wmctrl/xdotool) with graceful degrade, all with host tools stubbed
tests/fm-send-settle.test.sh # fm-send post-submit settle pause, tuning, disable, and --key bypass tests
tests/fm-send-popup-settle.test.sh # fm-send pre-Enter popup-settle selection for slash commands and codex $skill invocations
tests/fm-send-secondmate-marker.test.sh # fm-send from-firstmate marker for kind=secondmate targets: marked vs crewmate/explicit/--key, and the exact marker byte sequence
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ It routes each request to a crewmate in its own tmux window and git worktree, su
Persistent secondmate homes are linked firstmate worktrees; startup syncs live ones and secondmate launch syncs the target home to the primary default-branch commit without fetching from origin when it is safe.
When a routed request goes to a secondmate, firstmate marks it so the answer returns through status or a document pointer; direct typing into that secondmate window stays conversational.
A presence-gated sub-supervisor (`/afk`) can self-handle routine events and batch only what matters while you step away.
Whether you are at the pane or away, firstmate also pings you out of band with a native OS notification the moment a real decision needs you.
An opt-in X mode can also use the watcher check path to answer your public `@myfirstmate` mentions and act on normal reversible mention requests from the current fleet state, with `FMX_DRY_RUN` available to test the poll -> compose -> would-post loop without publishing.
The relay routes only the owner's own mentions to that owner's firstmate home; parent-thread context may still include other public accounts.
The token is standing authorization for those autonomous replies and eligible lifecycle actions; destructive, irreversible, or security-sensitive asks are flagged for trusted-channel confirmation instead of being executed from a public mention.
Expand Down
27 changes: 27 additions & 0 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ window_to_task() {
t="${w##*:}"; t="${t#fm-}"; printf '%s' "$t"
}

# Extract the first GitHub PR / GitLab MR URL from a status line, or print nothing.
# A done-state status that carries such a URL (e.g. "done: PR
# https://github.com/o/r/pull/7 checks green") lets the out-of-band notifier offer
# an "Open PR" button. The match is one whitespace-free token ending in
# /pull/<n> or /merge_requests/<n>, so trailing punctuation in the status line is
# excluded. Pure; the caller decides when an Open-PR button is warranted.
fm_status_pr_url() { # <status-line>
printf '%s' "$1" | grep -oiE 'https?://[^[:space:]]+/(pull|merge_requests)/[0-9]+' | head -1
}

# Read the delivery mode= a task recorded in its meta, or print nothing when the
# meta is absent or carries no mode. Pure file read; the state dir and task id
# locate state/<task>.meta.
fm_task_mode() { # <state-dir> <task>
local meta="$1/$2.meta"
[ -e "$meta" ] || return 0
sed -n 's/^mode=//p' "$meta" 2>/dev/null | tail -1
}

# 0 (true) when a task's project is local-only, 1 otherwise. A local-only task has
# no PR, so the notifier skips the "Open PR" button for it; an unknown/absent mode
# is treated as NOT local-only (a PR URL would not appear for a local-only task
# anyway).
fm_task_is_local_only() { # <state-dir> <task>
[ "$(fm_task_mode "$1" "$2")" = local-only ]
}

# 0 (actionable) if ANY status file listed in a "signal:" wake carries a
# captain-relevant last line; 1 otherwise. Pass the space-separated file list that
# follows the "signal:" prefix. Non-.status arguments (e.g. .turn-ended markers,
Expand Down
71 changes: 71 additions & 0 deletions bin/fm-focus-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# fm-focus-lib.sh - shared, platform-agnostic helpers for the out-of-band
# notifier (bin/fm-notify.sh) and its click-to-focus handler (bin/fm-focus.sh).
#
# Two things live here so they have ONE definition instead of drifting copies:
# 1. Platform detection (wsl|windows|macos|linux|unknown), used by the notifier
# to pick a backend and by the focus handler to pick how to raise the host
# terminal.
# 2. The dynamic tmux pane resolution + selection that lands a click on the
# EXACT firstmate pane. This is identical on every OS - only the terminal
# raise differs - so all of fm-focus.sh's per-platform paths share it.
#
# Sourced, not executed. Idempotent: a double-source is a no-op.
#
# Internal/testing knobs (not the public contract):
# FM_NOTIFY_UNAME override `uname -s` for platform-detection tests.
# FM_NOTIFY_PROC_VERSION path read instead of /proc/version (WSL detection).

[ -n "${FM_FOCUS_LIB_SOURCED:-}" ] && return 0
FM_FOCUS_LIB_SOURCED=1

_uname_s() { printf '%s' "${FM_NOTIFY_UNAME:-$(uname -s 2>/dev/null)}"; }

# fm_detect_platform: echo one of wsl|windows|macos|linux|unknown. WSL must be
# decided before plain Linux because a WSL kernel still reports `uname -s`=Linux;
# the discriminator is the "microsoft" marker in /proc/version.
fm_detect_platform() {
local u proc
u=$(_uname_s)
proc="${FM_NOTIFY_PROC_VERSION:-/proc/version}"
case "$u" in
Darwin) printf 'macos\n'; return 0 ;;
MINGW*|MSYS*|CYGWIN*|Windows_NT|Windows) printf 'windows\n'; return 0 ;;
esac
if grep -qi microsoft "$proc" 2>/dev/null; then
printf 'wsl\n'; return 0
fi
case "$u" in
Linux) printf 'linux\n'; return 0 ;;
esac
if [ "${OS:-}" = "Windows_NT" ]; then printf 'windows\n'; return 0; fi
printf 'unknown\n'
}

# fm_focus_resolve_pane <pane_file> <home>: echo the firstmate tmux pane id,
# resolved DYNAMICALLY with nothing hardcoded - no session name, window index, or
# pane literal - so it survives a session rename, a window move, and a restart.
# The recorded pane id is the primary signal; if that pane is gone, it falls back
# to the claude pane whose working directory is the firstmate home. Echoes empty
# when neither resolves (the caller then does nothing, never a wrong pane).
fm_focus_resolve_pane() {
local pane_file=$1 home=$2 pane=""
[ -r "$pane_file" ] && pane=$(cat "$pane_file" 2>/dev/null)
if [ -z "$pane" ] || ! tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$pane"; then
# Tab-delimit so a home path containing spaces still compares exactly.
pane=$(tmux list-panes -a -F $'#{pane_id}\t#{pane_current_command}\t#{pane_current_path}' 2>/dev/null \
| awk -F '\t' -v home="$home" '$2=="claude" && $3==home {print $1; exit}')
fi
printf '%s' "$pane"
}

# fm_focus_select_tmux <pane>: select the pane's CURRENT session:window and the
# pane itself, resolved now - so a renamed session or moved window still lands on
# the right place. A clean no-op when the pane could not be resolved.
fm_focus_select_tmux() {
local pane=$1 win
[ -n "$pane" ] || return 0
win=$(tmux display-message -p -t "$pane" '#{session_name}:#{window_index}' 2>/dev/null)
[ -n "$win" ] && tmux select-window -t "$win" >/dev/null 2>&1
tmux select-pane -t "$pane" >/dev/null 2>&1
}
Loading