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
24 changes: 20 additions & 4 deletions .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { refreshProjectInstructions } from "./lib/fm-agents-refresh.ts";
import {
classifyFirstmateCurrentOperationalText,
encodeFirstmateOperationalInput,
Expand Down Expand Up @@ -267,6 +268,10 @@ function runSessionstartHook(generation: SessionstartGeneration): Promise<Sessio
: ["--source", generation.source, "--pi-prerequisite"],
{
detached: supervised,
// Tells fm-session-start.sh that this primary refreshes drifted
// project instructions in the system prompt itself, so a compaction
// re-emit prints a one-line notice instead of the whole AGENTS.md.
env: { ...process.env, FM_SESSIONSTART_AGENTS_LIVE: "1" },
stdio: supervised
? ["ignore", "pipe", "ignore", "ipc"]
: ["ignore", "pipe", "ignore"],
Expand Down Expand Up @@ -528,11 +533,22 @@ export default function (pi: ExtensionAPI) {
);
});

pi.on?.("before_agent_start", async (_event, ctx) => {
// Two independent contributions share this one return: the session-start
// digest claimed exactly once per generation, and the live project-instruction
// refresh (lib/fm-agents-refresh.ts) that swaps a drifted AGENTS.md copy in
// the system prompt for the current file at every prompt. Either may be
// absent; nothing is returned when both are.
pi.on?.("before_agent_start", async (event, ctx) => {
const systemPrompt = refreshProjectInstructions(
String((event as { systemPrompt?: unknown })?.systemPrompt ?? ""),
);
const generation = sessionstartGeneration;
if (!generation) return;
const message = await claimSessionstartMessage(generation, ctx);
return message ? { message } : undefined;
const message = generation ? await claimSessionstartMessage(generation, ctx) : undefined;
if (!message && systemPrompt === undefined) return undefined;
return {
...(message ? { message } : {}),
...(systemPrompt !== undefined ? { systemPrompt } : {}),
};
});

// Pi's compaction equivalent. Manual compaction is idle and auto-compaction
Expand Down
59 changes: 59 additions & 0 deletions .pi/extensions/lib/fm-agents-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Live project-instruction refresh for the Pi primary's system prompt.
//
// Pi reads every context file (AGENTS.md and its ancestors) once, when the
// process starts, and embeds each one in the system prompt as a
// <project_instructions path="..."> block. A firstmate self-update that lands
// while the primary is alive therefore leaves the running session on the stale
// copy for the rest of the process. Firstmate used to bridge that gap by
// printing the complete current AGENTS.md into every post-compaction digest,
// which costs the whole file again on every compaction for as long as the
// drift lasts.
//
// This module owns the cheaper path: on each before_agent_start, compare every
// embedded block against the file currently on disk and, when one differs,
// return a system prompt with the current content swapped in. Pi applies that
// returned prompt to the whole agent run and resets to its base prompt
// afterwards, so the swap must be recomputed at every prompt; it is one file
// read and one string compare per block, and nothing is returned while the
// embedded copies still match the disk, so the provider prompt cache is only
// disturbed when the instructions really changed.
//
// The block shape mirrors Pi's own buildSystemPrompt(): the embedded content is
// the file read verbatim (BOM stripped, no trimming), so a file that ends in a
// newline leaves one blank line before the closing tag. The pattern below
// tolerates that exactly and never touches a block whose path is not a
// readable regular file.
import { readFileSync, statSync } from "node:fs";

const blockPattern = /<project_instructions path="([^"]*)">\n([\s\S]*?)\n<\/project_instructions>\n/g;

function stripBom(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}

export function readInstructionFile(path: string): string | undefined {
try {
if (!statSync(path).isFile()) return undefined;
return stripBom(readFileSync(path, "utf8"));
} catch {
return undefined;
}
}

// Returns the system prompt with every drifted project_instructions block
// replaced by its current on-disk content, or undefined when nothing drifted
// (including an empty prompt or one without any block).
export function refreshProjectInstructions(
systemPrompt: string,
readCurrent: (path: string) => string | undefined = readInstructionFile,
): string | undefined {
if (!systemPrompt) return undefined;
let changed = false;
const refreshed = systemPrompt.replace(blockPattern, (block, path: string, embedded: string) => {
const current = readCurrent(path);
if (current === undefined || current === embedded) return block;
changed = true;
return `<project_instructions path="${path}">\n${current}\n</project_instructions>\n`;
});
return changed ? refreshed : undefined;
}
5 changes: 2 additions & 3 deletions bin/fm-model-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
# Model history lives in state/<id>.model-history (one line per change).

FM_MODEL_SOURCE_SPAWN=spawn-config
FM_MODEL_SOURCE_PENDING=pending
FM_MODEL_SOURCE_UNKNOWN=unknown
# shellcheck disable=SC2034 # Read by sourcing callers (bin/fm-model-sync.sh).
FM_MODEL_DISPLAY_SOURCE=fm-model-display

fm_model_harness_label() { # <harness>
Expand Down Expand Up @@ -138,7 +138,7 @@ fm_model_relaunch_effective() { # <prior-meta>

fm_model_record_effective() { # <state> <id> <meta> <model> <source> [fallback-tag]
local state=$1 id=$2 meta=$3 model=$4 source=$5 tag=${6:-}
local prior stamp recorded
local prior stamp
[ -f "$meta" ] || return 1
[ -n "$model" ] || return 1
[ -n "$source" ] || return 1
Expand All @@ -156,7 +156,6 @@ fm_model_record_effective() { # <state> <id> <meta> <model> <source> [fallback-
elif [ "$prior" = pending ] && [ "$model" != pending ] && [ "$model" != UNKNOWN ]; then
fm_model_history_append "$state" "$id" "$stamp" "$model" "$tag"
fi
recorded=1
return 0
}

Expand Down
1 change: 0 additions & 1 deletion bin/fm-model-probe.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ PANE=${3:-}
META="$STATE/$ID.meta"
[ -f "$META" ] || { echo "error: no meta for $ID" >&2; exit 1; }

HARNESS=$(fm_model_meta_get "$META" harness)
[ -z "$PANE" ] && PANE=$(fm_model_meta_get "$META" herdr_pane_id)

probe_claude_jsonl() { # <session-id>
Expand Down
17 changes: 17 additions & 0 deletions bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@
# current AGENTS.md to print before the bulky digest. The baseline
# remains immutable so every later drifted compaction refreshes
# again, while an equal baseline emits no instruction refresh.
# With FM_SESSIONSTART_AGENTS_LIVE=1 in the environment (set only
# by the Pi extension, which refreshes the system prompt itself)
# a drifted compaction prints a short notice instead of the file.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand Down Expand Up @@ -704,9 +707,23 @@ agents_refresh_required() { # <rebuilding-session-pid>
agents_baseline_drifted "$lock_pid"
}

# FM_SESSIONSTART_AGENTS_LIVE=1 is set only by the Pi extension that launches
# this digest (.pi/extensions/fm-primary-turnend-guard.ts): that primary swaps a
# drifted AGENTS.md copy in its own system prompt for the current file at every
# prompt, so a drifted compaction owes the agent one notice, not the whole file
# again on every compaction.
print_agents_refresh_if_required() { # <rebuilding-session-pid>
local lock_pid=$1
agents_refresh_required "$lock_pid" || return 0
if [ "${FM_SESSIONSTART_AGENTS_LIVE:-}" = 1 ]; then
section "AGENTS.md - INSTRUCTION REFRESH (LIVE)"
cat <<'EOF'
AGENTS.md changed after this session started. Your system prompt already carries
the current file at every prompt, so it is not reprinted here; treat the copy in
your system prompt, not any earlier one in this conversation, as the contract.
EOF
return 0
fi
section "CURRENT AGENTS.md - INSTRUCTION REFRESH"
if [ -f "$FM_ROOT/AGENTS.md" ]; then
cat <<'EOF'
Expand Down
2 changes: 1 addition & 1 deletion docs/sessionstart-nudge.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ A lock another session holds and a truncated digest therefore surface as digest
| Claude | Run | `.claude/settings.json` registers one unmatched `SessionStart` hook, invoked through `CLAUDE_PROJECT_DIR` with a 180s timeout; the wrapper reads `source` from the hook payload. | Native stdout context injection is supported. |
| Codex exec | Run | `.codex/hooks.json` anchors to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and pipes the hook payload into the wrapper with a 180s timeout. | Native stdout context injection is supported under `codex exec`. |
| Codex interactive TUI | Uncovered | None. | Codex 0.146.0 does not fire the tracked project `SessionStart` hook in its interactive TUI; Firstmate ships no global hook, has no tracked compaction or re-emit channel, and does not claim instruction-refresh delivery for this surface. |
| Pi / pi-signed | Run | `.pi/extensions/fm-primary-turnend-guard.ts` maps `session_start` reasons `startup`, `new`, `resume`, and `fork` onto wrapper sources, refines a Pi-reported `startup` to `resume` only when a continuation, resume-selection, or explicit-session flag accompanies a session header older than the current process, maps a fork flag to `fork`, and handles `session_compact` as the compaction equivalent; setup-created entries such as `--name` are not restoration evidence. | Each mapped session generation starts one native prerequisite, and `before_agent_start` awaits its matching result and returns one persistent context message before the first provider call; Pi's `reload` reason is deliberately unmapped, as it always was. |
| Pi / pi-signed | Run | `.pi/extensions/fm-primary-turnend-guard.ts` maps `session_start` reasons `startup`, `new`, `resume`, and `fork` onto wrapper sources, refines a Pi-reported `startup` to `resume` only when a continuation, resume-selection, or explicit-session flag accompanies a session header older than the current process, maps a fork flag to `fork`, and handles `session_compact` as the compaction equivalent; setup-created entries such as `--name` are not restoration evidence. | Each mapped session generation starts one native prerequisite, and `before_agent_start` awaits its matching result and returns one persistent context message before the first provider call; Pi's `reload` reason is deliberately unmapped, as it always was. The same `before_agent_start` also swaps a drifted `<project_instructions>` block in Pi's system prompt for the current on-disk file at every prompt (`.pi/extensions/lib/fm-agents-refresh.ts`), and launches the digest with `FM_SESSIONSTART_AGENTS_LIVE=1` so a drifted compaction prints a one-line notice instead of the complete AGENTS.md. |
| OpenCode | Nudge | `.opencode/plugins/fm-primary-sessionstart-nudge.js` listens for `session.created`, runs once per session id, and calls `client.session.promptAsync` only when the wrapper prints a nudge. | Interactive TUI delivery is supported; headless `opencode run` is intentionally fail-open because the process can exit before the queued turn. That early exit is also why OpenCode cannot use the run tier. |
| Grok | Nudge | `.grok/hooks/fm-primary-sessionstart-nudge.json` registers a project `SessionStart` hook and invokes the wrapper through inline-defaulted `${GROK_WORKSPACE_ROOT:-}`. | The project hook runs when the checkout is trusted, but Grok currently discards hook stdout from model context, so this path is intentionally fail-open and cannot use the run tier. |
| Cursor | Run | `.cursor/hooks.json` registers `sessionStart`, anchored through `$CURSOR_PROJECT_DIR` with a 180s timeout, invoking `bin/fm-sessionstart-cursor.sh`. | Cursor's payload has no `source` field, so the registration supplies `--source` itself, and the adapter returns the digest as `additional_context`. Project hooks load only when the workspace is launched with `--trust`. |
Expand Down
1 change: 1 addition & 0 deletions tests/fm-calm-pi-extension.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3125,6 +3125,7 @@ test_interactive_terminal_e2e() {
cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts"
cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts"
cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$project/.pi/extensions/lib/fm-operational-input.ts"
cp "$ROOT/.pi/extensions/lib/fm-agents-refresh.ts" "$project/.pi/extensions/lib/fm-agents-refresh.ts"
cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$project/.pi/extensions/lib/fm-branch-dispatch.ts"
cp "$WATCH_EXT" "$project/.pi/extensions/fm-primary-pi-watch.ts"
cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$project/.pi/extensions/fm-primary-turnend-guard.ts"
Expand Down
4 changes: 2 additions & 2 deletions tests/fm-model-display.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ SH
}

test_sync_serializes_concurrent_probes() {
local fakebin fakehome sid session_dir meta i pid pids=()
local fakebin fakehome sid session_dir meta pid pids=()
fakebin=$(fm_fakebin "$TMP_ROOT/concurrent")
fakehome="$TMP_ROOT/concurrent/home"
sid=race1
Expand Down Expand Up @@ -183,7 +183,7 @@ SH
printf '%s\n' '{"type":"assistant","message":{"role":"assistant","model":"claude-sonnet-5"}}' \
> "$session_dir/$sid.jsonl"

for i in 1 2 3 4 5; do
for _ in 1 2 3 4 5; do
(HOME="$fakehome" PATH="$fakebin:$PATH" "$ROOT/bin/fm-model-sync.sh" "$STATE" t3 --probe-only >/dev/null 2>&1) &
pids+=("$!")
done
Expand Down
2 changes: 2 additions & 0 deletions tests/fm-pi-primary-live-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ run_native_ahoy_regressions() {
git init -q "$AHOY_PROJECT"
cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$AHOY_PROJECT/.pi/extensions/"
cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$AHOY_PROJECT/.pi/extensions/lib/"
cp "$ROOT/.pi/extensions/lib/fm-agents-refresh.ts" "$AHOY_PROJECT/.pi/extensions/lib/"
cp \
"$ROOT/bin/fm-sessionstart-nudge.sh" \
"$ROOT/bin/fm-primary-scope-lib.sh" \
Expand Down Expand Up @@ -257,6 +258,7 @@ cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$PROJECT/.pi/extensions/lib
cp "$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts" "$PROJECT/.pi/extensions/lib/fm-calm-working-ship.ts"
cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$PROJECT/.pi/extensions/lib/fm-branch-dispatch.ts"
cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$PROJECT/.pi/extensions/lib/fm-operational-input.ts"
cp "$ROOT/.pi/extensions/lib/fm-agents-refresh.ts" "$PROJECT/.pi/extensions/lib/fm-agents-refresh.ts"
cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$PROJECT/.pi/extensions/fm-primary-turnend-guard.ts"
cp "$ROOT/bin/fm-watch-arm.sh" "$PROJECT/bin/fm-watch-arm.sh"
cp "$ROOT/bin/fm-operational-input.sh" "$PROJECT/bin/fm-operational-input.sh"
Expand Down
1 change: 1 addition & 0 deletions tests/fm-pi-primary-types.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$TMP_ROOT/lib/
cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$TMP_ROOT/lib/fm-calm-visibility.ts"
cp "$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts" "$TMP_ROOT/lib/fm-calm-working-ship.ts"
cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$TMP_ROOT/lib/fm-operational-input.ts"
cp "$ROOT/.pi/extensions/lib/fm-agents-refresh.ts" "$TMP_ROOT/lib/fm-agents-refresh.ts"
ln -s "$PI_PACKAGE_DIR" "$TMP_ROOT/node_modules/@earendil-works/pi-coding-agent"
ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$TMP_ROOT/node_modules/@earendil-works/pi-tui"
ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-ai" "$TMP_ROOT/node_modules/@earendil-works/pi-ai"
Expand Down
37 changes: 37 additions & 0 deletions tests/fm-session-start.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2643,6 +2643,42 @@ $(hash_file_for_test "$root/AGENTS.md")" ] \
pass "true-start AGENTS baselines stay immutable while every drifted Pi compact re-emits the current contract"
}

test_live_refreshing_pi_compact_prints_a_notice_instead_of_the_file() {
local rec root home fakebin baseline live_out plain_out
rec=$(new_world agents-refresh-live)
IFS='|' read -r root home fakebin <<EOF
$rec
EOF
make_fake_toolchain "$fakebin"
make_fake_ps_harness "$fakebin" pi
printf '%s\n' 'FIRSTMATE_TEST_INSTRUCTION=original' > "$root/AGENTS.md"
FM_FAKE_HARNESS=pi run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH" --source startup >/dev/null
baseline=$(cat "$home/state/.session-start-agents-baseline")

live_out=$(FM_SESSIONSTART_AGENTS_LIVE=1 FM_FAKE_HARNESS=pi \
run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH" --reemit --source compact)
assert_not_contains "$live_out" "INSTRUCTION REFRESH" \
"an unchanged AGENTS file produced a refresh notice under live refresh"

printf '%s\n' 'FIRSTMATE_TEST_INSTRUCTION=updated' > "$root/AGENTS.md"
live_out=$(FM_SESSIONSTART_AGENTS_LIVE=1 FM_FAKE_HARNESS=pi \
run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH" --reemit --source compact)
assert_contains "$live_out" "AGENTS.md - INSTRUCTION REFRESH (LIVE)" \
"a drifted compact under live refresh did not print the drift notice"
assert_not_contains "$live_out" "FIRSTMATE_TEST_INSTRUCTION=updated" \
"a drifted compact under live refresh reprinted the complete AGENTS.md"
assert_not_contains "$live_out" "CURRENT AGENTS.md - INSTRUCTION REFRESH" \
"a drifted compact under live refresh used the full-file section"
[ "$(cat "$home/state/.session-start-agents-baseline")" = "$baseline" ] \
|| fail "a live-refresh compact rebased the true-start baseline"

plain_out=$(FM_FAKE_HARNESS=pi run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH" --reemit --source compact)
assert_contains "$plain_out" "FIRSTMATE_TEST_INSTRUCTION=updated" \
"without live refresh a drifted compact stopped reprinting the complete AGENTS.md"

pass "a Pi primary that refreshes its own system prompt gets a one-line drift notice on compaction, not the whole file"
}

test_read_only_pi_compact_refreshes_against_its_own_session_identity() {
local rec root home fakebin holder_pid out baseline_before completion_before
rec=$(new_world agents-refresh-read-only)
Expand Down Expand Up @@ -3057,6 +3093,7 @@ test_runtime_bound_leaves_a_healthy_digest_untouched
test_runtime_bound_leaves_harness_ancestry_headroom
test_reemit_skips_startup_sweeps_but_keeps_the_wake_drain
test_agents_baseline_stays_at_true_start_and_reemits_on_every_drifted_pi_compact
test_live_refreshing_pi_compact_prints_a_notice_instead_of_the_file
test_read_only_pi_compact_refreshes_against_its_own_session_identity
test_codex_unreachable_reset_sources_do_not_claim_instruction_refresh
test_agents_baseline_requires_sha256_and_successful_completion
Expand Down
2 changes: 2 additions & 0 deletions tests/fm-sessionstart-hook-live-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ SH
mkdir -p "$lab/.pi/extensions/lib"
cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$lab/.pi/extensions/"
cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" \
"$ROOT/.pi/extensions/lib/fm-agents-refresh.ts" \
"$ROOT/.pi/extensions/lib/fm-sessionstart-supervisor.mjs" "$lab/.pi/extensions/lib/"
cp "$ROOT/bin/fm-operational-input.sh" "$lab/bin/"
printf '%s\n' '{"compaction":{"keepRecentTokens":200}}' > "$lab/.pi/settings.json"
Expand Down Expand Up @@ -353,6 +354,7 @@ probe_pi_sessionstart_prerequisite() {
printf '# Offline Pi startup-prerequisite lab\n' > "$project/AGENTS.md"
cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$project/.pi/extensions/"
cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" \
"$ROOT/.pi/extensions/lib/fm-agents-refresh.ts" \
"$ROOT/.pi/extensions/lib/fm-sessionstart-supervisor.mjs" "$project/.pi/extensions/lib/"
cp "$ROOT/bin/fm-operational-input.sh" "$project/bin/"
cat > "$project/bin/fm-turnend-guard.sh" <<'SH'
Expand Down
Loading
Loading