From b609f0975a01b4708076e227d51ebee901cdb3c8 Mon Sep 17 00:00:00 2001 From: ironerumi <39033099+ironerumi@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:44:17 +0900 Subject: [PATCH 1/9] fix(calm): share the standalone Pi Calm working-ship widget slot Firstmate Calm and the user-global standalone Pi Calm both install an animated working-ship widget during agent runs. Each claimed its own Pi widget key, so a session loading both (the main Firstmate home) rendered two boats. Pi replaces widgets under one key, so claiming the shared "calm-working-ship" slot keeps dual-install sessions to a single boat while a Firstmate-only session is unchanged. Pins the shared slot contract in the working-ship module test so the key cannot silently diverge again. --- .pi/extensions/lib/fm-calm-working-ship.ts | 7 ++++++- tests/fm-calm-pi-extension.test.sh | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.pi/extensions/lib/fm-calm-working-ship.ts b/.pi/extensions/lib/fm-calm-working-ship.ts index d8f8ede4695..2b28633bfa0 100644 --- a/.pi/extensions/lib/fm-calm-working-ship.ts +++ b/.pi/extensions/lib/fm-calm-working-ship.ts @@ -44,7 +44,12 @@ const ANSI_FOREGROUND: Record, string> = // Restores the default foreground so color never bleeds into padding or later frames. const RESET = "\u001b[39m"; -export const CALM_WORKING_SHIP_WIDGET_KEY = "firstmate-calm-working-ship"; +// The working-row widget slot is deliberately shared with the standalone Pi Calm +// extension, which installs its boat under the same "calm-working-ship" key. Pi +// replaces widgets under one key, so a session that loads both Calms renders a +// single boat and a session loading either alone is unchanged. Rename the slot +// in both implementations together, or dual-install sessions duplicate the boat. +export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship"; export type CalmWorkingShipAnimation = Omit & { /** Render one frame that exactly fits `width`, clamping the track to it first. */ diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index f1f917f650a..a35edfe0759 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -3017,6 +3017,15 @@ const strip = (text) => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); const check = (condition, message) => { if (!condition) throw new Error(message); }; +// The working-row slot is deliberately shared with the standalone Pi Calm +// extension, which installs its own boat under the same key. Pi replaces widgets +// per key, so a session loading both Calms renders exactly one boat. Renaming +// this slot in only one implementation silently restores the duplicate-boat +// defect this suite exists to prevent. +check( + CALM_WORKING_SHIP_WIDGET_KEY === "calm-working-ship", + `Firstmate Calm claims a private working-row widget key (${CALM_WORKING_SHIP_WIDGET_KEY}); it must share the standalone Calm slot "calm-working-ship" so dual-install sessions render one boat`, +); const sailOf = (frame) => strip(frame[0]).includes(SAIL) ? SAIL : "none"; // --- Calm cadence: the boat is materially slower than the water ------------------ From 501b4b8a93155496297b20f22f9900e9083ae40b Mon Sep 17 00:00:00 2001 From: ironerumi <39033099+ironerumi@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:45:59 +0900 Subject: [PATCH 2/9] test(calm): pin the shared working-ship widget key in CI, document dual-install The key-parity assertion inside the Pi fixture only runs where the @earendil-works/pi-coding-agent package is installed, so CI never exercised it. Add a source-level twin that needs nothing but the tracked file, and note in docs/calm.md that the boat shares the standalone Pi Calm working-row widget slot. --- docs/calm.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/calm.md b/docs/calm.md index 1c979465ef5..78ac940a9d5 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -32,6 +32,7 @@ Streaming text and the genuine reply that ends a response remain visible. While Calm is active and an agent run is under way, Calm hides Pi's built-in `Working...` row and shows a small two-row animated boat in its place. No separate Calm status row is added. +The boat claims the same Pi working-row widget slot as the standalone Pi Calm extension, so a session that loads both Calms shows one boat rather than two. While Calm is off, Pi's stock working row is left exactly as Pi renders it. The boat looks like this: From ddaa5cd4b2b5c83c634a9a9839e52d29a298cb96 Mon Sep 17 00:00:00 2001 From: ironerumi <39033099+ironerumi@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:01:02 +0900 Subject: [PATCH 3/9] no-mistakes(review): Add executable dual-install widget replacement coverage --- tests/fm-calm-pi-extension.test.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index a35edfe0759..474b1e8e8ba 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -2955,6 +2955,12 @@ TS pass "Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering" } +test_working_ship_widget_key_parity() { + assert_grep 'export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship";' "$WORKING_SHIP" \ + "Firstmate Calm must keep the working-row widget key \"calm-working-ship\" so it shares the standalone Calm slot and dual-install sessions render one boat" + pass "Firstmate Calm's working-row widget key stays on the shared standalone Calm slot" +} + test_working_ship_geometry_and_lifecycle() { local fixture out status version if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then @@ -3651,6 +3657,26 @@ const reset = () => { }; const shipWidget = () => ui.widgets.get(CALM_WORKING_SHIP_WIDGET_KEY); +let standaloneDisposed = false; +const standaloneWidget = { + render: () => ["standalone boat"], + dispose: () => { standaloneDisposed = true; }, +}; +const firstmateWidget = { + render: () => ["firstmate boat"], + dispose: () => {}, +}; +ui.setWidget("calm-working-ship", () => standaloneWidget); +ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, () => firstmateWidget); +const renderedDualInstallWidgets = [...ui.widgets.values()].map((widget) => widget.render(80)); +check( + standaloneDisposed && + renderedDualInstallWidgets.length === 1 && + renderedDualInstallWidgets[0][0] === "firstmate boat", + `dual Calm install rendered ${renderedDualInstallWidgets.length} working widgets instead of one`, +); +ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined); + // --- Calm off leaves Pi's stock working behavior completely untouched ------------- await fire("session_start", { reason: "startup" }); reset(); @@ -4934,6 +4960,7 @@ test_calm_mid_turn_working_notes test_operational_followup_turn_e2e test_queued_operational_escape_e2e test_hidden_block_geometry_e2e +test_working_ship_widget_key_parity test_working_ship_geometry_and_lifecycle test_export_dom_render_guard test_interactive_terminal_e2e From 5bc501ba2556f83411343b708bf9d731b28e8908 Mon Sep 17 00:00:00 2001 From: ironerumi <39033099+ironerumi@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:14:29 +0900 Subject: [PATCH 4/9] no-mistakes(review): Guard shared widget cleanup with disposal ownership --- .pi/extensions/fm-calm.ts | 21 ++++++++++++------ tests/fm-calm-pi-extension.test.sh | 34 ++++++++++++++++-------------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index 2db2af3af8c..77c57cddffe 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -135,6 +135,7 @@ export default function (pi: ExtensionAPI) { // continuations, retries, or compaction that stay inside the same run. let agentRunActive = false; let workingShipShown = false; + let workingShipWidgetDisposed = false; // One animation instance per extension lifetime. Hiding the working widget freezes // this state; the next working period resumes it. session_start resets it so a fresh // Pi session starts at the normal initial position. Never module-global. @@ -149,12 +150,20 @@ export default function (pi: ExtensionAPI) { const showShip = agentRunActive && calmPresentationIsActive(); if (showShip !== workingShipShown) { workingShipShown = showShip; - ui.setWidget( - CALM_WORKING_SHIP_WIDGET_KEY, - showShip - ? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation) - : undefined, - ); + if (showShip) { + ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, (tui) => { + workingShipWidgetDisposed = false; + const widget = createCalmWorkingShipWidget(tui, workingShipAnimation); + const dispose = widget.dispose; + widget.dispose = () => { + workingShipWidgetDisposed = true; + dispose(); + }; + return widget; + }); + } else if (!workingShipWidgetDisposed) { + ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined); + } ui.setWorkingVisible(!showShip); } else if (forceStockVisibility && !showShip) { ui.setWorkingVisible(true); diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 474b1e8e8ba..e99b7e45d41 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -2955,12 +2955,6 @@ TS pass "Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering" } -test_working_ship_widget_key_parity() { - assert_grep 'export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship";' "$WORKING_SHIP" \ - "Firstmate Calm must keep the working-row widget key \"calm-working-ship\" so it shares the standalone Calm slot and dual-install sessions render one boat" - pass "Firstmate Calm's working-row widget key stays on the shared standalone Calm slot" -} - test_working_ship_geometry_and_lifecycle() { local fixture out status version if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then @@ -3023,15 +3017,6 @@ const strip = (text) => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); const check = (condition, message) => { if (!condition) throw new Error(message); }; -// The working-row slot is deliberately shared with the standalone Pi Calm -// extension, which installs its own boat under the same key. Pi replaces widgets -// per key, so a session loading both Calms renders exactly one boat. Renaming -// this slot in only one implementation silently restores the duplicate-boat -// defect this suite exists to prevent. -check( - CALM_WORKING_SHIP_WIDGET_KEY === "calm-working-ship", - `Firstmate Calm claims a private working-row widget key (${CALM_WORKING_SHIP_WIDGET_KEY}); it must share the standalone Calm slot "calm-working-ship" so dual-install sessions render one boat`, -); const sailOf = (frame) => strip(frame[0]).includes(SAIL) ? SAIL : "none"; // --- Calm cadence: the boat is materially slower than the water ------------------ @@ -3884,6 +3869,24 @@ check(liveTimers === 1, "a later run did not use the boat after an idle Calm tog await fire("agent_settled"); check(liveTimers === 0, "the later run did not clean up"); +await fire("agent_start"); +let survivingStandaloneDisposed = false; +const survivingStandaloneWidget = { + render: () => ["standalone boat"], + dispose: () => { survivingStandaloneDisposed = true; }, +}; +ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, () => survivingStandaloneWidget); +reset(); +await calmCommand.handler("", ctx); +check( + !survivingStandaloneDisposed && + ui.widgets.size === 1 && + ui.widgets.get(CALM_WORKING_SHIP_WIDGET_KEY) === survivingStandaloneWidget && + ui.widgetOps.length === 0, + "turning Firstmate Calm off cleared the standalone working ship", +); +ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined); + // --- The visual-only widget never touches session, transcript, or export data ------ check( sessionWrites.length === 0, @@ -4960,7 +4963,6 @@ test_calm_mid_turn_working_notes test_operational_followup_turn_e2e test_queued_operational_escape_e2e test_hidden_block_geometry_e2e -test_working_ship_widget_key_parity test_working_ship_geometry_and_lifecycle test_export_dom_render_guard test_interactive_terminal_e2e From 0d60f43c350be766d4790d652e4501cc58f8a364 Mon Sep 17 00:00:00 2001 From: ironerumi <39033099+ironerumi@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:26:14 +0900 Subject: [PATCH 5/9] no-mistakes(document): Document shared Calm working-ship slot behavior --- .pi/extensions/fm-calm.ts | 2 ++ docs/calm.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index 77c57cddffe..bbf11f8a804 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -143,6 +143,8 @@ export default function (pi: ExtensionAPI) { // Single owner of Calm's working-row presentation choice. The widget is only created // or removed on a real transition, so repeated starts cannot duplicate its timer. + // The slot is shared with standalone Pi Calm; the dispose signal prevents turning + // Firstmate Calm off from clearing a widget that the other extension installed. const applyWorkingPresentation = ( ui: ExtensionUIContext, forceStockVisibility = false, diff --git a/docs/calm.md b/docs/calm.md index 78ac940a9d5..c2130db72cc 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -32,7 +32,7 @@ Streaming text and the genuine reply that ends a response remain visible. While Calm is active and an agent run is under way, Calm hides Pi's built-in `Working...` row and shows a small two-row animated boat in its place. No separate Calm status row is added. -The boat claims the same Pi working-row widget slot as the standalone Pi Calm extension, so a session that loads both Calms shows one boat rather than two. +The boat claims the same Pi working-row widget slot as the standalone Pi Calm extension, so a session that loads both Calms shows one boat rather than two, and turning Firstmate Calm off does not clear the standalone boat. While Calm is off, Pi's stock working row is left exactly as Pi renders it. The boat looks like this: From cadf25690cc2d5a181d6232150d6e0fa440680aa Mon Sep 17 00:00:00 2001 From: ironerumi <39033099+ironerumi@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:32:18 +0900 Subject: [PATCH 6/9] test(calm): read the standalone Calm slot from its own module The dual-install check registered both boats itself under the shared slot, so it could only prove that Pi replaces a widget under one key: it would still pass if the standalone Pi Calm extension installed its boat under a different key, which is the two-boat regression the check exists to prevent. Read the standalone extension's own working-ship module when it is installed - FM_STANDALONE_CALM_SHIP, else ~/.pi/agent/extensions/calm - and drive the check with the key that module exports, so a rename on either side registers two widgets and fails naming both keys. A pinned shared-slot contract still covers a machine without the extension, and the run reports which side it used instead of passing silently over an absent extension. Verified: the touched Pi Calm suite passes and reads the installed standalone extension; with a copy of it whose key is renamed to calm-working-ship-v2 the suite fails naming the drift. --- tests/fm-calm-pi-extension.test.sh | 51 ++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index e99b7e45d41..f90de5e6e68 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -2956,7 +2956,7 @@ TS } test_working_ship_geometry_and_lifecycle() { - local fixture out status version + local fixture out status version standalone_ship 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 working-ship test" return 0 @@ -2968,6 +2968,17 @@ test_working_ship_geometry_and_lifecycle() { version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version") record_pi_version_evidence "$version" "Pi Calm working-ship assumptions" + # The standalone Pi Calm extension is a separate project that installs its own boat + # in the same Pi working-row slot, so the dual-install check below reads its real + # module when it is installed: a rename on either side then renders two boats and + # fails there, instead of passing against a key this test invented. The pinned slot + # contract inside the program covers a machine without that extension. + standalone_ship=${FM_STANDALONE_CALM_SHIP:-} + if [ -z "$standalone_ship" ] && [ -f "${HOME:-}/.pi/agent/extensions/calm/lib/working-ship.ts" ]; then + standalone_ship=${HOME:-}/.pi/agent/extensions/calm/lib/working-ship.ts + fi + [ -f "$standalone_ship" ] || standalone_ship= + fixture="$TMP_ROOT/working-ship" mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works" cp "$EXT" "$fixture/fm-calm.ts" @@ -2984,7 +2995,7 @@ test_working_ship_geometry_and_lifecycle() { ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox" printf '%s\n' '{"type":"module"}' >"$fixture/package.json" - out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' + out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" STANDALONE_CALM_SHIP="$standalone_ship" node --input-type=module 2>&1 <<'JS' import { pathToFileURL } from "node:url"; const packageRoot = process.env.PI_PACKAGE_DIR; @@ -3651,7 +3662,28 @@ const firstmateWidget = { render: () => ["firstmate boat"], dispose: () => {}, }; -ui.setWidget("calm-working-ship", () => standaloneWidget); +// The standalone Pi Calm extension installs its boat in the same Pi working-row +// widget slot. Where that extension is installed, the slot key comes from its own +// module, so a rename on either side registers two widgets and fails here rather +// than passing against a key this test invented; the pinned slot is the shared +// contract both implementations must keep. +const STANDALONE_SLOT = "calm-working-ship"; +let standaloneSlot = STANDALONE_SLOT; +if (process.env.STANDALONE_CALM_SHIP) { + const standaloneShip = await import( + `${pathToFileURL(process.env.STANDALONE_CALM_SHIP).href}?standalone=${Date.now()}` + ); + standaloneSlot = standaloneShip.CALM_WORKING_SHIP_WIDGET_KEY; +} +check( + CALM_WORKING_SHIP_WIDGET_KEY === STANDALONE_SLOT, + `Firstmate Calm registers the working ship under ${CALM_WORKING_SHIP_WIDGET_KEY} instead of the shared slot ${STANDALONE_SLOT}`, +); +check( + standaloneSlot === STANDALONE_SLOT, + `the standalone Pi Calm extension at ${process.env.STANDALONE_CALM_SHIP} registers the working ship under ${standaloneSlot} instead of the shared slot ${STANDALONE_SLOT}`, +); +ui.setWidget(standaloneSlot, () => standaloneWidget); ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, () => firstmateWidget); const renderedDualInstallWidgets = [...ui.widgets.values()].map((widget) => widget.render(80)); check( @@ -3660,7 +3692,7 @@ check( renderedDualInstallWidgets[0][0] === "firstmate boat", `dual Calm install rendered ${renderedDualInstallWidgets.length} working widgets instead of one`, ); -ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined); +ui.setWidget(standaloneSlot, undefined); // --- Calm off leaves Pi's stock working behavior completely untouched ------------- await fire("session_start", { reason: "startup" }); @@ -3875,17 +3907,17 @@ const survivingStandaloneWidget = { render: () => ["standalone boat"], dispose: () => { survivingStandaloneDisposed = true; }, }; -ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, () => survivingStandaloneWidget); +ui.setWidget(standaloneSlot, () => survivingStandaloneWidget); reset(); await calmCommand.handler("", ctx); check( !survivingStandaloneDisposed && ui.widgets.size === 1 && - ui.widgets.get(CALM_WORKING_SHIP_WIDGET_KEY) === survivingStandaloneWidget && + ui.widgets.get(standaloneSlot) === survivingStandaloneWidget && ui.widgetOps.length === 0, "turning Firstmate Calm off cleared the standalone working ship", ); -ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined); +ui.setWidget(standaloneSlot, undefined); // --- The visual-only widget never touches session, transcript, or export data ------ check( @@ -3901,6 +3933,11 @@ JS [ "$status" -eq 0 ] || fail "Pi Calm working-ship checks failed: $out" [ -z "$out" ] || fail "Pi Calm working-ship test printed output: $out" pass "Pi Calm working ship keeps its centered two-row asymmetric Unicode boat inside a deterministic long-wave trough, paints all water standard blue and the whole boat standard yellow with balanced resets, keeps ANSI-stripped width exact, reverses cleanly at both edges and every width, clamps visible and hidden resizes, falls back deterministically when narrow, freezes and resumes across settle/start without hidden-time jumps or duplicate timers, resets only on a fresh session, and leaves Calm-off visibility untouched" + if [ -n "$standalone_ship" ]; then + pass "Pi Calm dual-install coverage read the installed standalone Pi Calm extension's own working-ship slot from $standalone_ship" + else + pass "SKIP: no standalone Pi Calm extension is installed, so dual-install coverage used the pinned shared-slot contract; set FM_STANDALONE_CALM_SHIP to check one" + fi } # The rendered-DOM assertions below depend on a real browser, so the render step From 788a9145e7f9484a30f514fb8a0cb0d17c5d12b5 Mon Sep 17 00:00:00 2001 From: ironerumi <39033099+ironerumi@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:06:53 +0900 Subject: [PATCH 7/9] no-mistakes(review): Gate stock-row restoration by shared-widget ownership --- .pi/extensions/fm-calm.ts | 5 +++-- tests/fm-calm-pi-extension.test.sh | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index bbf11f8a804..bc3e6a3585b 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -163,11 +163,12 @@ export default function (pi: ExtensionAPI) { }; return widget; }); + ui.setWorkingVisible(false); } else if (!workingShipWidgetDisposed) { ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined); + ui.setWorkingVisible(true); } - ui.setWorkingVisible(!showShip); - } else if (forceStockVisibility && !showShip) { + } else if (forceStockVisibility && !showShip && !workingShipWidgetDisposed) { ui.setWorkingVisible(true); } }; diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index f90de5e6e68..6f874c743f7 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -3914,8 +3914,9 @@ check( !survivingStandaloneDisposed && ui.widgets.size === 1 && ui.widgets.get(standaloneSlot) === survivingStandaloneWidget && - ui.widgetOps.length === 0, - "turning Firstmate Calm off cleared the standalone working ship", + ui.widgetOps.length === 0 && + ui.workingVisible.length === 0, + "turning Firstmate Calm off cleared or exposed the standalone working ship", ); ui.setWidget(standaloneSlot, undefined); From f86822d171d84f32cd36f1e424ffbea2f81ffab4 Mon Sep 17 00:00:00 2001 From: ironerumi <39033099+ironerumi@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:27:40 +0900 Subject: [PATCH 8/9] no-mistakes(review): Removed redundant widget-key source assertions --- tests/fm-calm-pi-extension.test.sh | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 6f874c743f7..080ab2435af 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -3675,14 +3675,6 @@ if (process.env.STANDALONE_CALM_SHIP) { ); standaloneSlot = standaloneShip.CALM_WORKING_SHIP_WIDGET_KEY; } -check( - CALM_WORKING_SHIP_WIDGET_KEY === STANDALONE_SLOT, - `Firstmate Calm registers the working ship under ${CALM_WORKING_SHIP_WIDGET_KEY} instead of the shared slot ${STANDALONE_SLOT}`, -); -check( - standaloneSlot === STANDALONE_SLOT, - `the standalone Pi Calm extension at ${process.env.STANDALONE_CALM_SHIP} registers the working ship under ${standaloneSlot} instead of the shared slot ${STANDALONE_SLOT}`, -); ui.setWidget(standaloneSlot, () => standaloneWidget); ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, () => firstmateWidget); const renderedDualInstallWidgets = [...ui.widgets.values()].map((widget) => widget.render(80)); From 9c86afabe0f2b9418de70814d6697cb73ca1f1ff Mon Sep 17 00:00:00 2001 From: ironerumi <39033099+ironerumi@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:42:35 +0900 Subject: [PATCH 9/9] no-mistakes(document): Document shared Calm working-ship widget ownership --- docs/calm-mode-feasibility.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index 89e84796271..f1515eef7bd 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -154,10 +154,10 @@ Calm replaces Pi's stock working row with a small animated boat while Calm is on This path uses only public extension API and patches nothing: `ExtensionUIContext.setWorkingVisible(false)` hides the stock row, and `setWidget()` installs a temporary component factory above the editor. Pi's documented custom working-indicator frames are static and width-blind, so they cannot own responsive geometry; a widget component receives `render(width)` and can. -`.pi/extensions/fm-calm.ts` remains the sole owner of the presentation choice and the only caller of `setWorkingVisible()`, while `.pi/extensions/lib/fm-calm-working-ship.ts` owns Pi's ANSI painting and the widget over the sprite geometry, bounce track, cadences, and freeze/resume state in `.claude/mods/firstmate-calm/lib/fm-calm-working-ship-sprite.ts`, the harness-neutral core the Claude Code mod also draws from (reached from the Pi tree through a tracked symlink, because Claude Code refuses a hooks-module import from outside the plugin folder). +Within Firstmate Calm, `.pi/extensions/fm-calm.ts` remains the sole owner of its presentation choice and the only caller of `setWorkingVisible()`, while `.pi/extensions/lib/fm-calm-working-ship.ts` owns Pi's ANSI painting and the widget over the sprite geometry, bounce track, cadences, and freeze/resume state in `.claude/mods/firstmate-calm/lib/fm-calm-working-ship-sprite.ts`, the harness-neutral core the Claude Code mod also draws from (reached from the Pi tree through a tracked symlink, because Claude Code refuses a hooks-module import from outside the plugin folder). Visibility follows `agent_start` through `agent_settled` rather than turns or tool calls. Pi emits `agent_settled` from a `finally` block once a run will not continue automatically, so retries, automatic continuations, queued follow-ups, and compaction inside one run never remove the boat, while settle, abort, and failure all reach the same cleanup. -Repeated `agent_start` events inside one run are idempotent, and Pi disposes the previous component before installing a replacement under the same key and when it clears extension widgets, so the frame timer cannot duplicate or outlive the widget. +Repeated `agent_start` events inside one run are idempotent, and both Calm extensions use the shared `calm-working-ship` widget key, so Pi disposes the previous component before installing a replacement under that key and when it clears extension widgets, while Firstmate Calm's disposal signal prevents its cleanup from clearing a standalone Calm widget that replaced its own widget. Pi's above-editor widget container reserves one spacer row whether or not a widget is present, so removing the boat leaves no residual blank row. The sprite is two rows when the usable width admits the complete hull: an asymmetric three-cell `◿│◣` sail centered over a five-cell `╲▁▁▁╱` hull that sits inside the water row rather than adding a third row.