From 897931c7bef90f7d4057b5e582d11560267fb6fb Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 23 Jul 2026 12:37:00 -0700 Subject: [PATCH 1/2] fix(pi): preserve operational follow-up semantics in Calm --- .pi/extensions/fm-calm.ts | 32 -- .pi/extensions/lib/fm-calm-visibility.ts | 81 +--- README.md | 10 +- bin/fm-spawn.sh | 8 +- docs/calm-mode-feasibility.md | 82 ++-- docs/supervision-protocols/pi.md | 5 + tests/fm-calm-pi-extension.test.sh | 534 +++++++++++++---------- tests/fm-spawn-dispatch-profile.test.sh | 6 +- 8 files changed, 387 insertions(+), 371 deletions(-) diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index 7575dc90c64..aeaf7e8c8ee 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -35,10 +35,7 @@ import type { TSchema } from "typebox"; import { calmPresentationHides, calmPresentationIsActive, - classifyFirstmateSyntheticInput, - deliverFirstmateSyntheticInput, FIRSTMATE_CALM_PRESENTATION_EVENT, - FIRSTMATE_PI_LAUNCH_BRIEF_ENV, registerFirstmateSyntheticPresentation, setCalmPresentation, setCalmStockExportRendering, @@ -76,7 +73,6 @@ const root = resolve(extensionDir, "../.."); export default function (pi: ExtensionAPI) { let exportRendering = false; - let launchBriefContent: string | undefined; let removeTerminalInputHandler: (() => void) | undefined; const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; @@ -104,15 +100,6 @@ export default function (pi: ExtensionAPI) { } }; - const launchBriefPath = process.env[FIRSTMATE_PI_LAUNCH_BRIEF_ENV]; - if (launchBriefPath) { - try { - launchBriefContent = readFileSync(launchBriefPath, "utf8").replace(/\n+$/, ""); - } catch { - launchBriefContent = undefined; - } - } - const publishPresentationState = (): void => { pi.events.emit(FIRSTMATE_CALM_PRESENTATION_EVENT, { active: calmPresentationIsActive(), @@ -230,25 +217,6 @@ export default function (pi: ExtensionAPI) { registerBuiltIn(createFindToolDefinition); registerBuiltIn(createLsToolDefinition); - pi.on("input", (event, ctx) => { - if (event.images && event.images.length > 0) return { action: "continue" }; - const kind = classifyFirstmateSyntheticInput(event.text, event.source, launchBriefContent); - if (!kind) return { action: "continue" }; - if (kind === "launch-brief") launchBriefContent = undefined; - - const redrawPresentation = (): void => { - const expanded = ctx.ui.getToolsExpanded(); - ctx.ui.setToolsExpanded(!expanded); - ctx.ui.setToolsExpanded(expanded); - }; - deliverFirstmateSyntheticInput(pi, event.text, kind, { - deliverAs: event.streamingBehavior ?? "followUp", - redrawPresentation, - triggerTurn: true, - }); - return { action: "handled" }; - }); - pi.on("session_start", (_event, ctx) => { exportRendering = false; setCalmPresentation(loadCalmPreference()); diff --git a/.pi/extensions/lib/fm-calm-visibility.ts b/.pi/extensions/lib/fm-calm-visibility.ts index e63da2a33de..27a03f04c1f 100644 --- a/.pi/extensions/lib/fm-calm-visibility.ts +++ b/.pi/extensions/lib/fm-calm-visibility.ts @@ -3,13 +3,6 @@ import { type ExtensionAPI, UserMessageComponent, } from "@earendil-works/pi-coding-agent"; -import { - classifyFirstmateOperationalText, - encodeFirstmateOperationalInput, -} from "./fm-operational-input.ts"; - -export { encodeFirstmateOperationalInput } from "./fm-operational-input.ts"; - export const CALM_TRANSCRIPT_CLASSES = [ "genuine-user-prompt", "genuine-agent-response", @@ -41,10 +34,10 @@ const CALM_VISIBLE_CLASSES = new Set([ "working-status", ]); -export const FIRSTMATE_SYNTHETIC_CONTEXT_TYPE = "firstmate-synthetic-input"; +// Legacy session entries from Calm versions before 2026-07-23 retain this +// presentation type. New operational input stays user-role and is never rerouted. export const FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE = "firstmate-synthetic-input-presentation"; export const FIRSTMATE_CALM_PRESENTATION_EVENT = "firstmate:calm-presentation"; -export const FIRSTMATE_PI_LAUNCH_BRIEF_ENV = "FM_FIRSTMATE_PI_LAUNCH_BRIEF"; export type CalmPresentationState = { active: boolean; @@ -62,21 +55,12 @@ export const FIRSTMATE_SYNTHETIC_KINDS = [ ] as const; export type FirstmateSyntheticKind = (typeof FIRSTMATE_SYNTHETIC_KINDS)[number]; -export type FirstmateInputSource = "interactive" | "rpc" | "extension"; - -type SyntheticDeliveryOptions = { - deliverAs?: "steer" | "followUp" | "nextTurn"; - redrawPresentation?: () => void; - triggerTurn?: boolean; -}; - type FirstmateSyntheticPresentation = { content: string; kind: FirstmateSyntheticKind; }; let calm = false; -let mountingSyntheticPresentation = false; let stockExportRendering = false; export function calmTranscriptClassIsVisible(itemClass: CalmTranscriptClass): boolean { @@ -99,73 +83,14 @@ export function calmPresentationHides(itemClass: CalmTranscriptClass): boolean { return calm && !stockExportRendering && !calmTranscriptClassIsVisible(itemClass); } -function isFirstmateSyntheticKind(value: string): value is FirstmateSyntheticKind { - return (FIRSTMATE_SYNTHETIC_KINDS as readonly string[]).includes(value); -} - -export function classifyFirstmateSyntheticInput( - content: string, - source: FirstmateInputSource, - launchBriefContent?: string, -): FirstmateSyntheticKind | undefined { - const classified = classifyFirstmateOperationalText(content); - if (classified !== undefined && isFirstmateSyntheticKind(classified)) return classified; - - // Keep the exact per-process origin fallback only for positional launch - // commands created before the typed protocol. - if ( - source === "interactive" && - launchBriefContent !== undefined && - content === launchBriefContent - ) { - return "launch-brief"; - } - return undefined; -} - export function registerFirstmateSyntheticPresentation(pi: ExtensionAPI): void { pi.registerEntryRenderer( FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE, (entry) => { - if ( - calmPresentationHides("synthetic-user") && - !mountingSyntheticPresentation - ) { - return undefined; - } + if (calmPresentationHides("synthetic-user")) return undefined; const data = entry.data; if (!data || typeof data.content !== "string") return undefined; return new UserMessageComponent(data.content, getMarkdownTheme()); }, ); } - -export function deliverFirstmateSyntheticInput( - pi: ExtensionAPI, - content: string, - kind: FirstmateSyntheticKind, - options: SyntheticDeliveryOptions = {}, -): void { - const mountForRedraw = - calmPresentationHides("synthetic-user") && - options.redrawPresentation !== undefined; - mountingSyntheticPresentation = mountForRedraw; - try { - pi.appendEntry(FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE, { - content, - kind, - }); - } finally { - mountingSyntheticPresentation = false; - } - if (mountForRedraw) options.redrawPresentation?.(); - pi.sendMessage( - { - customType: FIRSTMATE_SYNTHETIC_CONTEXT_TYPE, - content, - display: false, - details: { kind }, - }, - options, - ); -} diff --git a/README.md b/README.md index 00acff1359e..6ce90c2ca78 100644 --- a/README.md +++ b/README.md @@ -104,13 +104,13 @@ pi For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. For Pi, approve the project trust prompt once per clone on first launch so the tracked `.pi/extensions/*.ts` files auto-load. `/calm` is a conversation-focused transcript toggle whose last choice persists for the effective Firstmate home across Pi session starts and resumes. -While active, it keeps Pi's built-in `Working...` activity visible and uses Pi's supported presentation APIs to hide collapsed thinking labels, all seven built-in tool shells, the Firstmate watcher tool shell, and canonically typed Firstmate operational inputs. -Calm adds no persistent status row, and hidden Firstmate operational rows are removed without reserving vertical space. -Every injected input remains in model context and session storage. -Inputs that ordinarily render as user rows use a TUI-only custom entry so Calm can hide and restore their presentation without changing delivery; the session-start nudge remains on its existing non-displayed custom-message path. +While active, it keeps Pi's built-in `Working...` activity visible and uses Pi's supported presentation APIs to hide collapsed thinking labels, all seven built-in tool shells, the Firstmate watcher tool shell, and compatible presentation entries stored by earlier Calm versions. +Calm adds no persistent status row, and controllable hidden rows are removed without reserving vertical space. +Canonically typed Firstmate operational input remains an ordinary user-role message with its exact origin, ordering, model authority, and session persistence unchanged. +Pi 0.81.1 exposes no supported renderer for ordinary user rows, so those operational rows remain visible rather than being semantically rerouted or risking a duplicate or lost turn; the session-start nudge remains on its existing non-displayed custom-message path. Toggling off restores ordinary rendering, and `Ctrl+O` expansion behavior stays unchanged. Tool execution, model context, session storage, diagnostics, and `/export` and `/share` operation remain unchanged. -Exports and shares remain complete session artifacts: their main message transcript may omit hidden operational custom messages, while serialized session data and Pi 0.81.1's sidebar tree retain the full operational text. +Exports and shares remain complete session artifacts, including visible current operational user messages and any legacy hidden custom messages retained in serialized session data and Pi 0.81.1's sidebar tree. Pi 0.81.1 still exposes no global transcript filter, so expanded reasoning, its reserved spacing, built-in tool images, user-bash rows, skill and summary rows, status notices, and arbitrary custom-tool or extension rows remain supported-API boundaries. The version-scoped feasibility evidence and complete render taxonomy are recorded in [docs/calm-mode-feasibility.md](docs/calm-mode-feasibility.md). diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 901cf40a27c..709aed2140c 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -98,7 +98,6 @@ # __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home # __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home # __OPINPUT__ absolute path to the canonical operational-input encoder -# __PIBRIEFENV__ shell assignment identifying the unchanged Pi positional brief # Per-harness turn-end hooks are installed automatically; some live outside the worktree. # grok uses a firstmate-owned global hook under ${GROK_HOME:-$HOME/.grok}/hooks # plus a gitignored .fm-grok-turnend worktree pointer and a state token. @@ -432,9 +431,9 @@ launch_template() { opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; pi) if [ "$kind" = secondmate ]; then - printf '%s' '__PIBRIEFENV__ pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' else - printf '%s' '__PIBRIEFENV__ pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' fi ;; # grok (Grok Build TUI): a positional prompt starts the supervised interactive @@ -1260,8 +1259,6 @@ sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh") -PIBRIEFENV= -[ "$HARNESS" != pi ] || PIBRIEFENV="FM_FIRSTMATE_PI_LAUNCH_BRIEF=$sq_brief" MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT") LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} @@ -1272,7 +1269,6 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} -LAUNCH=${LAUNCH//__PIBRIEFENV__/$PIBRIEFENV} if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index c07061d9bd3..0ea906246b0 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -5,7 +5,7 @@ The README owns the user-facing `/calm` usage and limitation contract. ## Required extension surface -A qualifying implementation must auto-load from the trusted project, persist the toggle choice for the effective Firstmate home across Pi session starts and resumes, keep Pi's built-in working activity visible, emit no Calm status row, redraw already-rendered controllable rows, hide Firstmate operational rows without gaps, restore ordinary rendering, and leave delivery, tool execution, model context, session storage, export and share operation, diagnostics, and expansion state unchanged. +A qualifying implementation must auto-load from the trusted project, persist the toggle choice for the effective Firstmate home across Pi session starts and resumes, keep Pi's built-in working activity visible, emit no Calm status row, redraw already-rendered controllable rows, remove supported hidden rows without gaps, restore ordinary rendering, and leave delivery, tool execution, model context, session storage, export and share operation, diagnostics, and expansion state unchanged. The governing presentation policy allows genuine original user prompts, genuine user-facing assistant text, and Pi's native working activity. Changing persisted context to remove hidden content, filtering provider context, patching installed harness code, or claiming coverage outside a supported renderer does not satisfy that boundary. @@ -36,7 +36,8 @@ The proven comparison path was a built-in text tool. Calm already owned both of that tool's supported renderer slots and switched its shell to `renderShell: "self"`, so returning empty components removed the complete row and `setToolsExpanded` redrew existing tool components. The earliest divergence for the watcher was its separate custom fallback definition, and the earliest divergence for thinking and user-role injections was Pi's built-in message component path rather than `ToolExecutionComponent`. -The smallest counterfactuals produced these results: +The original presentation-feasibility counterfactuals produced these results. +The later duplicate-turn evidence below supersedes custom-message rerouting as an acceptable implementation even where these rendering observations remain true. - Calling `setWorkingVisible(false)` removed the live working row without reserving space. - Calling `setHiddenThinkingLabel("")` removed every collapsed `Thinking...` label, but Pi's `AssistantMessageComponent` retained one leading spacer for each reasoning-bearing message. @@ -50,44 +51,62 @@ The smallest counterfactuals produced these results: - Synthetic delivery therefore mounts its presentation synchronously before Pi's `entry_appended` event returns, then immediately cycles the supported expansion state so Calm removes the host spacer and content while retaining the zero-height parent for later restoration. - Pi coalesces those synchronous render requests, so the genuine interactive fixture shows neither the temporary presentation nor a blank gap. - Whole-transcript reconstruction was rejected because it drops non-persisted diagnostics and adds an unrelated navigation status row. -- Pi's HTML exporter omits plain custom entries and `display: false` custom messages from the main message transcript and does not invoke TUI renderers, but the complete artifact retains hidden operational text in serialized session data and the sidebar tree. +- Pi's HTML exporter omits plain custom entries and `display: false` custom messages from the main message transcript and does not invoke TUI renderers, but the complete artifact retains legacy hidden operational text in serialized session data and the sidebar tree. The disconfirming checks deliberately retained contradictory evidence. An arbitrary third-party custom tool and a built-in read image remain visible because Pi exposes neither a global tool renderer nor image-row control. An expanded thinking fixture remains visible, and an empty collapsed-thinking label leaves blank spacing, so this implementation does not claim complete reasoning-row removal. An ordinary user prompt may quote or reuse watcher, guard, startup, or supervisor wording and remains visible unless it carries a structurally valid operational envelope. -## Central visibility and injection policy +## Duplicate-turn regression and semantic boundary -`.pi/extensions/lib/fm-calm-visibility.ts` owns the allowlist-style transcript policy and delivery into Pi's structured hidden context entries. +The captain-visible regression reproduced three consecutive times in the persisted Pi session at `/Users/kunchen/.pi/agent/sessions/--Users-kunchen-github-kunchenguid-firstmate--/2026-07-23T16-37-24-672Z_019f8fd6-c440-7641-b2bf-8065dab1622a.jsonl`. +Assistant `bb83873b` was followed by hidden custom input `9d087b52` and distinct duplicate assistant `f4232aa3`. +Assistant `3a388d8c` was followed by adjacent hidden custom inputs `e1914f28` and `cfdefb09` and distinct duplicate assistant `47c81eeb`. +Distinct provider response identifiers and signatures prove separate model turns rather than duplicate TUI paint. + +The initiating trigger was `pi.sendUserMessage(..., { deliverAs: "followUp" })` from the watcher or turn-end adapter after a captain-facing response. +The exposure condition was Calm's loaded `input` handler from commit `6db3b09`, which ran whether the persisted toggle was on or off, returned `handled`, replaced the user message with `pi.sendMessage`, and triggered a nested custom-message turn. +The visible symptom was a second assistant row repeating the prior captain answer. +The earliest persisted divergence was the operational entry type: Calm loaded produced `custom_message` with role `custom` before provider conversion, while Calm absent produced a normal `message` with role `user`. +The earliest lifecycle divergence was that the replacement path bypassed Pi's normal user-prompt processing after the `input` event. + +A native deterministic Pi TUI reproduction on landed PR 927 produced `CAPTAIN_VISIBLE_ANSWER` twice with Calm loaded and explicitly on, and produced the same duplicate with Calm loaded and explicitly off. +The same exact typed notification with Calm absent produced one captain answer followed by `MONITOR_NOTIFICATION_HANDLED`. +Removing only the input reroute from a scratch copy while leaving Calm loaded and on produced the same proven result and restored the operational entry to role `user`. +This is the smallest counterfactual and proves extension loading, not the active toggle, was the required exposure condition. +The extension-absent success path is evidence against an independent Pi-core duplicate-turn cause for the same sequence, but it does not claim Pi core could never contain a separate duplication bug. + +Pi 0.81.1 exposes no supported renderer for ordinary user-role rows. +The fix therefore removes Calm's input handler and custom-message delivery path completely. +Current operational input remains an exact ordinary user-role message, keeps its ordering and authority, and remains visible under Calm rather than risking duplicate or lost processing. +Legacy `firstmate-synthetic-input-presentation` entries remain renderable so existing sessions still preserve their stored presentation and zero-height hidden-row behavior. +A final 120 by 28 native Pi TUI capture with Calm persisted on showed one genuine captain row, one captain answer, one visible exact watcher row, and one monitoring result with only Pi's standard message spacing. +It showed no duplicate assistant row, residual hidden-row gap, Calm status, lost native activity control, or hidden captain content. + +## Central visibility and input policy + +`.pi/extensions/lib/fm-calm-visibility.ts` owns only the allowlist-style transcript presentation policy. `bin/fm-operational-input.sh` owns current cross-language operational-input construction and parsing, while the thin Pi adapter lives at `.pi/extensions/lib/fm-operational-input.ts`. Only `genuine-user-prompt`, `genuine-agent-response`, and `working-status` are policy-visible. -Every other audited class is policy-hidden even when Pi currently lacks a supported renderer for enforcing that result. +Every other audited class is policy-hidden when Pi exposes a supported presentation boundary, but semantic input is never transformed to enforce that preference. The home-local persistence schema is owned by [`docs/configuration.md`](configuration.md#pi-calm-preference-configcalm). -Current session-start, watcher, turn-end guard, away supervisor, and launch-brief inputs use the versioned kind carried after the landed U+2063 `FIRSTMATE_OP: ` prefix. -The established leading `[fm-from-firstmate]` plus U+2063 routing carrier remains current and is parsed as `from-firstmate` through the same owner so running secondmate charters remain compatible. -Pi persists the resulting exact kind in both the presentation entry and the non-displayed context message. -A landed untyped `FIRSTMATE_OP` input is retained as `legacy-operational` rather than having a subtype inferred from its body. -Narrow pre-protocol parsing for the exact startup line, watcher and guard shapes, and bare-marker away escalation is isolated from the current parser. -The per-process `FM_FIRSTMATE_PI_LAUNCH_BRIEF` binding remains only as compatibility for a raw launch created before typed launch instructions. - -Positive fixtures cover every current kind and a separate legacy matrix. -Near-miss fixtures cover quoted operational content, ASCII-only labels, arbitrary U+2063-prefixed text, altered legacy text, visible routing labels without U+2063, and launch-brief text without its source binding. -An exact current static envelope is sufficient provenance regardless of whether Pi reports its input source as interactive or extension, so exact copy-paste risk is accepted without nonce, source-authentication, replay-prevention, secondary-token, blocking, redaction, or private-retrieval machinery. - -Synthetic inputs that would otherwise render as user rows are rerouted only at Pi input presentation time. -Their full text is persisted in a non-displayed custom message that Pi converts back to an ordinary user message for provider context, and a TUI-only custom entry restores stock user styling while Calm is off. -The session-start nudge already uses a non-displayed custom message at its authoritative source, so it remains on that existing hidden presentation path while retaining model context and session persistence. -The custom-entry host omits the complete row when the renderer returns undefined under Calm, including its normally conditional leading spacer. -Cycling tool expansion and restoring its original value rebuilds those custom entries and leaves final `Ctrl+O` state unchanged. -Exported and shared HTML retain genuine user prompts, genuine assistant responses, ordinary tool rendering, and the complete session artifact. -The main message transcript omits the synthetic presentation entry and hidden context message, while serialized session data and Pi 0.81.1's sidebar tree retain the full hidden operational text. +Current session-start, watcher, turn-end guard, away supervisor, and launch-brief inputs retain their versioned U+2063 static envelopes. +The established leading `[fm-from-firstmate]` plus U+2063 routing carrier remains current so running secondmate charters remain compatible. +An exact current static envelope remains sufficient provenance without nonce, source-authentication, replay-prevention, secondary-token, blocking, redaction, or private-retrieval machinery. +Calm does not classify, replace, reorder, or weaken those messages. + +The session-start nudge already originates as a non-displayed custom message, so it remains on that existing path while retaining model context and session persistence. +Legacy Calm custom entries and messages remain in existing session artifacts, and their presentation entry still uses the supported zero-height renderer while active. +Cycling tool expansion and restoring its original value rebuilds controllable rows and leaves final `Ctrl+O` state unchanged. +Exported and shared HTML retain genuine user prompts, genuine assistant responses, current operational user messages, ordinary tool rendering, and the complete session artifact. +Serialized session data and Pi 0.81.1's sidebar tree also retain legacy hidden operational custom messages. ## Complete currently reachable Pi transcript taxonomy The taxonomy was derived from Pi 0.81.1's installed public declarations, documentation, examples, `interactive-mode.js`, and its exported component implementations. -The test fixture enumerates every class below through the centralized policy, and the interactive fixture exercises the screenshot classes plus positive and negative synthetic user presentation. +The test fixture enumerates every class below through the centralized policy, and the interactive fixture exercises the screenshot classes, current user-role operational input, and legacy synthetic presentation entries. | Policy class | Pi transcript path | Calm result on Pi 0.81.1 | | --- | --- | --- | @@ -99,8 +118,8 @@ The test fixture enumerates every class below through the centralized policy, an | `tool-image` | Image children appended outside tool renderer slots | Unsupported boundary; remains visible. | | `user-bash` | `BashExecutionComponent` for `!` and `!!` | Unsupported boundary; remains visible. | | `skill-invocation` | `SkillInvocationMessageComponent` plus parsed user text | Unsupported boundary; remains visible. | -| `custom-message` | `CustomMessageComponent` when `display` is true | Firstmate's known synthetic context messages use `display: false`; arbitrary extension messages remain an unsupported boundary. | -| `custom-entry` | `CustomEntryComponent` with a registered renderer | Firstmate's synthetic presentation entry is mounted synchronously, rebuilt to zero children without a residual spacer, and restored by the ordinary expansion redraw; arbitrary extension entries remain an unsupported boundary. | +| `custom-message` | `CustomMessageComponent` when `display` is true | The session-start nudge and legacy Calm context messages use `display: false`; arbitrary extension messages remain an unsupported boundary. | +| `custom-entry` | `CustomEntryComponent` with a registered renderer | Legacy Calm presentation entries rebuild to zero children without a residual spacer and restore through ordinary expansion redraw when mounted; arbitrary extension entries remain an unsupported boundary. | | `compaction-summary` | `CompactionSummaryMessageComponent` | Unsupported boundary; remains visible. | | `branch-summary` | `BranchSummaryMessageComponent` | Unsupported boundary; remains visible. | | `working-status` | `WorkingStatusIndicator` | Visible through Pi's unchanged built-in row while Calm is active. | @@ -108,7 +127,7 @@ The test fixture enumerates every class below through the centralized policy, an | `system-notice` | `showStatus`, `showError`, compaction, retry, and startup warning rows | Unsupported boundary; remains visible. | | `cache-notice` | Non-persisted cache-miss `Text` row | Unsupported boundary; remains visible. | | `project-trust-warning` | Non-persisted startup `Text` row | Unsupported boundary; remains visible. | -| `synthetic-user` | Firstmate extension `sendUserMessage`, terminal-injected input, Firstmate-generated Pi positional brief, or the already non-displayed session-start nudge | Forms that ordinarily render as user rows are rerouted to hidden context plus a gapless controllable presentation entry; the session-start nudge retains its existing non-displayed custom-message path. | +| `synthetic-user` | Firstmate extension `sendUserMessage`, terminal-injected input, Firstmate-generated Pi positional brief, or the already non-displayed session-start nudge | Current user-role forms remain ordinary visible user rows because Pi has no safe renderer for them; legacy Calm presentation entries stay gaplessly controllable, and the session-start nudge retains its existing non-displayed custom-message path. | | `synthetic-assistant` | No authoritative Firstmate source found | Policy-hidden, but Pi exposes no generic assistant-role renderer. | | `unknown` | Future or unclassified transcript component | Policy-hidden, but no generic renderer exists; never claimed as covered. | @@ -142,10 +161,15 @@ grok 0.2.106 (bde89716f679) These conclusions are deliberately limited to the named versions and supported surfaces. They do not claim that a harness can never add the missing renderer API. +For the duplicate-turn fix, the launch templates for Claude, Codex, OpenCode, Pi, and Grok and the watcher, turn-end, session-start, away-supervisor, and from-firstmate producers were re-inspected. +The canonical encoder and every non-Pi delivery path remain unchanged, and the tmux, Herdr, Zellij, Orca, and cmux runtime surfaces continue to transport the same input selected by the harness adapter. +Only Pi's obsolete Calm launch binding and semantic input interceptor were removed. ## Regression coverage -`tests/fm-calm-pi-extension.test.sh` compares wrapped and stock renderers, verifies all seven built-ins plus `fm_watch_arm_pi`, exercises redraw of already-rendered tool and synthetic rows, checks the gapless mounted custom-entry lifecycle, preserves a transient diagnostic while restoring an entry received under Calm, covers every policy class and synthetic fixture, pins exact static envelopes under interactive and extension sources, covers persisted preference restoration across session-start reasons and a real restart/resume, proves Pi's native `Working...` row through a delayed deterministic provider, asserts no Calm status row, asserts the main transcript omission plus complete sidebar and serialized export artifact, and drives a genuine 180 by 44 interactive terminal fixture. +`tests/fm-calm-pi-extension.test.sh` compares wrapped and stock renderers, verifies all seven built-ins plus `fm_watch_arm_pi`, exercises redraw of already-rendered tool and legacy synthetic rows, checks zero-height hidden legacy entries, covers every policy class, covers persisted preference restoration across session-start reasons and a real restart/resume, proves Pi's native `Working...` row through a delayed deterministic provider, asserts no Calm status row, verifies current operational rows remain ordinary user messages in the TUI and complete exports, and drives a genuine 180 by 44 interactive terminal fixture. +The same test runs a native deterministic Pi provider path that fails on landed PR 927 and covers Calm loaded on, loaded off, extension absent, restart with persisted state, a genuine captain prompt, and adjacent notifications coalesced into one intended processing turn. +It asserts one persisted and rendered captain answer, exact user-role operational envelopes in order, no replacement custom messages, and one processing result. `tests/fm-pi-primary-live-e2e.test.sh` also proves the unchanged built-in `Working...` row while Calm is active on the credentialed provider path before continuing its ordinary watcher lifecycle. `tests/fm-pi-primary-types.test.sh` performs strict no-emit TypeScript checking against the installed Pi 0.81.1 declarations. diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 4ed91e7ddbd..2ed1ce1b49e 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -41,3 +41,8 @@ Continuity and Calm verification on 2026-07-23 used Pi 0.81.1 with the existing The isolated live test activated Calm, proved Pi's native `Working...` row remained visible during a credentialed provider request, proved no `calm transcript` status appeared, restored Calm off, and then completed the unchanged watcher successor and clean-exit lifecycle. Command: `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh`. Observed output: `ok - Pi 0.81.1 live E2E covered native Calm Working visibility, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity`. + +Operational follow-up verification on 2026-07-23 used Pi 0.81.1's native TUI and a deterministic in-process provider without credential material. +It proved one captain answer, exact user-role monitoring envelopes, one intended processing result, Calm loaded on, Calm loaded off, Calm absent, adjacent coalesced notifications, and restart with persisted Calm state. +Command: `tests/fm-calm-pi-extension.test.sh`. +Observed output: `ok - Pi operational follow-up E2E preserves one captain answer, exact user-role monitoring turns, Calm on/off/absent behavior, adjacent coalescing, genuine prompts, and restart persistence`. diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 7d6bc63fd02..a046f7d9390 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -71,20 +71,20 @@ test_static_contract() { assert_contains "$text" 'ctx.ui.setToolsExpanded(!expanded)' "Pi calm extension does not redraw existing custom entries" assert_contains "$text" 'ctx.ui.setToolsExpanded(expanded)' "Pi calm extension does not restore Ctrl+O state after redraw" assert_not_contains "$text" 'ctx.navigateTree' "Pi calm extension reconstructs the transcript and drops transient diagnostics" - assert_contains "$visibility" 'mountingSyntheticPresentation' "Pi calm visibility policy cannot mount entries received while hidden" - assert_contains "$visibility" 'options.redrawPresentation' "Pi calm synthetic delivery does not retain mounted hidden entries" + assert_not_contains "$visibility" 'deliverFirstmateSyntheticInput' "Pi calm visibility policy can still replace operational input semantics" + assert_not_contains "$visibility" 'classifyFirstmateSyntheticInput' "Pi calm visibility policy still classifies operational input for interception" assert_contains "$text" 'ctx.ui.setWorkingVisible(true)' "Pi calm extension does not preserve Pi's live working row" assert_not_contains "$text" 'ctx.ui.setWorkingVisible(!active)' "Pi calm extension still hides Pi's live working row" assert_contains "$text" 'ctx.ui.setHiddenThinkingLabel(active ? "" : undefined)' "Pi calm extension does not hide collapsed thinking labels" assert_not_contains "$text" 'calm transcript' "Pi calm extension still adds a persistent Calm status row" - assert_contains "$text" 'pi.on("input"' "Pi calm extension does not classify input-origin Firstmate injections" + assert_not_contains "$text" 'pi.on("input"' "Pi calm extension still intercepts semantic input" + assert_not_contains "$text" 'sendMessage' "Pi calm extension still replaces user-role input with custom context" assert_contains "$text" 'ctx.ui.onTerminalInput' "Pi calm extension does not scope export rendering to terminal submissions" assert_contains "$text" 'getKeybindings().matches(data, "tui.input.submit")' "Pi calm export boundary ignores the active submit keybinding" assert_contains "$text" 'input !== "/share"' "Pi calm export boundary does not cover /share" - assert_contains "$text" 'FIRSTMATE_PI_LAUNCH_BRIEF_ENV' "Pi calm extension does not consume authoritative launch-brief origin" + assert_not_contains "$text" 'FIRSTMATE_PI_LAUNCH_BRIEF_ENV' "Pi calm presentation still depends on launch-input provenance" assert_contains "$text" 'renderShell: "self"' "Pi calm extension cannot remove complete built-in tool shells" assert_contains "$visibility" 'CALM_VISIBLE_CLASSES' "Pi calm policy does not centralize its visibility allowlist" - assert_contains "$visibility" 'classifyFirstmateSyntheticInput' "Pi calm policy does not centralize synthetic-input classification" assert_contains "$operational" 'fm-operational-input.sh' "Pi adapter does not delegate to the canonical cross-language owner" assert_not_contains "$visibility" 'FIRSTMATE WATCHER WAKE:' "current Calm classification still matches watcher payload prose" assert_not_contains "$visibility" 'TURN WOULD END BLIND' "current Calm classification still matches turn-end payload prose" @@ -96,7 +96,7 @@ test_static_contract() { for name in Read Bash Edit Write Grep Find Ls; do assert_contains "$text" "create${name}ToolDefinition" "Pi calm extension does not wrap the $name built-in" done - pass "Pi calm extension has one persisted visibility choice, no Calm status row, native working visibility, supported redraw controls, and the Firstmate watcher-tool integration" + pass "Pi calm extension is presentation-only with one persisted visibility choice, no Calm status row, native working visibility, supported redraw controls, and the Firstmate watcher-tool integration" } test_home_resolution() { @@ -244,16 +244,10 @@ const [{ CustomEntryComponent }, { ToolExecutionComponent }, { initTheme, theme ]); initTheme("dark"); setCapabilities({ images: null, trueColor: true, hyperlinks: false }); -const launchBrief = "You are the persistent secondmate.\nRead the charter and wait."; -writeFileSync("launch-brief.md", `${launchBrief}\n`); -process.env.FM_FIRSTMATE_PI_LAUNCH_BRIEF = `${process.cwd()}/launch-brief.md`; const tools = []; const handlers = new Map(); const entryRenderers = new Map(); -const appendedEntries = []; -const mountedPresentationComponents = []; -const sentMessages = []; const eventListeners = new Map(); let calmCommand; const pi = { @@ -267,18 +261,6 @@ const pi = { eventListeners.set(name, listeners); }, }, - appendEntry(customType, data) { - const entry = { customType, data }; - appendedEntries.push(entry); - const renderer = entryRenderers.get(customType); - if (!renderer) return; - const component = new CustomEntryComponent(entry, renderer); - component.setExpanded(expanded); - if (component.hasContent()) mountedPresentationComponents.push(component); - }, - sendMessage(message, options) { - sentMessages.push({ message, options }); - }, on(event, handler) { const eventHandlers = handlers.get(event) ?? []; eventHandlers.push(handler); @@ -297,14 +279,18 @@ const pi = { const extension = await import(`${pathToFileURL(process.env.EXT).href}?test=${Date.now()}`); extension.default(pi); const visibility = await import(`${pathToFileURL(`${process.cwd()}/lib/fm-calm-visibility.ts`).href}?policy=${Date.now()}`); +const operationalInput = await import(`${pathToFileURL(`${process.cwd()}/lib/fm-operational-input.ts`).href}?input=${Date.now()}`); const names = tools.map((tool) => tool.name); const expectedNames = ["read", "bash", "edit", "write", "grep", "find", "ls"]; if (JSON.stringify(names) !== JSON.stringify(expectedNames)) { throw new Error(`unexpected wrapped built-ins: ${names.join(",")}`); } -if (!calmCommand || !handlers.has("session_start") || !handlers.has("input")) { - throw new Error("calm command, input classifier, or session lifecycle handler was not registered"); +if (!calmCommand || !handlers.has("session_start")) { + throw new Error("calm command or session lifecycle handler was not registered"); +} +if (handlers.has("input")) { + throw new Error("Calm registered a semantic input interceptor"); } if ( calmCommand.description !== @@ -326,70 +312,7 @@ for (const itemClass of visibility.CALM_TRANSCRIPT_CLASSES) { const watcherBody = "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status\n\n" + "Run bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned."; -const turnEndBody = - "TURN WOULD END BLIND - supervision is off. " + - "The watcher cycle is missing, failed, or unhealthy. " + - "Follow the harness recovery instruction below before ending the turn.\n\n" + - "watcher: FAILED - probe"; -const currentBodies = new Map([ - ["session-start", "CURRENT_SESSION_START_BODY"], - ["watcher", watcherBody], - ["turn-end-guard", turnEndBody], - ["away-supervisor", "CURRENT_AWAY_BODY"], - ["from-firstmate", "corr=0123456789abcdef CURRENT_ROUTED_BODY"], - ["launch-brief", launchBrief], -]); -const positiveSyntheticFixtures = [...currentBodies].map(([kind, body]) => [ - kind, - visibility.encodeFirstmateOperationalInput(kind, body), -]); -for (const [kind, content] of positiveSyntheticFixtures) { - if (visibility.classifyFirstmateSyntheticInput(content, "extension") !== kind) { - throw new Error(`current Firstmate fixture was not classified as ${kind}`); - } - if (visibility.classifyFirstmateSyntheticInput(content, "interactive") !== kind) { - throw new Error(`exact static ${kind} envelope incorrectly required extension source provenance`); - } -} -const watcherMessage = positiveSyntheticFixtures.find(([kind]) => kind === "watcher")[1]; -const legacyUntyped = `\u2063FIRSTMATE_OP: ${watcherBody}`; -if (visibility.classifyFirstmateSyntheticInput(legacyUntyped, "interactive") !== "legacy-operational") { - throw new Error("landed untyped FIRSTMATE_OP input was falsely assigned a current subtype"); -} -const legacyFixtures = [ - ["session-start", "Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions."], - ["watcher", watcherBody], - ["turn-end-guard", turnEndBody], - ["away-supervisor", "\u2063Supervisor escalate (1 event(s)): done"], -]; -for (const [kind, content] of legacyFixtures) { - if (visibility.classifyFirstmateSyntheticInput(content, "interactive") !== kind) { - throw new Error(`isolated legacy fixture was not classified as ${kind}`); - } -} -if (visibility.classifyFirstmateSyntheticInput(launchBrief, "interactive", launchBrief) !== "launch-brief") { - throw new Error("legacy env-identified Pi launch brief was not classified"); -} -if (visibility.classifyFirstmateSyntheticInput(launchBrief, "interactive") !== undefined) { - throw new Error("unmarked genuine text matching a brief was hidden without its source binding"); -} -const nearMissGenuineFixtures = [ - "Run bin/fm-session-start.sh now, exactly once, before executing any other instructions.", - "FIRSTMATE WATCHER WAKE: can you explain this phrase?", - "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status\n\nRun bin/fm-wake-drain.sh when convenient.", - "TURN WOULD END BLIND - can you make this warning friendlier?", - "Supervisor escalate (1 event(s)): is this wording clear?", - "[fm-from-firstmate] inspect this visible label", - "FIRSTMATE_OP: v1 watcher", - "\u2063Captain-authored arbitrary invisible-separator text", - `Captain quote: ${positiveSyntheticFixtures[0][1]}`, - "Captain quote: Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.", -]; -for (const content of nearMissGenuineFixtures) { - if (visibility.classifyFirstmateSyntheticInput(content, "interactive") !== undefined) { - throw new Error(`genuine near-miss input was hidden: ${content}`); - } -} +const watcherMessage = operationalInput.encodeFirstmateOperationalInput("watcher", watcherBody); writeFileSync("sample.txt", "alpha\n"); const cases = [ @@ -564,9 +487,6 @@ const commandContext = { watchActual.setExpanded(value); customRow.setExpanded(value); imageRow.setExpanded(value); - for (const component of mountedPresentationComponents) { - component.setExpanded(value); - } }, setWorkingVisible(value) { workingVisible = value; @@ -578,58 +498,8 @@ await handlers.get("session_start")[0]({ reason: "startup" }, commandContext); if (workingVisible !== true || hiddenThinkingLabel !== undefined) { throw new Error("session start did not restore Pi's stock working and thinking presentation"); } -const inputHandler = handlers.get("input")[0]; -const launchBriefResult = await inputHandler({ - text: launchBrief, - images: undefined, - source: "interactive", - streamingBehavior: undefined, -}, commandContext); -if ( - launchBriefResult?.action !== "handled" || - appendedEntries.length !== 1 || - sentMessages.length !== 1 || - sentMessages[0].message.details.kind !== "launch-brief" || - sentMessages[0].message.content !== launchBrief -) { - throw new Error("Pi positional launch brief was not consumed through its exact origin path"); -} -const repeatedBriefResult = await inputHandler({ - text: launchBrief, - images: undefined, - source: "interactive", - streamingBehavior: undefined, -}, commandContext); -if ( - repeatedBriefResult?.action !== "continue" || - appendedEntries.length !== 1 || - sentMessages.length !== 1 -) { - throw new Error("consumed launch origin hid a later genuine matching prompt"); -} -const syntheticResult = await inputHandler({ - text: watcherMessage, - images: undefined, - source: "extension", - streamingBehavior: "followUp", -}, commandContext); -if ( - syntheticResult?.action !== "handled" || - appendedEntries.length !== 2 || - sentMessages.length !== 2 -) { - throw new Error("known Firstmate synthetic input was not rerouted through controllable delivery"); -} -if ( - sentMessages[1].message.content !== watcherMessage || - sentMessages[1].message.display !== false || - sentMessages[1].options.triggerTurn !== true || - sentMessages[1].options.deliverAs !== "followUp" -) { - throw new Error("synthetic input delivery or context semantics changed"); -} const presentationRenderer = entryRenderers.get("firstmate-synthetic-input-presentation"); -if (!presentationRenderer) throw new Error("synthetic presentation renderer was not registered"); +if (!presentationRenderer) throw new Error("legacy synthetic presentation renderer was not registered"); const presentationEntry = { customType: "firstmate-synthetic-input-presentation", data: { content: watcherMessage, kind: "watcher" }, @@ -640,20 +510,7 @@ if ( !presentationComponent.hasContent() || !presentationComponent.render(100).join("\n").includes("FIRSTMATE WATCHER WAKE") ) { - throw new Error("Calm-off synthetic presentation did not use a stock user-message row"); -} -const nearMissResult = await inputHandler({ - text: nearMissGenuineFixtures[1], - images: undefined, - source: "interactive", - streamingBehavior: undefined, -}, commandContext); -if ( - nearMissResult?.action !== "continue" || - appendedEntries.length !== 2 || - sentMessages.length !== 2 -) { - throw new Error("genuine near-miss input was intercepted"); + throw new Error("Calm-off legacy synthetic presentation did not use a stock user-message row"); } await calmCommand.handler("", commandContext); @@ -739,48 +596,14 @@ if (JSON.stringify(sessionEntries) !== entriesBefore) { throw new Error("calm mode changed session entries or model context"); } -const activeWatcherMessage = visibility.encodeFirstmateOperationalInput( - "watcher", - "FIRSTMATE WATCHER WAKE: signal: /tmp/active-probe.status\n\n" + - "Run bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.", -); -const activeSyntheticResult = await inputHandler({ - text: activeWatcherMessage, - images: undefined, - source: "extension", - streamingBehavior: "followUp", -}, commandContext); -if ( - activeSyntheticResult?.action !== "handled" || - appendedEntries.length !== 3 || - sentMessages.length !== 3 -) { - throw new Error("synthetic input received while Calm was active was not delivered"); -} -const activePresentationComponent = new CustomEntryComponent( - appendedEntries[2], - presentationRenderer, -); -activePresentationComponent.setExpanded(expanded); -if ( - activePresentationComponent.hasContent() || - activePresentationComponent.render(100).length !== 0 || - mountedPresentationComponents.length !== 3 || - mountedPresentationComponents[2].hasContent() || - mountedPresentationComponents[2].render(100).length !== 0 -) { - throw new Error("synthetic input received while Calm was active left a row or blank gap"); -} - for (const { baseline } of rows) baseline.setExpanded(expanded); await calmCommand.handler("", commandContext); +presentationComponent.setExpanded(expanded); if ( - mountedPresentationComponents.length !== 3 || - !mountedPresentationComponents.some((component) => - component.render(100).join("\n").includes("/tmp/active-probe.status") - ) + !presentationComponent.hasContent() || + !presentationComponent.render(100).join("\n").includes("FIRSTMATE WATCHER WAKE") ) { - throw new Error("turning Calm off did not restore the mounted synthetic row received while active"); + throw new Error("turning Calm off did not restore a legacy synthetic presentation row"); } for (const { name, baseline, actual } of rows) { if (JSON.stringify(actual.render(100)) !== JSON.stringify(baseline.render(100))) { @@ -841,8 +664,269 @@ JS pass "Pi calm centralizes transcript visibility, preserves execution/export data, keeps native working visible, and persists its choice across session starts" } +test_operational_followup_turn_e2e() { + local project home config sessions version label case_name calm_state expected_notifications session_file pane i + if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then + echo "skip: pi or tmux not found for Pi operational follow-up E2E" + return 0 + fi + version=$(pi --version 2>/dev/null || true) + [ "$version" = "0.81.1" ] || fail "Pi operational follow-up E2E requires Pi 0.81.1, found $version" + + project="$TMP_ROOT/followup-project" + home="$TMP_ROOT/followup-home" + config="$TMP_ROOT/followup-config" + sessions="$TMP_ROOT/followup-sessions" + mkdir -p "$project/.pi/extensions/lib" "$home/config" "$config" "$sessions" + fm_git_init_commit "$project" + cp "$EXT" "$project/.pi/extensions/fm-calm.ts" + cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts" + printf '%s\n' '{"followUpMode":"all"}' >"$config/settings.json" + + cat >"$project/followup-e2e.ts" <<'TS' +import { + type AssistantMessage, + createAssistantMessageEventStream, +} from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { encodeFirstmateOperationalInput } from "./.pi/extensions/lib/fm-operational-input.ts"; + +let phase: "idle" | "captain" | "monitor" = "idle"; +let label = ""; +let adjacent = false; +let latestInputRole: "user" | "custom" | undefined; + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((item): item is { type: "text"; text: string } => + typeof item === "object" && item !== null && + (item as { type?: unknown }).type === "text" && + typeof (item as { text?: unknown }).text === "string") + .map((item) => item.text) + .join("\n"); +} + +export default function (pi: ExtensionAPI): void { + pi.on("message_start", (event) => { + if (event.message.role === "user" || event.message.role === "custom") { + latestInputRole = event.message.role; + } + if (event.message.role !== "assistant" || phase !== "captain") return; + phase = "monitor"; + pi.sendUserMessage( + encodeFirstmateOperationalInput("watcher", `MONITOR_${label}_ONE`), + { deliverAs: "followUp" }, + ); + if (adjacent) { + pi.sendUserMessage( + encodeFirstmateOperationalInput("watcher", `MONITOR_${label}_TWO`), + { deliverAs: "followUp" }, + ); + } + }); + + pi.registerProvider("followup-e2e", { + baseUrl: "http://127.0.0.1/unused", + apiKey: "test-only", + api: "followup-e2e-api", + models: [{ + id: "deterministic", + name: "Deterministic operational follow-up regression", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 128, + }], + streamSimple(model, context) { + const stream = createAssistantMessageEventStream(); + const allUserText = context.messages + .filter((message) => message.role === "user") + .map((message) => contentText(message.content)) + .join("\n"); + const responseText = latestInputRole === "custom" + ? `CAPTAIN_ANSWER_${label}` + : allUserText.includes(`MONITOR_${label}_ONE`) + ? adjacent && allUserText.includes(`MONITOR_${label}_TWO`) + ? `MONITOR_HANDLED_${label}_ONE_TWO` + : `MONITOR_HANDLED_${label}_ONE` + : `CAPTAIN_ANSWER_${label}`; + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + queueMicrotask(() => { + stream.push({ type: "start", partial: output }); + const block = { type: "text" as const, text: responseText }; + output.content.push(block); + stream.push({ type: "text_start", contentIndex: 0, partial: output }); + stream.push({ type: "text_delta", contentIndex: 0, delta: responseText, partial: output }); + stream.push({ type: "text_end", contentIndex: 0, content: responseText, partial: output }); + stream.push({ type: "done", reason: "stop", message: output }); + stream.end(); + }); + return stream; + }, + }); + + pi.registerCommand("followup-e2e", { + description: "Run one captain prompt followed by typed monitoring input.", + handler: async (args, ctx) => { + const [nextLabel, shape] = args.trim().split(/\s+/); + if (!nextLabel) throw new Error("missing follow-up E2E label"); + const model = ctx.modelRegistry.find("followup-e2e", "deterministic"); + if (!model || !(await pi.setModel(model))) throw new Error("follow-up E2E model unavailable"); + label = nextLabel; + adjacent = shape === "adjacent"; + phase = "captain"; + pi.sendUserMessage(`CAPTAIN_PROMPT_${label}`); + }, + }); +} +TS + + run_followup_case() { + case_name=$1 + calm_state=$2 + label=$3 + expected_notifications=$4 + local session_arg=${5:-} + local shape=${6:-single} + local extensions + + tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true + if [ "$calm_state" = absent ]; then + rm -f "$home/config/calm" + extensions='-e ./followup-e2e.ts' + else + printf '%s\n' "$calm_state" >"$home/config/calm" + extensions='-e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts' + fi + if [ -z "$session_arg" ]; then + session_arg="--session-dir '$sessions/$label'" + mkdir -p "$sessions/$label" + else + session_arg="--session '$session_arg'" + fi + + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ + "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions $extensions $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + i=0 + while [ "$i" -lt 120 ]; do + pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) + printf '%s\n' "$pane" | grep -Fq 'followup-e2e.ts' && break + sleep 0.05 + i=$((i + 1)) + done + printf '%s\n' "$pane" | grep -Fq 'followup-e2e.ts' \ + || fail "Pi follow-up $case_name case ($label) did not reach the ready composer" + + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/followup-e2e $label $shape" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter + i=0 + while [ "$i" -lt 240 ]; do + session_file=$(find "$sessions" -type f -name '*.jsonl' -exec grep -l "CAPTAIN_PROMPT_$label" {} + 2>/dev/null | head -1 || true) + if [ -n "$session_file" ] && grep -Fq "MONITOR_HANDLED_${label}_ONE" "$session_file"; then + break + fi + sleep 0.05 + i=$((i + 1)) + done + if [ -z "$session_file" ] || ! grep -Fq "MONITOR_HANDLED_${label}_ONE" "$session_file"; then + fail "Pi follow-up $label case did not process the monitoring notification" + fi + + pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) + [ "$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true)" -eq 1 ] \ + || fail "Pi follow-up $label case rendered a duplicate captain answer" + assert_contains "$pane" "CAPTAIN_PROMPT_$label" "Pi follow-up $label case hid the genuine captain prompt" + assert_contains "$pane" "MONITOR_${label}_ONE" "Pi follow-up $label case lost the exact operational user row" + assert_contains "$pane" "MONITOR_HANDLED_${label}_ONE" "Pi follow-up $label case did not render the intended processing result" + if [ "$expected_notifications" -eq 2 ]; then + assert_contains "$pane" "MONITOR_${label}_TWO" "Pi follow-up $label case lost the adjacent operational user row" + fi + + node - "$session_file" "$label" "$expected_notifications" <<'JS' \ + || fail "Pi follow-up $label persisted the wrong turn or input semantics" +const fs = require("node:fs"); +const [file, label, expectedRaw] = process.argv.slice(2); +const expected = Number(expectedRaw); +const entries = fs.readFileSync(file, "utf8").trim().split("\n").map(JSON.parse); +const text = (content) => typeof content === "string" + ? content + : (content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n"); +const captainPrompt = `CAPTAIN_PROMPT_${label}`; +const captainAnswer = `CAPTAIN_ANSWER_${label}`; +const handled = expected === 2 + ? `MONITOR_HANDLED_${label}_ONE_TWO` + : `MONITOR_HANDLED_${label}_ONE`; +const matching = entries.filter((entry) => + (entry.type === "message" && text(entry.message.content).includes(label)) || + (entry.type === "custom_message" && text(entry.content).includes(label)) +); +const userEntries = matching.filter((entry) => entry.type === "message" && entry.message.role === "user"); +const customEntries = matching.filter((entry) => entry.type === "custom_message"); +const assistantEntries = matching.filter((entry) => entry.type === "message" && entry.message.role === "assistant"); +const assistantText = assistantEntries.map((entry) => text(entry.message.content)); +if (customEntries.length !== 0) throw new Error(`operational input was rerouted: ${JSON.stringify(customEntries)}`); +if (userEntries.length !== expected + 1) throw new Error(`expected ${expected + 1} user inputs, found ${userEntries.length}`); +if (text(userEntries[0].message.content) !== captainPrompt) throw new Error("genuine captain prompt changed"); +for (let index = 1; index <= expected; index += 1) { + const suffix = index === 1 ? "ONE" : "TWO"; + const exact = `\u2063FIRSTMATE_OP: v1 watcher: MONITOR_${label}_${suffix}`; + if (text(userEntries[index].message.content) !== exact) throw new Error(`operational origin changed: ${text(userEntries[index].message.content)}`); +} +if (assistantText.filter((value) => value === captainAnswer).length !== 1) { + throw new Error(`captain answer count changed: ${JSON.stringify(assistantText)}`); +} +if (assistantText.filter((value) => value === handled).length !== 1) { + throw new Error(`monitor processing count changed: ${JSON.stringify(assistantText)}`); +} +if (assistantEntries.length !== 2) throw new Error(`expected one captain and one processing turn, found ${assistantEntries.length}`); +const positions = matching.map((entry) => entries.indexOf(entry)); +if (positions.some((position, index) => index > 0 && position <= positions[index - 1])) { + throw new Error(`turn ordering changed: ${positions.join(",")}`); +} +JS + + if [ "$calm_state" != absent ]; then + [ "$(cat "$home/config/calm")" = "$calm_state" ] \ + || fail "Pi follow-up $label case changed the persisted Calm choice" + fi + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit' + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter + sleep 0.2 + tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true + } + + run_followup_case loaded-on on loaded_on 1 + run_followup_case loaded-off off loaded_off 1 + run_followup_case extension-absent absent absent 1 + run_followup_case adjacent on adjacent 2 '' adjacent + run_followup_case restart-before on restart_before 1 + local restart_session=$session_file + run_followup_case restart-after on restart_after 1 "$restart_session" + pass "Pi operational follow-up E2E preserves one captain answer, exact user-role monitoring turns, Calm on/off/absent behavior, adjacent coalescing, genuine prompts, and restart persistence" +} + test_interactive_terminal_e2e() { - local project config home session_file export_file export_dom default_snapshot expanded_snapshot hidden_snapshot active_before_snapshot active_hidden_snapshot active_hidden_boundary export_snapshot restored_snapshot working_snapshot working_response_snapshot restarted_snapshot resumed_restored_snapshot hash_before hash_after now version chrome chrome_pid chrome_wait active_wait active_screen_wait + local project config home session_file export_file export_dom default_snapshot expanded_snapshot hidden_snapshot active_before_snapshot active_hidden_snapshot export_snapshot restored_snapshot working_snapshot working_response_snapshot restarted_snapshot resumed_restored_snapshot hash_before hash_after now version chrome chrome_pid chrome_wait active_wait active_screen_wait if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then echo "skip: pi or tmux not found for Pi calm interactive E2E" return 0 @@ -861,7 +945,6 @@ test_interactive_terminal_e2e() { hidden_snapshot="$TMP_ROOT/hidden.txt" active_before_snapshot="$TMP_ROOT/active-before.txt" active_hidden_snapshot="$TMP_ROOT/active-hidden.txt" - active_hidden_boundary="$TMP_ROOT/active-hidden-boundary.txt" export_snapshot="$TMP_ROOT/export.txt" restored_snapshot="$TMP_ROOT/restored.txt" working_snapshot="$TMP_ROOT/working.txt" @@ -889,7 +972,7 @@ import { createAssistantMessageEventStream, } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { encodeFirstmateOperationalInput } from "./lib/fm-calm-visibility.ts"; +import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts"; export default function (pi: ExtensionAPI): void { pi.registerProvider("calm-e2e", { @@ -1096,14 +1179,14 @@ JSON tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-inject-e2e $kind" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s active_wait=0 - while ! grep -F '"customType":"firstmate-synthetic-input"' "$session_file" 2>/dev/null | + while ! grep -F '"role":"user"' "$session_file" 2>/dev/null | grep -Fq "$needle" && [ "$active_wait" -lt 120 ]; do sleep 0.05 active_wait=$((active_wait + 1)) done - grep -F '"customType":"firstmate-synthetic-input"' "$session_file" | + grep -F '"role":"user"' "$session_file" | grep -Fq "$needle" \ - || fail "current operational kind $kind was not received while Calm was active" + || fail "current operational kind $kind did not retain user-role delivery while Calm was active" sleep 0.1 done node - "$session_file" <<'JS' || fail "native Pi did not preserve every exact current operational kind" @@ -1129,17 +1212,21 @@ const expected = new Map([ ["CURRENT_LAUNCH_BRIEF_E2E", "launch-brief"], ]); const current = entries.filter((entry) => - entry.type === "custom_message" && - entry.customType === "firstmate-synthetic-input" && - [...expected.keys()].some((needle) => entry.content?.includes(needle)) + entry.type === "message" && + entry.message?.role === "user" && + [...expected.keys()].some((needle) => JSON.stringify(entry.message.content).includes(needle)) ); if (current.length !== expected.size) { - throw new Error(`expected ${expected.size} current entries, found ${current.length}: ${JSON.stringify(current)}`); + throw new Error(`expected ${expected.size} user-role current entries, found ${current.length}: ${JSON.stringify(current)}`); } for (const [needle, kind] of expected) { - const entry = current.find((candidate) => candidate.content.includes(needle)); - if (!entry || entry.display !== false || entry.details?.kind !== kind) { - throw new Error(`expected ${needle} as ${kind}, found ${JSON.stringify(entry)}`); + const entry = current.find((candidate) => JSON.stringify(candidate.message.content).includes(needle)); + const text = entry?.message.content?.find((item) => item.type === "text")?.text; + const exactEnvelope = kind === "from-firstmate" + ? text?.startsWith("[fm-from-firstmate]\u2063corr=0123456789abcdef ") + : text?.startsWith(`\u2063FIRSTMATE_OP: v1 ${kind}: `); + if (!entry || !exactEnvelope) { + throw new Error(`expected exact user-role ${needle} as ${kind}, found ${JSON.stringify(entry)}`); } } JS @@ -1157,21 +1244,17 @@ JS # shellcheck disable=SC2016 # Backticks are literal prompt markup. assert_not_contains "$(cat "$active_hidden_snapshot")" 'Run `bin/fm-session-start.sh` now' \ "Calm showed the native session-start operational input" - for hidden in \ + for visible in \ CURRENT_WATCHER_E2E \ CURRENT_TURN_END_E2E \ CURRENT_AWAY_E2E \ CURRENT_FROM_FIRSTMATE_E2E \ CURRENT_LAUNCH_BRIEF_E2E do - assert_not_contains "$(cat "$active_hidden_snapshot")" "$hidden" "Calm showed current operational kind $hidden" + assert_contains "$(cat "$active_hidden_snapshot")" "$visible" "Calm hid semantic operational input $visible instead of showing the safe user row" done - assert_contains "$(cat "$active_hidden_snapshot")" "Warning: CALM_TRANSIENT_DIAGNOSTIC" "synthetic arrival lost its preceding transient diagnostic" - assert_contains "$(cat "$active_hidden_snapshot")" " Error:" "synthetic delivery did not produce a transient provider diagnostic" - awk '/Warning: CALM_TRANSIENT_DIAGNOSTIC/ { capture = 1 } capture { print } / Error:/ { exit }' \ - "$active_hidden_snapshot" >"$active_hidden_boundary" - [ "$(wc -l <"$active_hidden_boundary" | tr -d ' ')" -eq 3 ] \ - || fail "Calm left a blank transcript gap between diagnostics around a synthetic row received while active" + assert_contains "$(cat "$active_hidden_snapshot")" "Warning: CALM_TRANSIENT_DIAGNOSTIC" "operational arrival lost its preceding transient diagnostic" + assert_contains "$(cat "$active_hidden_snapshot")" " Error:" "operational delivery did not produce a transient provider diagnostic" hash_before=$(shasum -a 256 "$session_file" | awk '{print $1}') tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/export $export_file" @@ -1222,6 +1305,9 @@ if (!/
]*>[\s\S]*Show a deterministic tool example\./ if (!/
]*>[\s\S]*The deterministic tool example is complete\./.test(messages)) process.exit(1); if (messages.includes('
Date: Thu, 23 Jul 2026 12:49:15 -0700 Subject: [PATCH 2/2] no-mistakes(document): Correct Calm operational-row visibility documentation --- docs/calm-mode-feasibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index 0ea906246b0..25d2277b592 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -56,7 +56,7 @@ The later duplicate-turn evidence below supersedes custom-message rerouting as a The disconfirming checks deliberately retained contradictory evidence. An arbitrary third-party custom tool and a built-in read image remain visible because Pi exposes neither a global tool renderer nor image-row control. An expanded thinking fixture remains visible, and an empty collapsed-thinking label leaves blank spacing, so this implementation does not claim complete reasoning-row removal. -An ordinary user prompt may quote or reuse watcher, guard, startup, or supervisor wording and remains visible unless it carries a structurally valid operational envelope. +Every ordinary user-role message remains visible, including a genuine captain prompt that quotes watcher, guard, startup, or supervisor wording and a structurally valid operational envelope. ## Duplicate-turn regression and semantic boundary