Skip to content
Open
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
14 changes: 12 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ state/ volatile runtime signals; gitignored
<id>.status appended by crewmates: "<state>: <note>" wake-event lines, not current-state truth
<id>.turn-ended touched by turn-end hooks
<id>.grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown
<id>.meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, and x_followups= for an X-mention-originated task (section 14)
<id>.meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; kind=adopted marks an adopted existing session, written by bin/fm-adopt.sh instead of fm-spawn (section 7); a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, and x_followups= for an X-mention-originated task (section 14)
<id>.check.sh optional slow poll you write per task (e.g. merged-PR check)
x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14)
x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14)
Expand Down Expand Up @@ -295,6 +295,7 @@ Reconcile reality with your records before doing anything else, working from the
6. For meta with no window, or an endpoint the digest reported dead, reconcile by kind.
For ordinary crewmates, check the recorded backend metadata first; use `treehouse status` for treehouse-backed tasks, and the recorded `orca_worktree_id=`/`terminal=` for Orca tasks.
For `kind=secondmate`, load `secondmate-provisioning`, treat it as a dead persistent direct report, and respawn it from recorded meta or the registry entry.
For `kind=adopted`, there is no treehouse worktree or branch and `treehouse status` does not apply; a dead endpoint means the human's cmux workspace is gone (typically an app relaunch discarding its UUID), so re-run `bin/fm-adopt.sh` against the workspace's new UUID to re-register it, or tear the task down to un-register it (section 7).
7. Do not reconstruct a secondmate's whole tree from the main home.
The main firstmate reconciles only direct reports.
Each secondmate is a firstmate in its own home, so it reconciles only work that is already its own and then idles; it never creates new work during recovery.
Expand Down Expand Up @@ -616,6 +617,14 @@ The crewmate keeps its worktree, loaded context, and repro, but the ship branch
The repro becomes the regression test.
From there the task is an ordinary ship task through its mode-specific validation, PR or local merge, and Teardown.

### Adopt (existing session)

Adopt registers an already-running cmux workspace - one a human started, not a firstmate spawn - as a supervised task, so peek, send, current-state reads, and the watcher work on it without a firstmate worktree or branch.
Run `bin/fm-adopt.sh <id> --workspace <ws-uuid> [--surface <surf-uuid>]`; it is cmux-only and experimental, and the task is supervise-only, with no delivery mode, no validation, and no PR.
Teardown un-registers the task without ever closing the human's workspace, and the watcher exempts it from stale-pane wakes.
As with a spawn, the orchestrator adds the `data/backlog.md` In flight line, so record the task there after adopting.
See the script's header comment for the full mechanics.

## 8. Supervision protocol

The watcher is the backbone.
Expand Down Expand Up @@ -688,7 +697,8 @@ Never rely on hooks or status files alone; when a heartbeat wake does reach you,
Each task's backend live-task inventory is the ground truth (tmux when `backend=` is absent; a task's meta may record a different `backend=` - herdr, zellij, orca, and cmux are the other implemented, spawn-capable, experimental backends today, docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, and docs/cmux-backend.md).
For `kind=secondmate`, an idle pane is healthy.
A secondmate may be sitting on its own watcher with no visible pane changes, so parent supervision uses status writes plus heartbeat review, not pane-staleness.
`fm-watch.sh` therefore skips stale-pane wakes for windows whose meta records `kind=secondmate`.
For `kind=adopted`, an idle human-driven cmux workspace is healthy too.
`fm-watch.sh` therefore skips stale-pane wakes for windows whose meta records `kind=secondmate` or `kind=adopted`.
This exception is narrow: ordinary crewmates still trip stale detection when their pane stops changing without a busy signature.

**Watcher liveness is guarded, not just disciplined.**
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ tests/fm-backend-orca.test.sh # fake Orca CLI unit tests for primiti
tests/cmux-test-safety.sh # guarded cleanup helper for real-cmux tests, refusing to close anything except a matching fm-test- workspace
tests/fm-backend-cmux.test.sh # fake cmux CLI unit tests for the experimental cmux adapter, including socket auth, title scoping, target recovery, fresh-surface liveness, current-path probing, structural composer verification, and secondmate refusal
tests/fm-backend-cmux-smoke.test.sh # real cmux adapter smoke test, skipped when cmux or jq is unavailable or the socket is not password-mode authenticated, using fm-test- workspaces and guarded cleanup
tests/fm-adopt.test.sh # fm-adopt.sh adopt-an-existing-cmux-workspace tests: adopted-meta write, absent-surface/non-cmux/already-registered refusals, no new-workspace or treehouse, teardown un-registers without closing the workspace, watcher stale-pane exemption, and peek/send by pure UUID (empty expected-label)
[ "$(readlink CLAUDE.md)" = "AGENTS.md" ]
[ "$(readlink .claude/skills)" = "../.agents/skills" ]
tmp=$(mktemp -d) && printf 'done: smoke\n' > "$tmp/smoke.status" && FM_STATE_OVERRIDE="$tmp" FM_SIGNAL_GRACE=1 FM_POLL=1 FM_HEARTBEAT=999999 bin/fm-watch-arm.sh # watcher re-arm smoke test (prints arm status, then an actionable signal)
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ This is.. a directory that turns any agent into your firstmate, and you the capt

- **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes.
- **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles.
- **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides.
- **Two task shapes** - ship tasks deliver a change; scout tasks investigate, plan, reproduce, or audit and leave a report.
- **Disposable worktrees** - each spawned ship or scout task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides.
- **Two task shapes** - normal dispatched work is either a ship task that delivers a change or a scout task that investigates, plans, reproduces, or audits and leaves a report.
- **Supervise-only adoption** - on cmux, firstmate can register an already-running workspace for peek/send/watch without creating a worktree, branch, PR, or closing that workspace on release.
- **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag.
- **Optional secondmates** - opt in to persistent domain supervisors that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, kept on the primary firstmate version by guarded local fast-forwards.
- **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; on Claude, a primary turn-end hook also blocks a blind stop when work is in flight and supervision is not live.
Expand Down Expand Up @@ -99,15 +100,16 @@ Setup guides for tmux (the default) and every other supported backend (herdr, ze
│crewmate│ │crewmate│ │crewmate│ one autonomous agent each
└───┬────┘ └───┬────┘ └───┬────┘
▼ ▼ ▼
treehouse worktree, Orca worktree, or isolated secondmate home
treehouse worktree, Orca worktree, isolated secondmate home, or adopted cmux workspace
├─ ship: project mode ► PR/local merge ► teardown
└─ scout: report at data/<id>/report.md ► relay findings ► teardown
```

You chat with the first mate.
It routes each request to a crewmate in its own session endpoint and git worktree, supervises the fleet with a zero-token event-driven watcher, and brings you finished PRs, approved local merges, or investigation reports.
It routes each project request to a crewmate in its own session endpoint and isolated worktree, supervises the fleet with a zero-token event-driven watcher, and brings you finished PRs, approved local merges, or investigation reports.
It can also register an existing cmux workspace for supervise-only follow-along; that path has no branch, PR, or firstmate-owned worktree.
Optional secondmates extend this to persistent domain supervisors, dispatch profiles let you steer which harness handles which task, and an opt-in X mode lets the same fleet answer public mentions.

Full architecture - the supervision engine, worktree isolation, secondmates, dispatch profiles, project modes, optional X mode, fleet sync, and self-update - is in [docs/architecture.md](docs/architecture.md).
Expand Down Expand Up @@ -143,7 +145,7 @@ Firstmate's skills live in two separate places with different audiences:
- [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.
- [docs/orca-backend.md](docs/orca-backend.md) - setup guide for the experimental Orca backend, plus its lifecycle notes and known gaps.
- [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps.
- [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, its `fm-adopt` supervise-only path, verification notes, and known gaps.
- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified Claude Code Stop-hook mechanism, scoping, and known gaps.
- [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference.
- [`AGENTS.md`](AGENTS.md) - firstmate's full operating manual for the orchestrator agent.
Expand Down
147 changes: 147 additions & 0 deletions bin/fm-adopt.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Adopt an ALREADY-RUNNING cmux workspace as a supervised firstmate task.
# Usage: fm-adopt.sh <id> --workspace <ws-uuid> [--surface <surf-uuid>] [--backend cmux]
#
# Registers a human-started cmux workspace/surface (NOT firstmate-spawned) as a
# task, so fm-peek.sh, fm-send.sh, fm-crew-state.sh, and the watcher operate on
# it. Unlike a spawn there is NO treehouse worktree and NO branch: worktree=
# records the workspace's OWN live cwd (a real existing directory, never a
# firstmate-managed worktree), and the task is supervise-only - it carries no
# delivery mode and never produces a PR. It records kind=adopted (and
# mode=adopted) so bin/fm-teardown.sh un-registers the task WITHOUT ever closing
# the human's cmux workspace, and bin/fm-watch.sh exempts it from stale-pane
# wakes (a human-driven or idle session being quiet is healthy, not stuck).
#
# The human's cmux workspace title is kept exactly as-is - never renamed.
# Peek/send target the surface by pure UUID because kind=adopted makes the
# expected-label empty (bin/fm-backend.sh's fm_backend_expected_label_of_selector),
# so no firstmate-scoped title check can reject the human's own title.
#
# cmux only, EXPERIMENTAL (docs/cmux-backend.md); any other backend refuses with
# an actionable error. Absent an app relaunch, cmux workspace ids are stable for
# the session; adopted workspaces are not fm-titled, so title-based recovery
# cannot re-find them after a relaunch - re-run fm-adopt then.
#
# As with fm-spawn, adding the data/backlog.md "In flight" line is the
# orchestrator's job, not this script's.
# On success prints: adopted <id> backend=cmux window=<ws>:<surface> worktree=<path>
set -eu

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"

# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"

"$SCRIPT_DIR/fm-guard.sh" || true

ID=
WS=
SURFACE=
BACKEND=cmux
want_value=
for a in "$@"; do
if [ -n "$want_value" ]; then
case "$a" in
--*) echo "error: --$want_value requires a value" >&2; exit 1 ;;
esac
case "$want_value" in
workspace) WS=$a ;;
surface) SURFACE=$a ;;
backend) BACKEND=$a ;;
esac
want_value=
continue
fi
case "$a" in
--workspace) want_value=workspace ;;
--workspace=*) WS=${a#--workspace=} ;;
--surface) want_value=surface ;;
--surface=*) SURFACE=${a#--surface=} ;;
--backend) want_value=backend ;;
--backend=*) BACKEND=${a#--backend=} ;;
--*) echo "error: unknown flag $a" >&2; exit 1 ;;
*)
if [ -z "$ID" ]; then
ID=$a
else
echo "error: unexpected extra argument '$a'" >&2; exit 1
fi
;;
esac
done
[ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; }

registered_target_owner() { # <target>
local target=$1 meta mw mt id
for meta in "$STATE"/*.meta; do
[ -e "$meta" ] || continue
mw=$(grep '^window=' "$meta" 2>/dev/null | tail -1 | cut -d= -f2- || true)
mt=$(grep '^terminal=' "$meta" 2>/dev/null | tail -1 | cut -d= -f2- || true)
[ "$mw" = "$target" ] || [ "$mt" = "$target" ] || continue
id=$(basename "$meta")
printf '%s' "${id%.meta}"
return 0
done
return 1
}

[ -n "$ID" ] || { echo "usage: fm-adopt.sh <id> --workspace <ws-uuid> [--surface <surf-uuid>] [--backend cmux]" >&2; exit 1; }
[ -n "$WS" ] || { echo "error: --workspace <ws-uuid> is required" >&2; exit 1; }
[ ! -f "$STATE/$ID.meta" ] || { echo "error: task $ID already registered ($STATE/$ID.meta)" >&2; exit 1; }
if [ "$BACKEND" != cmux ]; then
echo "error: fm-adopt currently supports only the cmux backend; got '$BACKEND'" >&2
exit 1
fi

fm_backend_validate_spawn cmux || exit 1
fm_backend_source cmux || exit 1
# Client version gate plus socket reachability (launches cmux if simply down).
fm_backend_cmux_container_ensure || exit 1

if [ -z "$SURFACE" ]; then
SURFACE=$(fm_backend_cmux_surface_id_for_workspace "$WS" || true)
[ -n "$SURFACE" ] || { echo "error: could not resolve a surface for cmux workspace $WS; pass --surface <surf-uuid> or check 'cmux workspace list'" >&2; exit 1; }
fi

T="$WS:$SURFACE"
OWNER=$(registered_target_owner "$T" || true)
[ -z "$OWNER" ] || { echo "error: cmux surface $T already registered by task $OWNER ($STATE/$OWNER.meta)" >&2; exit 1; }
# Pure-UUID liveness check (no expected-label, so no title scoping): the surface
# must currently exist in the running app.
if ! fm_backend_target_exists cmux "$T"; then
echo "error: no live cmux surface $T; is the workspace open? run 'cmux workspace list'" >&2
exit 1
fi

# Derive worktree/project from the workspace's own live cwd. Adopt never creates
# a treehouse worktree; worktree= must be a real existing directory so
# fm-crew-state.sh can probe it, and it must never point at a firstmate-managed
# worktree. Fall back to $HOME with a loud notice when cmux reports no usable cwd.
WSCWD=$(fm_backend_cmux_cli workspace list --json --id-format uuids 2>/dev/null | jq -r --arg id "$WS" '.workspaces[]? | select(.id==$id) | .current_directory // empty' 2>/dev/null || true)
if [ -z "$WSCWD" ] || [ ! -d "$WSCWD" ]; then
echo "NOTICE: cmux workspace $WS reported no usable current_directory; falling back to \$HOME for worktree=, so fm-crew-state.sh's worktree probe may be limited." >&2
WSCWD=$HOME
fi
WORKTREE=$WSCWD
PROJECT=$WSCWD

mkdir -p "$STATE"
{
echo "window=$WS:$SURFACE"
echo "worktree=$WORKTREE"
echo "project=$PROJECT"
echo "harness=adopted"
echo "kind=adopted"
echo "mode=adopted"
echo "yolo=off"
echo "model=default"
echo "effort=default"
echo "backend=cmux"
echo "cmux_workspace_id=$WS"
echo "cmux_surface_id=$SURFACE"
} > "$STATE/$ID.meta"

echo "adopted $ID backend=cmux window=$WS:$SURFACE worktree=$WORKTREE"
13 changes: 10 additions & 3 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@
# Compatibility contract: a task's meta may omit `backend=`; every reader here
# treats that as `tmux` (fm_backend_of_meta), and fm-spawn.sh does not write
# `backend=tmux` for a default-backend task, so existing and newly spawned
# default-path metas stay byte-identical. Only a task spawned on a non-tmux
# default-path metas stay byte-identical. Tasks spawned on a non-tmux
# spawn-capable backend, currently experimental herdr, zellij, orca, or cmux,
# carries an explicit `backend=` line.
# carry an explicit `backend=` line; `fm-adopt.sh` also writes `backend=cmux`
# for supervise-only adopted workspaces.
#
# Event-source framing (herdr-addendum "Events as the core abstraction"): a
# backend's supervision surface is conceptually an EVENT SOURCE - it produces
Expand Down Expand Up @@ -352,7 +353,13 @@ fm_backend_expected_label_of_selector() { # <raw-target> <state-dir>
case "$raw" in
fm-*)
meta="$state/${raw#fm-}.meta"
[ -f "$meta" ] && printf '%s' "$raw"
# An adopted task (kind=adopted) carries the human's OWN cmux workspace
# title, not a firstmate-scoped fm-<id> title, so a scoped-title label
# check would wrongly reject it; leave the expected-label empty so
# peek/send target it by pure UUID (surface_exists), never by title.
if [ -f "$meta" ] && [ "$(fm_meta_get "$meta" kind)" != adopted ]; then
printf '%s' "$raw"
fi
;;
esac
}
Expand Down
7 changes: 2 additions & 5 deletions bin/fm-crew-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,9 @@ LOG_VERB=$(log_verb_of "$LOG_LINE")
# herdr task is read through fm_backend_capture instead of a bare tmux probe.
TASK_BACKEND=$(fm_backend_of_meta "$META")
BACKEND_TARGET=$(fm_backend_target_of_meta "$META")
EXPECTED_LABEL="fm-$ID"
EXPECTED_LABEL=$(fm_backend_expected_label_of_selector "fm-$ID" "$STATE")
pane_readable() { # <target>
case "$TASK_BACKEND" in
tmux) tmux display-message -p -t "$1" '#{pane_id}' >/dev/null 2>&1 ;;
*) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;;
esac
fm_backend_target_exists "$TASK_BACKEND" "$1" "$EXPECTED_LABEL"
}
# crew_pane_is_busy: the busy-signature fallback, backend-aware the same way -
# fm_backend_busy_state's native semantic state (herdr's agent.get) when
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ for meta in "$STATE"/*.meta; do
target=$(fm_backend_target_of_meta "$meta")
if [ -n "$window" ]; then
backend=$(fm_backend_of_meta "$meta")
if fm_backend_target_exists "$backend" "${target:-$window}" "fm-$id"; then
label=$(fm_backend_expected_label_of_selector "fm-$id" "$STATE")
if fm_backend_target_exists "$backend" "${target:-$window}" "$label"; then
printf 'endpoint: alive (backend=%s window=%s)\n' "$backend" "$window"
else
printf 'endpoint: dead (backend=%s window=%s)\n' "$backend" "$window"
Expand Down
Loading