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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ Route by the nature of the task, not just the project name.
A project may appear in several `projects:` clone lists, so choose the secondmate whose natural-language scope actually fits the work, such as triage versus feature development.
If the resolved project is `local-only`, keep the work with the main firstmate even when a secondmate scope sounds relevant.
If a secondmate's scope fits, steer that secondmate with one concise instruction via `bin/fm-send.sh fm-<id> '<work request>'` and let it run the normal lifecycle inside its own home.
When a live secondmate lane needs a context refresh, run `bin/fm-restart.sh <id>` instead of hand-closing tabs or hand-launching an agent; it asks the lane to stow, waits for `stowed: restart-ready` in the lane status unless `--skip-stow` is explicitly passed, sends the harness exit command directly to the raw endpoint, verifies the harness process is gone, respawns through `fm-spawn`, and handles backend cleanup ordering.
The bare `fm-<id>` target resolves through this home's `state/<id>.meta`; pass an explicit backend target only when intentionally targeting an endpoint outside this firstmate home.
A secondmate is itself a firstmate, so a request reaches it in its own chat, which you never read - the return channel that wakes you is its status file.
So `fm-send` to a bare `fm-<id>` whose meta is `kind=secondmate` automatically prepends a from-firstmate marker (`bin/fm-marker-lib.sh`); the secondmate recognizes it and returns its answer via its status file, or via a doc under its home plus a status pointer for a detailed response, never only in chat.
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,14 @@ tests/fm-secondmate-sync.test.sh # local-HEAD secondmate sync, no-fetch
tests/fm-secondmate-harness.test.sh # secondmate-vs-crewmate harness resolution, optional secondmate model/effort pins, primary-to-secondmate config inheritance, and config-push tests
tests/fm-secondmate-lifecycle-e2e.test.sh # persistent secondmate routing, seeding, backlog handoff, spawn, recovery, teardown, and FM_HOME flow tests
tests/fm-secondmate-safety.test.sh # secondmate home safety, idle charter, handoff validation, and teardown boundary tests
tests/fm-restart.test.sh # secondmate lane refresh helper: kind refusal, stow gating, --skip-stow, raw harness exit routing, force handling, dead-lane respawn, and backend cleanup
tests/fm-teardown.test.sh # fm-teardown.sh landed-work safety and reminder checks: fork-remote allow, squash/content landings, dirty and unlanded refusals, PR-head metadata, no-pr= branch discovery, tasks-axi/manual backlog reminder, --force override
tests/fm-review-diff.test.sh # fm-review-diff.sh authoritative review diff coverage: recorded pr_head=, fetched refs/pull/<n>/head, no-pr local branch behavior, and warning fallback
tests/fm-pr-merge.test.sh # fm-pr-merge.sh records pr= and available pr_head= before merging, parses PR URLs into gh-axi number/--repo calls, defaults to squash, preserves explicit merge methods, rejects malformed URLs and repo overrides, and propagates real merge failures
tests/fm-crew-state.test.sh # fm-crew-state.sh current-state reconciliation: run-step authority including closed panes, stale needs-decision/blocked superseded by a resumed run, genuine-parked, cross-branch runs-list attribution, pane/status-log fallback, scout skip, torn-down/missing-meta graceful
tests/fm-backend.test.sh # runtime-backend abstraction: fm-backend.sh selection/meta/dispatch helpers, shell-portable sourced backend matching, and old-vs-new fake-tool command-log conformance for fm-send/fm-peek/fm-spawn/fm-teardown
tests/fm-backend-tmux-smoke.test.sh # real (private-socket) tmux smoke test for the tmux adapter: create/duplicate-refuse, send text + Enter, send literal + key, bounded capture, live-window resolve, kill
tests/fm-backend-herdr.test.sh # fake herdr CLI unit tests for the experimental herdr adapter, including version/tool gates, target parsing, send/capture, structural composer-state verification, slash-submit retry regression coverage, native busy state, per-home workspace-label resolution, default-tab prune safety, restored-layout husk replacement, and verified CLI bug workarounds
tests/fm-backend-herdr.test.sh # fake herdr CLI unit tests for the experimental herdr adapter, including version/tool gates, target parsing, send/capture, structural composer-state verification, slash-submit retry regression coverage, native busy state, foreground-process and label-guard reads, per-home workspace-label resolution, default-tab prune safety, restored-layout husk replacement, and verified CLI bug workarounds
tests/fm-backend-herdr-smoke.test.sh # real herdr adapter smoke test, skipped when herdr or jq is unavailable, using an isolated throwaway HERDR_SESSION and guarded session cleanup, including live-agent duplicate refusal and no-agent husk replacement
tests/fm-backend-autodetect-smoke.test.sh # real herdr auto-detection smoke test, skipped when herdr, jq, or treehouse is unavailable, using the same guarded session cleanup
tests/fm-backend-herdr-workspace-per-home-e2e.test.sh # mandatory isolated E2E for workspace-per-home: primary and secondmate-shaped homes, a crewmate spawned from a secondmate home, teardown, list-live recovery
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,26 @@ Setup guides for tmux (the default) and every other supported backend (herdr, ze

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.
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.
Persistent secondmate homes are linked firstmate worktrees; locked session start syncs live ones and secondmate launch syncs the target home to the primary default-branch commit without fetching from origin when it is safe.
Crewmate dispatch can stay on a static `config/crew-harness` or use optional natural-language profiles in local `config/crew-dispatch.json` to choose a per-task harness, model, and effort.
When that profile file exists, crewmate and scout spawns must pass the resolved harness explicitly so `config/crew-harness` is not used as an unnoticed bypass.
Secondmate launch can use a separate local `config/secondmate-harness`, whose first non-empty, non-comment line is parsed as `<harness> [<model>] [<effort>]` to durably pin that secondmate's launch profile.
The runtime session-provider backend is selected from explicit `--backend`, `FM_BACKEND`, local `config/backend`, runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then the hard `tmux` default.
`tmux` is the verified reference backend.
`herdr`, `zellij`, and `orca` are experimental task-spawn backends; herdr can also be auto-detected, while zellij and orca are selected only explicitly.
Unlike tmux/herdr/zellij, Orca provides the task worktree as well as the terminal endpoint.
Secondmate homes inherit the primary's declared local config, including `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`, at launch, during the locked session-start bootstrap step, or during an explicit `bin/fm-config-push.sh` run, so their own crewmates, dispatch profiles, and backlog backend use the primary settings.
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.
When a live secondmate lane needs a clean context refresh, `bin/fm-restart.sh <id>` asks it to stow, waits for `stowed: restart-ready` unless `--skip-stow` is explicitly passed, exits the harness through the raw backend endpoint, verifies the process is gone, then respawns it through normal secondmate launch bookkeeping.
A presence-gated sub-supervisor (`/afk`) can self-handle routine events and batch only what matters while you step away.
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.
Requests that finish immediately get one public-safe outcome reply.
Requests that spawn longer-running work get an acknowledgement first, a task link in local state, and one completion follow-up within the relay's 24h window when that task lands, reports, or fails.
It preserves parent-tweet context for conversational replies and dismisses pure acknowledgments at the relay without posting.
Replies can attach one local image with `--image <path>` when there is a visual artifact; long replies split into bounded numbered threads when needed, with the image attached only to the opener tweet.
When firstmate works on itself, spawn-time isolation checks and a primary-checkout tangle alarm keep the operating checkout on its default branch and stop a crewmate that did not land in a separate worktree.

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
50 changes: 47 additions & 3 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,18 @@ fm_backend_herdr_target_ready() { # <target>
fm_backend_herdr_server_ensure "$FM_BACKEND_HERDR_SESSION" || return 1
}

fm_backend_herdr_target_label_matches() { # <target> <expected-label>
local target=$1 expected_label=$2 out tab_id label
[ -n "$expected_label" ] || return 0
fm_backend_herdr_parse_target "$target" || return 1
out=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane get "$FM_BACKEND_HERDR_PANE" 2>/dev/null) || return 1
tab_id=$(printf '%s' "$out" | jq -r '.result.pane.tab_id // empty' 2>/dev/null)
[ -n "$tab_id" ] || return 1
label=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" tab list 2>/dev/null \
| jq -r --arg tab "$tab_id" '.result.tabs[]? | select(.tab_id == $tab) | .label' 2>/dev/null | head -1)
[ "$label" = "$expected_label" ]
}

# fm_backend_herdr_current_path: the live FOREGROUND process's cwd, or empty on
# any error. Mirrors tmux's pane_current_path poll used for worktree-path
# discovery after `treehouse get`.
Expand Down Expand Up @@ -550,15 +562,16 @@ fm_backend_herdr_send_literal() { # <target> <text>
}

# fm_backend_herdr_normalize_key: map firstmate's key vocabulary (Enter,
# Escape, C-c, as used by fm-send.sh --key and stuck-crewmate-recovery) onto
# Escape, C-c, and C-q as used by fm-send.sh --key and recovery paths) onto
# herdr's `pane send-keys` names. Verified empirically: enter, escape/esc, and
# both ctrl+c/C-c all work (case-insensitive on herdr's side, but normalize
# explicitly rather than relying on that).
# explicitly rather than relying on that). ctrl+q is used by grok's quit flow.
fm_backend_herdr_normalize_key() { # <key>
case "$1" in
Enter|enter) printf 'enter' ;;
Escape|escape|Esc|esc) printf 'escape' ;;
C-c|c-c|ctrl+c|Ctrl+C) printf 'ctrl+c' ;;
C-q|c-q|ctrl+q|Ctrl+Q) printf 'ctrl+q' ;;
*) printf '%s' "$1" ;;
esac
}
Expand Down Expand Up @@ -708,11 +721,42 @@ fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep>
# fm_backend_herdr_kill: remove the task's pane, best-effort (mirrors
# tmux-kill-window's `|| true` contract). Verified: closing a tab's only pane
# closes the tab too, so a separate tab close is unnecessary.
fm_backend_herdr_kill() { # <target>
fm_backend_herdr_kill() { # <target> [expected-label]
fm_backend_herdr_target_ready "$1" || return 0
fm_backend_herdr_target_label_matches "$1" "${2:-}" || return 1
fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane close "$FM_BACKEND_HERDR_PANE" >/dev/null 2>&1 || true
}

# fm_backend_herdr_foreground_process: read the real foreground process list
# from herdr's process-info primitive. This deliberately excludes herdr's
# persisted agent metadata because an exited agent can leave stale agent labels
# behind while the foreground process has returned to a shell.
fm_backend_herdr_foreground_process() { # <target> [expected-label]
local out
fm_backend_herdr_target_ready "$1" || return 0
fm_backend_herdr_target_label_matches "$1" "${2:-}" || return 0
out=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane process-info --pane "$FM_BACKEND_HERDR_PANE" 2>/dev/null) || return 0
printf '%s' "$out" | jq -r '
.result.process_info.foreground_processes[]? |
[
(.argv0 // empty),
(.argv[0] // empty),
(.cmdline // empty),
((.argv // []) | join(" "))
] | .[] | select(. != "")
' 2>/dev/null
}

fm_backend_herdr_relabel_task() { # <target> <new-label> [expected-label]
local target=$1 new_label=$2 tab_id
fm_backend_herdr_target_ready "$target" || return 1
fm_backend_herdr_target_label_matches "$target" "${3:-}" || return 1
tab_id=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane get "$FM_BACKEND_HERDR_PANE" 2>/dev/null \
| jq -r '.result.pane.tab_id // empty' 2>/dev/null)
[ -n "$tab_id" ] || { echo "error: could not resolve herdr tab id for $target" >&2; return 1; }
fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" tab rename "$tab_id" "$new_label" >/dev/null
}

# fm_backend_herdr_busy_state: semantic busy state from herdr's native
# agent-state detection (agent.get), the "first backend where fm_session_busy_state
# gets real semantics" per the design report. working -> busy (actively
Expand Down
43 changes: 43 additions & 0 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,46 @@ fm_backend_tmux_send_literal() { # <target> <text>
fm_backend_tmux_kill() { # <target>
tmux kill-window -t "$1" 2>/dev/null || true
}

# fm_backend_tmux_foreground_process: expose tmux's structured current command
# for callers that need to know whether the launched harness is still running.
fm_backend_tmux_foreground_process() { # <target>
local target=$1 pane_pid processes
pane_pid=$(tmux display-message -p -t "$target" '#{pane_pid}' 2>/dev/null || true)
if [ -n "$pane_pid" ]; then
processes=$(ps -axo pid=,ppid=,stat=,command= 2>/dev/null | awk -v root="$pane_pid" '
{
pid = $1
ppid = $2
stat = $3
command = $0
sub(/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+[[:space:]]+[^[:space:]]+[[:space:]]+/, "", command)
parent[pid] = ppid
state[pid] = stat
cmd[pid] = command
}
END {
child[root] = 1
changed = 1
while (changed) {
changed = 0
for (pid in parent) {
if (!child[pid] && child[parent[pid]]) {
child[pid] = 1
changed = 1
}
}
}
for (pid in cmd) {
if (pid != root && child[pid] && state[pid] !~ /^Z/) print cmd[pid]
}
}
' || true)
[ -z "$processes" ] || { printf '%s\n' "$processes"; return 0; }
fi
tmux display-message -p -t "$target" '#{pane_current_command}' 2>/dev/null || true
}

fm_backend_tmux_relabel_task() { # <target> <new-label>
tmux rename-window -t "$1" "$2"
}
45 changes: 42 additions & 3 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ fm_backend_send_text_submit() { # <backend> <target> <text> <retries> <enter-sl
# fm_backend_kill: remove the task's session endpoint (best-effort; a
# nonexistent/already-gone target is not an error - callers already swallow
# failures here exactly as the inline `tmux kill-window ... || true` did).
fm_backend_kill() { # <backend> <target>
fm_backend_kill() { # <backend> <target> [expected-label]
local backend=$1
shift
fm_backend_source "$backend" || return 1
Expand Down Expand Up @@ -591,7 +591,7 @@ fm_backend_composer_state() { # <backend> <target> -> empty|pending|unknown
# primitive so callers that only need a fast alive/dead read (recovery
# digests, the session-start fleet digest) do not re-derive it inline.
fm_backend_target_exists() { # <backend> <target> [expected-label]
local backend=$1 target=$2 expected_label=${3:-} session pane
local backend=$1 target=$2 expected_label=${3:-} session pane out tab_id label
case "$backend" in
tmux)
tmux display-message -p -t "$target" '#{pane_id}' >/dev/null 2>&1
Expand All @@ -609,7 +609,15 @@ fm_backend_target_exists() { # <backend> <target> [expected-label]
# flag on top, so this check is correctly scoped even when the caller's
# own ambient session (e.g. the primary firstmate's default session) is
# a DIFFERENT one than the target's.
fm_backend_herdr_cli "$session" pane get "$pane" >/dev/null 2>&1
out=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || return 1
if [ -n "$expected_label" ]; then
tab_id=$(printf '%s' "$out" | jq -r '.result.pane.tab_id // empty' 2>/dev/null)
[ -n "$tab_id" ] || return 1
label=$(fm_backend_herdr_cli "$session" tab list 2>/dev/null \
| jq -r --arg tab "$tab_id" '.result.tabs[]? | select(.tab_id == $tab) | .label' 2>/dev/null | head -1)
[ "$label" = "$expected_label" ] || return 1
fi
return 0
;;
zellij)
fm_backend_source zellij || return 1
Expand All @@ -628,3 +636,34 @@ fm_backend_target_exists() { # <backend> <target> [expected-label]
;;
esac
}

# fm_backend_foreground_process: best-effort structured read of the foreground
# process command(s) currently running inside TARGET. Empty output means no
# harness-like foreground process could be read. Callers must not parse pane
# chat to infer exit when this primitive is available.
fm_backend_foreground_process() { # <backend> <target> [expected-label]
local backend=$1
shift
fm_backend_source "$backend" || return 1
case "$backend" in
tmux) fm_backend_tmux_foreground_process "$@" ;;
herdr) fm_backend_herdr_foreground_process "$@" ;;
zellij) printf '' ;;
orca) printf '' ;;
*) echo "error: no foreground-process implementation for backend '$backend'" >&2; return 1 ;;
esac
}

# fm_backend_relabel_task: rename the backend task container that owns TARGET.
# Used before herdr restarts so fm-spawn's duplicate-label check can create a
# fresh fm-<id> tab before the old tab is closed.
fm_backend_relabel_task() { # <backend> <target> <new-label> [expected-label]
local backend=$1
shift
fm_backend_source "$backend" || return 1
case "$backend" in
tmux) fm_backend_tmux_relabel_task "$@" ;;
herdr) fm_backend_herdr_relabel_task "$@" ;;
*) echo "error: no relabel implementation for backend '$backend'" >&2; return 1 ;;
esac
}
Loading