From e968b4568a6a5f089845e66728a8fb14bd956154 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 21:53:32 +0000 Subject: [PATCH 1/3] fix(pi): refresh drifted AGENTS.md in the system prompt instead of reprinting it on every compaction Pi reads AGENTS.md once at process start. When a firstmate self-update lands while the primary is alive, fm-session-start.sh bridged the stale copy by printing the complete current AGENTS.md (about 25k tokens) into every post-compaction digest for the rest of the session, which fed the compaction loop it was trying to recover from. The Pi extension now compares every block in the system prompt against the file on disk at before_agent_start and returns a refreshed prompt only when one drifted, so the running session always carries the current instructions without touching the conversation. It launches the digest with FM_SESSIONSTART_AGENTS_LIVE=1, and a drifted compaction then prints a three-line notice instead of the whole file. The full-file path stays in place for any launcher that does not set the flag. Tests: tests/fm-sessionstart-nudge.test.sh proves the swap, the unchanged-block and missing-file cases, the per-prompt recompute, and the flag reaching the runner; tests/fm-session-start.test.sh proves the notice replaces the file under the flag and that the plain path is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AdHwuy8oJniNyAZUKeP381 --- .pi/extensions/fm-primary-turnend-guard.ts | 24 ++++- .pi/extensions/lib/fm-agents-refresh.ts | 59 ++++++++++++ bin/fm-session-start.sh | 17 ++++ docs/sessionstart-nudge.md | 2 +- tests/fm-calm-pi-extension.test.sh | 1 + tests/fm-pi-primary-live-e2e.test.sh | 2 + tests/fm-pi-primary-types.test.sh | 1 + tests/fm-session-start.test.sh | 37 +++++++ tests/fm-sessionstart-hook-live-e2e.test.sh | 2 + tests/fm-sessionstart-nudge.test.sh | 101 ++++++++++++++++++++ 10 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 .pi/extensions/lib/fm-agents-refresh.ts diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index cad464a8191..9810d9fdf09 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -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, @@ -267,6 +268,10 @@ function runSessionstartHook(generation: SessionstartGeneration): Promise { + // 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 diff --git a/.pi/extensions/lib/fm-agents-refresh.ts b/.pi/extensions/lib/fm-agents-refresh.ts new file mode 100644 index 00000000000..d357544d42a --- /dev/null +++ b/.pi/extensions/lib/fm-agents-refresh.ts @@ -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 +// 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 = /\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 `\n${current}\n\n`; + }); + return changed ? refreshed : undefined; +} diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index c9e1e67471d..b6942b27a58 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -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)" @@ -704,9 +707,23 @@ agents_refresh_required() { # 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() { # 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' diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index b3c7c7c75d7..ef3fd44b329 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -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 `` 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`. | diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 2efcc1b4e8a..8d7ad0ce378 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -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" diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index 7dfbf97868a..d29fc1b29e4 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -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" \ @@ -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" diff --git a/tests/fm-pi-primary-types.test.sh b/tests/fm-pi-primary-types.test.sh index 454b0a9c5cd..c42f1dddb6e 100755 --- a/tests/fm-pi-primary-types.test.sh +++ b/tests/fm-pi-primary-types.test.sh @@ -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" diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 31aa174c428..34daf7e4e0c 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -2629,6 +2629,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 < "$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) @@ -3043,6 +3079,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 diff --git a/tests/fm-sessionstart-hook-live-e2e.test.sh b/tests/fm-sessionstart-hook-live-e2e.test.sh index ba38197a0d2..b6e2c7e81d3 100755 --- a/tests/fm-sessionstart-hook-live-e2e.test.sh +++ b/tests/fm-sessionstart-hook-live-e2e.test.sh @@ -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" @@ -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' diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index 5b6cf779bdc..6656578cc6f 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -331,6 +331,7 @@ test_pi_startup_classifies_cli_continuations() { mkdir -p "$fixture/.pi/extensions/lib" "$fixture/bin" "$fixture/state" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$fixture/.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" "$fixture/.pi/extensions/lib/" cat > "$fixture/bin/fm-sessionstart-run.sh" <<'SH' #!/usr/bin/env bash @@ -429,6 +430,7 @@ test_pi_sessionstart_generation_prerequisite() { mkdir -p "$fixture/.pi/extensions/lib" "$fixture/bin" "$fixture/state" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$fixture/.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" "$fixture/.pi/extensions/lib/" cp "$ROOT/bin/fm-operational-input.sh" "$fixture/bin/" cat > "$fixture/bin/fm-turnend-guard.sh" <<'SH' @@ -755,6 +757,7 @@ test_pi_reload_releases_sessionstart_exit_listener() { mkdir -p "$fixture/.pi/extensions/lib" "$fixture/bin" "$fixture/state" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$fixture/.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" "$fixture/.pi/extensions/lib/" cp "$ROOT/bin/fm-operational-input.sh" "$fixture/bin/" cat > "$fixture/bin/fm-turnend-guard.sh" <<'SH' @@ -889,6 +892,7 @@ test_pi_large_sessionstart_digest_is_delivered_loudly() { : > "$fixture/AGENTS.md" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$fixture/.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" "$fixture/.pi/extensions/lib/" cp "$ROOT/bin/fm-sessionstart-run.sh" "$ROOT/bin/fm-sessionstart-nudge.sh" \ "$ROOT/bin/fm-primary-scope-lib.sh" "$ROOT/bin/fm-gate-refuse-lib.sh" \ @@ -1014,6 +1018,103 @@ test_run_reports_a_failed_session_start_as_digest_text() { pass "run wrapper: a session start that cannot take the lock still opens the session and says so" } +test_pi_before_agent_start_refreshes_drifted_project_instructions() { + local fixture out + command -v node >/dev/null 2>&1 || { + echo "skip: node not found for Pi live instruction refresh test" + return 0 + } + fixture="$TMP_ROOT/pi-agents-live-refresh" + mkdir -p "$fixture/.pi/extensions/lib" "$fixture/bin" "$fixture/state" + cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$fixture/.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" "$fixture/.pi/extensions/lib/" + cp "$ROOT/bin/fm-operational-input.sh" "$fixture/bin/" + printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/fm-turnend-guard.sh" + # The digest runner records whether the extension announced live refresh. + cat > "$fixture/bin/fm-sessionstart-run.sh" <<'SH' +#!/usr/bin/env bash +printf 'live=%s\n' "${FM_SESSIONSTART_AGENTS_LIVE:-unset}" > "${FM_HOME:?}/state/runner-env" +printf 'DIGEST\n' +SH + chmod +x "$fixture/bin/"*.sh + printf 'FIRSTMATE_LIVE_TEST=original\n' > "$fixture/AGENTS.md" + printf 'ANCESTOR=stale\n' > "$fixture/ancestor.md" + + out=$(EXT="$fixture/.pi/extensions/fm-primary-turnend-guard.ts" \ + FM_HOME="$fixture" FM_ROOT_OVERRIDE="$fixture" \ + node --input-type=module 2>&1 <<'JS' +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const home = process.env.FM_HOME; +const handlers = new Map(); +const pi = { on(event, handler) { handlers.set(event, handler); }, sendMessage() {} }; +const extension = await import(`${pathToFileURL(process.env.EXT).href}?live=${Date.now()}`); +extension.default(pi); +process.argv.splice(1, process.argv.length, "pi"); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const ctx = { + sessionManager: { + getHeader: () => ({ timestamp: new Date().toISOString() }), + getSessionId: () => "live-refresh", + }, +}; +const block = (path, content) => + `\n${content}\n\n`; +const agents = `${home}/AGENTS.md`; +const ancestor = `${home}/ancestor.md`; +const missing = `${home}/never-there.md`; +const prompt = (agentsContent) => + `You are Pi.\n\n\n\nProject-specific instructions and guidelines:\n\n` + + block(ancestor, "ANCESTOR=stale\n") + block(agentsContent === undefined ? missing : agents, agentsContent ?? "GHOST=1\n") + + `\n`; + +// Startup delivers the digest once; the runner saw the live-refresh announcement. +handlers.get("session_start")({ reason: "startup" }, ctx); +for (let i = 0; i < 400 && !existsSync(`${home}/state/runner-env`); i += 1) await delay(5); +assert(readFileSync(`${home}/state/runner-env`, "utf8").trim() === "live=1", + "the digest runner was not told that this primary refreshes instructions live"); + +// Matching copies: the digest message rides alone, no system prompt override. +const first = await handlers.get("before_agent_start")({ prompt: "p1", systemPrompt: prompt("FIRSTMATE_LIVE_TEST=original\n") }, ctx); +assert(first?.message?.content.includes("DIGEST"), "startup digest was not delivered on the first prompt"); +assert(first.systemPrompt === undefined, "an unchanged AGENTS.md produced a system prompt override"); + +// No drift and no pending digest: nothing at all is returned, exactly as before. +const quiet = await handlers.get("before_agent_start")({ prompt: "p2", systemPrompt: prompt("FIRSTMATE_LIVE_TEST=original\n") }, ctx); +assert(quiet === undefined, "a quiet prompt returned a result without message or drift"); + +// Drift: the embedded copy is replaced by the current file, the unchanged +// ancestor block stays byte-identical, and the swap is recomputed every prompt. +writeFileSync(agents, "FIRSTMATE_LIVE_TEST=updated\nSecond line.\n"); +const drifted = await handlers.get("before_agent_start")({ prompt: "p3", systemPrompt: prompt("FIRSTMATE_LIVE_TEST=original\n") }, ctx); +assert(typeof drifted?.systemPrompt === "string", "a drifted AGENTS.md did not override the system prompt"); +assert(drifted.message === undefined, "a drift-only prompt fabricated a digest message"); +assert(drifted.systemPrompt.includes(block(agents, "FIRSTMATE_LIVE_TEST=updated\nSecond line.\n")), + "the override did not carry the current AGENTS.md verbatim"); +assert(!drifted.systemPrompt.includes("FIRSTMATE_LIVE_TEST=original"), "the stale copy survived the refresh"); +assert(drifted.systemPrompt.includes(block(ancestor, "ANCESTOR=stale\n")), "an unchanged ancestor block was rewritten"); +assert(drifted.systemPrompt.startsWith("You are Pi.\n\n"), "text around the blocks was disturbed"); +const again = await handlers.get("before_agent_start")({ prompt: "p4", systemPrompt: prompt("FIRSTMATE_LIVE_TEST=original\n") }, ctx); +assert(again?.systemPrompt === drifted.systemPrompt, "the refresh was not recomputed on the next prompt"); + +// A block whose path no longer exists is left alone rather than emptied. +const ghost = await handlers.get("before_agent_start")({ prompt: "p5", systemPrompt: prompt(undefined) }, ctx); +assert(ghost === undefined, "a block for a missing file was rewritten"); + +// A prompt without any block (older Pi, or a test host) is ignored. +const bare = await handlers.get("before_agent_start")({ prompt: "p6" }, ctx); +assert(bare === undefined, "a prompt without a system prompt produced an override"); +console.log("LIVE_REFRESH_OK"); +JS + ) || fail "Pi live instruction refresh script failed: $out" + assert_contains "$out" "LIVE_REFRESH_OK" "Pi live instruction refresh assertions did not complete: $out" + pass "Pi before_agent_start swaps a drifted project-instruction block for the current file and announces live refresh to the digest" +} + test_genuine_primary_nudges test_gate_env_is_silent test_gate_common_dir_is_silent From 3566ea3099bf72d1b03d22574aae44e155b711d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:31:21 +0000 Subject: [PATCH 2/3] fix(bin): drop the unused variables CI lint flagged after #6 landed The model-display merge (#6) went in while its Lint job was still running; that job then failed on SC2034 for assignments nothing reads (FM_MODEL_SOURCE_PENDING, recorded, HARNESS, a loop counter) and one constant that only sourcing callers read. Remove the dead ones and mark the shared constant as an output global, so main lints clean again. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AdHwuy8oJniNyAZUKeP381 --- bin/fm-model-lib.sh | 5 ++--- bin/fm-model-probe.sh | 1 - tests/fm-model-display.test.sh | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/bin/fm-model-lib.sh b/bin/fm-model-lib.sh index d7ae78b6a33..bd7caeee2a7 100644 --- a/bin/fm-model-lib.sh +++ b/bin/fm-model-lib.sh @@ -6,8 +6,8 @@ # Model history lives in state/.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() { # @@ -138,7 +138,7 @@ fm_model_relaunch_effective() { # fm_model_record_effective() { # [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 @@ -156,7 +156,6 @@ fm_model_record_effective() { # [fallback- elif [ "$prior" = pending ] && [ "$model" != pending ] && [ "$model" != UNKNOWN ]; then fm_model_history_append "$state" "$id" "$stamp" "$model" "$tag" fi - recorded=1 return 0 } diff --git a/bin/fm-model-probe.sh b/bin/fm-model-probe.sh index c56f1252ba7..969502103f4 100755 --- a/bin/fm-model-probe.sh +++ b/bin/fm-model-probe.sh @@ -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() { # diff --git a/tests/fm-model-display.test.sh b/tests/fm-model-display.test.sh index 2f2c0ddc26d..cb44dec9714 100755 --- a/tests/fm-model-display.test.sh +++ b/tests/fm-model-display.test.sh @@ -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 @@ -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 From 1b1ca9e82aa51a071e907faa55cdebabc8df5684 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:53:41 +0000 Subject: [PATCH 3/3] test(pi): stage fm-agents-refresh.ts in the turnend-guard extension fixtures The guard now imports lib/fm-agents-refresh.ts, so the two fixtures in tests/fm-turnend-guard.test.sh that load the extension under node must copy it alongside fm-operational-input.ts; CI's serial-2 shard caught the missing copy. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AdHwuy8oJniNyAZUKeP381 --- tests/fm-turnend-guard.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index f19e12adb70..7eb045037cb 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -978,6 +978,7 @@ test_pi_extension_injects_once_per_logical_agent_run() { mkdir -p "$repo/.pi/extensions/lib" "$repo/bin" "$home/state" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$ext" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" + cp "$ROOT/.pi/extensions/lib/fm-agents-refresh.ts" "$repo/.pi/extensions/lib/fm-agents-refresh.ts" cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh" cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' #!/usr/bin/env bash @@ -1044,6 +1045,7 @@ test_pi_extension_retries_after_followup_delivery_failure() { mkdir -p "$repo/.pi/extensions/lib" "$repo/bin" "$home/state" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$ext" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" + cp "$ROOT/.pi/extensions/lib/fm-agents-refresh.ts" "$repo/.pi/extensions/lib/fm-agents-refresh.ts" cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh" cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' #!/usr/bin/env bash