Skip to content

feat(bin): reach claude workers through Claude Remote Control - #2695

Open
joshuabke wants to merge 1 commit into
kunchenguid:mainfrom
joshuabke:fm/fm-claude-remote-control-7q
Open

feat(bin): reach claude workers through Claude Remote Control#2695
joshuabke wants to merge 1 commit into
kunchenguid:mainfrom
joshuabke:fm/fm-claude-remote-control-7q

Conversation

@joshuabke

Copy link
Copy Markdown

Intent

Enable Claude Remote Control for firstmate-spawned claude workers.

Background the reviewer needs: claude --remote-control [name] starts an ordinary interactive Claude Code session that is additionally reachable from claude.ai/code and the Claude mobile app. On 2026-08-13 it was already verified (Claude Code 2.1.226) that such a session fires both UserPromptSubmit and Stop project hooks normally - exactly the two hooks firstmate's crewmate supervision depends on - but two caveats were left untested and are part of this task's acceptance criteria. The captain wants claude workers reachable from his phone. This task changes firstmate's shared tracked material (bin/), so the firstmate-coding-guidelines skill governs it.

Requirements as accepted:

  1. In bin/fm-spawn.sh, launch claude-harness crewmates, scouts, and secondmates with --remote-control <session-name> by default, naming the session after the task id so the captain can identify it in the claude.ai/code session list. Add a local opt-out knob config/claude-remote-control (absent or "on" = enabled, "off" = disabled), gitignored like the other config knobs, documented where the other knobs are documented (AGENTS.md layout table + docs/configuration.md, following the existing pattern). Scope strictly to harness=claude; other harnesses are untouched. Check claude --help for the current flag syntax first; if the installed CLI does not support the flag, degrade gracefully (launch without it, print a loud one-line notice).

  2. EMPIRICALLY verify the two open caveats from the 2026-08-13 learning, using a throwaway scratch task/window (never a real fleet task): (a) the per-task turn-end wiring (state/.turn-ended touched by the Stop hook) still works when the worker is launched through the modified fm-spawn with the flag; (b) fm-peek and fm-send behave unchanged against such a pane (send a trivial steer, verify submission and pane read). Record the evidence (commands + observed results) in the PR description. Do NOT edit data/learnings.md - it is firstmate-private and outside this worktree's scope; the done line instead tells firstmate the caveats are now verified so firstmate updates the learning itself.

  3. Tests: follow the repo's colocated-test convention (tests/) for the new config-knob resolution and launch-flag assembly, since the existing fm-spawn test surface already covers similar flags; keep it proportional to the existing test style.

Acceptance criteria:

  • A claude worker spawned through fm-spawn gets --remote-control <task-id> unless the knob says off; non-claude harnesses unchanged.
  • Both caveats verified with recorded evidence.
  • Docs updated (AGENTS.md config table line + docs/configuration.md), shellcheck-clean.

Decisions and tradeoffs made while implementing, which a reviewer reading only the diff would not know:

  • Flag placement is deliberate: --remote-control goes AFTER __MODELFLAG____EFFORTFLAG__ and immediately before the positional launch brief. That keeps every existing assert_contains "claude --dangerously-skip-permissions --model 'opus'" substring assertion in other suites valid, so only the single exact-equality canonical-launch assertion needed updating.
  • Resolution is guarded by the launch placeholder (case "$LAUNCH" in *__CLAUDEREMOTE__*), matching the existing muse/kimi resolutions, NOT by case "$HARNESS" in claude). This is intentional: HARNESS can also be derived from the raw launch-command escape hatch, so a hand-written command that merely starts with claude would otherwise be probed and warned about even though there is no placeholder to substitute.
  • The claude --help probe mirrors the existing pi_supports_tui_mode probe (same shape, same grep form) rather than inventing a new mechanism. The loud one-line notice fires only when claude IS installed but does not advertise the flag; when claude is not on PATH at all the code is silent, because that spawn's launch would already fail loudly in the pane and a second warning would just be noise in test and CI environments.
  • Knob parsing reuses the whole-file whitespace-stripped, case-folded convention of config/crew-harness and config/backlog-backend; an empty file means "on" and an unrecognized value warns and keeps the default rather than failing a spawn over a reachability setting.
  • The knob is deliberately NOT added to FM_INHERITABLE_CONFIG, matching config/calm and config/wedge-alarm. Because the default is on, the default behavior propagates to secondmate homes naturally; only an explicit "off" would need repeating. This is documented explicitly in both AGENTS.md and docs/configuration.md as "not inherited", so it is a stated choice rather than an oversight.
  • No .gitignore change was made because config/ is already ignored as a directory (pinned by tests/fm-gitignore-config.test.sh), which is what "gitignored like the other config knobs" already means here.
  • Per firstmate-coding-guidelines' harness-dependent-checks rule, a dated maintainer-verification record was added to docs/verification/runtime-backends.md rather than leaving the live evidence only in the PR.
  • Tests were added to the existing tests/fm-spawn-dispatch-profile.test.sh rather than a new file, and a fake claude that answers --help was added to that suite's fakebin. That fake is load-bearing: run_spawn puts the fakebin first on PATH but keeps the rest of PATH, so without it the probe would find the developer's real claude and produce a different verdict than CI, where claude is absent.

Empirical evidence for requirement 2, recorded live on Claude Code 2.1.234, tmux 3.7b, macOS 26.5.1 arm64, using a throwaway scout spawned through the modified bin/fm-spawn.sh into an isolated tmux server (separate TMUX_TMPDIR socket) and a scratch firstmate home, so no real fleet task or session was involved:

  • bin/fm-spawn.sh rc-probe-a1 <scratch-project> --scout --harness claude succeeded, and ps -o pid,ppid,command -ax showed the live process as claude --dangerously-skip-permissions --remote-control rc-probe-a1 -c<encoded launch brief>, proving the flag reaches the real CLI and does not swallow the positional brief. The pane footer rendered /rc active for the whole session.
  • Caveat (a) VERIFIED: state/rc-probe-a1.turn-ended was created on the first turn's Stop and re-touched on the second turn (19:42 then 19:43:25), and the semantic busy record advanced to seq=5 state=idle source=claude-hook event=stop, proving UserPromptSubmit and Stop both fired from the per-task .claude/settings.local.json across two turns rather than only at launch. bin/fm-crew-state.sh rc-probe-a1 read state: done.
  • Caveat (b) VERIFIED: bin/fm-peek.sh rc-probe-a1 12 exited 0 and captured the transcript, composer, and footer; bin/fm-send.sh rc-probe-a1 "<trivial steer>" exited 0 (submit confirmed) and the steer appeared in the transcript with the worker's answer.
  • The probe worktree, its treehouse pool, the isolated tmux server, and the scratch home were removed afterwards; the real fleet's tmux session was never touched.

Local gates already run green before validation: bin/fm-lint.sh clean (pinned ShellCheck 0.11.0 and actionlint 1.7.12, both installed through the repo's own bin/fm-install-*.sh), bin/fm-doc-audience-check.sh ok, and bin/fm-test-run.sh --changed green with 1007 passing assertions and zero failures.

Re-run note: an earlier run of this exact head (a4aa976) completed review, test, document, and lint green - review raised two informational points and nothing blocking, test found nothing, document found one non-blocking follow-up (an adapter-side pointer in an unrelated skill file), and lint passed - then failed at push with a 403, because the gate had been registered without a fork URL and was pushing straight to origin, where the authenticated account is read-only. The captain approved a fork and the gate is now re-registered with https://github.com/joshuabke/firstmate. The head under validation is unchanged from that green run; nothing in the diff was altered in response to any finding.

What Changed

  • bin/fm-spawn.sh now resolves a __CLAUDEREMOTE__ placeholder in the claude launch template, so claude-harness crewmates, scouts, and secondmates launch with --remote-control <task-id> and are reachable from claude.ai/code and the Claude mobile app under the task's name. Resolution is guarded by the placeholder rather than the harness name, matching the existing muse/kimi resolutions, so a raw launch command that merely starts with claude is left alone; every other adapter's launch command is untouched.
  • Added the local, gitignored config/claude-remote-control knob (absent, empty, or on = enabled; off = opt out; an unrecognized value warns and keeps the default), parsed with the same whole-file whitespace-stripped, case-folded convention as config/crew-harness and config/backlog-backend. A claude --help probe modeled on the neighbouring pi_supports_tui_mode degrades gracefully: an installed CLI that does not advertise the flag launches without it and prints one warning line.
  • Documented the knob in the AGENTS.md config-layout table and a new docs/configuration.md section (both stating it is not inherited by secondmate homes), recorded the dated live verification of the turn-end/busy-state hooks and fm-peek/fm-send behavior in docs/verification/runtime-backends.md, and extended tests/fm-spawn-dispatch-profile.test.sh with knob-resolution, older-CLI, secondmate, and non-claude-harness cases plus a fake claude --help in the suite's fakebin so the probe verdict does not depend on the host having claude installed.

Risk Assessment

✅ Low: The change is tightly scoped to the claude launch template behind a placeholder guard, documented in both required doc surfaces, covered by behavioral tests with a deterministic fake CLI, and the flag syntax matches the installed CLI's real help output; the remaining notes are informational degradation-path polish rather than reachable failures.

Testing

I ran the targeted fm-spawn dispatch-profile suite (all green, including the three new Remote Control cases) plus the two other suites that pin the claude launch command as a substring, which confirms the deliberate flag placement after the model/effort flags did not invalidate them. Because passing unit tests do not show the captain's actual experience, I also re-verified the two open caveats live and independently of the author: a throwaway scout spawned through the modified bin/fm-spawn.sh into an isolated tmux server and scratch firstmate home on claude 2.1.238 ran as claude --dangerously-skip-permissions --model haiku --remote-control rc-probe-2444 -c&lt;encoded brief&gt;, so the flag parses without swallowing the positional brief; the per-task turn-end notification was created on the first turn's Stop and re-touched on the second (busy record advancing seq=3 to seq=5, source=claude-hook event=stop), and fm-peek and fm-send both exited 0 against that pane with the steer visibly landing and answered. The end-user surface here is a terminal pane rather than a GUI, so the visual artifact is a rendered screenshot of the captured tmux pane showing the /rc active footer next to the live process line. Two initial probe attempts failed for environmental reasons I fixed (firstmate's gate-agent capability guard, which the repo's own test helpers bypass the same way, and fm-spawn's pre-existing refusal to launch from a pooled worktree with an unfetchable origin), not for any defect in the change. The probe cleaned up its treehouse pool, scratch home, and tmux server, and the worktree is unmodified.

Evidence: Same evidence as rendered HTML

Source: Same evidence as rendered HTML

<!doctype html><meta charset=utf-8><title>Claude Remote Control - live spawn evidence</title>
<style>
body{background:#12131a;color:#d6d8e0;font:14px/1.5 -apple-system,Segoe UI,sans-serif;margin:0;padding:28px}
h1{font-size:19px;margin:0 0 4px} h2{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#8b90a3;margin:26px 0 8px}
.sub{color:#8b90a3;font-size:12px;margin-bottom:18px}
pre{background:#000;border:1px solid #2a2d3a;border-radius:8px;padding:14px 16px;overflow-x:auto;
 font:12.5px/1.45 SFMono-Regular,Menlo,monospace;color:#e6e6e6;white-space:pre-wrap;word-break:break-word}
mark{background:#2f6f4f;color:#eafff2;border-radius:3px;padding:0 3px}
.grid{display:grid;grid-template-columns:minmax(0,1fr);gap:0}
</style>
<h1>Claude Remote Control reaches a firstmate-spawned worker</h1>
<div class=sub>Throwaway scout spawned through the modified <code>bin/fm-spawn.sh</code> into an isolated tmux server and a scratch firstmate home &middot; claude 2.1.238 &middot; tmux 3.7b &middot; macOS arm64</div>
<div class=grid>
<h2>The live claude process carries the flag (ps -o command= -ax)</h2>
<pre>claude --dangerously-skip-permissions --model haiku <mark>--remote-control rc-probe-2444</mark> \040-cFIRSTMATE_OP: v1 launch-brief: You are a crewmate: an autonomous worker agent managed by firstmate. Work on you</pre>
<h2>The worker's pane, as the captain sees it (tmux capture-pane)</h2>
<pre>
  Standing by for firstmate signal containing ALBATROSS.

✻ Baked for 13s

❯ ALBATROSS

  Ran 1 shell command

⏺ ✅ RC probe sequence complete.

  Status file updated:
  - working: rc probe ready
  - needs-decision [key=rc-probe]: awaiting codeword
  - done: received ALBATROSS

  Remote Control probe has received the codeword and is shutting down.

✻ Cogitated for 5s

────────────────────────────────────────────────────────────────────────────────
❯ 
────────────────────────────────────────────────────────────────────────────────
  ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents      <mark>/rc active</mark>
</pre>
<h2>Probe transcript</h2>
<pre>== claude 2.1.238 (Claude Code) / tmux tmux 3.7b / Darwin 25.5.0 arm64 ==

-- spawn --
spawned rc-probe-2444 harness=claude kind=scout window=firstmate:fm-rc-probe-2444 worktree=/Users/joshua/.treehouse/rcprobe-2444-3c1dbd/1/rcprobe-2444

-- live process (ps -o command= -ax) --
claude --dangerously-skip-permissions --model haiku --remote-control rc-probe-2444 \040-cFIRSTMATE_OP: v1 launch-brief: You are a crewmate: an autonomous worker agent managed by firstmate. Work on you

-- caveat (a) per-task turn-end notification --
state/rc-probe-2444.turn-ended after turn 1: 1787260162 Aug 20 23:09:22 2026
state/rc-probe-2444.turn-ended after turn 2: 1787260171 Aug 20 23:09:31 2026

semantic busy record (state/rc-probe-2444.busy-state tail):
v1 gen=g1787260143.4734.20135 seq=3 state=idle source=claude-hook event=stop ts=1787260162
v1 gen=g1787260143.4734.20135 seq=5 state=idle source=claude-hook event=stop ts=1787260171

-- worker status log --
working: rc probe ready
needs-decision [key=rc-probe]: awaiting codeword
done: received ALBATROSS

-- fm-crew-state.sh rc-probe-2444 --
state: done · source: status-log · received ALBATROSS

-- caveat (b) fm-send --
WARNING: watcher still down (same stale episode; last beat: never, grace 300s) - full banner already printed this episode.
(exit 0 = submit confirmed)
</pre>
</div>
Evidence: Live probe transcript (caveats a and b)

Source: Live probe transcript (caveats a and b)

== claude 2.1.238 (Claude Code) / tmux 3.7b / Darwin 25.5.0 arm64 == -- spawn -- spawned rc-probe-2444 harness=claude kind=scout window=firstmate:fm-rc-probe-2444 worktree=/Users/joshua/.treehouse/rcprobe-2444-3c1dbd/1/rcprobe-2444 -- live process (ps -o command= -ax) -- claude --dangerously-skip-permissions --model haiku --remote-control rc-probe-2444 \040-cFIRSTMATE_OP: v1 launch-brief: You are a crewmate: an autonomous worker agent managed by firstmate. Work on you -- caveat (a) per-task turn-end notification -- state/rc-probe-2444.turn-ended after turn 1: 1787260162 Aug 20 23:09:22 2026 state/rc-probe-2444.turn-ended after turn 2: 1787260171 Aug 20 23:09:31 2026 semantic busy record (state/rc-probe-2444.busy-state tail): v1 gen=g1787260143.4734.20135 seq=3 state=idle source=claude-hook event=stop ts=1787260162 v1 gen=g1787260143.4734.20135 seq=5 state=idle source=claude-hook event=stop ts=1787260171 -- worker status log -- working: rc probe ready needs-decision [key=rc-probe]: awaiting codeword done: received ALBATROSS -- fm-crew-state.sh rc-probe-2444 -- state: done · source: status-log · received ALBATROSS -- caveat (b) fm-send -- (exit 0 = submit confirmed)

== claude 2.1.238 (Claude Code) / tmux tmux 3.7b / Darwin 25.5.0 arm64 ==

-- spawn --
spawned rc-probe-2444 harness=claude kind=scout window=firstmate:fm-rc-probe-2444 worktree=/Users/joshua/.treehouse/rcprobe-2444-3c1dbd/1/rcprobe-2444

-- live process (ps -o command= -ax) --
claude --dangerously-skip-permissions --model haiku --remote-control rc-probe-2444 \040-cFIRSTMATE_OP: v1 launch-brief: You are a crewmate: an autonomous worker agent managed by firstmate. Work on you

-- caveat (a) per-task turn-end notification --
state/rc-probe-2444.turn-ended after turn 1: 1787260162 Aug 20 23:09:22 2026
state/rc-probe-2444.turn-ended after turn 2: 1787260171 Aug 20 23:09:31 2026

semantic busy record (state/rc-probe-2444.busy-state tail):
v1 gen=g1787260143.4734.20135 seq=3 state=idle source=claude-hook event=stop ts=1787260162
v1 gen=g1787260143.4734.20135 seq=5 state=idle source=claude-hook event=stop ts=1787260171

-- worker status log --
working: rc probe ready
needs-decision [key=rc-probe]: awaiting codeword
done: received ALBATROSS

-- fm-crew-state.sh rc-probe-2444 --
state: done · source: status-log · received ALBATROSS

-- caveat (b) fm-send --
WARNING: watcher still down (same stale episode; last beat: never, grace 300s) - full banner already printed this episode.
(exit 0 = submit confirmed)
Evidence: Worker pane as captured by fm-peek (caveat b), footer shows /rc active

Source: Worker pane as captured by fm-peek (caveat b), footer shows /rc active

❯ ALBATROSS Ran 1 shell command ⏺ ✅ RC probe sequence complete. Status file updated: - working: rc probe ready - needs-decision [key=rc-probe]: awaiting codeword - done: received ALBATROSS ──────────────────────────────────────── ❯ ──────────────────────────────────────── ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents /rc active

WARNING: watcher still down (same stale episode; last beat: never, grace 300s) - full banner already printed this episode.
treehouse get
joshua@Mac rcprobe-2444 % treehouse get
🌳 Setting up worktree...
🌳 Entered worktree at ~/.treehouse/rcprobe-2444-3c1dbd/1/rcprobe-2444. Type 'ex
it' to return.
joshua@Mac rcprobe-2444 % export GOTMPDIR=/tmp/fm-rc-probe-2444/gotmp
joshua@Mac rcprobe-2444 % env -u CURSOR_AGENT -u CURSOR_INVOKED_AS CLAUDE_CODE_E

  Standing by for firstmate signal containing ALBATROSS.

✻ Baked for 13s

❯ ALBATROSS

  Ran 1 shell command

⏺ ✅ RC probe sequence complete.

  Status file updated:
  - working: rc probe ready
  - needs-decision [key=rc-probe]: awaiting codeword
  - done: received ALBATROSS

  Remote Control probe has received the codeword and is shutting down.

✻ Cogitated for 5s

────────────────────────────────────────────────────────────────────────────────
❯ 
────────────────────────────────────────────────────────────────────────────────
  ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents      /rc active
Evidence: Reproducible live probe script (isolated tmux server + scratch firstmate home)

Source: Reproducible live probe script (isolated tmux server + scratch firstmate home)

#!/usr/bin/env bash
# Live verification of the two caveats in the Claude Remote Control change.
#
# Spawns ONE throwaway scout through the modified bin/fm-spawn.sh into an
# ISOLATED tmux server (private -L socket) and a scratch firstmate home, so no
# real fleet task, window, or home is touched. Verifies:
#   (0) the real claude CLI receives --remote-control <task-id> and still gets
#       the positional launch brief
#   (a) the per-task turn-end wiring (state/<id>.turn-ended, Stop hook) fires
#       across two turns
#   (b) fm-peek and fm-send behave unchanged against such a pane
set -u

ROOT=${FM_ROOT_PROBE:?set FM_ROOT_PROBE to the firstmate worktree under test}
EVID=${EVID:?set EVID to the evidence directory}
SOCKET="fm-rc-probe-$$"
TASK="rc-probe-$$"
LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-rc-probe.XXXXXX")
SPAWNED=0
PROJ=
REAL_TMUX=$(command -v tmux)

fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; }
pass() { printf 'ok - %s\n' "$1"; }
note() { printf '# %s\n' "$1"; }

cleanup() {
  if [ "$SPAWNED" -eq 1 ]; then
    mkdir -p "$LAB/data/$TASK"; : > "$LAB/data/$TASK/report.md"
    FM_HOME="$LAB" FM_BACKEND=tmux FM_GATE_REFUSE_BYPASS=1 PATH="$LAB/shim:$PATH" \
      "$ROOT/bin/fm-teardown.sh" "$TASK" >/dev/null 2>&1 || true
  fi
  "$REAL_TMUX" -L "$SOCKET" kill-server >/dev/null 2>&1 || true
  [ -z "${PROJ:-}" ] || rm -rf -- "$HOME"/.treehouse/"$PROJ"-*
  [ -z "${LAB:-}" ] || rm -rf -- "$LAB"
}
trap cleanup EXIT

command -v claude >/dev/null 2>&1 || fail "claude is not installed"
command -v treehouse >/dev/null 2>&1 || fail "treehouse is not installed"
note "claude $(claude --version)"
note "tmux $("$REAL_TMUX" -V)"
note "task=$TASK socket=$SOCKET lab=$LAB"

PROJ="rcprobe-$$"
mkdir -p "$LAB/shim" "$LAB/config" "$LAB/data/$TASK" "$LAB/projects/$PROJ" "$LAB/state"
cat > "$LAB/shim/tmux" <<SH
#!/usr/bin/env bash
exec "$REAL_TMUX" -L "$SOCKET" "\$@"
SH
chmod +x "$LAB/shim/tmux"

# A local bare origin: fm-spawn refuses a pooled worktree whose origin cannot
# be fetched, so the throwaway repo needs a real (still local) remote.
git init -q --bare "$LAB/origin.git"
git -C "$LAB/projects/$PROJ" init -q -b main || fail "could not init the probe repo"
git -C "$LAB/projects/$PROJ" config user.email 'rc-probe@example.invalid'
git -C "$LAB/projects/$PROJ" config user.name 'rc probe'
printf 'claude remote-control probe\n' > "$LAB/projects/$PROJ/README.md"
git -C "$LAB/projects/$PROJ" add README.md
git -C "$LAB/projects/$PROJ" commit -qm 'fixture: rc probe'
git -C "$LAB/projects/$PROJ" remote add origin "$LAB/origin.git"
git -C "$LAB/projects/$PROJ" push -q -u origin main

STATUS="$LAB/state/$TASK.status"
TURN="$LAB/state/$TASK.turn-ended"

FM_HOME="$LAB" "$ROOT/bin/fm-brief.sh" "$TASK" "$PROJ" --scout \
  || fail "could not scaffold the probe brief"
python3 - "$LAB/data/$TASK/brief.md" "$STATUS" <<'PY'
from pathlib import Path
import sys
brief = Path(sys.argv[1]); status = sys.argv[2]
brief.write_text(brief.read_text().replace("{TASK}", f'''Run a Remote Control probe. Do not read or change any project file.

Immediately append the line `working: rc probe ready` to `{status}`.
Then append exactly `needs-decision [key=rc-probe]: awaiting codeword` to that file and stop, waiting for a firstmate message.
When you receive a firstmate message containing `ALBATROSS`, append `done: received ALBATROSS` to that status file and stop.
Make no commit and write no report.'''))
PY

env -u TMUX FM_HOME="$LAB" FM_BACKEND=tmux FM_GATE_REFUSE_BYPASS=1 PATH="$LAB/shim:$PATH" \
  "$ROOT/bin/fm-spawn.sh" "$TASK" "$LAB/projects/$PROJ" --scout --harness claude --model haiku \
  > "$EVID/spawn-stdout.txt" 2>&1 || { cat "$EVID/spawn-stdout.txt"; fail "spawn failed"; }
SPAWNED=1
sed -n '1,40p' "$EVID/spawn-stdout.txt"

WINDOW=$(awk -F= '/^window=/{print $2}' "$LAB/state/$TASK.meta")
[ -n "$WINDOW" ] || fail "no endpoint recorded"
note "endpoint=$WINDOW"

# (0) the real CLI process carries the flag, and the brief is still positional.
PS_LINE=
for _ in $(seq 1 40); do
  PS_LINE=$(ps -o command= -ax | grep -F -- "--remote-control $TASK" | grep -v grep | head -1)
  [ -n "$PS_LINE" ] && break
  sleep 1
done
[ -n "$PS_LINE" ] || fail "no live claude process carrying --remote-control $TASK"
printf '%s\n' "$PS_LINE" | cut -c1-200 > "$EVID/ps-remote-control.txt"
note "live process: $(cut -c1-160 < "$EVID/ps-remote-control.txt")"
pass "the real claude CLI was launched with --remote-control $TASK"

pane() { "$REAL_TMUX" -L "$SOCKET" capture-pane -p -t "$WINDOW" 2>/dev/null; }

# Turn 1: accept the folder-trust prompt if it appears, then wait for the
# worker's own decision line.
for _ in $(seq 1 60); do
  case "$(pane)" in
    *'trust this folder'*|*'Yes, proceed'*)
      env -u TMUX FM_HOME="$LAB" FM_BACKEND=tmux FM_GATE_REFUSE_BYPASS=1 PATH="$LAB/shim:$PATH" \
        "$ROOT/bin/fm-send.sh" "$TASK" --key Enter >/dev/null 2>&1 || true ;;
  esac
  grep -q '^needs-decision \[key=rc-probe\]' "$STATUS" 2>/dev/null && break
  sleep 2
done
grep -q '^needs-decision \[key=rc-probe\]' "$STATUS" 2>/dev/null \
  || { pane | tail -40; fail "the remote-controlled worker never reached its decision"; }
pass "the remote-controlled worker ran turn 1 and reported its decision"

# (a) the Stop hook created the per-task turn-end notification for turn 1.
for _ in $(seq 1 30); do [ -e "$TURN" ] && break; sleep 1; done
[ -e "$TURN" ] || fail "state/$TASK.turn-ended was never created (Stop hook did not fire)"
T1=$(stat -f '%m %Sm' "$TURN")
note "turn-ended after turn 1: $T1"
BUSY1=$(tail -2 "$LAB/state/$TASK.busy-state" 2>/dev/null || true)
pass "caveat (a) turn 1: the Stop hook touched state/$TASK.turn-ended"

# (b) fm-peek reads such a pane unchanged.
env -u TMUX FM_HOME="$LAB" FM_BACKEND=tmux FM_GATE_REFUSE_BYPASS=1 PATH="$LAB/shim:$PATH" \
  "$ROOT/bin/fm-peek.sh" "$TASK" 30 > "$EVID/fm-peek-turn1.txt" 2>&1
PEEK_STATUS=$?
[ "$PEEK_STATUS" -eq 0 ] || { cat "$EVID/fm-peek-turn1.txt"; fail "fm-peek exited $PEEK_STATUS"; }
grep -q . "$EVID/fm-peek-turn1.txt" || fail "fm-peek captured nothing"
pass "caveat (b) fm-peek captured the remote-controlled pane (exit 0)"

sleep 2
# (b) fm-send delivers and confirms one steer.
env -u TMUX FM_HOME="$LAB" FM_BACKEND=tmux FM_GATE_REFUSE_BYPASS=1 PATH="$LAB/shim:$PATH" \
  "$ROOT/bin/fm-send.sh" "$TASK" "ALBATROSS" > "$EVID/fm-send.txt" 2>&1
SEND_STATUS=$?
cat "$EVID/fm-send.txt"
[ "$SEND_STATUS" -eq 0 ] || fail "fm-send exited $SEND_STATUS (submit not confirmed)"
pass "caveat (b) fm-send confirmed submission of a steer to the remote-controlled pane"

for _ in $(seq 1 60); do
  grep -q '^done: received ALBATROSS' "$STATUS" 2>/dev/null && break
  sleep 2
done
grep -q '^done: received ALBATROSS' "$STATUS" 2>/dev/null \
  || { pane | tail -40; fail "the worker did not act on the confirmed steer"; }
pass "the steer reached the worker: it answered on turn 2"

# (a) turn 2 re-touched the same notification: not a launch-time artifact.
for _ in $(seq 1 30); do
  T2=$(stat -f '%m %Sm' "$TURN"); [ "$T2" != "$T1" ] && break; sleep 1
done
T2=$(stat -f '%m %Sm' "$TURN")
[ "$T2" != "$T1" ] || fail "state/$TASK.turn-ended was not re-touched on turn 2"
note "turn-ended after turn 2: $T2"
pass "caveat (a) turn 2: the Stop hook re-touched state/$TASK.turn-ended"

CREW=$(env -u TMUX FM_HOME="$LAB" FM_BACKEND=tmux FM_GATE_REFUSE_BYPASS=1 PATH="$LAB/shim:$PATH" \
  "$ROOT/bin/fm-crew-state.sh" "$TASK" 2>&1 || true)

{
  printf '== claude %s / tmux %s / %s ==\n' "$(claude --version)" "$("$REAL_TMUX" -V)" "$(uname -srm)"
  printf '\n-- spawn --\n'; cat "$EVID/spawn-stdout.txt"
  printf '\n-- live process (ps -o command= -ax) --\n'; cat "$EVID/ps-remote-control.txt"
  printf '\n-- caveat (a) per-task turn-end notification --\n'
  printf 'state/%s.turn-ended after turn 1: %s\n' "$TASK" "$T1"
  printf 'state/%s.turn-ended after turn 2: %s\n' "$TASK" "$T2"
  printf '\nsemantic busy record (state/%s.busy-state tail):\n%s\n' "$TASK" "$BUSY1"
  printf '%s\n' "$(tail -3 "$LAB/state/$TASK.busy-state" 2>/dev/null || true)"
  printf '\n-- worker status log --\n'; cat "$STATUS"
  printf '\n-- fm-crew-state.sh %s --\n%s\n' "$TASK" "$CREW"
  printf '\n-- caveat (b) fm-send --\n'; cat "$EVID/fm-send.txt"
  printf '(exit %s = submit confirmed)\n' "$SEND_STATUS"
} > "$EVID/rc-live-probe-transcript.txt"

env -u TMUX FM_HOME="$LAB" FM_BACKEND=tmux FM_GATE_REFUSE_BYPASS=1 PATH="$LAB/shim:$PATH" \
  "$ROOT/bin/fm-peek.sh" "$TASK" 60 > "$EVID/fm-peek-final.txt" 2>&1 || true
"$REAL_TMUX" -L "$SOCKET" capture-pane -p -t "$WINDOW" > "$EVID/pane-final.txt" 2>&1 || true

echo "# rc live probe complete"

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 3 infos
  • ℹ️ bin/fm-spawn.sh:1280 - bin/fm-spawn.sh:1280 stays silent when claude is not resolvable from fm-spawn's own PATH, on the stated rationale that such a spawn would fail loudly in the pane anyway. That holds only when the pane's PATH equals fm-spawn's PATH. A tmux pane runs the launch through the tmux server's inherited environment, which can differ from the environment of whatever invoked fm-spawn (a daemon-driven spawn, an ssh &lt;host&gt; bin/fm-spawn.sh invocation, or the documented non-interactive-SSH case where ~/.local/bin and /opt/homebrew/bin are absent). In that case the worker launches fine but silently loses Remote Control, quietly breaking the default-on promise the docs make. Emitting the same one-line notice whenever the flag is dropped while the knob is on would close the gap, at the cost of the test/CI noise the author deliberately avoided - hence flagging rather than fixing.
  • ℹ️ bin/fm-spawn.sh:1134 - claude_supports_remote_control() (bin/fm-spawn.sh:1134) treats any non-zero claude --help as "flag unsupported", so a broken install, a bad CLAUDE_CONFIG_DIR, or any other probe failure is reported to the captain as "the installed claude CLI does not support --remote-control" - a misleading diagnosis for a non-version problem. The probe is also unbounded, unlike the cursor probes in bin/fm-cursor-lib.sh which run under timeout/gtimeout and are documented as bounded and fail-closed; a hung claude --help would stall every claude spawn. This matches the existing unbounded pi_supports_tui_mode precedent the author cited, and claude --help does no network I/O in practice, so this is a note rather than a defect.
  • ℹ️ bin/fm-spawn.sh:1279 - bin/fm-control.sh relaunch delegates to fm-spawn --relaunch, which re-resolves the flag and reuses the same task id as the Remote Control session name. The recorded verification covers one fresh spawn across two turns; it does not cover relaunching a task whose previous incarnation already registered that name. If Claude Code ever rejected or shadowed a duplicate session name, the wedged-pane recovery path would be affected. No evidence that it does - noting the untested interaction, not claiming a defect.
✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh tests/fm-spawn-dispatch-profile.test.sh (all 34 cases, including the new test_claude_remote_control_knob_selects_reachability, test_claude_remote_control_omitted_when_installed_cli_lacks_the_flag, and test_claude_secondmate_launch_carries_remote_control)
  • bin/fm-test-run.sh tests/fm-secondmate-harness.test.sh (the other suite asserting the claude launch substring with --model/--effort)
  • bin/fm-test-run.sh tests/fm-backend-orca.test.sh (asserts the claude launch prefix on a non-tmux backend)
  • claude --help | grep -- &#39;--remote-control&#39; on the installed CLI (2.1.238) to confirm the probed flag syntax is current
  • Live probe script rc-live-probe.sh (recorded in the evidence dir): spawned a throwaway scout with bin/fm-spawn.sh rc-probe-2444 &lt;scratch repo&gt; --scout --harness claude --model haiku into an isolated tmux server (private -L socket), a scratch FM_HOME, and a throwaway git repo
  • ps -o command= -ax | grep -F -- &#39;--remote-control rc-probe-2444&#39; — proved the real CLI receives the flag and still gets the positional encoded launch brief
  • Caveat (a): compared stat mtime of state/rc-probe-2444.turn-ended after turn 1 (23:09:22) and after turn 2 (23:09:31), plus the semantic busy record state/rc-probe-2444.busy-state advancing seq=3 -> seq=5 with source=claude-hook event=stop
  • Caveat (b): bin/fm-peek.sh rc-probe-2444 30 (exit 0, full transcript/composer/footer capture) and bin/fm-send.sh rc-probe-2444 &#34;ALBATROSS&#34; (exit 0 = submit confirmed, worker answered on the next turn)
  • bin/fm-crew-state.sh rc-probe-2444 — read state: done
  • tmux capture-pane of the live worker pane, showing the /rc active footer the captain sees
  • Cleanup verification: git status --porcelain empty, ~/.treehouse free of the probe pool, probe tmux socket and scratch home removed
⚠️ **Document** - 1 info
  • ℹ️ .agents/skills/harness-adapters/SKILL.md:183 - Judgment call, deliberately not applied: the claude adapter section could carry a one-line pointer noting that firstmate-launched claude panes now run with --remote-control (footer renders /rc active, and the captain may steer the same session from claude.ai/code or the phone concurrently with an agent's fm-send). I left it out because nothing in that section became false: bin/fm-spawn.sh:1112 declares launch commands as the script's own, the skill owns only busy-state/exit/dialogs/quirks, and the fact already has two owners (AGENTS.md layout table + docs/configuration.md "Claude Remote Control") plus its evidence in docs/verification/runtime-backends.md. Adding a third copy to always-loaded agent guidance would trade a real placement rule for a perceived gap. Worth a follow-up only if concurrent human-from-phone steering ever turns into an operational hazard an agent must reason about.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Every claude-harness crewmate, scout, and secondmate now launches with
`--remote-control <task-id>`, so the captain can read and steer a worker
from claude.ai/code or the Claude mobile app without changing anything
else about the session.

The local gitignored `config/claude-remote-control` file opts a home out
with "off"; absent, empty, or "on" enables it, and an unrecognized value
warns and keeps the default rather than failing a spawn over a
reachability setting. Resolution is guarded by the launch placeholder
rather than the harness name, so a raw launch command that happens to
start with `claude` is left alone. The flag is version-dependent, so
fm-spawn probes the same bare `claude` it launches, exactly like the Pi
`--tui-mode` probe: an installed CLI that does not advertise the flag
launches unchanged and says so once.

The per-task supervision wiring was verified live under the flag on
Claude Code 2.1.234: the Stop hook still touches the turn-end
notification and records the semantic busy state, and fm-peek and
fm-send read and steer such a pane unchanged. docs/verification/
runtime-backends.md owns that evidence.
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Reviewed the full diff. Claude Remote Control is on by default for every claude-harness spawn (absent/on enable; only off opts out). That is a default-behavior change (new reachability without an explicit grant), so I will not auto-merge it.

I approved fork CI. Waiting on green checks including no-mistakes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants