diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index c2f48a90fb..d78833917c 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -6,16 +6,25 @@ // with a disposable component factory, and setHiddenThinkingLabel(). // ./lib/fm-calm-working-ship.ts owns the animated working presentation this file // installs. The focused tests pin those assumptions but never reject a -// newer Pi solely for its version. The collapsed-thinking, operational-user, and -// transcript-redraw presentation adapters probe the exact API they patch and degrade -// independently with a diagnostic naming the adapter and the running Pi version +// newer Pi solely for its version. The collapsed-thinking, built-in-tool-row, +// operational-user, transcript-replay, and transcript-redraw presentation adapters +// probe the exact API they patch and degrade independently with a diagnostic naming +// the adapter and the running Pi version // (see installCalmPresentationAdapter below) if a future Pi removes it; Pi // still exposes no global renderer for arbitrary built-in or custom rows. // docs/configuration.md owns the home-local Calm preference contract. +// +// Pi has one complete ToolDefinition slot per tool name and rejects duplicate extension +// registrations during initial load. Keep extension-load registration empty and claim +// only uncontested built-ins from session_start or first activation, when getAllTools() +// is reliable. The exported component adapter above keeps already-mounted and replayed +// rows controllable without taking their execution definition. docs/calm-mode-feasibility.md +// owns the Pi-source evidence. import { randomUUID } from "node:crypto"; import { mkdirSync, readFileSync, + realpathSync, renameSync, rmSync, writeFileSync, @@ -27,6 +36,7 @@ import type { ExtensionContext, ExtensionUIContext, ToolDefinition, + ToolInfo, ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; import { @@ -37,6 +47,7 @@ import { createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, + ToolExecutionComponent, VERSION as PI_VERSION, } from "@earendil-works/pi-coding-agent"; import { @@ -102,6 +113,83 @@ const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); const CALM_REDRAW_CAPTURE_WIDGET_KEY = "firstmate-calm-redraw-capture"; +const realpathOrSelf = (path: string): string => { + try { + return realpathSync(path); + } catch { + return path; + } +}; +const extensionRealFile = realpathOrSelf(extensionFile); +const CALM_BUILT_IN_TOOL_NAMES = new Set([ + "read", + "bash", + "edit", + "write", + "grep", + "find", + "ls", +]); + +type ToolExecutionPresentation = { + imageComponents?: Component[]; + imageSpacers?: Array; + toolName?: string; +}; +type CalmBuiltInToolLayoutPatch = { + hidesBuiltInRows: () => boolean; +}; +const CALM_BUILT_IN_TOOL_LAYOUT_PATCH = Symbol.for( + "firstmate:calm-built-in-tool-layout:pi-0.84.1", +); + +// Tool rows created before Calm first claims an uncontested built-in keep the +// ToolDefinition captured by Pi's constructor. Patch Pi's exported component render +// seam so those mounted rows still follow Calm, while leaving their execution owner +// untouched. Image children remain visible, matching the established wrapper boundary. +function installCalmBuiltInToolLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmBuiltInToolLayoutPatch | undefined; + }; + const hidesBuiltInRows = (): boolean => + calmPresentationHides("assistant-tool-call") && + calmPresentationHides("tool-result"); + const installed = registry[CALM_BUILT_IN_TOOL_LAYOUT_PATCH]; + if (installed) { + installed.hidesBuiltInRows = hidesBuiltInRows; + return; + } + if (typeof ToolExecutionComponent !== "function") { + throw new Error("Firstmate Calm requires Pi ToolExecutionComponent"); + } + const originalRender = ToolExecutionComponent.prototype.render; + if (typeof originalRender !== "function") { + throw new Error("Firstmate Calm requires Pi ToolExecutionComponent.render"); + } + const patch: CalmBuiltInToolLayoutPatch = { hidesBuiltInRows }; + ToolExecutionComponent.prototype.render = function (width: number): string[] { + const state = this as unknown as ToolExecutionPresentation; + if ( + !patch.hidesBuiltInRows() || + typeof state.toolName !== "string" || + !CALM_BUILT_IN_TOOL_NAMES.has(state.toolName) + ) { + return originalRender.call(this, width); + } + + const images = state.imageComponents ?? []; + const spacers = state.imageSpacers ?? []; + const lines: string[] = []; + for (let index = 0; index < images.length; index += 1) { + const spacer = spacers[index]; + if (spacer) lines.push(...spacer.render(width)); + lines.push(...images[index].render(width)); + } + return lines; + }; + registry[CALM_BUILT_IN_TOOL_LAYOUT_PATCH] = patch; +} + // Each presentation adapter probes the exact Pi API it patches. If a future Pi removes // that API, only the affected adapter degrades; the rest of Calm keeps working. function installCalmPresentationAdapter(name: string, install: () => void): void { @@ -115,6 +203,7 @@ function installCalmPresentationAdapter(name: string, install: () => void): void export default function (pi: ExtensionAPI) { installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout); + installCalmPresentationAdapter("built-in-tool-row", installCalmBuiltInToolLayout); installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout); installCalmPresentationAdapter("transcript-replay-window", installCalmTranscriptReplayWindow); @@ -224,9 +313,9 @@ export default function (pi: ExtensionAPI) { registerFirstmateSyntheticPresentation(pi); - function registerBuiltIn( + function wrapBuiltIn( factory: DefinitionFactory, - ): void { + ): ToolDefinition { const definitions = new Map>(); const definitionFor = (cwd: string): ToolDefinition => { let definition = definitions.get(cwd); @@ -278,7 +367,7 @@ export default function (pi: ExtensionAPI) { return shell; }; - pi.registerTool({ + return { ...original, renderShell: "self", @@ -321,21 +410,92 @@ export default function (pi: ExtensionAPI) { refreshStandardShell(state, theme, context); return new Container(); }, + }; + } + + const wrappedBuiltIns: ToolDefinition[] = [ + wrapBuiltIn(createReadToolDefinition), + wrapBuiltIn(createBashToolDefinition), + wrapBuiltIn(createEditToolDefinition), + wrapBuiltIn(createWriteToolDefinition), + wrapBuiltIn(createGrepToolDefinition), + wrapBuiltIn(createFindToolDefinition), + wrapBuiltIn(createLsToolDefinition), + ]; + let builtInsRegistered = false; + + function registeredTools(): ToolInfo[] | undefined { + try { + return pi.getAllTools(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Firstmate Calm: built-in ownership check unavailable. ${reason}`); + return undefined; + } + } + + function ownerIsForeign(owner: ToolInfo["sourceInfo"] | undefined): boolean { + return ( + owner !== undefined && + owner.source !== "builtin" && + realpathOrSelf(owner.path) !== extensionRealFile + ); + } + + function activateBuiltInsIfNeeded(ui: ExtensionUIContext): void { + if (builtInsRegistered) return; + const registered = registeredTools(); + if (registered === undefined) { + builtInsRegistered = true; + ui.notify( + "Firstmate Calm: built-in ownership could not be checked, so Calm left every built-in tool definition unchanged this session.", + "warning", + ); + return; + } + const contested = wrappedBuiltIns.filter((tool) => { + const owner = registered.find((info) => info.name === tool.name)?.sourceInfo; + return ownerIsForeign(owner); }); + const contestedNames = new Set(contested.map((tool) => tool.name)); + for (const tool of wrappedBuiltIns) { + if (!contestedNames.has(tool.name)) pi.registerTool(tool); + } + builtInsRegistered = true; + if (contested.length === 0) return; + + const names = contested.map((tool) => `"${tool.name}"`).join(", "); + const plural = contested.length > 1; + ui.notify( + `Firstmate Calm: the ${names} built-in tool${plural ? "s are" : " is"} already provided by another extension, so Calm may not fully function for ${plural ? "them" : "it"} this session.`, + "warning", + ); + for (const tool of contested) { + console.error(`Firstmate Calm: skipped claiming built-in "${tool.name}" because another extension already owns it.`); + } } - registerBuiltIn(createReadToolDefinition); - registerBuiltIn(createBashToolDefinition); - registerBuiltIn(createEditToolDefinition); - registerBuiltIn(createWriteToolDefinition); - registerBuiltIn(createGrepToolDefinition); - registerBuiltIn(createFindToolDefinition); - registerBuiltIn(createLsToolDefinition); + // Report any later-observable loss rather than silently claiming that Calm controls + // a tool definition owned elsewhere. + function reportBuiltInLosses(): void { + if (!builtInsRegistered) return; + const registered = registeredTools(); + if (!registered) return; + for (const tool of wrappedBuiltIns) { + const owner = registered.find((info) => info.name === tool.name)?.sourceInfo; + if (!ownerIsForeign(owner)) continue; + console.error( + `Firstmate Calm: another extension (${owner.path}) owns the built-in "${tool.name}" tool; Calm's presentation for it is unavailable this session.`, + ); + } + } pi.on("session_start", (_event, ctx) => { resetCalmTranscriptOrigin(); exportRendering = false; setCalmPresentation(loadCalmPreference()); + if (calmPresentationIsActive()) activateBuiltInsIfNeeded(ctx.ui); + reportBuiltInLosses(); setCalmStockExportRendering(false); publishPresentationState(); agentRunActive = false; @@ -396,6 +556,7 @@ export default function (pi: ExtensionAPI) { const active = !calmPresentationIsActive(); persistCalmPreference(active); setCalmPresentation(active); + if (active) activateBuiltInsIfNeeded(ctx.ui); publishPresentationState(); applyWorkingPresentation(ctx.ui, true); ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts index 7b98828261..e6161d561b 100644 --- a/.pi/extensions/lib/fm-calm-assistant-layout.ts +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -2,8 +2,8 @@ // installCalmAssistantLayout() probes that exact method and throws if it is missing; // fm-calm.ts catches that and skips only this adapter with a diagnostic instead of // blocking Calm or Pi. -// The adapter owns both collapsed-thinking layout and the presentation-only exact -// operational acknowledgement rule. +// The adapter owns collapsed-thinking layout, mid-turn working-note layout, and the +// presentation-only exact operational acknowledgement rule. // Acknowledgement origin is scoped to one agent run rather than to the most recent user // row: a run counts as operational only while every Firstmate input it carries is // canonically operational, so a wake steered into a still-running captain turn keeps that @@ -39,13 +39,35 @@ type CalmAssistantLayoutPatch = { runOriginRecorded: boolean; hidesOperationalAcknowledgement: () => boolean; hidesThinking: () => boolean; + hidesWorkingNote: () => boolean; }; +function isMidTurnAssistantMessage(message: AssistantMessage): boolean { + if (message.stopReason === "toolUse") return true; + return ( + message.stopReason === "length" && + message.content.some((block) => block.type === "toolCall") + ); +} + +function isProtectedOperationalToolReply( + message: AssistantMessage, + isOperational: boolean, +): boolean { + if (!isOperational || !message.content.some((block) => block.type === "toolCall")) { + return false; + } + const text = message.content + .map((block) => (block.type === "text" ? block.text : "")) + .join(""); + return text === "Captain, shipshape."; +} + // The symbol changes only when the patch shape changes, so a compatible upgrade cannot // double-patch a live process and an incompatible one cannot keep a stale closure // installed under the same key. const CALM_ASSISTANT_LAYOUT_PATCH = Symbol.for( - "firstmate:calm-assistant-layout:operational-ack-v2", + "firstmate:calm-assistant-layout:operational-ack-working-note-v3", ); const FIRSTMATE_NO_ACTION_ACKNOWLEDGEMENT = "Captain, shipshape."; @@ -136,10 +158,12 @@ export function installCalmAssistantLayout(): void { const hidesThinking = (): boolean => calmPresentationHides("assistant-thinking"); const hidesOperationalAcknowledgement = (): boolean => calmPresentationHides("synthetic-assistant"); + const hidesWorkingNote = (): boolean => calmPresentationHides("assistant-working-note"); const installed = registry()[CALM_ASSISTANT_LAYOUT_PATCH]; if (installed) { installed.hidesThinking = hidesThinking; installed.hidesOperationalAcknowledgement = hidesOperationalAcknowledgement; + installed.hidesWorkingNote = hidesWorkingNote; return; } @@ -152,6 +176,7 @@ export function installCalmAssistantLayout(): void { runOriginRecorded: false, hidesOperationalAcknowledgement, hidesThinking, + hidesWorkingNote, }; const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; if (typeof AssistantMessageComponent !== "function") { @@ -178,16 +203,22 @@ export function installCalmAssistantLayout(): void { state.hiddenThinkingLabel === "" && state.hideThinkingBlock && patch.hidesThinking(); + const hideWorkingNote = + patch.hidesWorkingNote() && + isMidTurnAssistantMessage(message) && + !isProtectedOperationalToolReply(message, isOperational); const acknowledgementPresentation = withoutOperationalAcknowledgement( message, isOperational, patch.hidesOperationalAcknowledgement(), ); - const presentationMessage = hideThinking + const presentationMessage = hideThinking || hideWorkingNote ? { ...acknowledgementPresentation, content: acknowledgementPresentation.content.filter( - (block) => block.type !== "thinking", + (block) => + !(hideThinking && block.type === "thinking") && + !(hideWorkingNote && block.type === "text"), ), } : acknowledgementPresentation; diff --git a/.pi/extensions/lib/fm-calm-visibility.ts b/.pi/extensions/lib/fm-calm-visibility.ts index 27a03f04c1..2de15860d5 100644 --- a/.pi/extensions/lib/fm-calm-visibility.ts +++ b/.pi/extensions/lib/fm-calm-visibility.ts @@ -6,6 +6,7 @@ import { export const CALM_TRANSCRIPT_CLASSES = [ "genuine-user-prompt", "genuine-agent-response", + "assistant-working-note", "assistant-thinking", "assistant-tool-call", "tool-result", diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index b320b9d525..c6fce331b6 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -170,6 +170,7 @@ Compaction and retry loaders remain stock because Pi exposes no supported replac `.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. +`assistant-working-note` is deliberately absent from that allowlist, so Calm hides finalized mid-turn narration while leaving pending streams and final replies visible. 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). @@ -194,9 +195,10 @@ The test fixture enumerates every class below through the centralized policy, an | --- | --- | --- | | `genuine-user-prompt` | `UserMessageComponent` | Visible, including every tested operational near miss. | | `genuine-agent-response` | Assistant text in `AssistantMessageComponent` | Visible, subject only to the exact operational acknowledgement rule owned by [`calm.md`](calm.md). | +| `assistant-working-note` | Text in an `AssistantMessageComponent` whose intrinsic stop reason is `toolUse`, or is `length` with a tool call present | Hidden from a shallow presentation copy after finality is known; pending streams, final replies, session data, model context, export, and share data remain unchanged. | | `assistant-thinking` | Thinking content in `AssistantMessageComponent` | Collapsed reasoning is removed from the shallow presentation copy before layout and occupies zero rows; explicit expansion renders the original reasoning. | -| `assistant-tool-call` | `ToolExecutionComponent` | Seven built-ins and `fm_watch_arm_pi` hidden; arbitrary custom tools remain an unsupported boundary. | -| `tool-result` | `ToolExecutionComponent` | Text results for the controlled tools hidden; arbitrary custom results remain an unsupported boundary. | +| `assistant-tool-call` | `ToolExecutionComponent` | Seven built-in names and `fm_watch_arm_pi` hidden; the built-in row adapter also covers rows created before wrapper registration and rows whose execution definition belongs to another extension, while arbitrary custom names remain an unsupported boundary. | +| `tool-result` | `ToolExecutionComponent` | Text results for the controlled built-in names hidden without changing execution ownership; arbitrary custom results remain an unsupported boundary. | | `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. | @@ -214,8 +216,8 @@ The test fixture enumerates every class below through the centralized policy, an | `unknown` | Future or unclassified transcript component | Policy-hidden, but no generic renderer exists; never claimed as covered. | The installed extension API has no supported global transcript filter, user-message renderer, assistant-message renderer, chat-container API, or generic custom-tool wrapper. -Pi 0.81.1 through 0.84.1 export `AssistantMessageComponent` and `InteractiveMode`, so Calm uses three separate idempotent, API-probed exported-class adapters for assistant layout, the complete operational-user transcript row, and the transcript replay window while leaving all message data and non-Calm rendering unchanged. -A fourth adapter, transcript-redraw, probes the documented `setWidget()` factory and forcible render instead of an exported class, and is the only one probed per session because Pi's non-TUI modes supply a no-op `setWidget()` by design. +Pi 0.81.1 through 0.84.1 export `AssistantMessageComponent`, `InteractiveMode`, and `ToolExecutionComponent`, so Calm uses four separate idempotent, API-probed exported-class adapters for assistant layout, already-mounted built-in tool rows, the complete operational-user transcript row, and the transcript replay window while leaving all message data and non-Calm rendering unchanged. +A fifth adapter, transcript-redraw, probes the documented `setWidget()` factory and forcible render instead of an exported class, and is the only one probed per session because Pi's non-TUI modes supply a no-op `setWidget()` by design. See the [compatibility contract](calm.md#pi-compatibility) for how a future Pi lacking one of those exports or seams is handled. The current dated Pi rendering evidence and refresh commands are recorded in [`docs/verification/runtime-backends.md`](verification/runtime-backends.md#pi-calm-transcript-redraw). General component replacement, ANSI cursor erasure, provider-context mutation, and installed-file patching remain rejected as unsupported or preservation-breaking workarounds. @@ -242,7 +244,7 @@ grok 0.2.106 (bde89716f679) | Claude Code 2.1.218 | Not feasible through the inspected supported project surface. | Project hooks can observe lifecycle and tool events, while the plugin CLI packages supported components; neither inspected surface exposes a transcript-row renderer or transcript-wide redraw API. | | Codex CLI 0.144.6 | Not feasible through the inspected supported project surface. | The tracked hooks expose session, pre-tool, and stop handling, while the plugin and feature inventories expose no TUI tool-row renderer or transcript redraw control. | | OpenCode 1.17.18 | Not feasible without violating the preservation boundary. | Plugins expose events and tool execution hooks, not a built-in transcript-row renderer; same-name tool replacement changes execution rather than presentation alone. | -| Pi (verified 0.81.1 through 0.84.1) | Partially feasible with three API-probed exported-class adapters plus the probed transcript-redraw capture. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the collapsed-thinking, operational-user layout, and transcript replay boundaries, and the documented widget factory supplies the TUI whose forced render discards a stale frame, each gated on the exact method's presence rather than a version number, while generic user, tool, and status filtering remains unavailable. | +| Pi (verified 0.81.1 through 0.84.1) | Partially feasible with four API-probed exported-class adapters plus the probed transcript-redraw capture. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant, tool-execution, and interactive-mode classes provide the collapsed-thinking and working-note layout, already-mounted built-in row, operational-user layout, and transcript replay boundaries, and the documented widget factory supplies the TUI whose forced render discards a stale frame, each gated on the exact method's presence rather than a version number, while generic user, arbitrary-tool, and status filtering remains unavailable. | | Grok CLI 0.2.106 | Not feasible through the inspected supported project surface. | Project hooks expose lifecycle and tool interception, while the plugin CLI exposes no row-renderer contract; `--minimal` changes the whole screen mode rather than selected transcript rows. | These conclusions are deliberately limited to the named versions and supported surfaces. @@ -253,7 +255,7 @@ Only Pi's Calm presentation implementation changed; every producer and non-Pi tr ## 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, thinking, current operational-user, and legacy synthetic rows, and covers every policy class. +`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, thinking, current operational-user, and legacy synthetic rows, verifies off-state registration and foreign-owner preservation, and covers every policy class including mid-turn working notes. Its deterministic assistant-layout matrix covers the exact operational acknowledgement, Calm off, genuine-user collision, punctuation, prefix, suffix, Markdown, explanation, capitalization, whitespace, streaming divergence, queued operational inputs, intervening tools, interruption, and every session-start replay reason. It also drives Pi's real run lifecycle to prove that an operational wake steered into a still-running captain turn keeps that run's replies visible, that an operational-only run still hides the acknowledgement, and that a settled run does not carry its captain origin into the next wake. It drives Pi's own transcript rebuild inside an active operational run to prove that replayed rows keep per-row origin, that a previously hidden acknowledgement stays hidden while a replayed captain reply stays visible, and that the continuation of the surrounding run is unaffected. @@ -263,7 +265,8 @@ A native deterministic `/skill:ahoy` turn produces thinking, tool-call, and tool The operational provider path covers Calm loaded on, loaded off, default preference, extension absent, exact watcher delivery, narrow bare-marker legacy input, persisted restart replay, 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, one processing result, zero operational transcript rows, and the two-row neighboring-assistant geometry for live, adjacent, and restart paths. Quoted current markers, ASCII-only labels, ordinary text before a marker, unrelated U+2063 placement, and image-bearing input remain visible in component and native transcript checks. -`tests/fm-pi-primary-live-e2e.test.sh` also proves the working ship replaces the built-in `Working...` row while Calm is active on the credentialed provider path, and that it clears when the run settles, before continuing its ordinary watcher lifecycle. +`tests/fm-pi-primary-live-e2e.test.sh` includes a deterministic real-Pi guard that proves finalized mid-turn text leaves the rendered TUI but remains in session data, and that a foreign built-in owner remains executable after Calm activates. +Its broader credentialed path also proves the working ship replaces the built-in `Working...` row while Calm is active and clears when the run settles before continuing its ordinary watcher lifecycle. `tests/fm-pi-primary-types.test.sh` performs strict no-emit TypeScript checking against the installed Pi declarations when `tsc` is available. The relevant commands are: @@ -271,6 +274,7 @@ The relevant commands are: ```sh tests/fm-calm-pi-extension.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh +FM_PI_LIVE_E2E=1 FM_PI_CALM_LIVE_ONLY=1 tests/fm-pi-primary-live-e2e.test.sh tests/fm-pi-primary-types.test.sh ``` diff --git a/docs/calm.md b/docs/calm.md index 3db4894716..bd65bfff0c 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -13,7 +13,10 @@ Hidden elapsed time does not advance the animation, and a resize while hidden cl A fresh Pi session or new Calm extension lifetime starts at the normal initial position. Very narrow terminals fall back to a smaller deterministic sprite. While Calm is off, Pi's stock working row is left exactly as Pi renders it. -Calm hides collapsed thinking labels, the shells for Pi's seven built-in tools, the `fm_watch_arm_pi` tool shell, and canonically classified Firstmate operational user rows. +Calm hides collapsed thinking labels, mid-turn assistant working notes, the shells for Pi's seven built-in tools, the `fm_watch_arm_pi` tool shell, and canonically classified Firstmate operational user rows. +A mid-turn working note is assistant text in a message whose own `stopReason` is `toolUse`, or is `length` with a tool call present. +Text remains visible while its stop reason is `pending`, because Pi cannot yet distinguish a working note from a genuine final reply. +The text is removed only from a shallow presentation copy, while the message, model context, session storage, export, and share data remain unchanged. The operational inputs remain ordinary user-role messages, while Pi's transcript layout renders their complete rows at zero height. Calm also hides the exact whole assistant text `Captain, shipshape.` only when the assistant component belongs to an agent run whose every Firstmate input is canonically classified operational. A genuine captain message anywhere in that run, including one steered in while the run is still under way, keeps every later reply in the run visible. @@ -22,8 +25,8 @@ During streaming, Calm holds only text that is still a prefix of the exact ackno A transcript replay or rebuild, including the rebuild Pi performs when it compacts inside a run, scores each replayed row against its own preceding input and leaves the surrounding run unchanged, so an acknowledgement hidden before the rebuild stays hidden after it. The session-start nudge remains on its existing non-displayed custom-message path. -Calm changes presentation only. -Tool execution, input delivery, ordering, model context, session storage, diagnostics, and `/export` and `/share` operation remain unchanged. +Calm changes presentation only, including when another extension owns a Pi built-in tool name. +Tool execution ownership, input delivery, ordering, model context, session storage, diagnostics, and `/export` and `/share` operation remain unchanged. Every hidden Firstmate input remains available to the model and in serialized session data and exported artifacts. Legacy operational custom messages remain in session data and Pi's sidebar tree, although the main HTML transcript may omit them. Toggling Calm off restores ordinary rendering, and `Ctrl+O` expansion state is preserved. @@ -35,15 +38,21 @@ These are supported-API boundaries rather than hidden-content failures. ## Pi compatibility Calm has no numeric Pi version minimum or maximum and never refuses Pi solely because its version is newer than a previously verified version. -The collapsed-thinking, operational-user-row, transcript-replay, and transcript-redraw presentation adapters each probe the exact Pi API seam they patch when Calm loads. +The collapsed-thinking, built-in-tool-row, operational-user-row, transcript-replay, and transcript-redraw presentation adapters each probe the exact Pi API seam they patch when Calm loads. If Pi removes one of those seams, Calm logs a diagnostic naming the unavailable adapter and skips only that adapter; `/calm`, the other adapters, and unrelated Pi extensions remain available. The transcript-redraw adapter probes its seam per session in Pi's `tui` mode only, because Pi supplies a no-op `setWidget()` outside the TUI by design; if a TUI Pi stops invoking the documented widget factory or stops exposing a forcible render, that diagnostic names the Pi version rather than silently losing the forced redraw. Losing only the transcript-replay adapter keeps operational user rows, acknowledgement origin, and every other Calm rule active, and a rebuild inside a run then only makes more replies visible. +Pi gives each extension one complete registration slot per tool name and rejects duplicate extension registrations during initial load. +Calm therefore registers no built-in wrapper while extensions load. +When a session starts with Calm already on, or when `/calm` first turns on, Calm reads Pi's live ownership registry, claims only uncontested built-in names, leaves every foreign definition intact, and warns with each contested name. +If the ownership registry is unavailable, Calm leaves every definition unchanged and warns instead of guessing. +The built-in-tool-row adapter keeps already-mounted and replayed built-in rows controllable without changing their captured execution definition, so the Pi 0.84.1 forced-redraw guarantee survives the ownership gate. + [`calm-mode-feasibility.md`](calm-mode-feasibility.md) owns the renderer taxonomy and mechanism rationale. [`verification/runtime-backends.md`](verification/runtime-backends.md#pi-calm-transcript-redraw) owns the current dated per-harness rendering evidence. [`configuration.md`](configuration.md#pi-calm-preference-configcalm) owns the persisted preference file and resolution rules. -`.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row, the assistant-origin association, and the separately probed transcript replay window, `.pi/extensions/lib/fm-calm-assistant-layout.ts` owns collapsed-thinking and exact-acknowledgement layout, and `.pi/extensions/lib/fm-calm-working-ship.ts` owns the animated working presentation. +`.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row, the assistant-origin association, and the separately probed transcript replay window, `.pi/extensions/lib/fm-calm-assistant-layout.ts` owns collapsed-thinking, working-note, and exact-acknowledgement layout, `.pi/extensions/fm-calm.ts` owns built-in tool registration and the already-mounted row adapter, and `.pi/extensions/lib/fm-calm-working-ship.ts` owns the animated working presentation. Regression entry points: @@ -51,4 +60,5 @@ Regression entry points: tests/fm-calm-pi-extension.test.sh tests/fm-pi-primary-types.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh +FM_PI_LIVE_E2E=1 FM_PI_CALM_LIVE_ONLY=1 tests/fm-pi-primary-live-e2e.test.sh ``` diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index df9a3531ed..cf45f81a3f 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -96,6 +96,27 @@ pi-signed ### Pi Calm transcript redraw +Pi Calm's mid-turn visibility, built-in ownership, and existing forced-redraw guarantees were reverified on 2026-08-15 against the installed Pi 0.84.1 CLI and package. +The portable fixture proved that Calm registers no wrappers during extension load, claims the seven uncontested built-ins from a Calm-on session start, preserves and warns about a foreign same-name owner, hides a built-in row constructed before wrapper registration, keeps pending and final assistant text visible, and leaves the underlying mid-turn message unchanged. +The env-gated live guard used Pi's real extension loader, ownership registry, deterministic provider, session file, and tmux TUI to prove that a foreign `read` owner still executed, finalized mid-turn text remained serialized, and the forced redraw removed that text from the rendered transcript. +The full broader credentialed continuity run continued past this new guard but later failed its pre-existing model-response assertion because the model replied `Watcher wake handled and acknowledged.` instead of the requested exact `HANDLED`; that unrelated result is not presented as green evidence. + +```sh +pi --version +tests/fm-calm-pi-extension.test.sh +FM_PI_LIVE_E2E=1 FM_PI_CALM_LIVE_ONLY=1 tests/fm-pi-primary-live-e2e.test.sh +``` + +Observed bounded output: + +```text +0.84.1 +ok - Calm registers no built-in wrappers during load, claims all 7 from a Calm-on session start, and preserves plus warns about foreign same-name tool owners on first activation +ok - Pi calm centralizes transcript visibility, preserves execution/export data, keeps Pi's stock working row visible while no run is active, and persists its choice across session starts +ok - Pi calm native E2E replaces the stock working row with a moving, resize-clamped working ship that freezes and resumes across two working periods in one Pi session, clears on abort, keeps captain turns visible, hides exact operational user rows without changing persistence, restores stock rendering Calm-off, survives restart, and preserves export plus Ctrl+O behavior +ok - Pi 0.84.1 live Calm guard hid persisted mid-turn text after forced redraw and preserved a foreign built-in owner +``` + Pi Calm's renderer-dependent transcript guarantees were reverified on 2026-08-12 against the installed Pi 0.84.1 package and CLI in a real isolated 180 by 44 tmux TUI. Pi 0.84.1 called the registered watcher tool's renderer with Calm active but could leave the prior zero-height row painted until a later frame, so Calm now captures the current TUI through the documented widget factory and requests one forced redraw whenever its presentation choice takes effect. The portable renderer fixture proves that activating Calm requests that forced redraw, while the real TUI fixture proves the already-rendered `fm_watch_arm_pi` call and result, built-in tool rows, collapsed thinking labels, and operational wake are absent without weakening their negative assertions. diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 7912984e18..00dcc7582c 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -131,6 +131,9 @@ function registerCalm() { }, registerEntryRenderer() {}, registerTool() {}, + getAllTools() { + return []; + }, }; extension.default(pi); if (!calmCommand || !handlers.has("session_start")) { @@ -250,6 +253,9 @@ const registerCalm = () => { registerCommand() {}, registerEntryRenderer() {}, registerTool() {}, + getAllTools() { + return []; + }, }; extension.default(pi); return handlers.get("session_start"); @@ -396,6 +402,9 @@ const pi = { }, registerEntryRenderer() {}, registerTool() {}, + getAllTools() { + return []; + }, }; let threw = false; @@ -602,6 +611,198 @@ JS pass "missing Pi presentation class exports and both transcript replay seam paths reach the independent adapter degradation path" } +test_builtin_registration_ownership() { + local fixture out output_file status + if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + echo "skip: node or npm not found for Pi calm built-in ownership test" + return 0 + fi + if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return 0 + fi + + fixture="$TMP_ROOT/builtin-ownership" + mkdir -p \ + "$fixture/project/.pi/extensions/lib" \ + "$fixture/project/node_modules/@earendil-works" \ + "$fixture/home-off/config" \ + "$fixture/home-on/config" + cp "$EXT" "$fixture/project/.pi/extensions/fm-calm.ts" + cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts" + cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" + ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent" + ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui" + ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/project/node_modules/typebox" + printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json" + printf '%s\n' on >"$fixture/home-on/config/calm" + printf '%s\n' 'export default function () {}' >"$fixture/project/foreign-bash-extension.ts" + + output_file="$fixture/node-output" + (cd "$fixture/project" && \ + EXT="$fixture/project/.pi/extensions/fm-calm.ts" \ + FOREIGN_EXT="$fixture/project/foreign-bash-extension.ts" \ + HOME_OFF="$fixture/home-off" \ + HOME_ON="$fixture/home-on" \ + PI_PACKAGE_DIR="$PI_PACKAGE_DIR" \ + node --input-type=module) >"$output_file" 2>&1 <<'JS' +import { fileURLToPath, pathToFileURL } from "node:url"; + +const extensionPath = fileURLToPath(pathToFileURL(process.env.EXT).href); +const foreignPath = fileURLToPath(pathToFileURL(process.env.FOREIGN_EXT).href); +const packageRoot = process.env.PI_PACKAGE_DIR; +const [{ ToolExecutionComponent }, { initTheme }, { setCapabilities }] = await Promise.all([ + import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/tool-execution.js`).href), + import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href), + import(pathToFileURL(`${packageRoot}/node_modules/@earendil-works/pi-tui/dist/index.js`).href), +]); +initTheme("dark"); +setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + +function fakePi(initial = []) { + const registry = new Map(initial.map(({ tool, ownerPath }) => [tool.name, { tool, ownerPath }])); + const handlers = new Map(); + const notifications = []; + let calmCommand; + const pi = { + events: { emit() {}, on() {} }, + on(event, handler) { + handlers.set(event, handler); + }, + registerCommand(name, command) { + if (name === "calm") calmCommand = command; + }, + registerEntryRenderer() {}, + registerTool(tool) { + if (!registry.has(tool.name)) registry.set(tool.name, { tool, ownerPath: extensionPath }); + }, + getAllTools() { + return Array.from(registry.entries()).map(([name, { ownerPath }]) => ({ + name, + sourceInfo: { source: "extension", path: ownerPath }, + })); + }, + }; + const ui = { + getEditorText: () => "", + getToolsExpanded: () => false, + onTerminalInput: () => () => {}, + setHiddenThinkingLabel() {}, + setStatus() {}, + setToolsExpanded() {}, + setWidget(_key, content) { + if (typeof content === "function") content({ requestRender() {} }); + }, + setWorkingVisible() {}, + notify(message, type) { + notifications.push({ message, type }); + }, + }; + return { calmCommand: () => calmCommand, handlers, notifications, pi, registry, ui }; +} + +process.env.FM_HOME = process.env.HOME_OFF; +const off = fakePi(); +const offExtension = await import(`${pathToFileURL(process.env.EXT).href}?off=${Date.now()}`); +offExtension.default(off.pi); +if (off.registry.size !== 0) { + throw new Error(`Calm registered built-ins while config/calm was absent: ${JSON.stringify(Array.from(off.registry.keys()))}`); +} + +process.env.FM_HOME = process.env.HOME_ON; +const on = fakePi(); +const onExtension = await import(`${pathToFileURL(process.env.EXT).href}?on=${Date.now()}`); +onExtension.default(on.pi); +if (on.registry.size !== 0) { + throw new Error(`Calm registered built-ins during extension load: ${JSON.stringify(Array.from(on.registry.keys()))}`); +} +await on.handlers.get("session_start")({ reason: "startup" }, { mode: "tui", ui: on.ui }); +const expected = ["bash", "edit", "find", "grep", "ls", "read", "write"]; +const onNames = Array.from(on.registry.keys()).sort(); +if (JSON.stringify(onNames) !== JSON.stringify(expected)) { + throw new Error(`Calm-on session start registered ${JSON.stringify(onNames)}, expected ${JSON.stringify(expected)}`); +} + +const foreignBash = { + name: "bash", + label: "Foreign bash", + description: "Foreign extension ownership probe", + parameters: { type: "object", properties: {} }, + async execute() { + return { content: [{ type: "text", text: "FOREIGN_BASH_EXECUTED" }], details: {}, isError: false }; + }, +}; +process.env.FM_HOME = process.env.HOME_OFF; +const collision = fakePi([{ tool: foreignBash, ownerPath: foreignPath }]); +const collisionExtension = await import(`${pathToFileURL(process.env.EXT).href}?collision=${Date.now()}`); +collisionExtension.default(collision.pi); +const command = collision.calmCommand(); +if (!command) throw new Error("Calm did not register /calm in the collision fixture"); +await collision.handlers.get("session_start")( + { reason: "startup" }, + { mode: "tui", ui: collision.ui }, +); +const renderUi = { requestRender() {} }; +const preActivationRead = new ToolExecutionComponent( + "read", + "pre-activation-read", + { path: "sample.txt" }, + { showImages: false }, + undefined, + renderUi, + process.cwd(), +); +preActivationRead.markExecutionStarted(); +preActivationRead.setArgsComplete(); +preActivationRead.updateResult({ + content: [{ type: "text", text: "PRE_ACTIVATION_READ_OUTPUT" }], + details: {}, + isError: false, +}); +if (preActivationRead.render(100).length === 0) { + throw new Error("the pre-activation built-in row was hidden while Calm was off"); +} +const diagnostics = []; +const originalConsoleError = console.error; +console.error = (...args) => diagnostics.push(args.join(" ")); +await command.handler("", { ui: collision.ui }); +console.error = originalConsoleError; +if (collision.registry.get("bash")?.tool !== foreignBash) { + throw new Error("Calm replaced the foreign extension's bash registration"); +} +const foreignResult = await collision.registry.get("bash").tool.execute(); +if (foreignResult.content[0]?.text !== "FOREIGN_BASH_EXECUTED") { + throw new Error("the foreign bash owner no longer executes its own behavior"); +} +for (const name of ["read", "edit", "write", "grep", "find", "ls"]) { + if (collision.registry.get(name)?.ownerPath !== extensionPath) { + throw new Error(`Calm did not claim uncontested built-in ${name} on first activation`); + } +} +if ( + collision.notifications.length !== 1 || + collision.notifications[0].type !== "warning" || + !collision.notifications[0].message.includes("bash") +) { + throw new Error(`Calm did not issue one warning naming the contested tool: ${JSON.stringify(collision.notifications)}`); +} +if (!diagnostics.some((line) => line.includes("bash"))) { + throw new Error(`Calm did not log the contested built-in name: ${JSON.stringify(diagnostics)}`); +} +if (preActivationRead.render(100).length !== 0) { + throw new Error("Calm activation did not hide a built-in row constructed before wrapper registration"); +} +JS + status=$? + out=$(cat "$output_file") + [ "$status" -eq 0 ] || fail "Pi calm built-in ownership contract failed: $out" + [ -z "$out" ] || fail "Pi calm built-in ownership test printed output: $out" + pass "Calm registers no built-in wrappers during load, claims all 7 from a Calm-on session start, and preserves plus warns about foreign same-name tool owners on first activation" +} + test_rendering_and_session_lifecycle() { local fixture out status version if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then @@ -637,7 +838,9 @@ SH out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" WATCH_EXT="$fixture/fm-primary-pi-watch.ts" FM_HOME="$fixture/home" FM_OPERATIONAL_INPUT_SCRIPT="$fixture/operational-input-probe.sh" FM_OPERATIONAL_INPUT_OWNER="$OPERATIONAL_INPUT" FM_OPERATIONAL_INPUT_CALLS="$fixture/operational-input-calls" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' import { readFileSync, writeFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const extensionPath = fileURLToPath(pathToFileURL(process.env.EXT).href); const packageRoot = process.env.PI_PACKAGE_DIR; const [{ AssistantMessageComponent }, { CustomEntryComponent }, { ToolExecutionComponent }, { UserMessageComponent }, { InteractiveMode }, { initTheme, theme }, { Text, getKeybindings, setCapabilities }, { createToolHtmlRenderer }] = await Promise.all([ @@ -681,7 +884,15 @@ const pi = { entryRenderers.set(customType, renderer); }, registerTool(tool) { - tools.push(tool); + const existing = tools.findIndex((candidate) => candidate.name === tool.name); + if (existing === -1) tools.push(tool); + else tools[existing] = tool; + }, + getAllTools() { + return tools.map((tool) => ({ + name: tool.name, + sourceInfo: { source: "extension", path: extensionPath }, + })); }, }; const extension = await import(`${pathToFileURL(process.env.EXT).href}?test=${Date.now()}`); @@ -689,6 +900,19 @@ 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 earlyActivationUi = { + getEditorText: () => "", + getToolsExpanded: () => false, + onTerminalInput: () => () => {}, + setHiddenThinkingLabel() {}, + setStatus() {}, + setToolsExpanded() {}, + setWorkingVisible() {}, + notify() {}, +}; +await calmCommand.handler("", { ui: earlyActivationUi }); +await calmCommand.handler("", { ui: earlyActivationUi }); + const names = tools.map((tool) => tool.name); const expectedNames = ["read", "bash", "edit", "write", "grep", "find", "ls"]; if (JSON.stringify(names) !== JSON.stringify(expectedNames)) { @@ -952,11 +1176,41 @@ const assistantThinkingTool = new AssistantMessageComponent({ ], stopReason: "toolUse", }, true); +const assistantWorkingNoteMessage = { + ...assistantBase, + content: [ + { type: "text", text: "MIDTURN_WORKING_NOTE" }, + { type: "toolCall", id: "working-note-tool", name: "read", arguments: { path: "sample.txt" } }, + ], + stopReason: "toolUse", +}; +const assistantWorkingNoteBefore = JSON.stringify(assistantWorkingNoteMessage); +const assistantWorkingNote = new AssistantMessageComponent(assistantWorkingNoteMessage, true); +const assistantStreaming = new AssistantMessageComponent({ + ...assistantBase, + content: [{ type: "text", text: "STREAMING_ASSISTANT_TEXT" }], + stopReason: "pending", +}, true); +const assistantTruncatedFinal = new AssistantMessageComponent({ + ...assistantBase, + content: [{ type: "text", text: "TRUNCATED_FINAL_TEXT" }], + stopReason: "length", +}, true); if (!assistantThinkingText.render(100).join("\n").includes("Thinking...")) { throw new Error("stock collapsed-thinking fixture did not render before Calm was active"); } +if (!assistantWorkingNote.render(100).join("\n").includes("MIDTURN_WORKING_NOTE")) { + throw new Error("stock working-note fixture did not render before Calm was active"); +} -const assistantComponents = [assistantTextOnly, assistantThinkingText, assistantThinkingTool]; +const assistantComponents = [ + assistantTextOnly, + assistantThinkingText, + assistantThinkingTool, + assistantWorkingNote, + assistantStreaming, + assistantTruncatedFinal, +]; const assistantMessage = (text, stopReason = "stop") => ({ ...assistantBase, content: [{ type: "text", text }], @@ -1417,6 +1671,18 @@ if (watchActual.render(100).length !== 0) { if (assistantThinkingTool.render(100).length !== 0) { throw new Error("Calm-hidden thinking beside a tool call retained vertical height"); } +if (assistantWorkingNote.render(100).join("\n").includes("MIDTURN_WORKING_NOTE")) { + throw new Error("Calm left a mid-turn assistant working note visible"); +} +if (!assistantStreaming.render(100).join("\n").includes("STREAMING_ASSISTANT_TEXT")) { + throw new Error("Calm hid assistant text before its stop reason established a mid-turn message"); +} +if (!assistantTruncatedFinal.render(100).join("\n").includes("TRUNCATED_FINAL_TEXT")) { + throw new Error("Calm hid a length-limited final response with no tool call"); +} +if (JSON.stringify(assistantWorkingNoteMessage) !== assistantWorkingNoteBefore) { + throw new Error("Calm mutated a mid-turn assistant message instead of its presentation copy"); +} if (JSON.stringify(assistantThinkingText.render(100)) !== JSON.stringify(assistantTextOnly.render(100))) { throw new Error("Calm-hidden thinking changed final assistant row geometry"); } @@ -1468,6 +1734,9 @@ if (workingVisible !== true || hiddenThinkingLabel !== undefined || statuses.get if (!assistantThinkingTool.render(100).join("\n").includes("Thinking...")) { throw new Error("turning Calm off did not restore the collapsed thinking label"); } +if (!assistantWorkingNote.render(100).join("\n").includes("MIDTURN_WORKING_NOTE")) { + throw new Error("turning Calm off did not restore a mid-turn assistant working note"); +} if (readFileSync(`${process.env.FM_HOME}/config/calm`, "utf8") !== "off\n") { throw new Error("Calm did not persist the inactive choice in the effective Firstmate home"); } @@ -2696,6 +2965,9 @@ const pi = { }, registerEntryRenderer() {}, registerTool() {}, + getAllTools() { + return []; + }, appendEntry: (...args) => sessionWrites.push(["appendEntry", ...args]), sendMessage: (...args) => sessionWrites.push(["sendMessage", ...args]), sendUserMessage: (...args) => sessionWrites.push(["sendUserMessage", ...args]), @@ -3262,7 +3534,7 @@ JSON do assert_contains "$(cat "$hidden_snapshot")" "$near_miss" "/calm hid the genuine operational near miss $near_miss" done - assert_contains "$(cat "$hidden_snapshot")" "I will run one command." "/calm removed assistant conversation before a tool" + assert_not_contains "$(cat "$hidden_snapshot")" "I will run one command." "/calm left a mid-turn assistant working note visible" assert_contains "$(cat "$hidden_snapshot")" "The deterministic tool example is complete." "/calm removed assistant conversation after a tool" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-diagnostic-e2e" @@ -3792,6 +4064,7 @@ test_pi_compat_no_upper_bound test_pi_compat_degraded_adapter test_pi_compat_redraw_capture_drift test_pi_compat_missing_adapter_exports +test_builtin_registration_ownership test_rendering_and_session_lifecycle test_operational_followup_turn_e2e test_hidden_block_geometry_e2e diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index 63f3cb8abb..92b7e198e1 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -26,6 +26,8 @@ SESSION=pi-live-e2e LAB="$ROOT/.pi-live-e2e.$$" PROJECT="$LAB/project" AHOY_PROJECT="$LAB/ahoy-project" +CALM_LIVE_PROJECT="$LAB/calm-live-project" +CALM_LIVE_HOME="$LAB/calm-live-home" HOME_DIR="$LAB/fmhome" PI_VERSION=$(pi --version) # shellcheck source=/dev/null @@ -117,6 +119,181 @@ wait_pid_dead() { return 1 } +run_calm_visibility_live_guard() { + local calm_session=pi-calm-live-guard + local owner_evidence="$CALM_LIVE_HOME/foreign-read-owner" + local session_file="$CALM_LIVE_HOME/calm-live-session.jsonl" + local pane i=0 + + mkdir -p "$CALM_LIVE_PROJECT/.pi/extensions/lib" "$CALM_LIVE_HOME/config" + git init -q "$CALM_LIVE_PROJECT" + cp "$ROOT/.pi/extensions/fm-calm.ts" "$CALM_LIVE_PROJECT/.pi/extensions/fm-calm.ts" + cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$CALM_LIVE_PROJECT/.pi/extensions/lib/fm-calm-assistant-layout.ts" + cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$CALM_LIVE_PROJECT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$CALM_LIVE_PROJECT/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts" "$CALM_LIVE_PROJECT/.pi/extensions/lib/fm-calm-working-ship.ts" + cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$CALM_LIVE_PROJECT/.pi/extensions/lib/fm-operational-input.ts" + : >"$CALM_LIVE_PROJECT/AGENTS.md" + + cat >"$CALM_LIVE_PROJECT/foreign-provider.ts" <<'TS' +import { writeFileSync } from "node:fs"; +import { + createAssistantMessageEventStream, + type AssistantMessage, +} from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const assistantMessage = (model: { api: string; provider: string; id: string }): 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: "pending", + timestamp: Date.now(), +}); + +export default function (pi: ExtensionAPI): void { + pi.registerTool({ + name: "read", + label: "Foreign read", + description: "Live Calm built-in ownership probe", + parameters: Type.Object({ path: Type.String() }), + async execute() { + writeFileSync(process.env.CALM_LIVE_OWNER_EVIDENCE!, "foreign-read-executed\n", "utf8"); + return { + content: [{ type: "text", text: "FOREIGN_READ_RESULT" }], + details: {}, + }; + }, + }); + + pi.registerProvider("calm-live", { + baseUrl: "http://127.0.0.1/unused", + apiKey: "test-only", + api: "calm-live-api", + models: [ + { + id: "deterministic", + name: "Calm live visibility fixture", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 128, + }, + ], + streamSimple(model, context) { + const stream = createAssistantMessageEventStream(); + const output = assistantMessage(model); + void (async () => { + stream.push({ type: "start", partial: output }); + const hasToolResult = context.messages.some((message) => message.role === "toolResult"); + const text = hasToolResult ? "CALM_LIVE_FINAL_REPLY" : "MIDTURN_LIVE_NOTE"; + const textIndex = output.content.length; + output.content.push({ type: "text", text: "" }); + stream.push({ type: "text_start", contentIndex: textIndex, partial: output }); + const textBlock = output.content[textIndex]; + if (textBlock.type !== "text") throw new Error("live fixture text block drifted"); + textBlock.text = text; + stream.push({ type: "text_delta", contentIndex: textIndex, delta: text, partial: output }); + stream.push({ type: "text_end", contentIndex: textIndex, content: text, partial: output }); + + if (!hasToolResult) { + const toolCall = { + type: "toolCall" as const, + id: "calm-live-read", + name: "read", + arguments: { path: "unused.txt" }, + }; + const toolIndex = output.content.length; + output.content.push(toolCall); + stream.push({ type: "toolcall_start", contentIndex: toolIndex, partial: output }); + stream.push({ type: "toolcall_delta", contentIndex: toolIndex, delta: '{"path":"unused.txt"}', partial: output }); + stream.push({ type: "toolcall_end", contentIndex: toolIndex, toolCall, partial: output }); + output.stopReason = "toolUse"; + stream.push({ type: "done", reason: "toolUse", message: output }); + } else { + output.stopReason = "stop"; + stream.push({ type: "done", reason: "stop", message: output }); + } + stream.end(); + })(); + return stream; + }, + }); +} +TS + + "$TMUX" -L "$SOCKET" new-session -d -s "$calm_session" -x 140 -y 42 \ + "cd '$CALM_LIVE_PROJECT' && env FM_HOME='$CALM_LIVE_HOME' CALM_LIVE_OWNER_EVIDENCE='$owner_evidence' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./foreign-provider.ts -e ./.pi/extensions/fm-calm.ts --model calm-live/deterministic --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" + + while [ "$i" -lt 120 ]; do + pane=$("$TMUX" -L "$SOCKET" capture-pane -p -t "$calm_session" 2>/dev/null || true) + printf '%s\n' "$pane" | grep -Fq "(calm-live)" && break + sleep 0.25 + i=$((i + 1)) + done + printf '%s\n' "$pane" | grep -Fq "(calm-live)" \ + || fail "Pi $PI_VERSION Calm live guard did not reach the composer" + + "$TMUX" -L "$SOCKET" send-keys -t "$calm_session" -l "/calm" + "$TMUX" -L "$SOCKET" send-keys -t "$calm_session" Enter + i=0 + while [ "$i" -lt 80 ]; do + pane=$("$TMUX" -L "$SOCKET" capture-pane -p -t "$calm_session") + if printf '%s\n' "$pane" | grep -Fq "read" && [ "$(cat "$CALM_LIVE_HOME/config/calm" 2>/dev/null || true)" = on ]; then + break + fi + sleep 0.25 + i=$((i + 1)) + done + [ "$(cat "$CALM_LIVE_HOME/config/calm" 2>/dev/null || true)" = on ] \ + || fail "Pi $PI_VERSION Calm live guard did not activate" + printf '%s\n' "$pane" | grep -Fq "read" \ + || fail "Pi $PI_VERSION Calm live guard did not warn about the foreign read owner" + + "$TMUX" -L "$SOCKET" send-keys -t "$calm_session" -l "RUN_CALM_LIVE_GUARD" + "$TMUX" -L "$SOCKET" send-keys -t "$calm_session" Enter + i=0 + while [ "$i" -lt 160 ]; do + pane=$("$TMUX" -L "$SOCKET" capture-pane -p -t "$calm_session") + printf '%s\n' "$pane" | grep -Fq "CALM_LIVE_FINAL_REPLY" && break + sleep 0.25 + i=$((i + 1)) + done + printf '%s\n' "$pane" | grep -Fq "CALM_LIVE_FINAL_REPLY" \ + || fail "Pi $PI_VERSION Calm live guard did not render the genuine final reply" + printf '%s\n' "$pane" | grep -Fq "MIDTURN_LIVE_NOTE" \ + && fail "Pi $PI_VERSION Calm live guard left the mid-turn working note rendered" + [ "$(cat "$owner_evidence" 2>/dev/null || true)" = foreign-read-executed ] \ + || fail "Pi $PI_VERSION Calm live guard did not execute the foreign read owner" + grep -Fq "MIDTURN_LIVE_NOTE" "$session_file" \ + || fail "Pi $PI_VERSION Calm live guard removed the working note from session data" + + "$TMUX" -L "$SOCKET" send-keys -t "$calm_session" -l "/quit" + "$TMUX" -L "$SOCKET" send-keys -t "$calm_session" Enter + i=0 + while [ "$i" -lt 40 ]; do + pane=$("$TMUX" -L "$SOCKET" capture-pane -p -t "$calm_session" 2>/dev/null || true) + printf '%s\n' "$pane" | grep -Fq "PI_EXIT=0" && break + sleep 0.25 + i=$((i + 1)) + done + printf '%s\n' "$pane" | grep -Fq "PI_EXIT=0" \ + || fail "Pi $PI_VERSION Calm live guard did not exit cleanly" + "$TMUX" -L "$SOCKET" kill-session -t "$calm_session" 2>/dev/null || true +} + run_ahoy_case() { local label=$1 preceding=$2 expected=$3 out status=0 out=$( @@ -245,6 +422,11 @@ run_native_ahoy_regressions() { } mkdir -p "$LAB" +run_calm_visibility_live_guard +if [ "${FM_PI_CALM_LIVE_ONLY:-0}" = 1 ]; then + printf 'ok - Pi %s live Calm guard hid persisted mid-turn text after forced redraw and preserved a foreign built-in owner\n' "$PI_VERSION" + exit 0 +fi git clone -q "$ROOT" "$PROJECT" run_ahoy_transcript_regressions run_native_ahoy_regressions @@ -341,4 +523,4 @@ wait_for_text "PI_EXIT=0" 60 || fail "Pi did not exit cleanly" wait_pid_dead "$watcher_pid" || fail "watcher child survived clean Pi exit" wait_pid_dead "$arm_pid" || fail "arm child survived clean Pi exit" -printf 'ok - Pi %s live E2E covered the Calm working ship, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity\n' "$PI_VERSION" +printf 'ok - Pi %s live E2E covered Calm mid-turn visibility and foreign built-in ownership, the working ship, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity\n' "$PI_VERSION"