Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e2fe5b7
fix: decide session-lock ownership by session, not by process tree
Aug 18, 2026
db797f4
docs: state the launch-marker scope limit where the contract lives
Aug 18, 2026
9560ddb
no-mistakes(review): make session-cohort proof symmetric and stop sam…
Aug 18, 2026
2932ca7
no-mistakes(review): narrow MainThread identity and report the real f…
Aug 18, 2026
af5014b
no-mistakes(review): isolate autoarm fixtures and guard the sample-ga…
Aug 18, 2026
503ff21
no-mistakes(review): refuse guessed MainThread scripts and own the si…
Aug 18, 2026
c2175bc
no-mistakes(review): scope the argv match claims and unify the yield …
Aug 19, 2026
a9425ca
no-mistakes(review): scope launch markers to the harness that exports…
Aug 19, 2026
789f22e
no-mistakes(review): narrow bare-interpreter identity to pre-flag arg…
Aug 19, 2026
fdbe22d
no-mistakes(review): type cohort acceptance from executable identity …
Aug 20, 2026
27af517
fix: type acceptance from the command name a real install reports
Aug 20, 2026
c67c1e2
no-mistakes(review): announce reclaims of live but unidentified lock …
Aug 24, 2026
a2ec599
no-mistakes(review): name the co-located holder and state the residua…
Aug 24, 2026
e241586
no-mistakes(review): defer the race statement and guard the ancestry …
Aug 24, 2026
9b81a43
no-mistakes(review): state the general cause of an announced lock rec…
Aug 24, 2026
03826bd
no-mistakes(review): order launch markers by process start time
Aug 24, 2026
66419f3
no-mistakes(test): register tests/session-signals.sh in changed-test map
Aug 24, 2026
96a879c
no-mistakes(document): widen FM_PROC_ROOT_OVERRIDE scope, add launch-…
Aug 24, 2026
2a6169e
feat(bin): report remaining context window from session transcripts
Aug 30, 2026
0f05bb5
feat(bin): add fm-time.sh for propose/approve time tracking and month…
Sep 8, 2026
63f2bcf
no-mistakes(document): docs(scripts): list fm-time.sh in bin/ toolbel…
Sep 8, 2026
87e2507
no-mistakes: apply CI fixes
Sep 8, 2026
393b128
no-mistakes: apply CI fixes
Sep 8, 2026
1b3972b
no-mistakes: apply CI fixes
Sep 8, 2026
f2569b0
Merge pull request #1 from adriantoczydlowski/fm/fm-time-tracking-b
adriantoczydlowski Sep 8, 2026
f679ccd
Merge remote-tracking branch 'fork/main' into fm/fm-session-lock-land…
Sep 8, 2026
0dc5056
fix(test): open fm-session-lock-identity-live-e2e with the shared fm_…
Sep 8, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Tool references record empirical knowledge for those executable owners.

For an approved new adapter check, use the spawn owner's raw-launch escape hatch only for a trivial supervised task.
Verify detection in `../../../bin/fm-harness.sh`, launch in `../../../bin/fm-spawn.sh`, busy state in `../../../bin/fm-busy-lib.sh`, shared composer behavior in `../../../bin/fm-composer-lib.sh`, lifecycle in `../../../bin/fm-control-lib.sh`, and tmux liveness in `../../../bin/backends/tmux.sh` when secondmate use is supported.
Also observe, in a real child of a real session of that harness, whether it exports a launch marker naming its own session pid: `FM_SESSION_LAUNCH_MARKERS` in `../../../bin/fm-session-lock-lib.sh` has a verified row for Claude alone, and every other harness decides session-lock ownership by process ancestry until a row is verified for it, which `../../../docs/verification/runtime-backends.md#session-lock-identity-and-the-suspended-holder` owns.
Also verify primary integration through `references/common/primary-hooks.md`, model discovery through `references/common/model-and-effort.md`, and one tool record.
A value remains unreachable until its executable owner, portable regression, applicable credentialed live guard, and verification record land together.
`../firstmate-coding-guidelines/SKILL.md` owns harness-dependent proof.
9 changes: 5 additions & 4 deletions bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,19 @@ fm_hook_payload_is_foreign_host "$PAYLOAD" && exit 0
fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0

# --- identity: only the lock-owning session's hooks may arm ------------------
# A prior session may have died after leaving its numeric harness pid in .lock.
# Use the shared liveness predicate to recognize only that stale-owner case.
# A prior session may have left its numeric harness pid in .lock after dying or
# being suspended. The shared holder predicate recognizes exactly those
# recoverable cases and keeps a genuinely competing session inert.
# Defer the mutating claim until after the unchanged AFK and need gates, so an
# idle or away home remains byte-for-byte inert. Missing or malformed locks are
# uncertainty rather than stale-owner evidence and remain inert.
# uncertainty rather than recoverable-owner evidence and remain inert.
RECOVER_SESSION_LOCK=0
if ! fm_session_lock_owned_by_self "$STATE"; then
LOCK_PID=$(cat "$STATE/.lock" 2>/dev/null || true)
case "$LOCK_PID" in
''|*[!0-9]*) exit 0 ;;
esac
fm_harness_pid_alive "$LOCK_PID" && exit 0
fm_session_lock_holder_competes "$LOCK_PID" && exit 0
RECOVER_SESSION_LOCK=1
fi

Expand Down
154 changes: 154 additions & 0 deletions bin/fm-context.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# fm-context.sh - how much context window this session and each crew has left.
#
# Why this exists: a session that runs out of context compacts, and a crew that
# compacts mid-task can lose a decision it was told once and then repeat work or
# quietly drop a constraint. The percentage a harness renders in its own status
# line is visible only to whoever is looking at that pane, so it cannot be acted
# on by supervision and it cannot be read for THIS session at all.
#
# The authoritative source is the session transcript, not rendered output. Claude
# Code records exact token usage on every assistant record, so the current
# context is the last such record's input_tokens + cache_creation_input_tokens +
# cache_read_input_tokens. That is a structural fact rather than a vendor string,
# so it does not break when a status line is restyled.
#
# Usage: fm-context.sh [--json] [--window <tokens>] [--threshold <percent>] [<task-id>...]
# No task ids reports this session plus every task with a state/<id>.meta.
# --window context window to measure against (default 1000000).
# --threshold exit 1 if any reported session has less than this percent of the
# window remaining, so a guard can gate on it. Default 0, never fails.
# --json one JSON object per line instead of the table.
#
# Both numbers are always printed - used and remaining - because a bare
# percentage is read as either one depending on who is reading it.
#
# LIMITATION, stated rather than hidden: the window is a parameter, not a
# measurement. The transcript records what was consumed, never the model's
# capacity, and it does not record the harness's auto-compact headroom. The
# model name is printed so a wrong --window is visible instead of silent.
set -euo pipefail

FM_HOME="${FM_HOME:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)}"
PROJECTS_ROOT="${CLAUDE_PROJECTS_ROOT:-$HOME/.claude/projects}"

window=1000000
threshold=0
as_json=0
declare -a wanted=()

die() { printf 'fm-context.sh: %s\n' "$1" >&2; exit 2; }

while [ $# -gt 0 ]; do
case "$1" in
--json) as_json=1; shift ;;
--window) [ $# -ge 2 ] || die "--window needs a value"; window="$2"; shift 2 ;;
--threshold) [ $# -ge 2 ] || die "--threshold needs a value"; threshold="$2"; shift 2 ;;
-h|--help) sed -n '2,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
-*) die "unknown option: $1" ;;
*) wanted+=("$1"); shift ;;
esac
done

command -v jq >/dev/null 2>&1 || die "jq is required"
[ "$window" -gt 0 ] 2>/dev/null || die "--window must be a positive integer"

# A transcript lives under a directory named after its working directory with
# every '/' and '.' replaced by '-', so the mapping is derivable rather than
# needing a lookup table.
transcript_dir() {
printf '%s/%s' "$PROJECTS_ROOT" "$(printf '%s' "$1" | tr './' '--')"
}

# Streams the file and keeps the newest assistant usage, so a large transcript is
# not slurped into memory. Prints "<tokens> <model>" or nothing.
read_usage() {
local file="$1"
[ -r "$file" ] || return 0
jq -rn '
reduce inputs as $r ([0,"?"];
if $r.type == "assistant" and ($r.message.usage != null)
then [ (($r.message.usage.input_tokens // 0)
+ ($r.message.usage.cache_creation_input_tokens // 0)
+ ($r.message.usage.cache_read_input_tokens // 0)),
($r.message.model // "?") ]
else . end)
| select(.[0] > 0) | "\(.[0]) \(.[1])"
' "$file" 2>/dev/null || true
}

# The newest transcript in a directory, used when no session id is known.
newest_transcript() {
local dir="$1"
[ -d "$dir" ] || return 0
find "$dir" -maxdepth 1 -name '*.jsonl' -printf '%T@ %p\n' 2>/dev/null |
sort -rn | head -1 | cut -d' ' -f2-
}

emit() {
local label="$1" tokens="$2" model="$3"
local used_pct left left_pct
used_pct=$(( tokens * 100 / window ))
left=$(( window - tokens ))
[ "$left" -lt 0 ] && left=0
left_pct=$(( left * 100 / window ))
if [ "$as_json" = 1 ]; then
jq -cn --arg label "$label" --arg model "$model" \
--argjson used "$tokens" --argjson left "$left" \
--argjson used_pct "$used_pct" --argjson left_pct "$left_pct" \
--argjson window "$window" \
'{label:$label, model:$model, window:$window, used:$used,
used_percent:$used_pct, remaining:$left, remaining_percent:$left_pct}'
else
printf '%-30s %9d used (%3d%%) %9d left (%3d%%) %s\n' \
"$label" "$tokens" "$used_pct" "$left" "$left_pct" "$model"
fi
if [ "$threshold" -gt 0 ] && [ "$left_pct" -lt "$threshold" ]; then
return 1
fi
return 0
}

breached=0

report_one() {
local label="$1" file="$2"
local line
line=$(read_usage "$file")
[ -n "$line" ] || return 0
emit "$label" "${line%% *}" "${line##* }" || breached=1
}

if [ "$as_json" = 0 ]; then
printf '%-30s %-22s %-22s %s\n' "session" "context" "remaining" "model"
fi

# This session, when its id is known; otherwise the newest transcript for this home.
if [ ${#wanted[@]} -eq 0 ]; then
self_dir=$(transcript_dir "$FM_HOME")
self_file=""
if [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && [ -r "$self_dir/$CLAUDE_CODE_SESSION_ID.jsonl" ]; then
self_file="$self_dir/$CLAUDE_CODE_SESSION_ID.jsonl"
else
self_file=$(newest_transcript "$self_dir")
fi
[ -n "$self_file" ] && report_one "this session" "$self_file"
fi

# Crew: each task's worktree is recorded in its metadata, and the worktree maps
# to a transcript directory by the same rule.
shopt -s nullglob
for meta in "$FM_HOME"/state/*.meta; do
id=$(basename "$meta" .meta)
if [ ${#wanted[@]} -gt 0 ]; then
match=0
for w in "${wanted[@]}"; do [ "$w" = "$id" ] && match=1; done
[ "$match" = 1 ] || continue
fi
worktree=$(sed -n 's/^worktree=//p' "$meta" | head -1)
[ -n "$worktree" ] || continue
report_one "$id" "$(newest_transcript "$(transcript_dir "$worktree")")"
done
shopt -u nullglob

exit "$breached"
48 changes: 42 additions & 6 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
# Writes the harness (agent) process PID found by walking the shell's ancestry,
# which lives as long as the firstmate session - unlike the transient subshell
# PID of any one tool call, which is dead moments after it is written.
#
# Acquisition yields ONLY to a genuinely competing session, decided by
# fm_session_lock_holder_competes in bin/fm-session-lock-lib.sh: a recorded
# holder that is this same session in another process tree, or a durably
# suspended one, does not block. Every acquisition then converges the lock onto
# the acquiring session's own pid, so repeated runs are idempotent, and any live
# holder it converged onto, took over from, or could not identify is named on
# stdout rather than being silent.
#
# Usage: fm-lock.sh acquire; exit 1 unless ownership is verified
# fm-lock.sh status print holder and liveness; always exits 0
set -u
Expand All @@ -29,7 +38,13 @@ if [ "${1:-}" = "status" ]; then
echo "lock: unreadable"
exit 0
}
if fm_harness_pid_alive "$old"; then echo "lock: held by live harness pid $old"; else echo "lock: stale (pid $old dead or not a harness)"; fi
if ! fm_harness_pid_alive "$old"; then
echo "lock: stale (pid $old dead or not a harness)"
elif fm_harness_pid_suspended "$old"; then
echo "lock: held by SUSPENDED harness pid $old (reclaimable: a stopped session is not holding this home)"
else
echo "lock: held by live harness pid $old"
fi
exit 0
fi

Expand All @@ -55,13 +70,17 @@ release_claim_lock() {
trap release_claim_lock EXIT
trap 'exit 1' HUP INT TERM

# Why the yield reason is captured rather than printed here: the fast path
# below is only a pre-check, and the authoritative decision is retaken under the
# claim lock. Printing it once, at the end, keeps one acquisition to one line.
YIELDED=
if [ -f "$LOCK" ] && [ ! -L "$LOCK" ]; then
old=$(cat "$LOCK" 2>/dev/null || true)
if [ "$old" = "$me" ]; then
echo "lock acquired: harness pid $me"
exit 0
fi
if fm_harness_pid_alive "$old"; then
if fm_session_lock_holder_competes "$old"; then
echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2
exit 1
fi
Expand All @@ -86,9 +105,22 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then
echo "error: session lock is unreadable; operate read-only until resolved" >&2
exit 1
}
if [ "$old" != "$me" ] && fm_harness_pid_alive "$old"; then
echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2
exit 1
if [ "$old" != "$me" ]; then
if fm_session_lock_holder_competes "$old"; then
echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2
exit 1
fi
# Reclaiming an owner that is gone has always been silent and stays that
# way. The three holders that are alive and still yield are the ones worth
# naming - this session's own holder in another tree, a durably suspended
# session, and a live process the identity rules cannot type as a harness -
# so name them rather than moving the lock out from under a visible process
# quietly. None of the three is exceptional; the last is the ordinary
# reading of a stale lock whose pid an unrelated process now occupies.
# The predicate supplies the whole clause and leaves it empty for the silent
# case, so this is one assignment rather than a second liveness question that
# could disagree with the classification that just ran.
YIELDED=$FM_SESSION_HOLDER_YIELD_REASON
fi
fi
if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then
Expand All @@ -104,4 +136,8 @@ if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then
exit 1
fi
release_claim_lock
echo "lock acquired: harness pid $me"
if [ -n "$YIELDED" ]; then
echo "lock acquired: harness pid $me ($YIELDED)"
else
echo "lock acquired: harness pid $me"
fi
Loading
Loading