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.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ state/ volatile runtime signals; gitignored
<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>.kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); kind=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_target= for a remote route; 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, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); kind=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_herdr_session=/remote_target= for a remote route; 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, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
<id>.herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces"
<id>.check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution
<id>.check-trust private content binding created by fm-check-register.sh for an intentional custom check
Expand Down
16 changes: 10 additions & 6 deletions bin/fm-remote-doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
# recomposing it, so the entrypoint stays the single owner of that ordering and
# the two can never drift.
#
# A remote second mate always runs on the Herdr backend, so readiness is more
# than tool resolution. herdr must resolve, its server must be reachable, and on
# macOS the Firstmate-owned launch agent dev.firstmate.herdr at
# ~/Library/LaunchAgents/dev.firstmate.herdr.plist must exist, carry
# A remote second mate always runs on the Herdr backend in the dedicated
# fm-remote session, so readiness is more than tool resolution. herdr must
# resolve, that server must be reachable, and on macOS the Firstmate-owned
# launch agent dev.firstmate.herdr.fm-remote at
# ~/Library/LaunchAgents/dev.firstmate.herdr.fm-remote.plist must exist, carry
# LimitLoadToSessionType=Aqua, and be loaded into the console user's gui/<uid>
# domain, so the server belongs to the GUI login session and survives logout and
# SSH disconnection. SSH cannot create an Aqua session, so a host with no GUI
Expand Down Expand Up @@ -53,8 +54,11 @@ SCRIPT_DIR=${SCRIPT_SELF%/*}
SCRIPT_DIR=$(CDPATH='' cd -- "$SCRIPT_DIR" && pwd -P)
REQUIRED_TOOLS=(git jq)
OPTIONAL_TOOLS=(tmux treehouse no-mistakes tasks-axi claude codex opencode pi grok kimi)
LAUNCH_AGENT_LABEL=dev.firstmate.herdr
HERDR_SESSION_NAME=default
LAUNCH_AGENT_LABEL=dev.firstmate.herdr.fm-remote
# The dedicated remote-secondmate session. The user's interactive Herdr work
# remains in the separate default session, which this readiness check never
# requires or changes.
HERDR_SESSION_NAME=fm-remote
LAUNCH_AGENT_DIR="${HOME:-}/Library/LaunchAgents"
LAUNCH_AGENT_PLIST="$LAUNCH_AGENT_DIR/$LAUNCH_AGENT_LABEL.plist"
LAUNCH_AGENT_LOG_DIR="${HOME:-}/Library/Logs"
Expand Down
138 changes: 82 additions & 56 deletions bin/fm-remote-secondmate-control.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@
# fm-remote-secondmate-control.sh update <id>
# fm-remote-secondmate-control.sh retire <id> [--force]
#
# Remote placement ends here, but the second-mate agent itself always runs on
# the Herdr backend, so launch refuses any other selection rather than reading
# this home's config/backend; fm-spawn/fm-send/fm-teardown keep owning the local
# endpoint mechanics, and the home's own workers keep its ordinary backend
# selection. bin/fm-remote-doctor.sh owns that host's readiness for Herdr, and
# docs/remote-secondmates.md owns why. A private parent-route state directory
# stores only the remote secondmate agent's endpoint record; the home's own
# Remote placement ends here, but the second-mate agent always runs on the
# Herdr backend in the dedicated fm-remote session, so launch refuses any other
# selection rather than reading this home's config/backend. The interactive
# default session remains for the user's work.
# fm-spawn/fm-send/fm-teardown keep owning the local endpoint mechanics.
# The home's own workers keep their ordinary backend selection.
# bin/fm-remote-doctor.sh owns that host's readiness for Herdr.
# docs/remote-secondmates.md owns why.
# A private parent-route state directory stores only the remote secondmate
# agent's endpoint record; the home's own
# state/*.meta remains reserved for workers the secondmate supervises.
# Retirement closes only this secondmate's panes or workspace and never
# stops fm-remote or removes a sibling secondmate's workspace or panes.
#
# The optional launch traceparent is the per-task W3C trace-context carrier the
# PARENT home resolved for this secondmate; this host only delivers it to the
Expand All @@ -35,6 +40,7 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
TARGET_HOME=${FM_HOME:?FM_HOME is required}
CONTROL_STATE="$TARGET_HOME/state/parent-route"
CONTROL_DATA="$TARGET_HOME/data/.parent-route"
REMOTE_HERDR_SESSION=fm-remote

# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"
Expand All @@ -58,26 +64,59 @@ validate_home() { # <id> [allow-absent]

meta_path() { printf '%s/%s.meta\n' "$CONTROL_STATE" "$1"; }

remote_endpoint_load() {
local id=$1 herdr_session
REMOTE_ENDPOINT_ERROR=
REMOTE_ENDPOINT_META=$(meta_path "$id")
if ! fm_backend_validate_task_endpoint "$REMOTE_ENDPOINT_META" "$id" 2>/dev/null; then
REMOTE_ENDPOINT_ERROR="remote secondmate $id endpoint metadata is invalid; refusing access until it is explicitly migrated"
return 1
fi
REMOTE_ENDPOINT_BACKEND=$FM_BACKEND_VALIDATED_BACKEND
REMOTE_ENDPOINT_TARGET=$FM_BACKEND_VALIDATED_TARGET
if [ "$REMOTE_ENDPOINT_BACKEND" != herdr ]; then
REMOTE_ENDPOINT_ERROR="remote secondmate $id endpoint is recorded on backend '$REMOTE_ENDPOINT_BACKEND', expected 'herdr'; refusing access until it is explicitly migrated"
return 1
fi
herdr_session=$(fm_backend_meta_exact_value "$REMOTE_ENDPOINT_META" herdr_session 2>/dev/null || true)
if [ "$herdr_session" != "$REMOTE_HERDR_SESSION" ]; then
REMOTE_ENDPOINT_ERROR="remote secondmate $id endpoint is recorded in Herdr session '${herdr_session:-missing}', expected '$REMOTE_HERDR_SESSION'; refusing access until it is explicitly migrated"
return 1
fi
case "$REMOTE_ENDPOINT_TARGET" in
"$REMOTE_HERDR_SESSION":?*) ;;
*)
REMOTE_ENDPOINT_ERROR="remote secondmate $id endpoint target '$REMOTE_ENDPOINT_TARGET' is outside Herdr session '$REMOTE_HERDR_SESSION'; refusing access until it is explicitly migrated"
return 1
;;
esac
}

remote_endpoint_require() {
remote_endpoint_load "$1" || die "$REMOTE_ENDPOINT_ERROR"
}

state_value() { # <id>; prints recovery-grade state
local id=$1 meta backend target
local id=$1 meta
meta=$(meta_path "$id")
[ -f "$meta" ] && [ ! -L "$meta" ] || { printf 'missing\n'; return 0; }
backend=$(fm_backend_of_meta "$meta")
target=$(fm_backend_target_of_meta "$meta")
[ -n "$target" ] || { printf 'unreadable\n'; return 0; }
fm_backend_agent_state "$backend" "$target" 2>/dev/null || printf 'unreadable\n'
if ! remote_endpoint_load "$id"; then
printf 'error: %s\n' "$REMOTE_ENDPOINT_ERROR" >&2
printf 'unverified\n'
return 0
fi
fm_backend_agent_state "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" 2>/dev/null || printf 'unreadable\n'
}

print_route() { # <id>
local meta=$1 backend target harness traceparent
meta=$(meta_path "$meta")
backend=$(fm_backend_of_meta "$meta")
target=$(fm_backend_target_of_meta "$meta")
harness=$(fm_meta_get "$meta" harness)
traceparent=$(fm_meta_get "$meta" traceparent)
local id=$1 harness traceparent
remote_endpoint_require "$id"
harness=$(fm_meta_get "$REMOTE_ENDPOINT_META" harness)
traceparent=$(fm_meta_get "$REMOTE_ENDPOINT_META" traceparent)
printf 'schema=fm-remote-secondmate-control.v1\n'
printf 'backend=%s\n' "$backend"
printf 'target=%s\n' "$target"
printf 'backend=%s\n' "$REMOTE_ENDPOINT_BACKEND"
printf 'target=%s\n' "$REMOTE_ENDPOINT_TARGET"
printf 'herdr_session=%s\n' "$REMOTE_HERDR_SESSION"
printf 'harness=%s\n' "$harness"
[ -z "$traceparent" ] || printf 'traceparent=%s\n' "$traceparent"
}
Expand All @@ -95,7 +134,7 @@ cmd_route() {

cmd_launch() {
local id=$1 harness=$2 model=$3 effort=$4 selected_backend=$5 traceparent=${6:-}
local current meta out backend target
local current meta out herdr_session

validate_id "$id"
validate_home "$id"
Expand All @@ -108,19 +147,16 @@ cmd_launch() {
mkdir -p "$CONTROL_STATE" "$CONTROL_DATA"
meta=$(meta_path "$id")
if [ -f "$meta" ]; then
current=$(state_value "$id")
remote_endpoint_require "$id"
current=$(fm_backend_agent_state "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" 2>/dev/null || printf 'unreadable\n')
case "$current" in
alive)
backend=$(fm_backend_of_meta "$meta")
[ "$backend" = herdr ] \
|| die "remote secondmate $id has an alive endpoint recorded on backend '$backend'; refusing reuse until it is explicitly migrated or retired"
print_route "$id"
return 0
;;
dead)
backend=$(fm_backend_of_meta "$meta")
target=$(fm_backend_target_of_meta "$meta")
fm_backend_kill "$backend" "$target" 2>/dev/null || die "could not remove the confirmed agent-less endpoint"
fm_backend_kill "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" 2>/dev/null \
|| die "could not remove the confirmed agent-less endpoint"
;;
missing) ;;
*) die "remote endpoint state is $current; refusing duplicate launch" ;;
Expand All @@ -130,65 +166,55 @@ cmd_launch() {
[ "$model" = - ] || ARGS+=(--model "$model")
[ "$effort" = - ] || ARGS+=(--effort "$effort")
[ -z "$traceparent" ] || ARGS+=(--traceparent "$traceparent")
if ! out=$(FM_HOME="$FM_ROOT" FM_ROOT_OVERRIDE="$FM_ROOT" \
if ! out=$(HERDR_SESSION="$REMOTE_HERDR_SESSION" FM_HOME="$FM_ROOT" FM_ROOT_OVERRIDE="$FM_ROOT" \
FM_STATE_OVERRIDE="$CONTROL_STATE" FM_DATA_OVERRIDE="$CONTROL_DATA" \
FM_CONFIG_OVERRIDE="$TARGET_HOME/config" FM_SKIP_SECONDMATE_INHERIT=1 \
"$SCRIPT_DIR/fm-spawn.sh" "${ARGS[@]}" 2>&1); then
[ -z "$out" ] || printf '%s\n' "$out" >&2
die "remote host-local secondmate launch failed"
fi
[ -f "$meta" ] || die "remote launch returned without endpoint metadata"
herdr_session=$(fm_meta_get "$meta" herdr_session)
[ "$herdr_session" = "$REMOTE_HERDR_SESSION" ] \
|| die "remote launch recorded Herdr session '${herdr_session:-missing}', expected '$REMOTE_HERDR_SESSION'"
print_route "$id"
}

cmd_send() {
local id=$1 message=$2 meta backend target
local id=$1 message=$2
validate_id "$id"
validate_home "$id"
meta=$(meta_path "$id")
[ -f "$meta" ] || die "remote secondmate has no endpoint metadata"
backend=$(fm_backend_of_meta "$meta")
target=$(fm_backend_target_of_meta "$meta")
[ -n "$target" ] || die "remote secondmate endpoint is unreadable"
remote_endpoint_require "$id"
FM_HOME="$TARGET_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$TARGET_HOME/state" \
"$SCRIPT_DIR/fm-send.sh" "$target" "$message"
"$SCRIPT_DIR/fm-send.sh" "$REMOTE_ENDPOINT_TARGET" "$message"
}

cmd_key() {
local id=$1 key=$2 meta target
local id=$1 key=$2
validate_id "$id"
validate_home "$id"
meta=$(meta_path "$id")
[ -f "$meta" ] || die "remote secondmate has no endpoint metadata"
target=$(fm_backend_target_of_meta "$meta")
remote_endpoint_require "$id"
FM_HOME="$TARGET_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$TARGET_HOME/state" \
"$SCRIPT_DIR/fm-send.sh" "$target" --key "$key"
"$SCRIPT_DIR/fm-send.sh" "$REMOTE_ENDPOINT_TARGET" --key "$key"
}

cmd_capture() {
local id=$1 lines=${2:-20} meta backend target
local id=$1 lines=${2:-20}
validate_id "$id"
validate_home "$id"
case "$lines" in ''|*[!0-9]*|0) die "capture line count must be positive" ;; esac
[ "$lines" -le 100 ] || die "capture line count exceeds 100"
meta=$(meta_path "$id")
[ -f "$meta" ] || die "remote secondmate has no endpoint metadata"
backend=$(fm_backend_of_meta "$meta")
target=$(fm_backend_target_of_meta "$meta")
fm_backend_capture "$backend" "$target" "$lines" "fm-$id" | head -c 65536
remote_endpoint_require "$id"
fm_backend_capture "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" "$lines" "fm-$id" | head -c 65536
}

cmd_observe() {
local id=$1 meta backend target harness
local id=$1 harness
validate_id "$id"
validate_home "$id"
meta=$(meta_path "$id")
[ -f "$meta" ] || die "remote secondmate has no endpoint metadata"
backend=$(fm_backend_of_meta "$meta")
target=$(fm_backend_target_of_meta "$meta")
harness=$(fm_meta_get "$meta" harness)
[ -n "$target" ] || die "remote secondmate endpoint is unreadable"
fm_pending_reply_backend_observation "$backend" "$target" "fm-$id" "$harness"
remote_endpoint_require "$id"
harness=$(fm_meta_get "$REMOTE_ENDPOINT_META" harness)
fm_pending_reply_backend_observation "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" "fm-$id" "$harness"
printf '\n'
}

Expand Down Expand Up @@ -244,7 +270,7 @@ cmd_retire() {
return 0
fi
[ -z "$force" ] || [ "$force" = --force ] || usage
[ -f "$(meta_path "$id")" ] || die "remote secondmate has no endpoint metadata to retire safely"
remote_endpoint_require "$id"
FM_HOME="$TARGET_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$TARGET_HOME/state" \
FM_CONFIG_OVERRIDE="$TARGET_HOME/config" "$SCRIPT_DIR/fm-guard.sh" || true
if [ -n "$force" ]; then
Expand Down
5 changes: 5 additions & 0 deletions bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ fm_send_resolve_target() { # <raw-target>
fi

case "$raw" in
fm-*:*)
# A named Herdr session may itself begin with "fm-". Keep that explicit
# session:pane target on the validated backend-target path below rather
# than mistaking it for an unresolved task selector.
;;
fm-*)
RESOLUTION_TRIED="meta=$STATE/$raw.meta; legacy-meta=$STATE/${raw#fm-}.meta; backend=none"
echo "error: no metadata for $raw in $STATE (tried $RESOLUTION_TRIED); pass a well-formed explicit backend target only when targeting outside this firstmate home" >&2
Expand Down
11 changes: 10 additions & 1 deletion bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ fi

spawn_remote_secondmate() {
local id=$1 remote host root home harness positional model effort backend out rc meta tmp
local remote_backend remote_target remote_harness registry_lock remote_lock remote_generation
local remote_backend remote_target remote_harness remote_herdr_session registry_lock remote_lock remote_generation
local remote_traceparent remote_recorded_traceparent
local -a launch_args
id=${POS[0]:-}
Expand Down Expand Up @@ -513,6 +513,7 @@ spawn_remote_secondmate() {
remote_backend=$(printf '%s\n' "$out" | sed -n 's/^backend=//p' | tail -1)
remote_target=$(printf '%s\n' "$out" | sed -n 's/^target=//p' | tail -1)
remote_harness=$(printf '%s\n' "$out" | sed -n 's/^harness=//p' | tail -1)
remote_herdr_session=$(printf '%s\n' "$out" | sed -n 's/^herdr_session=//p' | tail -1)
if [ "$remote_backend" != herdr ]; then
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
Expand All @@ -527,6 +528,13 @@ spawn_remote_secondmate() {
echo "error: remote launch returned malformed route metadata; preserving the remote route for reconciliation" >&2
return 1
}
if [ "$remote_herdr_session" != fm-remote ] || [ "${remote_target%%:*}" != "$remote_herdr_session" ]; then
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: remote launch returned Herdr session '${remote_herdr_session:-missing}', expected 'fm-remote'; preserving the remote route for reconciliation" >&2
return 1
fi
# Record what the remote endpoint ACTUALLY carries, read back from its own
# launch, rather than what this side hoped to deliver. That keeps the #995
# guarantee that the recorded carrier is the identity the child received even
Expand All @@ -553,6 +561,7 @@ spawn_remote_secondmate() {
echo "remote_host=$host"
echo "remote_root=$root"
echo "remote_backend=$remote_backend"
echo "remote_herdr_session=$remote_herdr_session"
echo "remote_target=$remote_target"
[ -z "$remote_recorded_traceparent" ] || echo "traceparent=$remote_recorded_traceparent"
} > "$tmp"
Expand Down
Loading
Loading