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
28 changes: 20 additions & 8 deletions .pi/extensions/fm-calm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,28 +135,40 @@ export default function (pi: ExtensionAPI) {
// continuations, retries, or compaction that stay inside the same run.
let agentRunActive = false;
let workingShipShown = false;
let workingShipWidgetDisposed = false;
// One animation instance per extension lifetime. Hiding the working widget freezes
// this state; the next working period resumes it. session_start resets it so a fresh
// Pi session starts at the normal initial position. Never module-global.
const workingShipAnimation = createCalmWorkingShipAnimation();

// Single owner of Calm's working-row presentation choice. The widget is only created
// or removed on a real transition, so repeated starts cannot duplicate its timer.
// The slot is shared with standalone Pi Calm; the dispose signal prevents turning
// Firstmate Calm off from clearing a widget that the other extension installed.
const applyWorkingPresentation = (
ui: ExtensionUIContext,
forceStockVisibility = false,
): void => {
const showShip = agentRunActive && calmPresentationIsActive();
if (showShip !== workingShipShown) {
workingShipShown = showShip;
ui.setWidget(
CALM_WORKING_SHIP_WIDGET_KEY,
showShip
? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation)
: undefined,
);
ui.setWorkingVisible(!showShip);
} else if (forceStockVisibility && !showShip) {
if (showShip) {
ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, (tui) => {
workingShipWidgetDisposed = false;
const widget = createCalmWorkingShipWidget(tui, workingShipAnimation);
const dispose = widget.dispose;
widget.dispose = () => {
workingShipWidgetDisposed = true;
dispose();
};
return widget;
});
ui.setWorkingVisible(false);
} else if (!workingShipWidgetDisposed) {
ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined);
ui.setWorkingVisible(true);
}
} else if (forceStockVisibility && !showShip && !workingShipWidgetDisposed) {
ui.setWorkingVisible(true);
}
};
Expand Down
7 changes: 6 additions & 1 deletion .pi/extensions/lib/fm-calm-working-ship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ const ANSI_FOREGROUND: Record<Exclude<CalmWorkingShipColor, "plain">, string> =
// Restores the default foreground so color never bleeds into padding or later frames.
const RESET = "\u001b[39m";

export const CALM_WORKING_SHIP_WIDGET_KEY = "firstmate-calm-working-ship";
// The working-row widget slot is deliberately shared with the standalone Pi Calm
// extension, which installs its boat under the same "calm-working-ship" key. Pi
// replaces widgets under one key, so a session that loads both Calms renders a
// single boat and a session loading either alone is unchanged. Rename the slot
// in both implementations together, or dual-install sessions duplicate the boat.
export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship";

export type CalmWorkingShipAnimation = Omit<CalmWorkingShipSprite, "frame"> & {
/** Render one frame that exactly fits `width`, clamping the track to it first. */
Expand Down
69 changes: 68 additions & 1 deletion bin/fm-control.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@
# worker account pin (bin/fm-worker-account-lib.sh) here, so a pin
# that no longer resolves or is signed out refuses before the old
# agent stops.
# The same pre-stop refusal applies to this home's worker tool
# exclusions (bin/fm-exclude-tools-lib.sh): a malformed list, or a
# replacement runtime that cannot hide the listed tools.
# --note is required for a ship or scout, whose replacement
# inherits the local copy but none of the conversation; a
# secondmate reconciles its own home's records at startup, so its
Expand Down Expand Up @@ -180,6 +183,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
. "$SCRIPT_DIR/fm-worker-account-lib.sh"
# shellcheck source=bin/fm-dock-lib.sh
. "$SCRIPT_DIR/fm-dock-lib.sh"
# shellcheck source=bin/fm-exclude-tools-lib.sh
. "$SCRIPT_DIR/fm-exclude-tools-lib.sh"

POLL=${FM_CONTROL_POLL:-0.5}
SETTLE_WAIT=${FM_CONTROL_SETTLE_WAIT:-5}
Expand Down Expand Up @@ -569,9 +574,65 @@ do_interrupt() {
printf '%s cancel=%s' "$proof" "$cancel"
}

# Drop busy_gen from the task record when it still names <gen>.
# fm-busy-event.sh owns the sidecar and the record; fm_backlog_atomic_transition
# publish owns the task record. Clearing the line inside the busy writer would
# take the task-record lock that teardown and spawn already hold; the busy
# writer is their child process, so it would wait on a live holder that is
# itself waiting on the child, and neither would ever proceed.
clear_retired_meta_busy_gen() { # <gen>
local gen=$1 meta="$STATE/$ID.meta" lock tmp current line
[ -n "$gen" ] || return 0
[ -f "$meta" ] && [ ! -L "$meta" ] || return 0
if ! declare -F fm_backlog_atomic_transition >/dev/null 2>&1; then
# shellcheck source=bin/fm-tasks-axi-lib.sh
. "$SCRIPT_DIR/fm-tasks-axi-lib.sh"
# shellcheck source=bin/fm-backlog-transition-lib.sh
. "$SCRIPT_DIR/fm-backlog-transition-lib.sh"
fi
lock=$(fm_meta_lock_path "$meta") || return 1
fm_lock_acquire_wait "$lock"
current=$(fm_meta_get "$meta" busy_gen)
if [ "$current" != "$gen" ]; then
fm_lock_release "$lock"
return 0
fi
tmp=$(mktemp "$STATE/.$ID.meta.retire.XXXXXX") || {
fm_lock_release "$lock"
return 1
}
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
busy_gen=*) ;;
*)
printf '%s\n' "$line" >> "$tmp" || {
rm -f "$tmp"
fm_lock_release "$lock"
return 1
}
;;
esac
done < "$meta" || {
rm -f "$tmp"
fm_lock_release "$lock"
return 1
}
if ! fm_backlog_atomic_transition publish "$tmp" "$meta" "task record" "$STATE"; then
rm -f "$tmp"
fm_lock_release "$lock"
return 1
fi
fm_lock_release "$lock"
}

retire_busy_incarnation() {
local gen=
if [ -f "$STATE/$ID.busy-gen" ]; then
"$SCRIPT_DIR/fm-busy-event.sh" retire "$STATE" "$ID" --current-gen >/dev/null 2>&1 || true
gen=$(fm_busy_current_gen "$STATE" "$ID" 2>/dev/null || true)
if [ -n "$gen" ] \
&& "$SCRIPT_DIR/fm-busy-event.sh" retire "$STATE" "$ID" --gen "$gen" >/dev/null 2>&1; then
clear_retired_meta_busy_gen "$gen" || true
fi
fi
}

Expand Down Expand Up @@ -913,6 +974,12 @@ resolve_relaunch_profile() {
[ "$account_model" != default ] || account_model=
fm_worker_account_select "$TARGET_HARNESS" "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" \
"$account_model" "$TARGET_HARNESS" >/dev/null || return 1
# Likewise config/crew-exclude-tools: a malformed file, or a replacement
# runtime that cannot hide the listed tools, refuses here, before the old
# agent stops. Secondmate agents are not covered.
if [ "$KIND" != secondmate ]; then
fm_exclude_tools_check "$TARGET_HARNESS" 0 "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" >/dev/null || return 1
fi
}

# safe_checkpoint: prove, before anything is stopped, that the work a relaunch
Expand Down
64 changes: 64 additions & 0 deletions bin/fm-exclude-tools-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# fm-exclude-tools-lib.sh - shared parsing and runtime-support checks for
# config/crew-exclude-tools, sourced by bin/fm-spawn.sh and bin/fm-control.sh.
#
# docs/configuration.md "Worker tool exclusions" owns the file format and
# operator contract; bin/fm-spawn.sh's header owns launch-flag mechanics.
# Name verification belongs inside the worker's loaded-tool registry, never
# in an out-of-band server connection from these launch-time checks.

# fm_exclude_tools_names <config-dir>: print the comma-joined names, empty when
# the file is absent or lists nothing. Non-zero with the reason on stderr when
# the file or an entry is invalid.
fm_exclude_tools_names() {
local config=$1 file line names='' present
file=$config/crew-exclude-tools
present=$(perl -MErrno=ENOENT -e '
if (lstat $ARGV[0]) { print 1 }
elsif ($! == ENOENT) { print 0 }
else { die "error: cannot inspect config/crew-exclude-tools: $!\n" }
' -- "$file") || return 1
[ "$present" = 1 ] || return 0
if [ ! -f "$file" ] || [ ! -r "$file" ]; then
echo "error: config/crew-exclude-tools must be a readable regular file" >&2
return 1
fi
while IFS= read -r line || [ -n "$line" ]; do
line=${line#"${line%%[![:space:]]*}"}
line=${line%"${line##*[![:space:]]}"}
case "$line" in
'' | '#'*) continue ;;
esac
if [ -n "${line//[A-Za-z0-9_.-]/}" ]; then
echo "error: config/crew-exclude-tools has a malformed entry '$line'; expected one tool name per line using only letters, digits, _ . and - (blank lines and # comment lines are allowed)" >&2
return 1
fi
names="${names:+$names,}$line"
done <"$file" || {
echo "error: cannot read config/crew-exclude-tools" >&2
return 1
}
printf '%s' "$names"
}

# fm_exclude_tools_check <harness> <raw-launch 0|1> <config-dir>: succeed when
# this launch can honor the home's list (empty list, or a runtime that hides
# tools); otherwise refuse naming the file. Prints the names on stdout.
fm_exclude_tools_check() {
local harness=$1 raw=$2 config=$3 names
names=$(fm_exclude_tools_names "$config") || return 1
if [ -n "$names" ]; then
if [ "$raw" = 1 ]; then
echo "error: config/crew-exclude-tools lists tools to hide, but a raw launch command cannot hide tools; remove the entries or launch a runtime that supports them (pi, pi-signed)" >&2
return 1
fi
case "$harness" in
pi | pi-signed) ;;
*)
echo "error: config/crew-exclude-tools lists tools to hide, but the $harness runtime cannot hide tools, so this launch is refused rather than running with them available; empty the file or launch a runtime that supports exclusion (pi, pi-signed)" >&2
return 1
;;
esac
fi
printf '%s' "$names"
}
58 changes: 55 additions & 3 deletions bin/fm-procevent-remote-reply.sh
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ source_id() {

cursor_path() { printf '%s/%s.cursor\n' "$CURSOR_DIR" "$1"; }
ingest_receipt_path() { printf '%s/%s.%s.ingested\n' "$CURSOR_DIR" "$1" "$2"; }
retirement_count_path() { printf '%s/%s.retirements\n' "$CURSOR_DIR" "$1"; }
mirrored_source_path() { printf '%s/.remote-reply-mirrored-%s\n' "$STATE" "$1"; }

read_cursor() { # <id>; sets CURSOR_OFFSET and CURSOR_HASH
Expand Down Expand Up @@ -164,6 +165,43 @@ write_cursor() { # <id> <offset> <hash>
mv -f -- "$tmp" "$path"
}

# A missing file is zero and is not created. Ingest only reads this.
# Retirement is the one writer, so a crash during ingest cannot change it.
read_retirement_count() { # <id>; sets RETIREMENT_COUNT
local path count lines
path=$(retirement_count_path "$1")
RETIREMENT_COUNT=0
[ -e "$path" ] || [ -L "$path" ] || return 0
[ -f "$path" ] && [ ! -L "$path" ] || die "reply retirement count is unsafe: $path"
lines=$(grep -c '^count=' "$path" 2>/dev/null || true)
[ "$lines" = 1 ] || die "reply retirement count is invalid: $path"
count=$(sed -n 's/^count=//p' "$path")
case "$count" in ''|*[!0-9]*) die "reply retirement count is invalid: $path" ;; esac
RETIREMENT_COUNT=$count
}

write_retirement_count() { # <id> <count>
local id=$1 count=$2 path tmp
case "$count" in ''|*[!0-9]*) return 1 ;; esac
mkdir -p "$CURSOR_DIR" || return 1
chmod 700 "$CURSOR_DIR" 2>/dev/null || true
path=$(retirement_count_path "$id")
[ ! -L "$path" ] || return 1
tmp=$(umask 077; mktemp "$CURSOR_DIR/.retirements.XXXXXX") || return 1
printf 'count=%s\n' "$count" > "$tmp" || { rm -f -- "$tmp"; return 1; }
chmod 600 "$tmp" || { rm -f -- "$tmp"; return 1; }
if ! mv -f -- "$tmp" "$path"; then
rm -f -- "$tmp"
return 1
fi
}

# Twelve characters distinguish breaks in the status line. The cursor keeps
# the full digest the reader uses.
continuity_prefix() {
printf '%.12s' "$CURSOR_HASH"
}

ingest_receipt_matches() { # <id> <sequence> <result>
local path stored actual count
path=$(ingest_receipt_path "$1" "$2")
Expand Down Expand Up @@ -544,7 +582,12 @@ cmd_ingest() {
die "result does not continue the current cursor for $id"
fi
if [ "$class" = continuity-broken ]; then
line="blocked [key=remote-reply-continuity-$id]: remote reply continuity broke for $id ($reason)"
# The same offset, prefix, and retirement count build the same line, so a
# retry appends nothing. Retirement removes the cursor before it records
# the next count, so a later break is a new line even when the restored
# bytes match, and a stop between those steps leaves the count unchanged.
read_retirement_count "$id"
line="blocked [key=remote-reply-continuity-$id]: remote reply continuity broke for $id ($reason) at offset ${CURSOR_OFFSET} prefix $(continuity_prefix) retirements ${RETIREMENT_COUNT}"
append_rc=0
if status_event_recorded "$status_file" "$line"; then
append_rc=1
Expand Down Expand Up @@ -726,7 +769,7 @@ cmd_retire_quiesce_locked() {
}

cmd_retire_finalize_locked() {
local id=${1:-} force=${2:-} sid path
local id=${1:-} force=${2:-} sid path cursor
validate_id "$id"
[ -z "$force" ] || [ "$force" = --force ] || die "invalid retirement option: $force"
sid=$(source_id "$id")
Expand All @@ -742,7 +785,16 @@ cmd_retire_finalize_locked() {
done
fi
fi
rm -f -- "$(cursor_path "$id")"
# Remove the cursor first. A stop before the count write leaves that count
# unchanged, so the same break still builds the same line.
cursor=$(cursor_path "$id")
rm -f -- "$cursor" || die "cannot remove remote reply cursor"
if [ -e "$cursor" ] || [ -L "$cursor" ]; then
die "cannot remove remote reply cursor"
fi
read_retirement_count "$id"
write_retirement_count "$id" "$((RETIREMENT_COUNT + 1))" \
|| die "cannot record remote reply retirement"
rm -f -- "$CURSOR_DIR/$id".*.ingested
rm -f -- "$(fm_pending_reply_remote_channel_watermark_path "$STATE" "$id")"
}
Expand Down
Loading
Loading