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/operational-home-layout/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ state/ runtime records and signals; gitignored
branch-outcomes.jsonl .branch-outcomes-cursor .branch-outcomes-processed .<task>.branch-outcome-index .branch-outcome-index-ready .branch-outcomes-tail.jsonl Pi supervision-branch durable outcome store, its read cursor, main's processed marker, bounded latest per-task status-coverage caches, their recovery marker, and a bounded display copy of the newest rows; bin/fm-branch-outcome.sh owns the formats
branch-session/ .branch-session .branch-mirror-cursor the branch's per-main-session conversations, the pointer to the current one, and the dialog-mirror cursor; extension-owned (docs/pi-supervision-branch.md)
.branch-eligible-rows .branch-eligible-owner .main-eligible-rows per-actor wake-row claims and branch-owner evidence; docs/watcher-continuity.md owns the acknowledgement contract
.supervision-host* supervision host process record, engine conversation, current turn scope and report receipts, and bounded ledger of every close and engine turn; bin/fm-supervision-host.sh owns them; never touch
.supervision-host* supervision host process record, engine conversation, every engine session id it has launched (read by bin/fm-captain-message-sweep.py to skip those transcripts), current turn scope and report receipts, and bounded ledger of every close and engine turn; bin/fm-supervision-host.sh owns them; never touch
.lease-<task> per-task supervision lease naming which actor (main or branch) may change that task; bin/fm-lease-lib.sh owns the contract the guarded scripts enforce
x-watch.check.sh generated Relay poll shim; present only when opted in (section 14)
tool-updates.check.sh generated watched-tool update poll shim and its .check-trust binding; present only after bin/fm-tool-update-check.sh arm; its report record .tool-updates is what keeps one pending update from being reported on every poll
Expand Down
35 changes: 35 additions & 0 deletions bin/fm-captain-message-sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
# and swept by the directory runs too, and until one has, the capture record
# says the directory is only inferred so the page never shows a green band over
# a list it cannot vouch for.
# A supervision-host engine conversation can land in the same directory and is
# never swept (supervision_engine_sessions).
#
# WHO RUNS IT. Two callers, each enough for what it can see:
# - bin/fm-captain-message-hook.sh, a Claude Stop hook, right as a turn ends,
Expand Down Expand Up @@ -168,6 +170,37 @@ def utc_now():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def supervision_engine_sessions(home):
"""The session ids of every supervision-branch engine conversation this
home has run (bin/fm-supervision-host.sh): the ones
state/.supervision-host-engine-sessions lists, written before each
conversation's first turn starts and never removed, plus the one
state/.supervision-host-engine records. The engine's working notes (stale
wake, seq numbers, MAIN, pane ids) are never his chat, so a transcript
named for one of these sessions is never read, identified by
session identity and never by what its notes say."""
state = os.path.join(home, "state")
sessions = set()
try:
with open(os.path.join(state, ".supervision-host-engine-sessions"),
encoding="utf-8") as fh:
sessions.update(fh.read().split())
except (OSError, UnicodeDecodeError):
pass
try:
with open(os.path.join(state, ".supervision-host-engine"),
encoding="utf-8") as fh:
sessions.update(re.findall(r"(?m)^session=(\S+)$", fh.read()))
except (OSError, UnicodeDecodeError):
pass
return sessions


def is_engine_transcript(path, sessions):
name = os.path.basename(path)
return name.endswith(".jsonl") and name[:-len(".jsonl")] in sessions


def default_transcript_dir(home):
base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
encoded = re.sub(r"[^A-Za-z0-9]", "-", os.path.abspath(home))
Expand Down Expand Up @@ -737,6 +770,8 @@ def sweep(home, since, paths=None, directory=None):
and os.path.isfile(os.path.join(directory, f))),
key=os.path.getmtime)
targets += [p for p in named if p not in targets]
engine_sessions = supervision_engine_sessions(home)
targets = [p for p in targets if not is_engine_transcript(p, engine_sessions)]
targets = [p for p in targets if os.path.isfile(p)]
if not targets:
return {"active": False, "dir": directory, "transcripts": 0, "new": 0,
Expand Down
3 changes: 3 additions & 0 deletions bin/fm-supervision-host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ unset FM_WATCH_PREDECESSOR_ARM_PID FM_SUPERVISION_ACTOR FM_BRANCH_REPORT_TURN

HOST_RECORD="$STATE/.supervision-host"
ENGINE_RECORD="$STATE/.supervision-host-engine"
ENGINE_SESSIONS="$STATE/.supervision-host-engine-sessions"
TURN_FILE="$STATE/.supervision-host-turn"
RECEIPTS="$STATE/.supervision-host-receipts"
PROMPT_FILE="$STATE/.supervision-host-prompt"
Expand Down Expand Up @@ -948,6 +949,8 @@ handle_wake() { # <reason-lines>
errors=$(mktemp "$STATE/.supervision-host-errors.XXXXXX") || errors=/dev/null
TURN_RESULT=$result
TURN_ERRORS=$errors
grep -qxF -- "$ENGINE_SESSION" "$ENGINE_SESSIONS" 2>/dev/null \
|| printf '%s\n' "$ENGINE_SESSION" >> "$ENGINE_SESSIONS"
ENGINE_RUNNING=1
# Backgrounded and waited, so a signal to the host is handled at once
# instead of after the whole turn; the cleanup stops the engine.
Expand Down
1 change: 1 addition & 0 deletions docs/command-center.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ The reply box says which of these it is before you send.
Your replies appear under the message, so the exchange reads as a conversation.

These are captured automatically: `bin/fm-captain-message-sweep.py` reads the Claude conversation record on disk, which holds every message verbatim, and records every reply firstmate gave - no agent chooses or remembers to record anything. Only what firstmate said to you counts: its working narration between tool calls, a subagent's chatter, and the lines the harness wrote itself are not replies and never become messages.
Neither does anything the [supervision host](supervision-host.md)'s background engine writes: its conversations can sit in the same directory as firstmate's, and the sweep skips every one of them by session id.
It runs from two places, and they know different things. The Claude Stop hook (`bin/fm-captain-message-hook.sh`) fires as each turn ends and hands over the hook payload, which NAMES the transcript that session writes; the sweep reads that one file and nothing else, so the hook is bounded to a few seconds and never holds a turn end. This server sweeps on its own poll cadence, over the directory it derives from the home's path plus every transcript a payload has named - that is what catches turns that ended unusually (interrupted, errored, killed) once their session moves on, and what backfills at startup.
Until some session has named its transcript, the only thing capture has is that derived directory, which is a guess - a session started from somewhere else writes where nothing is looking - so the Messages list says so rather than showing a green band over a list it cannot vouch for.
The messages already in the log are the dedupe record, so the two runners can never record the same message twice.
Expand Down
2 changes: 2 additions & 0 deletions docs/supervision-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,8 @@ Today the only verified engine is Claude's print mode, measured on Claude Code 2
- The prompt is the first argument and stdin is `/dev/null`, because an open stdin costs a three-second wait.
- The engine runs from the tracked code root.
So its session files land in Claude's own project store for that directory and appear in that directory's resume list.
- Before a conversation's first turn starts, the host appends its session id to `state/.supervision-host-engine-sessions` and never removes it.
So the captain-message capture (`bin/fm-captain-message-sweep.py`) never reads an engine conversation as firstmate's, whether its turn failed or the host has since moved to a new conversation.

**Result and cost**

Expand Down
66 changes: 66 additions & 0 deletions tests/command-center.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2369,6 +2369,71 @@ test_a_transcript_the_payload_names_is_captured_wherever_it_lives() {
pass "a transcript the payload names is captured wherever it lives"
}

# bin/fm-supervision-host.sh runs a headless branch engine beside main in the
# same per-home transcript directory; its own working notes (stale wake, seq
# numbers, MAIN, pane ids) are not his chat. The sweep identifies an engine
# transcript by session identity - every session id the host listed in
# state/.supervision-host-engine-sessions before that conversation's first
# turn, plus the one state/.supervision-host-engine records - never by what
# it says.
test_the_supervision_engines_own_transcript_is_never_captured() {
local home tdir log at
home="$TMP_ROOT/engine-skip"
tdir="$TMP_ROOT/engine-skip-transcripts"
mkdir -p "$home/state" "$tdir"
at=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
entry r-main end_turn false "$at" \
'{"type":"text","text":"The fix landed on main."}' > "$tdir/sess-main.jsonl"
entry r-engine end_turn false "$at" \
'{"type":"text","text":"stale wake seq 61, holding for jt-metal-process-explained"}' \
> "$tdir/sess-engine.jsonl"
printf 'engine=claude\nmodel=sonnet\nsession=sess-engine\nkey=k\nturns=3\nconversation_cost=0\n' \
> "$home/state/.supervision-host-engine"

python3 "$SWEEP" --home "$home" --transcripts "$tdir" \
|| fail "the directory sweep failed"
log="$home/data/captain-messages.jsonl"
assert_equals 1 "$(wc -l < "$log")" \
"the supervision engine's own transcript was captured alongside main's"
assert_grep 'fix landed on main' "$log" \
"the main session's message was lost while excluding the engine's"
assert_not_contains "$(cat "$log")" 'stale wake seq 61' \
"the supervision engine's working notes were captured as his chat"

# A payload naming the main session's transcript still records as before.
printf '{"transcript_path":"%s"}' "$tdir/sess-main.jsonl" \
| python3 "$SWEEP" --home "$home" --from-payload \
|| fail "the sweep failed on a payload naming the main session"
assert_equals 1 "$(jq -r '.named' "$home/state/.captain-message-capture")" \
"the main session's transcript was not remembered once its payload named it"

# A conversation the host has only launched - listed, its first turn not
# yet succeeded, or failed - is the engine's from its first byte.
entry r-engine-2 end_turn false "$at" \
'{"type":"text","text":"stale wake seq 62, a new conversation"}' \
> "$tdir/sess-engine-2.jsonl"
printf 'sess-engine\nsess-engine-2\n' > "$home/state/.supervision-host-engine-sessions"
python3 "$SWEEP" --home "$home" --transcripts "$tdir" \
|| fail "the directory sweep failed on a listed engine conversation"
assert_equals 1 "$(wc -l < "$log")" \
"a listed engine conversation was captured before its first turn was recorded"

# Rotation and a failed turn move or remove the record; every conversation
# once listed stays the engine's.
printf 'engine=claude\nmodel=sonnet\nsession=sess-engine-2\nkey=k\nturns=1\nconversation_cost=0\n' \
> "$home/state/.supervision-host-engine"
python3 "$SWEEP" --home "$home" --transcripts "$tdir" \
|| fail "the directory sweep failed after the engine record rotated"
assert_equals 1 "$(wc -l < "$log")" \
"the previous engine conversation was captured once the record rotated"
rm -f "$home/state/.supervision-host-engine"
python3 "$SWEEP" --home "$home" --transcripts "$tdir" \
|| fail "the directory sweep failed after the engine record was removed"
assert_equals 1 "$(wc -l < "$log")" \
"an engine conversation was captured once a failed turn removed the record"
pass "the supervision engine's own transcript is identified by session identity and never captured"
}

# The server sweeps every few seconds, so it is routinely the one that reads a
# turn's bytes first. The hook's run then has nothing new to read - and the
# payload it carries is still the only thing that knows where this session
Expand Down Expand Up @@ -2661,6 +2726,7 @@ test_a_hand_recorded_question_without_its_id_folds_only_when_identical
test_a_captured_message_is_labelled_only_by_the_evidence_it_carries
test_a_message_recorded_by_hand_is_never_a_second_unlabelled_row
test_a_transcript_the_payload_names_is_captured_wherever_it_lives
test_the_supervision_engines_own_transcript_is_never_captured
test_a_named_transcript_is_remembered_even_with_nothing_new_to_read
test_a_response_read_across_two_sweeps_is_recorded_once
test_a_torn_row_costs_itself_and_nothing_after_it
Expand Down
9 changes: 9 additions & 0 deletions tests/fm-supervision-host.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ n=$(( $(ls "$FM_HOME"/engine-call.* 2>/dev/null | wc -l) + 1 ))
printf 'mode=%s\nactor=%s\nholder=%s\nprimary=%s\nturn=%s\n' "$mode" "${FM_SUPERVISION_ACTOR:-}" \
"${FM_LEASE_HOLDER_PID:-}" "${FM_SUPERVISION_PRIMARY_HARNESS:-}" "${FM_BRANCH_REPORT_TURN:-}"
for a in "$@"; do printf 'arg=%s\n' "$a"; done
listed=no
for a in "$@"; do
! grep -qxF -- "$a" "$STATE/.supervision-host-engine-sessions" 2>/dev/null || listed=yes
done
printf 'listed=%s\n' "$listed"
} > "$FM_HOME/engine-call.$n"
case "$mode" in held|captain-held) printf 'ready\n' > "$FM_HOME/stub-ready" ;; esac
# Like Claude, the reported cost is the conversation's running total.
Expand Down Expand Up @@ -1933,6 +1938,9 @@ test_attended_latch_keeps_closes_on_main_and_records_recovery_off_main() {
assert_re '^supervision-host: the supervision session could not take this wake: the engine turn failed \(exit 3\); this wake is yours$' \
"$home/host.out" "the first engine error must hand the wake back with its reason"
assert_no_re 'paused' "$home/host.out" "one engine error must not latch the session"
assert_re '^listed=yes$' "$home/engine-call.1" "a conversation's session id must be listed before a turn that then fails"
assert_grep "$(sed -n '/^arg=--session-id$/{n;s/^arg=//p;}' "$home/engine-call.1")" "$home/state/.supervision-host-engine-sessions" \
"a failed turn's conversation must stay listed as the engine's"
main_drain_and_ack "$home"

park_again "$home"
Expand Down Expand Up @@ -2006,6 +2014,7 @@ test_away_wake_is_handled_on_the_engine_and_never_reaches_main() {
assert_re '^arg=dontAsk$' "$first" "the engine must never prompt"
assert_re '^arg=sonnet$' "$first" "the engine must default to its default model"
assert_re '^arg=--session-id$' "$first" "the first turn must open a new conversation"
assert_re '^listed=yes$' "$first" "a new conversation's session id must be listed before its first turn starts"
assert_re '^POSTURE: AWAY\.' "$first" "the wake must carry the away tail"
assert_grep 'keep the export worker on low effort' "$home/state/.host-mirror.jsonl" "fixture: the captain's dialog was not mirrored"
assert_no_re 'MAIN DIALOG MIRROR|low effort' "$first" "an away wake must carry no dialog mirror"
Expand Down
Loading