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
2 changes: 1 addition & 1 deletion .agents/skills/firstmate-orca/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Do not manually patch metadata to make an externally-created Orca terminal look
## Supervision

Use `bin/fm-peek.sh`, `bin/fm-send.sh`, `bin/fm-crew-state.sh`, and `bin/fm-teardown.sh` for routine operation.
For steer messages, send short lines through `bin/fm-send.sh fm-<id> '...'`.
For steer messages, send short lines through `bin/fm-send.sh <id> '...'`; the stable `fm-<id>` alias also works.
Put long instructions in the task brief or a temporary file and point the crewmate at that file.

When supervising, treat `state/<id>.meta` as the routing record and Orca's own ids as backend implementation details.
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ After those settings are loaded, hook command resolution is still cwd-sensitive
| Skill invocation | `$<skill>` (e.g. `$no-mistakes`); `/<skill>` is claude-only and codex rejects it as "Unrecognized command" |

A `$<skill>` invocation opens a `$`-autocomplete (skill) popup, the same hazard as the `/` slash popup: submitting too fast lets the popup swallow the Enter, so the invocation never lands.
`fm-send` handles it the same way it handles `/` - it gives the popup a longer settle (1.2s) between typing and the first Enter, with the target backend's submit retry as the safety net - but the `$` settle is scoped to `harness=codex`, read from the target's `state/<id>.meta`.
`fm-send` handles it the same way it handles `/` - it gives the popup a longer settle (1.2s) between typing and the first Enter, with the target backend's submit retry as the safety net - but the `$` settle is scoped to `harness=codex`, read from the target metadata for exact task ids or legacy `fm-<id>` labels.
That scope matters because, unlike `/`, a leading `$` commonly starts ordinary text (`$5/month`, `$HOME`), so a universal `$` rule would needlessly slow plain steers to claude/opencode/pi; only a codex target receiving a `$...` message gets the popup-settle.
An explicit `session:window` target has no meta, so its harness is unknown and treated as non-codex (the safe fast-path default).
This is why the validation trigger (`$no-mistakes`) to a codex crew now lands on the first Enter instead of biting the popup.
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Otherwise it prints one line per problem or capability fact; handle each:
A secondmate that was skipped, already current, or whose advance changed no instructions is not listed and must not be disturbed.
- `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts; follow section 14 for watcher cadence restart only when a running watcher needs the transition applied immediately.

Bootstrap's fleet refresh is bounded by `FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT` seconds, default 20; a timeout is reported as a `FLEET_SYNC` skip and does not block startup.
Bootstrap's fleet refresh is bounded by `FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT` seconds when set, otherwise by a fleet-size-aware default with a 20 second floor; a timeout is reported as a `FLEET_SYNC` skip and does not block startup.

The digest's context section already contains `data/projects.md`, the fleet registry of what each project is; `data/secondmates.md`, the registered secondmate routing table used to route work by scope (section 7); `data/captain.md`, this captain's curated preferences and working style; and `data/learnings.md`, fleet-local operational facts and gotchas this home has captured.
Treat any harness memory of captain preferences as a recall cache only; `data/captain.md` is the canonical, harness-portable home.
Expand Down Expand Up @@ -467,10 +467,10 @@ Read `data/secondmates.md` before dispatching and compare the work request to ea
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.
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.
If a secondmate's scope fits, steer that secondmate with one concise instruction via `bin/fm-send.sh <id> '<work request>'` and let it run the normal lifecycle inside its own home.
The stable `fm-<id>` label printed by lifecycle commands still works, but exact task ids resolve first through this home's `state/<id>.meta`; pass an explicit backend target containing `:` 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.
So `fm-send` to a task selector 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.
Expect and read that response on the status/doc path the same way you read any other status signal; do not peek the secondmate's chat for the answer.
A captain typing directly into the secondmate's window is unmarked and stays a conversational captain intervention, so do not relay captain-destined chat through this path; the marker is applied only by `fm-send` to a `kind=secondmate` target.
Do not spawn a direct crewmate for work that belongs to a secondmate scope unless the secondmate is blocked or the captain explicitly redirects it.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ tests/fm-wake-daemon-lifecycle-e2e.test.sh # watcher + daemon lifecycle e2e: res
tests/fm-composer-ghost.test.sh # dim-ghost stripping, ghost-only composer detection, and escape-free peek tests
tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of the afk injection path (partial-input deferral, swallowed-Enter retry)
tests/fm-afk-inject-herdr-e2e.test.sh # real-herdr end-to-end test of the afk daemon's herdr transport, on an isolated throwaway HERDR_SESSION: partial-input deferral, swallowed-Enter retry, a normal digest, and the max-defer wedge alarm on a persistently pending composer
tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, and crew-dispatch reporting tests
tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, fleet-sync timeout, and crew-dispatch reporting tests
tests/fm-session-start.test.sh # fm-session-start.sh: ABSENT vs empty-vs-present digest files, lock-refusal read-only path skipping every mutating step, diagnostics-first section ordering, status-tail bounding, tmux/herdr endpoint liveness, and composition of the real fm-lock/fm-bootstrap/fm-wake-drain scripts
tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard, teardown cleanup, and session-lock detection tests
tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, single-project name resolution, and bootstrap relay
Expand Down
5 changes: 3 additions & 2 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
# remainder is the whole pane id - fm_backend_herdr_parse_target splits on the
# first colon only). This is the value stored in a herdr task's meta window=
# field and is what fm_backend_resolve_selector already returns unchanged for
# both the fm-<id> and explicit backend-target forms (that function has no
# herdr-specific logic; it just returns meta's window= verbatim).
# exact task-id, legacy fm-<id>, and explicit backend-target forms (that
# function has no herdr-specific logic; it just returns meta's window=
# verbatim).
#
# Recovery/orphan discovery (ids may not deterministically match live state
# after a server restart in a differently-configured session; see the
Expand Down
2 changes: 1 addition & 1 deletion bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
. "$FM_BACKEND_LIB_DIR/fm-tmux-lib.sh"

# fm_backend_tmux_resolve_bare_selector: the live-window-listing fallback for a
# selector that is neither "session:window" nor a bare "fm-<id>" routed
# selector that is neither an explicit target nor a task selector routed
# through meta - an ad hoc window name with no recorded task. Mirrors the
# `tmux list-windows -a ... | grep` pipeline that used to live inline in
# fm-send.sh's and fm-peek.sh's own (until now duplicated) resolve().
Expand Down
8 changes: 4 additions & 4 deletions bin/backends/zellij.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@
# target. Mitigated: send/capture/cwd ops verify session liveness first
# (fm_backend_zellij_session_exists, a passive list-sessions query, never
# auto-creating), verify the specific pane still appears in list-panes JSON,
# and, for metadata-routed fm-<id> operations, verify the pane's tab still
# matches the expected caller-facing task label through the home-scoped or
# and, for metadata-routed task selector operations, verify the pane's tab
# still matches the expected caller-facing task label through the home-scoped or
# unambiguous legacy title before use. Kill verifies the session and, when
# teardown supplies an expected tab label, verifies a tab id still matches
# that label before closing it. Output-SHAPE validation (a bare integer tab
Expand Down Expand Up @@ -595,8 +595,8 @@ fm_backend_zellij_list_live() { # <session>
# posture. Rare path in practice (zellij tasks normally carry meta);
# best-effort. Not wired into fm_backend_resolve_selector's dispatcher
# (bin/fm-backend.sh), mirroring herdr: that bare-selector fallback stays
# tmux-only by design, and zellij/herdr tasks are targeted via fm-<id> meta or
# an explicit recorded target.
# tmux-only by design, and zellij/herdr tasks are targeted via task-selector
# meta or an explicit recorded target.
fm_backend_zellij_resolve_bare_selector() { # <name>
local name=$1 scoped sessions session tabs tab_id count=0 pane_id bare_session='' bare_tab_id=''
scoped=$(fm_backend_zellij_scoped_title "$name")
Expand Down
68 changes: 46 additions & 22 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -335,14 +335,36 @@ fm_backend_meta_for_window() { # <target> <state-dir>
return 1
}

fm_backend_of_selector() { # <raw-target> <resolved-target> <state-dir>
local raw=$1 resolved=$2 state=$3 meta
fm_backend_task_id_for_selector() { # <raw-target> <state-dir>
local raw=$1 state=$2 id
case "$raw" in
*:*) return 1 ;;
esac
if [ -f "$state/$raw.meta" ]; then
printf '%s' "$raw"
return 0
fi
case "$raw" in
fm-*)
meta="$state/${raw#fm-}.meta"
[ -f "$meta" ] && { fm_backend_of_meta "$meta"; return 0; }
id=${raw#fm-}
[ -f "$state/$id.meta" ] || return 1
printf '%s' "$id"
return 0
;;
esac
return 1
}

fm_backend_meta_for_selector() { # <raw-target> <state-dir>
local raw=$1 state=$2 id
id=$(fm_backend_task_id_for_selector "$raw" "$state") || return 1
printf '%s/%s.meta' "$state" "$id"
}

fm_backend_of_selector() { # <raw-target> <resolved-target> <state-dir>
local raw=$1 resolved=$2 state=$3 meta
meta=$(fm_backend_meta_for_selector "$raw" "$state" 2>/dev/null || true)
[ -n "$meta" ] && { fm_backend_of_meta "$meta"; return 0; }
if [ -n "$resolved" ]; then
meta=$(fm_backend_meta_for_window "$resolved" "$state" 2>/dev/null || true)
[ -n "$meta" ] && { fm_backend_of_meta "$meta"; return 0; }
Expand All @@ -351,13 +373,10 @@ fm_backend_of_selector() { # <raw-target> <resolved-target> <state-dir>
}

fm_backend_expected_label_of_selector() { # <raw-target> <state-dir>
local raw=$1 state=$2 meta
case "$raw" in
fm-*)
meta="$state/${raw#fm-}.meta"
[ -f "$meta" ] && printf '%s' "$raw"
;;
esac
local raw=$1 state=$2 id
id=$(fm_backend_task_id_for_selector "$raw" "$state" 2>/dev/null || true)
[ -n "$id" ] && printf 'fm-%s' "$id"
return 0
}

# fm_backend_source: source the named backend's adapter file, once per shell.
Expand Down Expand Up @@ -404,15 +423,18 @@ fm_backend_source() { # <name>
}

# fm_backend_resolve_selector: resolve a raw fm-send.sh/fm-peek.sh style
# selector to a live session-provider target. Three forms, in order:
# selector to a live session-provider target. Four forms, in order:
# target with ":" used as-is (the escape hatch for a window/pane outside
# this firstmate home) - backend-independent, a literal string.
# "fm-<id>" routed through <state-dir>/<id>.meta's backend target
# exact task id routed through <state-dir>/<id>.meta's backend target
# (`window=` normally, `terminal=` for Orca) -
# backend-independent, a stored value, NOT re-verified
# against a live backend inventory (matches today's
# behavior: tmux window names can be trusted from meta
# without a live re-check).
# "fm-<id>" legacy task window label fallback routed through
# <state-dir>/<id>.meta when no exact
# <state-dir>/fm-<id>.meta exists.
# anything else first matched against recorded `window=`/`terminal=`
# metadata, then treated as an ad hoc bare window name and
# resolved by searching the legacy tmux live inventory.
Expand All @@ -423,16 +445,18 @@ fm_backend_resolve_selector() { # <raw-target> <state-dir>
printf '%s' "$raw"
return 0
;;
esac
meta=$(fm_backend_meta_for_selector "$raw" "$state" 2>/dev/null || true)
if [ -n "$meta" ]; then
window=$(fm_backend_target_of_meta "$meta")
[ -n "$window" ] || { echo "error: no backend target recorded in $meta" >&2; return 1; }
printf '%s' "$window"
return 0
fi
case "$raw" in
fm-*)
meta="$state/${raw#fm-}.meta"
if [ ! -f "$meta" ]; then
echo "error: no metadata for $raw in $state; pass session:window to target a window outside this firstmate home" >&2
return 1
fi
window=$(fm_backend_target_of_meta "$meta")
[ -n "$window" ] || { echo "error: no backend target recorded in $meta" >&2; return 1; }
printf '%s' "$window"
return 0
echo "error: no metadata for $raw in $state; pass session:window to target a window outside this firstmate home" >&2
return 1
;;
*)
meta=$(fm_backend_meta_for_window "$raw" "$state" 2>/dev/null || true)
Expand Down
90 changes: 69 additions & 21 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@
# the relay poll shim and 30s cadence config, and prints an FMX line.
# Fleet sync fetches, fast-forwards safe default-branch states, reports
# recovered and STUCK clone drift, and prunes gone local branches; it is
# bounded by FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT, default 20s.
# bounded by FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT when it is a non-empty
# numeric override, while non-numeric values fall back to 20s.
# When the override is unset or blank, the timeout is
# max(20, 5 + 3 * origin-backed project clone count). A timed-out
# refresh relays any completed fm-fleet-sync.sh output before the
# aggregate timeout skip line with timeout and elapsed seconds.
# Set FM_FLEET_PRUNE=0 to skip branch pruning during that refresh.
# Set FM_BOOTSTRAP_DETECT_ONLY=1 to skip the four MUTATING sweeps
# (secondmate_sync, secondmate_liveness_sweep, x_mode_setup,
Expand All @@ -77,41 +82,92 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}"
CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
# shellcheck source=bin/fm-tasks-axi-lib.sh
# shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-tasks-axi-lib.sh"
# shellcheck source=bin/fm-tangle-lib.sh
# shellcheck source=bin/fm-tangle-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-tangle-lib.sh"
# shellcheck source=bin/fm-ff-lib.sh
# shellcheck source=bin/fm-ff-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-ff-lib.sh"
# shellcheck source=bin/fm-config-inherit-lib.sh
# shellcheck source=bin/fm-config-inherit-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-config-inherit-lib.sh"
# shellcheck source=bin/fm-x-lib.sh
# shellcheck source=bin/fm-x-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-x-lib.sh"
# shellcheck source=bin/fm-clickstack-lib.sh
# shellcheck source=bin/fm-clickstack-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-clickstack-lib.sh"
# shellcheck source=bin/fm-backend.sh
# shellcheck source=bin/fm-backend.sh disable=SC1091
. "$SCRIPT_DIR/fm-backend.sh"

fleet_sync_origin_backed_project_count() {
local count proj
count=0
[ -d "$PROJECTS" ] || { echo 0; return 0; }
for proj in "$PROJECTS"/*; do
[ -d "$proj" ] || continue
git -C "$proj" rev-parse --git-dir >/dev/null 2>&1 || continue
git -C "$proj" remote get-url origin >/dev/null 2>&1 || continue
count=$((count + 1))
done
echo "$count"
}

fleet_sync_bootstrap_timeout() {
local count timeout
if [ -n "${FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT:-}" ]; then
case "$FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT" in
*[!0-9]*) echo 20 ;;
*) echo "$FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT" ;;
esac
return 0
fi

count=$(fleet_sync_origin_backed_project_count)
timeout=$((5 + (3 * count)))
[ "$timeout" -ge 20 ] || timeout=20
echo "$timeout"
}

fleet_sync_relay_filtered_output() {
local tmp=$1 line
while IFS= read -r line; do
case "$line" in
*': skipped: local-only project') ;;
*': skipped: no origin remote') ;;
*': skipped:'*) echo "FLEET_SYNC: $line" ;;
*': STUCK:'*) echo "FLEET_SYNC: $line" ;;
*': recovered:'*) echo "FLEET_SYNC: $line" ;;
esac
done < "$tmp"
}

fleet_sync_relay_all_output() {
local tmp=$1 line
while IFS= read -r line; do
[ -n "$line" ] || continue
echo "FLEET_SYNC: $line"
done < "$tmp"
}

fleet_sync() {
[ -x "$FM_ROOT/bin/fm-fleet-sync.sh" ] || return 0
[ -d "$PROJECTS" ] || return 0

tmp=$(mktemp "${TMPDIR:-/tmp}/fm-fleet-sync.XXXXXX" 2>/dev/null) || return 0
timeout=$(fleet_sync_bootstrap_timeout)
monitor_was_on=0
case $- in *m*) monitor_was_on=1 ;; esac
set -m 2>/dev/null || true
"$FM_ROOT/bin/fm-fleet-sync.sh" >"$tmp" 2>/dev/null &
pid=$!

timeout=${FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT:-20}
case "$timeout" in ''|*[!0-9]*) timeout=20 ;; esac
start=$SECONDS
while jobs -r -p | grep -qx "$pid"; do
if [ $((SECONDS - start)) -ge "$timeout" ]; then
elapsed=$((SECONDS - start))
if [ "$elapsed" -ge "$timeout" ]; then
kill -TERM "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
[ "$monitor_was_on" -eq 1 ] || set +m 2>/dev/null || true
echo "FLEET_SYNC: fleet: skipped: bootstrap refresh timed out"
fleet_sync_relay_all_output "$tmp"
echo "FLEET_SYNC: fleet: skipped: bootstrap refresh timed out (timeout=${timeout}s elapsed=${elapsed}s)"
rm -f "$tmp"
return 0
fi
Expand All @@ -120,15 +176,7 @@ fleet_sync() {
wait "$pid" 2>/dev/null || true
[ "$monitor_was_on" -eq 1 ] || set +m 2>/dev/null || true

while IFS= read -r line; do
case "$line" in
*': skipped: local-only project') ;;
*': skipped: no origin remote') ;;
*': skipped:'*) echo "FLEET_SYNC: $line" ;;
*': STUCK:'*) echo "FLEET_SYNC: $line" ;;
*': recovered:'*) echo "FLEET_SYNC: $line" ;;
esac
done < "$tmp"
fleet_sync_relay_filtered_output "$tmp"
rm -f "$tmp"
}

Expand Down
Loading