From 3cf1c20d826d9d9969925c0ce9a00d3d2de3d4d7 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 28 Oct 2026 00:30:21 +0100 Subject: [PATCH 1/7] feat(runtime): pin bundled Pi 1.4.0 and admit audited Pi 1.3.0/1.4.0 editors --- lib/vim-editor-adapter.ts | 5 +- pnpm-lock.yaml | 83 ++++++++++++++++++-------------- scripts/installer-preflight.mjs | 2 +- tests/vim-editor-adapter.test.ts | 4 +- 4 files changed, 53 insertions(+), 41 deletions(-) diff --git a/lib/vim-editor-adapter.ts b/lib/vim-editor-adapter.ts index 9a65dbc39..44db2342b 100644 --- a/lib/vim-editor-adapter.ts +++ b/lib/vim-editor-adapter.ts @@ -1,7 +1,7 @@ import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui"; import { createRequire } from "node:module"; -// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0, 1.1.0 and 1.2.0: +// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0, 1.1.0, 1.2.0, 1.3.0 and 1.4.0: // editor.js state/pastes/history, sticky cursor reset, layoutText/render geometry, // autocomplete cancellation and { state, pastes, pasteCounter } undo restoration; // undo-stack.js clone-on-push/pop snapshots. The two 0.99 releases ship identical @@ -17,6 +17,7 @@ import { createRequire } from "node:module"; // markers resolved by the host TUI (including hardware-cursor suppression). // Keep these markers intact and rearm selection after their reverse-off; // state, undo, paste, layout and autocomplete contracts remain unchanged. +// 1.3.0 and 1.4.0 ship editor.js, undo-stack.js and keys.js byte-identical to 1.2.0. // Never silently adapt another build: undo also owns the paste registry. interface Position { line: number; col: number } interface State { lines: string[]; cursorLine: number; cursorCol: number } @@ -57,7 +58,7 @@ interface PrivateEditor { // The single audited-release source for the adapter and Gentle Shell's runtime // identity gates. Add a release only after re-auditing the files named above. -export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0"] as const); +export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0"] as const); export type AuditedPiEditorVersion = (typeof AUDITED_PI_EDITOR_VERSIONS)[number]; export function isAuditedPiEditorVersion(version: unknown): version is AuditedPiEditorVersion { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c0135af2..dd1d3fbf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,20 +10,20 @@ importers: dependencies: '@heyhuynhgiabuu/pi-pretty': specifier: 0.6.27 - version: 0.6.27(@earendil-works/pi-ai@1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0))(@earendil-works/pi-coding-agent@1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0))(@earendil-works/pi-tui@1.2.0) + version: 0.6.27(@earendil-works/pi-ai@1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0))(@earendil-works/pi-coding-agent@1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0))(@earendil-works/pi-tui@1.4.0) typebox: specifier: '*' version: 1.1.38 devDependencies: '@earendil-works/pi-ai': specifier: '>=1.2.0' - version: 1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) + version: 1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) '@earendil-works/pi-coding-agent': specifier: '>=1.2.0' - version: 1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0) + version: 1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0) '@earendil-works/pi-tui': specifier: '>=1.2.0' - version: 1.2.0 + version: 1.4.0 '@types/node': specifier: ^24.13.3 version: 24.13.3 @@ -126,34 +126,34 @@ packages: resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} engines: {node: '>=6.9.0'} - '@earendil-works/pi-agent-core@1.2.0': - resolution: {integrity: sha512-pUR0vYmGhHkBQGeCeWjwVIA+l1+MrDu6m6VGuysjh6KBzBpovTV/MlcVrEIOaM8jv/slpAyUmuCEQl1jOBqHJQ==} + '@earendil-works/pi-agent-core@1.4.0': + resolution: {integrity: sha512-PoKY84SKUwnt7S5QBZaGUqfNO1kjP3KHpHUA9QpFTj4cOjaVWMYIcBPJ6FDRCdE8Zen9OSECQUd0uUfVgtSmgA==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@1.2.0': - resolution: {integrity: sha512-U5/1zXqfV1on7/9sbez03tT4Ru49Lvfb4+zXSDK3DBKF5cRk6KxvAOTOY3HJ4jimlZth+rs7vbJqN6iBQAoNuQ==} + '@earendil-works/pi-ai@1.4.0': + resolution: {integrity: sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-codemode@1.2.0': - resolution: {integrity: sha512-UZF7IN0IIrf3bop2wTxrwoAakGctH6d3zdPwhPN+QHMECfoI5E6B6l6I17YGFZPj8cmGex6xX7mLCNJXRf7wsw==} + '@earendil-works/pi-codemode@1.4.0': + resolution: {integrity: sha512-23CNAXpS9DuLzPMyYuvrXVVio46x4YhffCgCqaWmF7F5H8PooqjrQvX4ucF6M0BhUd+x2tV6r1J0dv8cCME4Cg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-coding-agent@1.2.0': - resolution: {integrity: sha512-h/4WaVWv1VlCaY8x8qEN5U+7jbF/e7+Ck6moPeEOFn5juMTWwNAXjwgYM7gRgbBYh8Zppwt6qWvJDzNTf4GNEQ==} + '@earendil-works/pi-coding-agent@1.4.0': + resolution: {integrity: sha512-Z41LpZ3yYHSC/UUps6Mr39UMFPwH8elhJBG9DY1P7kuzsE5qSK/6eMj2EHzO5evJRQWLbKSqUMw+4nCNtIyiQg==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-mcp@1.2.0': - resolution: {integrity: sha512-ertUv40rUBgssTzw2lIPlejjIBdhvdg4uw+whWCDJYKYHU+ldp1ezhbB0BzwpBfnwef6/YN+m6eKLscj+r3p7w==} + '@earendil-works/pi-mcp@1.4.0': + resolution: {integrity: sha512-92aZmmGsUpHHknYuKXZHRINQfahntw5hU9If0I0ZM49GojOaFHpkl5/GF3/aatqpt7v7GjZbBfpfSygGKW+pnw==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-telemetry@1.2.0': - resolution: {integrity: sha512-NZ3aJE6dr7aTgzzXDe53OW7BsAsF8ZUKHPJTtnjaQcxtlagkUT0Ew8Qas0EvYQsZqR/KpF1lB+z209XBzIC7xg==} + '@earendil-works/pi-telemetry@1.4.0': + resolution: {integrity: sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-tui@1.2.0': - resolution: {integrity: sha512-OdJCiYklkL3TqmdmGKUflHoLy9zw18GBYO0DPASZvGlP4RkDXAZzLLDt7cidJ6wUR7DBrKb13rrekDsXgMF60g==} + '@earendil-works/pi-tui@1.4.0': + resolution: {integrity: sha512-4obPOjgOD7g4mGSlngIvxyujC1UPdmyI9zdVthbr8GufacFEX+Ce0+scPgHg++k2xj5+QjESM2d5TxvIuzCVmg==} engines: {node: '>=22.19.0'} '@ff-labs/fff-bin-android-arm64@0.10.6': @@ -622,6 +622,12 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + marked-cjk-friendly@0.1.2: + resolution: {integrity: sha512-GUj8HjiEbur9yhgUo52FBgKizggF1QtT6gVdrYdWW6ZB4lVVjgS3EyyXPyfyRD0V4ghGRPlO7MO80rgSWbm0eA==} + engines: {node: '>=18'} + peerDependencies: + marked: '>=15.0.0' + marked@18.0.11: resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} engines: {node: '>= 20'} @@ -1045,9 +1051,9 @@ snapshots: '@babel/runtime@7.29.10': {} - '@earendil-works/pi-agent-core@1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0)': + '@earendil-works/pi-agent-core@1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0)': dependencies: - '@earendil-works/pi-ai': 1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) + '@earendil-works/pi-ai': 1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) typebox: 1.3.27 transitivePeerDependencies: - '@aws-sdk/credential-provider-node' @@ -1062,11 +1068,11 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0)': + '@earendil-works/pi-ai@1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0)': dependencies: '@anthropic-ai/sdk': 0.129.0 '@aws-sdk/client-bedrock-runtime': 3.1127.0 - '@earendil-works/pi-telemetry': 1.2.0 + '@earendil-works/pi-telemetry': 1.4.0 '@google/genai': 2.21.0 '@smithy/node-http-handler': 4.12.1 http-proxy-agent: 9.1.0 @@ -1087,17 +1093,17 @@ snapshots: - ws - zod - '@earendil-works/pi-codemode@1.2.0': + '@earendil-works/pi-codemode@1.4.0': dependencies: quickjs-wasi: 3.6.2 - '@earendil-works/pi-coding-agent@1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0)': + '@earendil-works/pi-coding-agent@1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0)': dependencies: - '@earendil-works/pi-agent-core': 1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) - '@earendil-works/pi-ai': 1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) - '@earendil-works/pi-codemode': 1.2.0 - '@earendil-works/pi-mcp': 1.2.0 - '@earendil-works/pi-tui': 1.2.0 + '@earendil-works/pi-agent-core': 1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) + '@earendil-works/pi-ai': 1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) + '@earendil-works/pi-codemode': 1.4.0 + '@earendil-works/pi-mcp': 1.4.0 + '@earendil-works/pi-tui': 1.4.0 '@silvia-odwyer/photon-node': 0.3.4 brace-expansion: 5.0.12 chalk: 6.0.0 @@ -1127,16 +1133,17 @@ snapshots: - ws - zod - '@earendil-works/pi-mcp@1.2.0': + '@earendil-works/pi-mcp@1.4.0': dependencies: cross-spawn: 7.0.6 - '@earendil-works/pi-telemetry@1.2.0': {} + '@earendil-works/pi-telemetry@1.4.0': {} - '@earendil-works/pi-tui@1.2.0': + '@earendil-works/pi-tui@1.4.0': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.11 + marked-cjk-friendly: 0.1.2(marked@18.0.11) '@ff-labs/fff-bin-android-arm64@0.10.6': optional: true @@ -1190,11 +1197,11 @@ snapshots: - supports-color - utf-8-validate - '@heyhuynhgiabuu/pi-pretty@0.6.27(@earendil-works/pi-ai@1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0))(@earendil-works/pi-coding-agent@1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0))(@earendil-works/pi-tui@1.2.0)': + '@heyhuynhgiabuu/pi-pretty@0.6.27(@earendil-works/pi-ai@1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0))(@earendil-works/pi-coding-agent@1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0))(@earendil-works/pi-tui@1.4.0)': dependencies: - '@earendil-works/pi-ai': 1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) - '@earendil-works/pi-coding-agent': 1.2.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0) - '@earendil-works/pi-tui': 1.2.0 + '@earendil-works/pi-ai': 1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(ws@8.22.0) + '@earendil-works/pi-coding-agent': 1.4.0(@aws-sdk/credential-provider-node@3.972.86)(@smithy/signature-v4@5.7.4)(ws@8.22.0) + '@earendil-works/pi-tui': 1.4.0 '@ff-labs/fff-node': 0.10.6 '@shikijs/cli': 4.2.0 shiki: 4.0.2 @@ -1591,6 +1598,10 @@ snapshots: lru-cache@11.5.3: {} + marked-cjk-friendly@0.1.2(marked@18.0.11): + dependencies: + marked: 18.0.11 + marked@18.0.11: {} mdast-util-to-hast@13.2.1: diff --git a/scripts/installer-preflight.mjs b/scripts/installer-preflight.mjs index ec356ec47..a7314aeb6 100644 --- a/scripts/installer-preflight.mjs +++ b/scripts/installer-preflight.mjs @@ -25,7 +25,7 @@ export const requirements = Object.freeze({ /** Pi version the installer installs next to gentle-pi, and the version an older * Pi is updated to (never below requirements.pi; the runner asserts it). */ -export const PI_INSTALL_VERSION = "1.2.0"; +export const PI_INSTALL_VERSION = "1.4.0"; /** Runtime persisted under PNPM_HOME. Bootstrap-only Node: `pnpm runtime set * node -g`, then `pnpm add -g npm@ pnpm@`, or only npm when diff --git a/tests/vim-editor-adapter.test.ts b/tests/vim-editor-adapter.test.ts index d17cd96a8..3adc03070 100644 --- a/tests/vim-editor-adapter.test.ts +++ b/tests/vim-editor-adapter.test.ts @@ -277,12 +277,12 @@ test("resolveVimRuntime resolves local bundle cli entrypoint when provided", () }); test("audited Pi editor releases are one frozen exact allowlist that includes the installed TUI", () => { - assert.deepEqual([...AUDITED_PI_EDITOR_VERSIONS], ["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0"]); + assert.deepEqual([...AUDITED_PI_EDITOR_VERSIONS], ["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0"]); assert.equal(bundledAgent.VERSION, INSTALLED_PI, "bundled and local metadata must describe the same actual installed release"); assert.ok(Object.isFrozen(AUDITED_PI_EDITOR_VERSIONS)); assert.throws(() => (AUDITED_PI_EDITOR_VERSIONS as unknown as string[]).push("0.99.3"), TypeError); assert.ok(isAuditedPiEditorVersion(INSTALLED_PI), `installed pi-tui ${INSTALLED_PI} must be audited`); - for (const unknown of ["0.99.3", "0.99.10", "0.99", "v0.99.2", " 0.99.2", "0.99.2-rc.1", ">=0.99.2", "1.0.1", ">=1.0.0", "v1.0.0", " 1.0.0", "1.0.0-rc.1", "1.2.1", "1.2.0-rc.1", "v1.2.0", ">=1.2.0", undefined, 99.2]) { + for (const unknown of ["0.99.3", "0.99.10", "0.99", "v0.99.2", " 0.99.2", "0.99.2-rc.1", ">=0.99.2", "1.0.1", ">=1.0.0", "v1.0.0", " 1.0.0", "1.0.0-rc.1", "1.2.1", "1.2.0-rc.1", "v1.2.0", ">=1.2.0", "1.4.1", "1.5.0", "1.4.0-rc.1", undefined, 99.2]) { assert.equal(isAuditedPiEditorVersion(unknown), false, String(unknown)); } }); From 697d2e4b619d4f2e078f73395d4ad83482f850ea Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 28 Oct 2026 00:30:26 +0100 Subject: [PATCH 2/7] feat(bundled): add 'gentle-shell update --latest' for the newest published Pi A bundled install could only reach the Pi its Gentle Shell release pins. 'update --latest' (also pi|self|--self|--all forms) resolves the newest stable Pi with the bundled pnpm, installs it beside the active version without the release lockfile after a warning, and activates it through the same launcher check and rollback as 'upgrade'. A plain upgrade never downgrades that Pi for the same Gentle Shell version. Docs list the audited Pi 1.3.0/1.4.0 editors. --- README.md | 2 +- bin/gentle-shell.mjs | 41 +++++++++-- docs/gentle-shell.md | 4 +- docs/readme-reference.md | 8 ++- lib/gentle-shell-bundled.ts | 26 ++++--- lib/gentle-shell-launcher.ts | 2 + odd/tasks/bundled-pi-latest.md | 31 +++++++++ runtime/gentle-shell-bundled.mjs | 26 ++++--- runtime/gentle-shell-launcher.mjs | 2 + scripts/bundled-install.mjs | 101 ++++++++++++++++++++++++---- scripts/pi-update.mjs | 4 +- tests/bundled-install.test.ts | 100 ++++++++++++++++++++++++++- tests/gentle-shell-bundled.test.ts | 20 ++++++ tests/gentle-shell-launcher.test.ts | 1 + tests/pi-update.test.ts | 5 ++ 15 files changed, 333 insertions(+), 40 deletions(-) create mode 100644 odd/tasks/bundled-pi-latest.md diff --git a/README.md b/README.md index d59436884..50072444f 100644 --- a/README.md +++ b/README.md @@ -585,7 +585,7 @@ gentle-shell upgrade --rollback - **Main channel:** On the main channel it rebuilds Gentle AI and Gentle Shell from the latest `main` commits (only what changed) and needs pnpm and Go (a Go older than the minimum is replaced, for the build only, by the pinned Go the installer left in its own folder); switching back to release restores the pinned Gentle AI binary. When the Gentle AI build fails but Gentle Shell moved, the new Gentle Shell is still installed and the command exits with an error asking you to run `gentle-shell upgrade --channel main` again. The retry rebuilds Gentle AI with the new Shell's code, and finishes once the cause of the original error is resolved. - **Your Pi:** With pnpm 11, the updated Gentle Shell may run a newer Pi that pnpm puts next to it (always at least the minimum); your own Pi is never changed. - **Bundled install:** A bundled install (the web installer's own Node, pnpm and Pi) follows the release channel only: it installs the new version beside the current one, verifies it, then switches, keeping the previous version for `--rollback`. On release, once the latest release publishes the bundled distribution, `upgrade` moves a global pnpm or npm installation to the bundled one and asks once before removing the old global `gentle-pi`. -- **`update` vs `upgrade`:** `gentle-shell update` is a different command: it is Pi's own package update. In a bundled install Pi ships pinned with Gentle Shell, so updating Pi itself runs `gentle-shell upgrade` instead. +- **`update` vs `upgrade`:** `gentle-shell update` is a different command: it is Pi's own package update. In a bundled install Pi ships pinned with Gentle Shell, so updating Pi itself runs `gentle-shell upgrade` instead. `gentle-shell update --latest` (also with `pi`, `self`, `--self` or `--all`) installs the newest published stable Pi with the active Gentle Shell instead, without the release's verified lockfile: it first warns that Gentle Shell features relying on audited Pi internals (such as the Vim editor) may turn off until a Gentle Shell release verifies that Pi, then verifies and switches to it like `upgrade`, keeping the previous version for `gentle-shell upgrade --rollback`. Afterwards `gentle-shell upgrade` never downgrades that Pi: a release of the same Gentle Shell with an older verified Pi changes nothing, and a newer Gentle Shell release upgrades as usual. diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index f885aae3e..f09841d59 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -1684,6 +1684,32 @@ async function handleBundledUpgrade(bundled, commandArgs) { } } +// `gentle-shell update --latest` in a bundled install: the newest published +// Pi installed with this Gentle Shell beside the active version, without the +// release's verified lockfile (after a warning), verified, then `current` +// switched. Returns the exit code like handleBundledUpgrade; a failure leaves +// `current` as it was. +async function handleBundledPiLatest(bundled) { + const { prefixLayout, publishedPi, upgradePiLatest } = await import("../scripts/bundled-install.mjs"); + const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: bundled.root }); + try { + await upgradePiLatest({ + layout, + home: homedir(), + shell: ownPackageVersion(), + platform: process.platform, + arch: process.arch, + env: process.env, + adapters: { latestPi: () => publishedPi(layout, { platform: process.platform, env: process.env }) }, + out: (line) => process.stdout.write(`${line}\n`), + }); + return 0; + } catch (error) { + process.stderr.write(`gentle-shell update: ${error.message}${error.stderr ? `\n${error.stderr}` : ""}\n`); + return 1; + } +} + // The bundled npm as the `npmCommand` of a home Gentle Shell owns (the isolated // home, or a --home it created or provisioned; never --link or a foreign home), // so Pi installs packages with our npm while its tools keep the user's PATH. @@ -1933,14 +1959,21 @@ async function main() { // S5: in a bundled install Pi updates only with Gentle Shell, so Pi's own // update becomes `gentle-shell upgrade` (run when interactive, named - // otherwise); package updates still reach Pi. + // otherwise), or with `--latest` the newest published Pi without the + // release's lockfile (run even when not interactive: it was asked for); + // package updates still reach Pi. let passthrough = args.passthrough; if (bundled && args.piSubcommand === "update") { const plan = bundledUpdatePlan(args.passthrough); if (plan.self) { - const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true; - for (const line of bundledUpdateNotice({ piVersion: versionCheck.version, interactive })) process.stdout.write(`${line}\n`); - const code = interactive ? await handleBundledUpgrade(bundled, []) : undefined; + let code; + if (plan.latest) { + code = await handleBundledPiLatest(bundled); + } else { + const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true; + for (const line of bundledUpdateNotice({ piVersion: versionCheck.version, interactive })) process.stdout.write(`${line}\n`); + code = interactive ? await handleBundledUpgrade(bundled, []) : undefined; + } const next = bundledUpdateNext(plan, code); if ("exitCode" in next) process.exit(next.exitCode); if (next.note) process.stderr.write(`${next.note}\n`); diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 40249d005..316fbecae 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -10,7 +10,7 @@ Source map: [shell extension](../extensions/gentle-shell.ts), [shell bar](../lib Run `gentle-shell update` to update the Pi this launcher runs. It also works when that Pi is older than the required minimum version, which every other command refuses. Shell verifies npm/pnpm global listing JSON and canonical package roots before installing exactly one supplying package. This supports pnpm v11 linked stores without relying on `PNPM_HOME` containment. When the launcher runs its adjacent Pi peer, the supplier is the listed `gentle-pi` package itself: Shell is reinstalled at its current version so the package manager re-resolves the peer, because updating a separate global Pi never changes the peer a Shell install runs. A main-channel Shell (`-main.`) is never published, so it is reinstalled from the tarball `gentle-shell upgrade --channel main` packed under `/main/packages/` (an absolute `GENTLE_PI_CONFIG_HOME`, else `~/.pi/gentle-ai`; a relative override is refused); when that tarball is missing, `update` installs nothing and names the rebuild path (`gentle-shell upgrade --channel release`, then `--channel main`), because `upgrade --channel main` alone is a no-op when already at the latest main. A Pi found on `PATH` or through `GENTLE_SHELL_PI` is updated through its own listed global package; a package-manager shim is followed to the single `cli.js` it names. Pi and Shell are never combined into a comma install. -Unknown or ambiguous ownership, npm source/link packages, and pnpm groups shared with other top-level packages refuse without installation changes. Separate groups in one aggregated listing are allowed. After the install, Shell reads the supplied Pi version back from a fresh listing and reports whether it changed. pnpm installs only releases older than its `minimumReleaseAge` setting (pnpm 11 defaults to 4320 minutes), so a very recent Pi release is picked up once it is old enough; Shell reports that rule instead of overriding it. Package managers run from the system temporary directory so a project's `packageManager` pin never changes which pnpm answers. Use the supplying package manager for source checkouts or unsupported layouts. `gentle-shell update --extensions-only` remains an extension-only Pi command; prefix-bundled Pi still updates with `gentle-shell upgrade`. See [managing packages](readme-reference.md#managing-packages) for the ownership contract. +Unknown or ambiguous ownership, npm source/link packages, and pnpm groups shared with other top-level packages refuse without installation changes. Separate groups in one aggregated listing are allowed. After the install, Shell reads the supplied Pi version back from a fresh listing and reports whether it changed. pnpm installs only releases older than its `minimumReleaseAge` setting (pnpm 11 defaults to 4320 minutes), so a very recent Pi release is picked up once it is old enough; Shell reports that rule instead of overriding it. Package managers run from the system temporary directory so a project's `packageManager` pin never changes which pnpm answers. Use the supplying package manager for source checkouts or unsupported layouts. `gentle-shell update --extensions-only` remains an extension-only Pi command; prefix-bundled Pi still updates with `gentle-shell upgrade`, or with `gentle-shell update --latest` to the newest published Pi without the release's verified lockfile (after a warning that features relying on audited Pi internals, such as the Vim editor, may turn off; `upgrade` never downgrades it afterwards). See [managing packages](readme-reference.md#managing-packages) for the ownership contract. When the install fails, `update` names the package manager and the exact command it ran, then one sanitized line of that manager's own output. That line is the last error line (`Error:` or an `ERR_` code) in its stdout or stderr, stderr's when both have one, because npm prints its error on stderr while pnpm 11 prints `ERR_PNPM_*` on stdout. A warning on stderr never hides that error. Without any error line, it is the last line of stderr, else of stdout. Terminal escapes and control characters are removed, your home directory becomes `~`, and the line is at most 300 characters. For example: @@ -245,7 +245,7 @@ Gentle Shell ships its own interactive tools instead of depending on third-party ### Gentle Agents -The current package requires Pi 1.2.0 or newer and Node >=22.19.0. Development tests resolve Pi through the open `>=1.0.0` development range. The private Vim editor adapter admits only the audited Pi `0.99.1`, `0.99.2`, `1.0.0`, `1.1.0`, and `1.2.0` releases; any other release keeps ordinary prompt editing until its editor is audited. Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. +The current package requires Pi 1.2.0 or newer and Node >=22.19.0. Development tests resolve Pi through the open `>=1.0.0` development range. The private Vim editor adapter admits only the audited Pi `0.99.1`, `0.99.2`, `1.0.0`, `1.1.0`, `1.2.0`, `1.3.0`, and `1.4.0` releases; any other release keeps ordinary prompt editing until its editor is audited. Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. The `subagent_*` tools and the agents card replace the third-party subagents package (remove `npm:pi-subagents-j0k3r` from your pi packages; while it is still installed the tools stay unregistered and a warning says so at startup). Agent definitions and settings are the ones you already have: markdown agents in `~/.pi/agent/agents/`, `~/.pi/agent/subagents/`, `/.pi/agents/`, `/.pi/subagents/` (project beats global, `subagents/` beats `agents/`), and `subagents.json` at the global and project level (`default_model`, `default_effort`, `default_mode`, `model_profiles`, `stall_timeout_ms`, `tool_stall_timeout_ms`, `max_concurrency`, `history_max_tasks`). diff --git a/docs/readme-reference.md b/docs/readme-reference.md index da021fd6b..a045c4ad5 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -222,7 +222,7 @@ This checkout declares `gentle-pi` `4.0.0` with a package-local Gentle AI `v4.3. ### Pi compatibility -The current package requires Pi 1.2.0 or newer and Node >=22.19.0. Development tests resolve Pi through the open `>=1.0.0` development range. The private Vim editor adapter admits only the audited Pi `0.99.1`, `0.99.2`, `1.0.0`, `1.1.0`, and `1.2.0` releases; any other release keeps ordinary prompt editing until its editor is audited. Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. +The current package requires Pi 1.2.0 or newer and Node >=22.19.0. Development tests resolve Pi through the open `>=1.0.0` development range. The private Vim editor adapter admits only the audited Pi `0.99.1`, `0.99.2`, `1.0.0`, `1.1.0`, `1.2.0`, `1.3.0`, and `1.4.0` releases; any other release keeps ordinary prompt editing until its editor is audited. Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. The [`v2.6.0` release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v2.6.0) added persistent registered worktrees and grouped `/gentle:changes` views; fuller workspace interaction details are in the [Gentle Shell reference](gentle-shell.md). It also adds named atomic `/gentle:profiles`, native review intended-untracked selection and provider continuations, and opt-in custom ask responses. Pi recognizes its global Git-managed package path; subsystems install with explicit recovery guidance when npm lifecycle work was skipped. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed. @@ -304,6 +304,7 @@ gentle-shell home [link|isolated|] gentle-shell [home selectors] setup [--dry-run] gentle-shell upgrade [--channel release|main] gentle-shell upgrade --rollback +gentle-shell update [pi|self|--self|--all] --latest gentle-shell self-uninstall [--dry-run] [--yes] [--include-shared] ``` @@ -382,7 +383,7 @@ Tradeoff: `--link` shares one credential store with pi; `import-pi` duplicates i After a successful install, Shell reads the supplied Pi version back from a fresh global listing and reports it, naming the previous version when it changed. An unchanged pnpm result also names pnpm's `minimumReleaseAge` setting: pnpm installs only releases older than that age (pnpm 11 defaults to 4320 minutes), so a very recent Pi release is picked up once it is old enough; Shell reports the rule rather than overriding it. Both the listing and the install run from the system temporary directory, so a project's `packageManager` pin in the current directory never changes which pnpm answers. When the version cannot be read back, the output says so and points at `gentle-shell --version`. -Prefix-bundled installations keep their existing `gentle-shell upgrade` route for Pi. `update --all` updates the verified owner before forwarding extension updates; an ownership refusal stops without mutation. Shell's `upgrade` and `self-uninstall` ownership behavior is unchanged. +Prefix-bundled installations keep their existing `gentle-shell upgrade` route for Pi, or `gentle-shell update --latest` for the newest published Pi without the release's verified lockfile (see [`upgrade`](#upgrade-subcommand-and-channels)). Outside a bundled install `--latest` changes nothing: `update` already installs the latest Pi. `update --all` updates the verified owner before forwarding extension updates; an ownership refusal stops without mutation. Shell's `upgrade` and `self-uninstall` ownership behavior is unchanged. ### `setup` subcommand @@ -555,6 +556,7 @@ every `update` form that updates Pi itself explains that and runs `gentle-shell upgrade` (in a terminal) or names it. Those forms are no target, `--self`, `self` or `pi`, `--all`, and `self` or `pi` with `--extensions`. The extension part of an "all" form, and every package update, still reaches Pi. +`gentle-shell update --latest` (also with `pi`, `self`, `--self` or `--all`) installs the newest published stable Pi with the active Gentle Shell instead, without the release's verified lockfile: it first warns that Gentle Shell features relying on audited Pi internals (such as the Vim editor) may turn off until a Gentle Shell release verifies that Pi, then verifies and switches to it like `upgrade`, keeping the previous version for `gentle-shell upgrade --rollback`. Afterwards `gentle-shell upgrade` never downgrades that Pi: a release of the same Gentle Shell with an older verified Pi changes nothing, and a newer Gentle Shell release upgrades as usual. In a bundled install Pi and the agent's commands keep your own environment; Pi installs packages with Gentle Shell's npm through the `npmCommand` setting of its own home. Details: [Bundled install](bundled-install.md#launcher-in-a-bundled-install). @@ -1347,7 +1349,7 @@ The frame labels INSERT, NORMAL, VISUAL (characterwise), or VISUAL LINE (linewis **Deliberate `/` divergence from Claude Code:** NORMAL `/` hands off to **Pi's native slash commands and skills**, enters INSERT, and inserts `/` at the existing cursor. Pi offers slash completion only at the start of the first line; elsewhere it inserts a literal slash without moving or replacing the draft. There is **no reverse prompt-history search**. Pi's explicit history shortcuts still work, transferring to INSERT first. Unknown NORMAL printable input, encoded text and bracketed paste do not silently insert; application shortcuts can transfer to INSERT before acting. -This is a bounded command subset, not full Claude Code/Vim parity. The private editor adapter supports only the audited Pi coding-agent/TUI `0.99.1`, `0.99.2`, `1.0.0`, `1.1.0`, and `1.2.0` package pairs. The two 0.99 releases have byte-identical editor and undo-stack sources. Pi 1.0.0, 1.1.0 and 1.2.0 are separately audited against the touched state, paste/history, cursor/layout, autocomplete and undo-snapshot contracts. Pi 1.2.0's paired APC fake-cursor markers remain intact so the host can suppress software cursors when hardware cursors are visible; selection inversion and empty-prompt hints support both these markers and legacy SGR cursors. Current bundled and unbundled 1.2.0 tests prove identity, edit/undo, paste, selection, wrapping/scroll and autocomplete behavior, not old/new byte identity. Fabricated metadata tests preserve exact-version admission coverage for the older audited releases. Version metadata must come from a canonical candidate host package root whose actual `CustomEditor` and `Editor` classes match the loaded classes, never from the extension's local metadata or CLI path alone. Unknown versions, mismatched prototypes, or invalid layouts fail closed: a single compatibility warning is shown and the prompt continues with ordinary editing instead of silently entering inert NORMAL mode. Operations that would cross a registered collapsed paste marker, or encounter duplicate occurrences of a registered marker ID, are rejected without editing it. Visual highlighting relies on Pi's render layout and may be omitted if its geometry cannot be validated. No live-terminal proof of every layout or complete parity is claimed. +This is a bounded command subset, not full Claude Code/Vim parity. The private editor adapter supports only the audited Pi coding-agent/TUI `0.99.1`, `0.99.2`, `1.0.0`, `1.1.0`, `1.2.0`, `1.3.0`, and `1.4.0` package pairs. The two 0.99 releases have byte-identical editor and undo-stack sources. Pi 1.0.0, 1.1.0 and 1.2.0 are separately audited against the touched state, paste/history, cursor/layout, autocomplete and undo-snapshot contracts. Pi 1.2.0's paired APC fake-cursor markers remain intact so the host can suppress software cursors when hardware cursors are visible; selection inversion and empty-prompt hints support both these markers and legacy SGR cursors. Current bundled and unbundled 1.2.0 tests prove identity, edit/undo, paste, selection, wrapping/scroll and autocomplete behavior, not old/new byte identity. Fabricated metadata tests preserve exact-version admission coverage for the older audited releases. Version metadata must come from a canonical candidate host package root whose actual `CustomEditor` and `Editor` classes match the loaded classes, never from the extension's local metadata or CLI path alone. Unknown versions, mismatched prototypes, or invalid layouts fail closed: a single compatibility warning is shown and the prompt continues with ordinary editing instead of silently entering inert NORMAL mode. Operations that would cross a registered collapsed paste marker, or encounter duplicate occurrences of a registered marker ID, are rejected without editing it. Visual highlighting relies on Pi's render layout and may be omitted if its geometry cannot be validated. No live-terminal proof of every layout or complete parity is claimed. Startup banner settings remain global in `banner.json` under `GENTLE_PI_CONFIG_HOME` (default `~/.pi/gentle-ai`). Existing `showRose` and `showTextLogo` opt-outs independently control the main startup artwork; both default to enabled. Changes apply on the next session or `/reload`. Color presets are `pink` (default), `cyan`, `yellow`, and `green`. The static sidebar heading is independent of these preferences and follows the active theme. diff --git a/lib/gentle-shell-bundled.ts b/lib/gentle-shell-bundled.ts index 3befd1318..6cd446e8c 100644 --- a/lib/gentle-shell-bundled.ts +++ b/lib/gentle-shell-bundled.ts @@ -568,6 +568,9 @@ export function withoutMissingBundledNpmCommand(settingsText: string | undefined export interface BundledUpdatePlan { // Pi's own update was asked for: redirect it to `gentle-shell upgrade`. self: boolean; + // `--latest` asked for the newest published Pi in place of the release's + // verified one (scripts/bundled-install.mjs upgradePiLatest). Only set then. + latest?: boolean; // What Pi still runs, if anything: the extension part of `--all`, or the // whole command when it never updates Pi itself. piArgs?: string[]; @@ -578,7 +581,9 @@ export interface BundledUpdatePlan { // `update`, `--self`, or a `self`/`pi` positional, and "all" for `--all`, // `--self --extensions` or `self`/`pi` with `--extensions`. Only Pi's own part // is redirected; "all" still sends `update --extensions` (with the other -// flags) to Pi. Packages, models, help, and forms Pi refuses (conflicting or +// flags) to Pi. `--latest` marks Pi's own part as the newest published Pi; +// with any form that is not Pi's own update it is unknown to Pi, so it passes +// through. Packages, models, help, and forms Pi refuses (conflicting or // unknown options) reach Pi unchanged. export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan { const passThrough = { self: false, piArgs: passthrough }; @@ -587,8 +592,10 @@ export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan { let self = false; let extensions = false; let all = false; + let latest = false; for (const token of passthrough.slice(1)) { if (token === "--self") self = true; + else if (token === "--latest") latest = true; else if (token === "--extensions") extensions = true; else if (token === "--all") all = true; else if (["--approve", "-a", "--no-approve", "-na", "--force"].includes(token)) flags.push(token); @@ -600,19 +607,21 @@ export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan { if ((positional[0] !== "self" && positional[0] !== "pi") || self) return passThrough; self = true; } - if (all || (self && extensions)) return { self: true, piArgs: ["update", "--extensions", ...flags] }; + const marked = latest ? { latest: true } : {}; + if (all || (self && extensions)) return { self: true, ...marked, piArgs: ["update", "--extensions", ...flags] }; if (extensions) return passThrough; - return { self: true }; + return { self: true, ...marked }; } -// What `gentle-shell update` does after the redirected upgrade (`upgradeCode` -// is undefined when it did not run): with nothing left for Pi it exits with the -// upgrade's code; otherwise Pi still runs, so `update --all` updates the -// extensions even when the upgrade could not complete. +// What `gentle-shell update` does after the redirected upgrade or the +// `--latest` Pi update (`upgradeCode` is undefined when it did not run): with +// nothing left for Pi it exits with that code; otherwise Pi still runs, so +// `update --all` updates the extensions even when that step could not complete. export function bundledUpdateNext(plan: BundledUpdatePlan, upgradeCode: number | undefined): { exitCode: number } | { piArgs: string[]; note?: string } { if (plan.piArgs === undefined) return { exitCode: upgradeCode ?? 0 }; if (plan.self && upgradeCode !== undefined && upgradeCode !== 0) { - return { piArgs: plan.piArgs, note: "gentle-shell: the Gentle Shell upgrade did not complete; updating the extensions anyway." }; + const step = plan.latest ? "latest Pi update" : "Gentle Shell upgrade"; + return { piArgs: plan.piArgs, note: `gentle-shell: the ${step} did not complete; updating the extensions anyway.` }; } return { piArgs: plan.piArgs }; } @@ -623,6 +632,7 @@ export function bundledUpdateNotice(input: { piVersion: string; interactive: boo input.interactive ? "Running 'gentle-shell upgrade'..." : "Run 'gentle-shell upgrade' to install the latest Gentle Shell release and its Pi.", + "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.", ]; } diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index ec9331e31..472b6c030 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1351,6 +1351,8 @@ export function helpText(): string { " gentle-shell update [target] Run pi's own 'update' against the resolved home.", " In a bundled install Pi ships pinned: 'update' without a package", " runs 'gentle-shell upgrade' instead of updating Pi.", + " 'update --latest' installs the newest published Pi instead,", + " without the release's verified lockfile.", " gentle-shell config Run pi's own 'config' against the resolved home.", " gentle-shell auth Run pi's own 'auth' against the resolved home.", " gentle-shell mcp Run pi's own 'mcp' (add, list, login, logout) against the", diff --git a/odd/tasks/bundled-pi-latest.md b/odd/tasks/bundled-pi-latest.md new file mode 100644 index 000000000..84953bed5 --- /dev/null +++ b/odd/tasks/bundled-pi-latest.md @@ -0,0 +1,31 @@ +# Bundled Pi latest update +Objective: let bundled Gentle Shell users reach the latest Pi: bump the audited pin to Pi 1.4.0 and add an explicit opt-in latest-Pi update. Branch: fix/bundled-pi-latest (worktree .worktrees/bundled-pi-latest). +Delivery: single-pr; commit, push, merge and release authorized from main (includes unreleased main commits). Runner: `pnpm test` (node --test). + +## Specs +S1. User: "bug ... se le tiene que permitir a la persona actualizar pi a la ultima version". Observed: bundled `gentle-shell update` prints "Pi ships pinned with Gentle Shell (Pi 1.2.0): a newer Pi arrives with a Gentle Shell release." then "Gentle Shell 4.4.0 with Pi 1.2.0 is already current." while npm latest Pi is 1.4.0. +S2. User chose "Ambas": "Subir el pin a Pi 1.4.0 y sacar release": audit and adapt private Pi contracts (Vim, etc.) against Pi 1.4.0, bump the pin, release. `gentle-shell update` keeps its verified, frozen behavior. +S3. User chose "Ambas": "Opción explícita para el último Pi": something like `gentle-shell update pi --latest` installs the newest Pi without a verified lockfile, with a warning. +S4. User: "hagamos las dos, commit push merge y saca release" and chose "Release desde main con todo" (the release includes the unreleased main commits). + +S5. User: "y esto necesitamos meter tambien gracais en el paquete autocontenido estaria bien si incluyeras tambien lo necesario para que la opcion de musica funcione a la primera sin desacrgar complementos , ya que me indica esto" (screenshot warning: "/gentle:music needs gentle-stream, and neither gentle-stream nor uv is on PATH. Install uv (https://docs.astral.sh/uv/getting-started/installation/) and run /gentle:music again, or install gentle-stream with: uv tool install 'gentle-stream[samples] @ git+https://github.com/Gentleman-Programming/gentle-stream'"). + S5 refined by user: "es que mas facil, que ya venga con gentle-shell, no un paquete externo", "que venga dentro", "si se puede porque es una extension", then: "que se ofrezca para el que quiera, y se lo instalamos asi no lo tienen que instalar manual, PERO, que utilice lo que ya tiene gentle shell de paquetes, si necesita uno nuevo se lo agrega dentro de gentle shell". Assumption: the offer applies to bundled installs (Gentle Shell-owned runtime); pnpm/npm installs keep the current hint. +S6. User: "hay dos bugs, 1- yo dejaria solo el comando gentle:profiles y por otro lado al cambiar entre tabs de los perfiles parpadea". Chosen: "Los arreglo yo en esta release": remove /gentle:models (move its export/restore subcommands to /gentle:profiles) and fix the flicker when switching profile tabs (unreleased #2588 screen). + +## Tasks +T1 | S2 | inline: audit Pi 1.3.0/1.4.0 private surfaces (delegated read-only explorer for coding-agent/tui.js), extend audited versions, bump PI_INSTALL_VERSION, docs, lockfile 1.4.0 | done | focused 587/587 pass | +T2 | S1,S3 | delegated (context backstop at ~150k parent tokens): bundled `update --latest` opt-in path with warning, tests, help, docs; task mvrb4jp3-2-jw7v | done (verify pending: Risk item 5 installer) | writer RED/GREEN; focused 587/587 pass | +T4 | S5 | delegated after T2 (shared scripts/bundled-install.mjs; context backstop): /gentle:music offers (consent) to install the player: pinned sha256-verified uv added to the bundled runtime like engram/go, uv-managed Python 3.13 and gentle-stream inside the prefix, from a GitHub archive URL (no git) | pending | +T5 | S6 | delegated (parallel unit, disjoint surfaces, context backstop): remove /gentle:models, move subcommands, fix tab flicker | pending | +T3 | S4 | inline: full suite, commit, push, PR, merge, release per gentle-pi-release | pending | + +## Log +L1 (2026-10-28) User: "bug [Image#1] se le tiene que permitir a la persona actualizar pi a la ultima version" +L2 User choice: "Ambas" (bump pin to 1.4.0 + release, and explicit latest-Pi option). +L3 User: "hagamos las dos, commit push merge y saca release SOLO con esto please, como hotfix". publish.yml requires tag == main head; main holds unreleased commits after v4.4.0. User then chose "Release desde main con todo". +L4 Evidence: pi-tui dist/components/editor.js, undo-stack.js, keys.js are byte-identical in 1.2.0, 1.3.0 and 1.4.0. Changed tui files 1.2.0→1.4.0: autocomplete.js, components/markdown.js, terminal-image.js, terminal.js, tui-alt-screen.js, tui.js; coding-agent ~180 files. +L5 Design for S3: `gentle-shell update [pi|self|--self|--all] --latest` resolves newest stable Pi via bundled pnpm view, installs `-` unverified (pnpm install without --frozen-lockfile), same launcher check/activation/rollback; plain `upgrade` never downgrades a newer --latest Pi for the same Shell version; Vim gate stays exact-allowlist (unaudited Pi turns it off). +L6 S5 added mid-turn. gentle-stream is not on PyPI (git spec, needs git for uv); plan: pinned uv artifact like engram/go (scripts/installer-downloads.mjs artifactFor), bundled PATH entry, best-effort `uv tool install` from a GitHub archive URL into the prefix so /gentle:music works first time. +L7 User rejected install-time provisioning in favor of an opt-in offer from /gentle:music that reuses the bundled runtime and adds only uv inside Gentle Shell. +L8 T4 pins gathered (GitHub asset digests, re-hash on download before pinning): uv 0.13.6 — darwin-arm64 uv-aarch64-apple-darwin.tar.gz 60fbd5c8a1ed681845eea28d9d622fcde03c51e91a43465b64a1e17db1ed3472 14235670; darwin-x64 uv-x86_64-apple-darwin.tar.gz 63ba17fdda55ad1a230d4b37fc6017ffa10c23688c83818bd3bfdf914c652a02 17004934; linux-x64 uv-x86_64-unknown-linux-gnu.tar.gz f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 16799024; linux-arm64 uv-aarch64-unknown-linux-gnu.tar.gz 229e194255c7958d86d56e8790d969a6f74736592133f0dfd0076f754c642c1d 15794886; win32-x64 uv-x86_64-pc-windows-msvc.zip a2a8820d5eea32f08ef467ef3b7f66883a9b076d5737a2fc2fbb5f458d1bb50f 15239500; win32-arm64 uv-aarch64-pc-windows-msvc.zip c2426c0c8e28b53d8c440e47a18d96ad4db51e159866c0e81435b196d7f0081c 15439263. gentle-stream archive pin: https://github.com/Gentleman-Programming/gentle-stream/archive/381290451544ad2ca3348a68a81b3056bdac73b7.tar.gz +L9 decode2 (author of #2588) has no open work on the screen: last branch commit 2026-10-27 20:05, before the merge; no issue for the two bugs. diff --git a/runtime/gentle-shell-bundled.mjs b/runtime/gentle-shell-bundled.mjs index 890db2387..cf4269146 100644 --- a/runtime/gentle-shell-bundled.mjs +++ b/runtime/gentle-shell-bundled.mjs @@ -574,12 +574,17 @@ export function withoutMissingBundledNpmCommand(settingsText + + + // `gentle-shell update [target]` in a bundled install, read the way Pi's // package-manager-cli.js reads it: the target is Pi itself for a bare // `update`, `--self`, or a `self`/`pi` positional, and "all" for `--all`, // `--self --extensions` or `self`/`pi` with `--extensions`. Only Pi's own part // is redirected; "all" still sends `update --extensions` (with the other -// flags) to Pi. Packages, models, help, and forms Pi refuses (conflicting or +// flags) to Pi. `--latest` marks Pi's own part as the newest published Pi; +// with any form that is not Pi's own update it is unknown to Pi, so it passes +// through. Packages, models, help, and forms Pi refuses (conflicting or // unknown options) reach Pi unchanged. export function bundledUpdatePlan(passthrough ) { const passThrough = { self: false, piArgs: passthrough }; @@ -588,8 +593,10 @@ export function bundledUpdatePlan(passthrough ) { let self = false; let extensions = false; let all = false; + let latest = false; for (const token of passthrough.slice(1)) { if (token === "--self") self = true; + else if (token === "--latest") latest = true; else if (token === "--extensions") extensions = true; else if (token === "--all") all = true; else if (["--approve", "-a", "--no-approve", "-na", "--force"].includes(token)) flags.push(token); @@ -601,19 +608,21 @@ export function bundledUpdatePlan(passthrough ) { if ((positional[0] !== "self" && positional[0] !== "pi") || self) return passThrough; self = true; } - if (all || (self && extensions)) return { self: true, piArgs: ["update", "--extensions", ...flags] }; + const marked = latest ? { latest: true } : {}; + if (all || (self && extensions)) return { self: true, ...marked, piArgs: ["update", "--extensions", ...flags] }; if (extensions) return passThrough; - return { self: true }; + return { self: true, ...marked }; } -// What `gentle-shell update` does after the redirected upgrade (`upgradeCode` -// is undefined when it did not run): with nothing left for Pi it exits with the -// upgrade's code; otherwise Pi still runs, so `update --all` updates the -// extensions even when the upgrade could not complete. +// What `gentle-shell update` does after the redirected upgrade or the +// `--latest` Pi update (`upgradeCode` is undefined when it did not run): with +// nothing left for Pi it exits with that code; otherwise Pi still runs, so +// `update --all` updates the extensions even when that step could not complete. export function bundledUpdateNext(plan , upgradeCode ) { if (plan.piArgs === undefined) return { exitCode: upgradeCode ?? 0 }; if (plan.self && upgradeCode !== undefined && upgradeCode !== 0) { - return { piArgs: plan.piArgs, note: "gentle-shell: the Gentle Shell upgrade did not complete; updating the extensions anyway." }; + const step = plan.latest ? "latest Pi update" : "Gentle Shell upgrade"; + return { piArgs: plan.piArgs, note: `gentle-shell: the ${step} did not complete; updating the extensions anyway.` }; } return { piArgs: plan.piArgs }; } @@ -624,6 +633,7 @@ export function bundledUpdateNotice(input input.interactive ? "Running 'gentle-shell upgrade'..." : "Run 'gentle-shell upgrade' to install the latest Gentle Shell release and its Pi.", + "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.", ]; } diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index 268153dc5..f6a75575b 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -1352,6 +1352,8 @@ export function helpText() { " gentle-shell update [target] Run pi's own 'update' against the resolved home.", " In a bundled install Pi ships pinned: 'update' without a package", " runs 'gentle-shell upgrade' instead of updating Pi.", + " 'update --latest' installs the newest published Pi instead,", + " without the release's verified lockfile.", " gentle-shell config Run pi's own 'config' against the resolved home.", " gentle-shell auth Run pi's own 'auth' against the resolved home.", " gentle-shell mcp Run pi's own 'mcp' (add, list, login, logout) against the", diff --git a/scripts/bundled-install.mjs b/scripts/bundled-install.mjs index 5e4ff912a..a7b80c695 100644 --- a/scripts/bundled-install.mjs +++ b/scripts/bundled-install.mjs @@ -23,6 +23,8 @@ const LAUNCHER_MARK = "gentle-shell bundled launcher (generated; do not edit)"; const LAUNCHER_MISSING = "gentle-shell: no active Gentle Shell version; run the Gentle Shell installer again."; const PROFILE_MARK = "# gentle-shell bundled install"; const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const PI_PACKAGE = "@earendil-works/pi-coding-agent"; const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/; const MAX_EXPANDED = 512 * 1024 * 1024; const INSTALL_TIMEOUT = 15 * 60 * 1000; @@ -670,11 +672,15 @@ function pinnedGo(layout, go) { * not allowed. An installed version is reused; a folder left by an interrupted * install of this id (its process gone) is removed and installed again; any * other folder is never replaced. On Windows the pinned Go is required, so - * gentle-pi's postinstall never builds with the user's go.exe. Adapters (run, - * and distribution for local fixture packages in place of the registry) are + * gentle-pi's postinstall never builds with the user's go.exe. `unverified` + * (`gentle-shell update --latest` only) installs without a release lockfile: + * no pnpm-lock.yaml is written and pnpm resolves the exact versions itself + * (`--no-frozen-lockfile`, so a CI environment cannot turn it frozen); the + * installed versions are still checked exactly. Adapters (run, and + * distribution for local fixture packages in place of the registry) are * trusted local test code only. */ -export async function installVersion({ layout, manifest, lockfile, platform = layout.platform, env = {}, go = null, adapters = {} }) { +export async function installVersion({ layout, manifest, lockfile, unverified = false, platform = layout.platform, env = {}, go = null, adapters = {} }) { const files = (adapters.distribution ?? distributionFiles)(manifest); const { shell, pi, id } = versionsOf(manifest); const destination = versionDirectory(layout, id); @@ -682,7 +688,7 @@ export async function installVersion({ layout, manifest, lockfile, platform = la const marker = path.join(destination, VERSION_MARKER); if (installedVersion(layout, id)) return { id, path: destination, installed: false }; if (platform === "win32" && !pinnedGo(layout, go)) throw new Error("A Windows version install needs the pinned Go from ensureRuntime({ go: true })"); - if (typeof lockfile !== "string" || lockfile.length === 0) throw new Error("Release lockfile missing"); + if (!unverified && (typeof lockfile !== "string" || lockfile.length === 0)) throw new Error("Release lockfile missing"); const existing = stat(destination); if (existing) { const pending = existing.isDirectory() ? /^installing (\S+) ([1-9]\d*)\n$/.exec(read(marker) ?? "") : null; @@ -695,8 +701,8 @@ export async function installVersion({ layout, manifest, lockfile, platform = la mkdirSync(destination, { mode: 0o700 }); try { writeFileSync(marker, `installing ${id} ${process.pid}\n`, { flag: "wx", mode: 0o600 }); - for (const [name, text] of Object.entries({ ...files, "pnpm-lock.yaml": lockfile })) writeFileSync(path.join(destination, name), text, { flag: "wx", mode: 0o600 }); - const result = await (adapters.run ?? runCommand)(layout.node, [layout.pnpm, "install", "--frozen-lockfile"], + for (const [name, text] of Object.entries(unverified ? files : { ...files, "pnpm-lock.yaml": lockfile })) writeFileSync(path.join(destination, name), text, { flag: "wx", mode: 0o600 }); + const result = await (adapters.run ?? runCommand)(layout.node, [layout.pnpm, "install", unverified ? "--no-frozen-lockfile" : "--frozen-lockfile"], { cwd: destination, env: pnpmEnvironment(layout, { platform, env, go }), timeout: INSTALL_TIMEOUT }); // pnpm prints install and postinstall failures on stdout: keep both tails. if (result.status !== 0) { @@ -704,7 +710,7 @@ export async function installVersion({ layout, manifest, lockfile, platform = la { stderr: [String(result.stdout ?? "").slice(-6000), String(result.stderr ?? "").slice(-4000)].filter(Boolean).join("\n") }); } const shellDirectory = installedPackage(destination, "gentle-pi", shell, "gentle-pi"); - installedPackage(destination, "@earendil-works/pi-coding-agent", pi, "Pi"); + installedPackage(destination, PI_PACKAGE, pi, "Pi"); if (!regular(path.join(shellDirectory, "bin", "gentle-shell.mjs"))) throw new Error("Installed gentle-pi has no launcher entry"); replaceAtomically(marker, (temp) => writeFileSync(temp, `${id}\n`, { flag: "wx", mode: 0o600 })); return { id, path: destination, installed: true }; @@ -882,11 +888,12 @@ function activateWithLauncher(layout, id, active, { module, next }) { * print exactly the release's gentle-pi and Pi before `current` switches; the * release's launcher is then written (a failure puts `current` back) and two * versions are kept (a pruning failure is only a warning). An already current - * release changes nothing; any failure leaves `current` as it was and removes a - * version this call installed. Returns { id, changed, previous?, removed?, warning? }. + * release changes nothing, and so does a release of the active Gentle Shell + * whose Pi is older than the one `gentle-shell update --latest` installed (no + * downgrade); any failure leaves `current` as it was and removes a version this + * call installed. Returns { id, changed, previous?, removed?, warning? }. */ export async function upgradeBundled({ layout, home, platform = layout.platform, arch = process.arch, env = {}, adapters, out = () => {} }) { - const path = pathFor(process.platform); const latest = await adapters.latest(); const release = await adapters.distribution(latest); if (release?.status !== "published") throw new Error((UNPUBLISHED[release?.status] ?? UNPUBLISHED.unavailable)(latest)); @@ -897,9 +904,40 @@ export async function upgradeBundled({ layout, home, platform = layout.platform, out(`Gentle Shell ${shell} with Pi ${pi} is already current.`); return { id, changed: false }; } + const activePi = piOfVersion(active, shell); + if (activePi && newerVersion(activePi, pi)) { + out(`Gentle Shell ${shell} is current; Pi ${activePi} (installed with 'gentle-shell update --latest') is newer than this release's verified Pi ${pi}.`); + return { id: active, changed: false }; + } + return installAndActivate({ layout, home, platform, arch, env, adapters, out, manifest, lockfile, active, + success: `Upgraded to Gentle Shell ${shell} with Pi ${pi} (from ${active ?? "no active version"}). Run 'gentle-shell upgrade --rollback' to switch back.` }); +} + +/** The Pi of version id `-` for exactly this `shell`, or null. */ +function piOfVersion(id, shell) { + const pi = typeof id === "string" && id.startsWith(`${shell}-`) ? id.slice(shell.length + 1) : ""; + return SEMVER.test(pi) ? pi : null; +} +/** Whether version `a` is newer than `b` by major, minor and patch. */ +function newerVersion(a, b) { + const [left, right] = [a, b].map((version) => SEMVER.exec(version).slice(1, 4).map(Number)); + const index = left.findIndex((part, at) => part !== right[at]); + return index !== -1 && left[index] > right[index]; +} + +/** Installs `manifest` beside the active version (with the release lockfile, + * or `unverified` without one) and switches to it the way upgradeBundled + * always has: our runtime first, then the new version's own module and pinned + * runtime, its launcher checked for exactly `shell` and `pi`, `current` + * switched with that launcher, `success` printed and two versions kept (a + * pruning failure is only a warning). Any failure leaves `current` as it was + * and removes a version this call installed. + */ +async function installAndActivate({ layout, home, platform, arch, env, adapters, out, manifest, lockfile, unverified = false, active, success }) { + const { shell, pi, id } = versionsOf(manifest); const go = platform === "win32"; const runtime = await (adapters.ensureRuntime ?? ensureRuntime)({ layout, platform, arch, go, env }); - const version = await installVersion({ layout, manifest, lockfile, platform, env, go: runtime.go, adapters: adapters.install ?? {} }); + const version = await installVersion({ layout, manifest, lockfile, unverified, platform, env, go: runtime.go, adapters: adapters.install ?? {} }); try { const loaded = await loadRelease({ layout, id, home, platform, env, adapters, active }); // The release's own pins, its Engram included (an older module ignores `engram`). @@ -914,7 +952,7 @@ export async function upgradeBundled({ layout, home, platform = layout.platform, if (version.installed && activeVersion(layout) !== id) rmSync(version.path, { recursive: true, force: true }); throw error; } - out(`Upgraded to Gentle Shell ${shell} with Pi ${pi} (from ${active ?? "no active version"}). Run 'gentle-shell upgrade --rollback' to switch back.`); + out(success); try { return { id, changed: true, previous: active, removed: (adapters.prune ?? pruneVersions)(layout, 2) }; } catch (error) { @@ -924,6 +962,45 @@ export async function upgradeBundled({ layout, home, platform = layout.platform, } } +/** The newest published stable Pi: our pnpm's `view` of Pi's `latest` + * dist-tag, with the prefix npmrc (its registry and credentials). Only an + * exact major.minor.patch is accepted. + */ +export async function publishedPi(layout, { platform = layout.platform, env = {}, run = runCommand } = {}) { + const result = await (run ?? runCommand)(layout.node, [layout.pnpm, "view", `${PI_PACKAGE}@latest`, "version"], + { cwd: layout.root, env: pnpmEnvironment(layout, { platform, env }), timeout: 60000 }); + if (result.status !== 0) { + throw Object.assign(new Error(`the latest published Pi could not be resolved (pnpm view exit ${result.status})`), { stderr: String(result.stderr ?? "").slice(-4000) }); + } + const version = String(result.stdout ?? "").trim(); + if (!STABLE.test(version)) throw new Error(`the latest published Pi version ${JSON.stringify(version)} was rejected`); + return version; +} + +/** `gentle-shell update --latest` in a bundled install (S3): the newest + * published stable Pi (adapters.latestPi) installed with this Gentle Shell + * `shell` as version `-`, without the release's verified lockfile + * (installVersion `unverified`), after a warning; then verified and activated + * exactly like upgradeBundled (installAndActivate). An active version that + * already has that Pi changes nothing. Returns what upgradeBundled returns. + */ +export async function upgradePiLatest({ layout, home, shell, platform = layout.platform, arch = process.arch, env = {}, adapters, out = () => {} }) { + const latest = await adapters.latestPi(); + if (!STABLE.test(String(latest))) throw new Error(`the latest published Pi version ${JSON.stringify(latest)} was rejected`); + const manifest = { shell, pi: latest }; + const { pi, id } = versionsOf(manifest); + const active = activeVersion(layout); + if (active === id) { + out(`Pi ${pi} is already the latest published Pi.`); + return { id, changed: false }; + } + const activePi = piOfVersion(active, shell); + if (!activePi) throw new Error(`the active version ${active ?? "(none)"} is not Gentle Shell ${shell}; current stays as it is`); + out(`warning: Pi ${pi} is newer than the Pi verified for this Gentle Shell release (${activePi}); it installs without the release's verified lockfile, and Gentle Shell features that rely on audited Pi internals (such as the Vim editor) may turn off until a Gentle Shell release verifies it.`); + return installAndActivate({ layout, home, platform, arch, env, adapters, out, manifest, unverified: true, active, + success: `Installed Pi ${pi} with Gentle Shell ${shell} (from ${active}). Run 'gentle-shell upgrade --rollback' to switch back.` }); +} + /** Whether version `id` reports exactly `gentle-shell ` and `pi ` to * `--version`: its entry run by our Node, or with `launcher` on POSIX the * bin/gentle-shell launcher itself (it runs the version `current` names). diff --git a/scripts/pi-update.mjs b/scripts/pi-update.mjs index 58a3a5d95..6fe4e14d8 100644 --- a/scripts/pi-update.mjs +++ b/scripts/pi-update.mjs @@ -21,7 +21,9 @@ const SHIM_TARGET = /"((?:\$basedir[\\/]|%~?dp0%?[\\/])?[^"]*?[\\/]dist[\\/]bund export function managedUpdatePlan(args) { // The legacy extension-only alias must never trigger self-update. if (args.includes('--extensions-only')) return {self:false, piArgs:args}; - return bundledUpdatePlan(args); + // Its self update already installs the latest Pi, so --latest changes nothing here. + const {latest: _latest, ...plan} = bundledUpdatePlan(args); + return plan; } function pathsFor(platform) { diff --git a/tests/bundled-install.test.ts b/tests/bundled-install.test.ts index 8993aaba7..88263c917 100644 --- a/tests/bundled-install.test.ts +++ b/tests/bundled-install.test.ts @@ -8,7 +8,7 @@ import test, { after, type TestContext } from "node:test"; import { crc32, gzipSync } from "node:zlib"; import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, filterNpmrc, installVersion, nodeExecutable, nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, bundledNpmCommand, ensureNpmShim, upgradeBundled, userNpmrc, writeNpmrcAuth, - describePathReceipt, readPathReceipt, scheduleWindowsRemoval, writePathReceipt } + publishedPi, upgradePiLatest, describePathReceipt, readPathReceipt, scheduleWindowsRemoval, writePathReceipt } from "../scripts/bundled-install.mjs"; // Real directories, modes, symlinks and uids need a POSIX host. @@ -1175,6 +1175,104 @@ posixTest("upgradeBundled reports success with a warning when only pruning fails assert.match(lines.join("\n"), /Upgraded to Gentle Shell 4\.2\.0.*warning: older versions were not removed \(EBUSY\)/s); }); +posixTest("upgradeBundled never downgrades a newer Pi installed with update --latest, and a newer Gentle Shell release still upgrades", async () => { + const { user, layout } = await activeInstall(); + await installed(layout, [["4.1.0", "1.0.5"]]); + activateVersion(layout, "4.1.0-1.0.5"); + const same = upgradeAdapters(layout, { release: { shell: "4.1.0", pi: "1.0.1" } }); + const lines: string[] = []; + const result = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: same.adapters, out: (line: string) => lines.push(line) }); + assert.deepEqual(result, { id: "4.1.0-1.0.5", changed: false }); + assert.deepEqual(lines, ["Gentle Shell 4.1.0 is current; Pi 1.0.5 (installed with 'gentle-shell update --latest') is newer than this release's verified Pi 1.0.1."]); + assert.deepEqual([same.calls.ensureRuntime.length, same.calls.verify.length, same.calls.loaded.length], [0, 0, 0]); + assert.equal(activeVersion(layout), "4.1.0-1.0.5"); + const newer = upgradeAdapters(layout); + const upgraded = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: newer.adapters }); + assert.equal(upgraded.id, "4.2.0-1.0.2"); + assert.equal(activeVersion(layout), "4.2.0-1.0.2"); +}); + +function latestAdapters(layout: ReturnType, { latestPi = "1.0.5", reported = { shell: "4.1.0", pi: latestPi } } = {}) { + const upgrade = upgradeAdapters(layout, { release: { shell: "4.1.0", pi: latestPi }, reported }); + const pnpm = fakePnpm({ shell: "4.1.0", pi: latestPi }); + const { latest: _latest, distribution: _distribution, ...rest } = upgrade.adapters; + return { calls: upgrade.calls, pnpm, adapters: { ...rest, install: { run: pnpm.run }, latestPi: async () => latestPi } }; +} + +posixTest("upgradePiLatest changes nothing when the active version already has the latest published Pi", async () => { + const { user, layout } = await activeInstall(); + const { adapters, calls, pnpm } = latestAdapters(layout, { latestPi: "1.0.1" }); + const lines: string[] = []; + const result = await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) }); + assert.deepEqual(result, { id: "4.1.0-1.0.1", changed: false }); + assert.deepEqual(lines, ["Pi 1.0.1 is already the latest published Pi."]); + assert.deepEqual([pnpm.runs.length, calls.ensureRuntime.length, calls.verify.length], [0, 0, 0]); + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); +}); + +posixTest("upgradePiLatest warns, installs the newest Pi without a lockfile beside the active version, verifies and switches current", async () => { + const { user, layout } = await activeInstall(); + const { adapters, calls, pnpm } = latestAdapters(layout); + const lines: string[] = []; + adapters.install.run = (command: string, args: string[], options: { cwd: string; env: Record }) => { + lines.push(""); + return fakePnpm({ shell: "4.1.0", pi: "1.0.5" }).run(command, args, options); + }; + const result = await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) }); + assert.deepEqual(result, { id: "4.1.0-1.0.5", changed: true, previous: "4.1.0-1.0.1", removed: ["4.0.0-1.0.0"] }); + assert.equal(activeVersion(layout), "4.1.0-1.0.5"); + assert.deepEqual(lines, [ + "warning: Pi 1.0.5 is newer than the Pi verified for this Gentle Shell release (1.0.1); it installs without the release's verified lockfile, and Gentle Shell features that rely on audited Pi internals (such as the Vim editor) may turn off until a Gentle Shell release verifies it.", + "", + "Installed Pi 1.0.5 with Gentle Shell 4.1.0 (from 4.1.0-1.0.1). Run 'gentle-shell upgrade --rollback' to switch back.", + ]); + const folder = join(layout.versions, "4.1.0-1.0.5"); + assert.equal(existsSync(join(folder, "pnpm-lock.yaml")), false); + for (const [name, text] of Object.entries(distributionFiles({ shell: "4.1.0", pi: "1.0.5" }))) assert.equal(readFileSync(join(folder, name), "utf8"), text, name); + assert.deepEqual(calls.loaded, [releaseModuleFile(layout, "4.1.0-1.0.5")]); + assert.equal(calls.verify[0].env.GENTLE_SHELL_VERIFY_VERSION, "4.1.0-1.0.5"); + assert.equal(pnpm.runs.length, 0); +}); + +posixTest("upgradePiLatest installs with plain pnpm install, never the frozen lockfile", async () => { + const { user, layout } = await activeInstall(); + const { adapters, pnpm } = latestAdapters(layout); + await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters }); + assert.equal(pnpm.runs.length, 1); + assert.equal(pnpm.runs[0].command, layout.node); + assert.equal(pnpm.runs[0].args[0], layout.pnpm); + assert.equal(pnpm.runs[0].args[1], "install"); + assert.equal(pnpm.runs[0].args.includes("--frozen-lockfile"), false); + assert.equal(pnpm.runs[0].env.pnpm_config_store_dir, layout.store); +}); + +posixTest("upgradePiLatest leaves current untouched and removes the new folder when verification fails, and refuses a prerelease", async () => { + const { user, layout } = await activeInstall(); + const wrong = latestAdapters(layout, { reported: { shell: "4.1.0", pi: "1.0.1" } }); + await assert.rejects(upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters: wrong.adapters }), /reported.*current stays at 4\.1\.0-1\.0\.1/s); + assert.equal(activeVersion(layout), "4.1.0-1.0.1"); + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); + const beta = latestAdapters(layout, { latestPi: "1.1.0-beta.1" }); + await assert.rejects(upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters: beta.adapters }), /latest published Pi.*rejected/); + assert.equal(beta.pnpm.runs.length, 0); + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); +}); + +posixTest("publishedPi asks our pnpm for Pi's latest dist-tag with the prefix npmrc and accepts only an exact stable version", async () => { + const { layout } = claimed(); + const runs: { command: string; args: string[]; env: Record }[] = []; + const run = (stdout: string, status = 0) => async (command: string, args: string[], options: { env: Record }) => { + runs.push({ command, args, env: options.env }); + return { status, stdout, stderr: "" }; + }; + assert.equal(await publishedPi(layout, { platform: "darwin", env: {}, run: run("1.4.0\n") }), "1.4.0"); + assert.equal(runs[0].command, layout.node); + assert.deepEqual(runs[0].args, [layout.pnpm, "view", "@earendil-works/pi-coding-agent@latest", "version"]); + assert.equal(runs[0].env.pnpm_config_npmrc_auth_file, layout.npmrc); + for (const stdout of ["1.4.0-rc.1\n", "", "v1.4.0\n", "1.4\n"]) await assert.rejects(publishedPi(layout, { platform: "darwin", env: {}, run: run(stdout) }), /rejected/, stdout); + await assert.rejects(publishedPi(layout, { platform: "darwin", env: {}, run: run("", 1) }), /exit 1/); +}); + function rollbackAdapters(module: Record = {}) { const calls: { loaded: string[]; runtimes: unknown[]; engram: unknown[]; launchers: unknown[] } = { loaded: [], runtimes: [], engram: [], launchers: [] }; const releaseModule = { prefixLayout, ensureRuntime: async (options: { layout: unknown; engram?: boolean }) => { diff --git a/tests/gentle-shell-bundled.test.ts b/tests/gentle-shell-bundled.test.ts index 82a91906c..a84d44812 100644 --- a/tests/gentle-shell-bundled.test.ts +++ b/tests/gentle-shell-bundled.test.ts @@ -274,6 +274,18 @@ test("bundledUpdatePlan redirects Pi's own update and keeps the extension update } }); +test("bundledUpdatePlan marks Pi's own update with --latest and keeps every other --latest form passing through", () => { + for (const args of [["update", "--latest"], ["update", "pi", "--latest"], ["update", "--latest", "self"], ["update", "--self", "--latest"]]) { + assert.deepEqual(bundledUpdatePlan(args), { self: true, latest: true }, args.join(" ")); + } + assert.deepEqual(bundledUpdatePlan(["update", "--all", "--latest"]), { self: true, latest: true, piArgs: ["update", "--extensions"] }); + assert.deepEqual(bundledUpdatePlan(["update", "--latest", "--self", "--extensions", "-a"]), { self: true, latest: true, piArgs: ["update", "--extensions", "-a"] }); + for (const passthrough of [["update", "--extensions", "--latest"], ["update", "npm:x", "--latest"], ["update", "--models", "--latest"], ["update", "--all", "--self", "--latest"], + ["update", "pi", "npm:x", "--latest"], ["update", "--latest", "--bogus"], ["update", "--help", "--latest"]]) { + assert.deepEqual(bundledUpdatePlan(passthrough), { self: false, piArgs: passthrough }, passthrough.join(" ")); + } +}); + test("bundledUpdateNext still updates extensions when the upgrade did not complete", () => { assert.deepEqual(bundledUpdateNext({ self: false, piArgs: ["update", "npm:x"] }, undefined), { piArgs: ["update", "npm:x"] }); assert.deepEqual(bundledUpdateNext({ self: true }, 0), { exitCode: 0 }); @@ -283,6 +295,9 @@ test("bundledUpdateNext still updates extensions when the upgrade did not comple const failed = bundledUpdateNext({ self: true, piArgs: ["update", "--extensions"] }, 1); assert.deepEqual("piArgs" in failed ? failed.piArgs : [], ["update", "--extensions"]); assert.match("note" in failed ? String(failed.note) : "", /upgrade did not complete.*extensions/); + const latest = bundledUpdateNext({ self: true, latest: true, piArgs: ["update", "--extensions"] }, 1); + assert.match("note" in latest ? String(latest.note) : "", /latest Pi update did not complete.*extensions/); + assert.deepEqual(bundledUpdateNext({ self: true, latest: true }, 1), { exitCode: 1 }); }); test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => { @@ -290,6 +305,11 @@ test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/); assert.match(interactive, /Running 'gentle-shell upgrade'/); assert.match(bundledUpdateNotice({ piVersion: "1.2.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/); + for (const interactive of [true, false]) { + const lines = bundledUpdateNotice({ piVersion: "1.2.0", interactive }); + assert.equal(lines[0], "Pi ships pinned with Gentle Shell (Pi 1.2.0): a newer Pi arrives with a Gentle Shell release."); + assert.equal(lines.at(-1), "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile."); + } }); test("parseBundledUpgradeArgs accepts the release channel and --rollback and refuses the main channel", () => { diff --git a/tests/gentle-shell-launcher.test.ts b/tests/gentle-shell-launcher.test.ts index f538b33cb..4e9511da3 100644 --- a/tests/gentle-shell-launcher.test.ts +++ b/tests/gentle-shell-launcher.test.ts @@ -214,6 +214,7 @@ test("help text documents the bundled upgrade, its rollback and the pinned Pi up assert.ok(text.includes(" gentle-shell upgrade --rollback")); assert.match(text, /bundled install.*release channel only.*--rollback switches back to the previous kept version/s); assert.match(text, /In a bundled install Pi ships pinned: 'update' without a package\n\s+runs 'gentle-shell upgrade'/); + assert.match(text, /'update --latest' installs the newest published Pi instead,\n\s+without the release's verified lockfile\./); }); test("parseLauncherArgs treats home as a plain passthrough token when it is not argv[0]", () => { diff --git a/tests/pi-update.test.ts b/tests/pi-update.test.ts index 2ddb260ed..19085e069 100644 --- a/tests/pi-update.test.ts +++ b/tests/pi-update.test.ts @@ -98,6 +98,11 @@ for (const args of [['update', '--extensions-only'], ['update', '--extensions'], assert.deepEqual(managedUpdatePlan(args), {self: false, piArgs: args}); }); +test('a non-bundled install ignores --latest: its managed self update already installs the latest Pi', () => { + assert.deepEqual(managedUpdatePlan(['update', '--latest']), {self: true}); + assert.deepEqual(managedUpdatePlan(['update', '--all', '--latest']), {self: true, piArgs: ['update', '--extensions']}); +}); + test('all strips only self update before forwarding extension flags', () => { assert.deepEqual(managedUpdatePlan(['update', '--all', '--approve']), {self: true, piArgs: ['update', '--extensions', '--approve']}); }); From 536277e3c3342b38eefd7e98ca3308859384ef70 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 28 Oct 2026 00:41:05 +0100 Subject: [PATCH 3/7] fix(profiles): keep /gentle:profiles as the one models screen and stop the tab flicker /gentle:models opened the same screen as /gentle:profiles, so it is retired: its routing file verbs move to '/gentle:profiles export-routing' and 'restore-routing' (they move the global routing, not a profile), and the command palette lists one 'Models and profiles' entry. Switching profile tabs closed the overlay and reopened it after awaiting the model catalog, painting the screen below for a frame. The panel now switches profiles in place through a switcher callback, saving first when asked. --- README.md | 2 +- docs/readme-reference.md | 16 +- extensions/gentle-ai.ts | 175 ++++++++++++++-------- lib/command-palette-catalog.ts | 3 +- tests/command-palette.test.ts | 6 +- tests/gentle-ai.test.ts | 169 +++++++++++++++------ tests/gentle-shell.test.ts | 18 +-- tests/model-profiles-preservation.test.ts | 4 +- tests/runtime-harness.mjs | 30 ++-- 9 files changed, 273 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 50072444f..6241d73d8 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ The first-party `nan` provider is included; no third-party provider package is r - **Streaming:** Pi streams chat completions through its OpenAI-compatible provider. - **Model discovery:** Model discovery intersects NaN's authenticated `/v1/models` response with a maintained subset of known chat IDs from the [official model documentation](https://nan.builders/docs/models); unknown and non-chat IDs are omitted. A successful response with no known chat IDs stays empty. Documented context, reasoning, and text/image capabilities are preserved with conservative numeric bounds for abbreviated limits; audio input is not advertised by Pi. - **Output caps:** The provider configures `maxTokens` caps rather than claiming undocumented model maxima: 32,768 for GLM 5.3, GLM 5.3 Flash, and MiMo; 16,384 for DeepSeek V4 Flash; 65,536 for Gemma 4 and Qwen 3.6; and 131,000 for Qwen 3.8 Flash (NaN documents 131K). Reasoning shares the output budget. -- **Offline fallback:** Before a successful refresh, all seven documented chat models are available as the offline fallback in `/gentle:models`: `glm5.3`, `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.8-flash`, `mimo-v2.6-flash`, `gemma4`, and `qwen3.6`. This fallback declares documented support, not proof of access for your key. Once refreshed, the successful live key-scoped list remains authoritative (including an empty list), even offline or after a failed refresh. Changing credentials resets the catalog to the full documented fallback until discovery succeeds for the new key. +- **Offline fallback:** Before a successful refresh, all seven documented chat models are available as the offline fallback in `/gentle:profiles`: `glm5.3`, `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.8-flash`, `mimo-v2.6-flash`, `gemma4`, and `qwen3.6`. This fallback declares documented support, not proof of access for your key. Once refreshed, the successful live key-scoped list remains authoritative (including an empty list), even offline or after a failed refresh. Changing credentials resets the catalog to the full documented fallback until discovery succeeds for the new key. - **Not included:** NaN MCP search and media bridges are not included. Thinking levels follow NaN's [reasoning contract](https://nan.builders/docs/models): diff --git a/docs/readme-reference.md b/docs/readme-reference.md index a045c4ad5..761b25fd7 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -659,8 +659,7 @@ Synthetic tests prove the observer's behavior, not the cause of issue #213. A ne ```text /gentle:status Check package assets and global model config. /gentle:doctor Run read-only diagnostics for assets, config, tools, guards, and Herdr setup. -/gentle:models Open the models screen on this repository's profile. Subcommands: export, restore. -/gentle:profiles Open the same models screen. Subcommands: export , import, publish . +/gentle:profiles Open the models screen on this repository's profile. Subcommands: export , import, publish , export-routing, restore-routing. /gentle:persona Switch between gentleman and neutral persona modes. /gentle:background-subagents Show or set the managed background-subagents policy, with its deciding source. /gentle:review-mode Show or set the receipt-driven development mode (status|enable|disable). @@ -1008,10 +1007,10 @@ A project can still override the global default with: ## Model and effort assignment ```text -/gentle:models +/gentle:profiles ``` -`/gentle:models` and `/gentle:profiles` open the same screen: one table, with your profiles as tabs across the top. +`/gentle:profiles` opens the models screen on the profile this repository resolves: one table, with your profiles as tabs across the top. It replaces the retired `/gentle:models`, which opened the same screen. ```text ╭──────────────────────────────────────────────────────────────────────────────╮ @@ -1029,7 +1028,7 @@ A project can still override the global default with: ╰──────────────────────────────────────────────────────────────────────────────╯ ``` -- **Tabs are profiles.** Click a tab or press `←` `→` to show another one. Symbols show where a profile is in effect: `◉` this session, `★` the default for new sessions, `⌂` this repository. Leaving a profile with unsaved changes asks first: **Save and switch**, **Discard**, or **Stay**. +- **Tabs are profiles.** Click a tab or press `←` `→` to show another one; the screen redraws in place. Symbols show where a profile is in effect: `◉` this session, `★` the default for new sessions, `⌂` this repository. Leaving a profile with unsaved changes asks first: **Save and switch**, **Discard**, or **Stay**. - **The table is that profile's routing.** The orchestrator is the first row; agents follow. Click a model or press `enter` to choose from a searchable list (the current one is marked `✓`; **Custom model id…** takes any `provider/model`). Click an effort or press `e` to pick from a single row of levels: only the levels that row's model supports, as its provider's catalog declares them (every level when the row inherits its model or the model is not in the catalog). Pi lands an unsupported level on the nearest supported one at launch, and the table says where: `max → high`. Click `Model ▾` or `Effort ▾`, or move the cursor to that header, to change every agent at once; the orchestrator is never part of that. - **Three buttons decide where it applies.** **Use in this session** (`u`) binds what you see to this Pi session only and switches its orchestrator; nothing shared is written. **Make default** (`d`) makes it the default for new sessions through the guarded global apply (diff confirmation, pin honesty, rollback). **Save** (`s` or `ctrl+s`) writes what you see into the profile. Use and Make default save pending edits first and say so. - **The profile menu**, `▾` on the active tab (`m`), holds **Rename**, **Duplicate**, **Use in this repo** (the clone pin; it reads **Stop using in this repo** when it already applies), and **Delete**. **+ New** (`n`) starts a profile as a copy of the current one, from what is running now, or empty. @@ -1063,7 +1062,7 @@ Global routing is owned by profiles: edit a profile's routing on the models scre Startup and reload materialize saved non-empty routing, but never treat a saved empty agent entry (`{}`) as permission to delete an existing `subagents.json` model profile or agent frontmatter. This also preserves previously materialized routes and routes whose agent definition is missing. To clear routing, make a profile whose entries are inherit the default (or confirm a profile replacement) on the models screen. Activation needs no ownership sidecar or lock. -`/gentle:models export` writes the saved routing to `~/.pi/gentle-ai/models.export.json`, and `/gentle:models restore` restores from that file after confirmation. Export uses a versioned envelope and restore writes the normal `models.json` shape before applying routing to agents. +`/gentle:profiles export-routing` writes the saved routing to `~/.pi/gentle-ai/models.export.json`, and `/gentle:profiles restore-routing` restores from that file after confirmation. They carry `-routing` because they move the global `models.json` routing, not a profile: `/gentle:profiles export ` and `import` move one profile through `profiles.export.json`. Export uses a versioned envelope and restore writes the normal `models.json` shape before applying routing to agents. Config shape (per agent): @@ -1169,7 +1168,7 @@ omitted if no valid profile remains. For a given working directory the winner is the local pin, then the repository declaration, then the globally active profile. A session resolves these layers once, when it starts, and keeps the result. Both are toggles: using one on the profile that already holds that layer removes it (the menu then reads **Stop using in this repo**), and either one outside a Git worktree writes nothing and says so. -In a pinned repository the pinned profile governs subagent launches: the agents it names take its model and effort, and the agents it omits return to inherit (their own definition, then the default model). The default profile does not reach those launches, which `/gentle:models` reports when it runs inside a pinned repository. **Make default** follows the same boundary: when a valid pin wins it re-pins this clone (the clone-local pin) instead of writing the global routing; a malformed pin or one naming a missing profile is ignored, and Make default applies globally as it would outside a pinned repository, so it can never move another repository's routing. A committed repository declaration is never rewritten; when one still names another profile, the clone-local pin takes precedence and the screen says so. **Use in this session** writes no pin or shared routing (only pending table edits, saved to the profile first): it binds the profile to the current session, which outranks the pin for this session's launches. The tab of the pinned profile carries `⌂`. +In a pinned repository the pinned profile governs subagent launches: the agents it names take its model and effort, and the agents it omits return to inherit (their own definition, then the default model). The default profile does not reach those launches, which `/gentle:profiles` reports when it runs inside a pinned repository. **Make default** follows the same boundary: when a valid pin wins it re-pins this clone (the clone-local pin) instead of writing the global routing; a malformed pin or one naming a missing profile is ignored, and Make default applies globally as it would outside a pinned repository, so it can never move another repository's routing. A committed repository declaration is never rewritten; when one still names another profile, the clone-local pin takes precedence and the screen says so. **Use in this session** writes no pin or shared routing (only pending table edits, saved to the profile first): it binds the profile to the current session, which outranks the pin for this session's launches. The tab of the pinned profile carries `⌂`. To share a pin, commit the repository declaration. When `.pi/` is ignored, Git cannot re-include a nested file until its parent directories are visible. The panel therefore prints these ordered root `.gitignore` rules, which keep unrelated `.pi` content ignored while making only the declaration committable: @@ -1225,8 +1224,7 @@ The notice appears once per distinct change: further launches stay quiet until t | -------------------------------- | ------------------------------------------------------------------- | | `/gentle:status` | Shows package assets and global model config status. | | `/gentle:doctor` | Runs read-only diagnostics for assets, model/persona config, memory tools, safety guards, and (inside Herdr) the task-summary sidebar setup. | -| `/gentle:models` | Opens the models screen on this repository's profile. `export` and `restore` move the saved routing through `models.export.json`. | -| `/gentle:profiles` | Opens the same screen. `export `, `import`, and `publish ` handle profile files and the repository declaration. | +| `/gentle:profiles` | Opens the models screen on this repository's profile. `export `, `import`, and `publish ` handle profile files and the repository declaration; `export-routing` and `restore-routing` move the saved global routing through `models.export.json`. | | `/gentle:commands` | Opens the command palette (default `alt+k`): a curated, grouped menu (Configuration, Session, Diagnostics, Skills) of registered Gentle commands; search and run by label. | | `/gentle:persona` | Switches global persona mode, with project override support. | | `/gentle:background-subagents` | Shows or sets the managed background-subagents policy (`status\|enable\|disable`), naming the source that decided it. | diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 070245846..db03092d7 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -3247,6 +3247,22 @@ type RoutingTableResult = RoutingTableAction & { saveFirst?: AgentModelConfig; d type RoutingTableButton = "use" | "default" | "save" | "switch-save" | "switch-discard" | "switch-stay"; +/** + * What the screen needs to show another profile without closing: the store + * after an optional save, fresh scope state, and the footer line. `error` + * means the save failed and the shown profile stays, edits intact. + */ +type RoutingProfileSwitch = + | { file: AgentProfilesFile; sessionBoundName?: string; pinWinner?: string; notices: string[]; feedback: string } + | { error: string }; + +/** + * Switches the shown profile in place. Closing the overlay and reopening it on + * the next tab let the terminal paint the screen beneath for a frame (and wait + * on the async model catalog before the new frame), which is the tab flicker. + */ +type RoutingProfileSwitcher = (name: string, saveFirst?: AgentModelConfig) => RoutingProfileSwitch; + type RoutingTableMouseAction = | { kind: "tab"; name: string } | { kind: "tab-menu" } @@ -3274,7 +3290,7 @@ const ROUTING_TABLE_BUTTONS: Record = { }; /** - * `/gentle:models` and `/gentle:profiles` — one screen, one table, profiles as + * `/gentle:profiles` — one screen, one table, profiles as * tabs. Each tab is a profile and carries where it is in effect (◉ this * session, ★ default for new sessions, ⌂ this repo); the table below is that * profile's routing, orchestrator first; three buttons decide where it applies. @@ -3297,21 +3313,22 @@ class RoutingTablePanel implements OverlayComponent { private hitRegions: RoutingHitRegion[] = []; private activeTabColumn = 0; private newColumn = 0; - private readonly draft: AgentModelConfig; - private readonly stored: AgentModelConfig; + private draft: AgentModelConfig; + private stored: AgentModelConfig; private readonly rows: string[]; private readonly agents: string[]; private readonly catalog: string[]; private readonly done: (result: RoutingTableResult) => void; private readonly theme: Theme | undefined; private readonly terminalRows: (() => number) | undefined; - private readonly profileName: string; - private readonly file: AgentProfilesFile; - private readonly sessionBoundName: string | undefined; - private readonly pinWinner: string | undefined; + private profileName: string; + private file: AgentProfilesFile; + private sessionBoundName: string | undefined; + private pinWinner: string | undefined; private readonly thinkingSupport: Record; /** Configuration problems the screen must not hide: pins that do not resolve, keys no agent matches. */ - private readonly notices: string[]; + private notices: string[]; + private readonly switchProfile: RoutingProfileSwitcher | undefined; constructor( draft: AgentModelConfig, @@ -3328,8 +3345,10 @@ class RoutingTablePanel implements OverlayComponent { feedback?: string; thinkingSupport?: Record; notices?: string[]; + switchProfile?: RoutingProfileSwitcher; } = {}, ) { + this.switchProfile = options.switchProfile; this.thinkingSupport = options.thinkingSupport ?? {}; this.notices = options.notices ?? []; this.file = options.file ?? bootstrapProfilesFile(cloneModelConfig(draft)); @@ -3478,10 +3497,36 @@ class RoutingTablePanel implements OverlayComponent { private finish(result: RoutingTableResult): void { if (this.completed) return; + if (result.type === "load" && this.switchProfile) { + this.showProfile(result.name, result.saveFirst); + return; + } this.completed = true; this.done(result); } + /** Replaces the shown profile on this same screen, as a fresh open on that tab would. */ + private showProfile(name: string, saveFirst?: AgentModelConfig): void { + const next = this.switchProfile!(name, saveFirst); + this.closeOverlay(); + if ("error" in next) { + this.say(next.error); + return; + } + this.file = next.file; + this.profileName = hasOwnProfile(this.file.profiles, name) ? name : (this.profileNames()[0] ?? name); + this.stored = cloneModelConfig(normalizeModelConfig(this.file.profiles[this.profileName]) ?? {}); + this.draft = cloneModelConfig(this.stored); + this.sessionBoundName = next.sessionBoundName; + this.pinWinner = next.pinWinner; + this.notices = next.notices; + this.target = PROFILE_ORCHESTRATOR_KEY; + this.cursor = 0; + this.hovered = undefined; + this.feedback = next.feedback; + this.escArmed = false; + } + private say(message: string): void { this.feedback = message; this.escArmed = false; @@ -4433,6 +4478,7 @@ interface RoutingTablePanelRequest { pinWinner?: string; feedback?: string; notices?: string[]; + switchProfile?: RoutingProfileSwitcher; } /** @@ -4516,6 +4562,7 @@ async function showRoutingTablePanel( feedback: request.feedback, thinkingSupport, notices: request.notices, + switchProfile: request.switchProfile, }, ); const container = createNativeFullscreenInteraction({ @@ -4537,50 +4584,19 @@ async function showRoutingTablePanel( ); } -const MODELS_COMMAND_USAGE = "Usage: /gentle:models [export | restore]"; - /** - * `/gentle:models` opens the models screen on the profile this repository - * resolves. `export` writes the saved global routing to models.export.json and - * `restore` brings it back after confirmation; both are file operations, so - * they live here instead of on the screen. + * Bare `/gentle:profiles` opens the models screen on the profile this + * repository resolves, after the routing preflight: legacy project overrides + * migrate and a pinned repository is named before any edit. An unreadable + * saved routing file does not block the screen; Make default discloses it. */ -async function handleModelsCommand(ctx: ExtensionContext, live: LiveSession, args = ""): Promise { - const verb = args.trim(); - if (verb === "export") { - try { - const count = await exportSavedModelConfig(ctx); - ctx.ui.notify(`el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, "info"); - } catch (error) { - ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning"); - } - return; - } - if (verb === "restore") { - await restoreModelExport(ctx); - return; - } - if (verb !== "") { - ctx.ui.notify(MODELS_COMMAND_USAGE, "warning"); - return; - } +async function openModelsScreen(ctx: ExtensionContext, live: LiveSession): Promise { migrateLegacyProjectModelOverrides(ctx.cwd); // A pinned repository resolves its subagent routing from the profile at launch, // so global routing written elsewhere will not reach its subagents. Saying it // before the edits, not after them, is the difference between a note and a surprise. const pinNote = profilePinScopeNote(ctx.cwd); if (pinNote) ctx.ui.notify(pinNote, "info"); - const savedConfig = await readModelRoutingAuthorityAsync( - modelConfigPath(ctx.cwd), - legacyProjectModelConfigPath(ctx.cwd), - ); - if (savedConfig.status === "invalid") { - ctx.ui.notify( - `el Gentleman cannot open model config because ${savedConfig.path} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`, - "warning", - ); - return; - } const read = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())); const target = read.status === "valid" ? resolveCurrentProfileTarget(ctx.cwd, read) @@ -4590,7 +4606,17 @@ async function handleModelsCommand(ctx: ExtensionContext, live: LiveSession, arg await openProfilesPanelLoop(ctx, live, target?.name); } -/** `/gentle:models restore`: replace the saved global routing with models.export.json, then apply it. */ +/** `/gentle:profiles export-routing`: write the saved global routing to models.export.json. */ +async function exportRoutingCommand(ctx: ExtensionContext): Promise { + try { + const count = await exportSavedModelConfig(ctx); + ctx.ui.notify(`el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, "info"); + } catch (error) { + ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning"); + } +} + +/** `/gentle:profiles restore-routing`: replace the saved global routing with models.export.json, then apply it. */ async function restoreModelExport(ctx: ExtensionContext): Promise { const restored = await readModelExport(ctx); if (!restored) { @@ -4625,7 +4651,7 @@ async function restoreModelExport(ctx: ExtensionContext): Promise { } } -/** The profile `/gentle:models` preselects in the unified panel, and why it is that one. */ +/** The profile bare `/gentle:profiles` preselects in the unified panel, and why it is that one. */ interface CurrentProfileTarget { name: string; source: "pinned" | "active"; @@ -5447,18 +5473,30 @@ async function runProfilesPanelAction( } /** `/gentle:profiles`: seed or open the store, then loop the panel over one action at a time until it closes. */ -const PROFILES_COMMAND_USAGE = "Usage: /gentle:profiles [export | import | publish ]"; +const PROFILES_COMMAND_USAGE = "Usage: /gentle:profiles [export | import | publish | export-routing | restore-routing]"; /** * `/gentle:profiles` opens the models screen. File operations live in * subcommands rather than on the screen: `export ` and `import` move a - * profile through `profiles.export.json`, and `publish ` toggles the - * committed repository declaration. + * profile through `profiles.export.json`, `publish ` toggles the + * committed repository declaration, and `export-routing` / `restore-routing` + * move the saved global routing (models.json, not a profile) through + * `models.export.json`. The routing verbs carry their own names so a profile + * can never be mistaken for them. */ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession, args = ""): Promise { const [verb = "", ...rest] = args.trim().split(/\s+/).filter(Boolean); if (verb === "") { - await openProfilesPanelLoop(ctx, live); + await openModelsScreen(ctx, live); + return; + } + if (verb === "export-routing" || verb === "restore-routing") { + if (rest.length > 0) { + ctx.ui.notify(PROFILES_COMMAND_USAGE, "warning"); + return; + } + if (verb === "export-routing") await exportRoutingCommand(ctx); + else await restoreModelExport(ctx); return; } const store = await loadProfilesStore(ctx); @@ -5519,8 +5557,9 @@ async function loadProfilesStore(ctx: ExtensionContext): Promise<{ path: string; /** * The models screen loop: shows one profile's table, runs what the user chose, - * and reopens with fresh store, binding, and pin state. `/gentle:models` passes - * the profile this repository resolves so it is the tab on screen. Every + * and reopens with fresh store, binding, and pin state; a tab switch stays on + * the open screen instead. Bare `/gentle:profiles` passes the profile this + * repository resolves so it is the tab on screen. Every * outcome is reported on the screen's feedback line, because notifications stay * hidden behind the fullscreen overlay until it closes. */ @@ -5553,6 +5592,23 @@ async function openProfilesPanelLoop( return false; } }; + const switchProfile: RoutingProfileSwitcher = (name, saveFirst) => { + const previous = selectedName!; + if (saveFirst && !writeDraft(previous, saveFirst)) { + const error = feedback ?? `"${previous}" was not saved.`; + feedback = undefined; + return { error }; + } + selectedName = name; + const boundName = sessionBoundName(); + return { + file, + sessionBoundName: boundName, + pinWinner: pinWinner(), + notices: routingScreenNotices(ctx, file, name, boundName), + feedback: `${saveFirst ? `Saved "${previous}". ` : ""}Showing "${name}".`, + }; + }; const differsFromStore = (name: string, config: AgentModelConfig) => JSON.stringify(normalizeModelConfig(config) ?? {}) !== JSON.stringify(normalizeModelConfig(file.profiles[name]) ?? {}); while (true) { @@ -5574,6 +5630,7 @@ async function openProfilesPanelLoop( pinWinner: pinWinner(), feedback, notices: routingScreenNotices(ctx, file, selectedName, boundName), + switchProfile, }); draftOverride = undefined; feedback = undefined; @@ -5616,7 +5673,8 @@ async function openProfilesPanelLoop( continue; case "use-session": { // What you see is what the session uses: edits are saved first. - const config = result.config ?? stored; + // A tab switch may have changed the shown profile since this visit opened. + const config = result.config ?? cloneModelConfig(normalizeModelConfig(file.profiles[previous]) ?? {}); const saved = differsFromStore(previous, config); if (saved && !writeDraft(previous, config)) { draftOverride = config; @@ -10759,7 +10817,7 @@ function createGentleAiExtensionForTesting( const modelResult = await applySavedModelConfig(ctx); if (ctx.hasUI && modelResult.invalidPath) { ctx.ui.notify( - `el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`, + `el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:profiles again.`, "warning", ); return; @@ -11054,15 +11112,8 @@ function createGentleAiExtensionForTesting( }); } - pi.registerCommand("gentle:models", { - description: "Open the models screen on this repository's profile. Subcommands: export, restore.", - handler: async (args, ctx) => { - await handleModelsCommand(ctx, pi, typeof args === "string" ? args : ""); - }, - }); - pi.registerCommand("gentle:profiles", { - description: "Open the models screen (profiles as tabs). Subcommands: export , import, publish .", + description: "Open the models screen (profiles as tabs). Subcommands: export , import, publish , export-routing, restore-routing.", handler: async (args, ctx) => { await handleProfilesCommand(ctx, pi, typeof args === "string" ? args : ""); }, diff --git a/lib/command-palette-catalog.ts b/lib/command-palette-catalog.ts index 1f57b88ad..889d7d799 100644 --- a/lib/command-palette-catalog.ts +++ b/lib/command-palette-catalog.ts @@ -20,8 +20,7 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [ { title: "Configuration", items: [ - { command: "gentle:models", label: "Assign models and effort" }, - { command: "gentle:profiles", label: "Agent-model profiles" }, + { command: "gentle:profiles", label: "Models and profiles" }, { command: "gentle:persona", label: "Switch persona" }, { command: "gentle:review-mode", label: "Review mode (receipt-driven development)" }, { command: "gentle:background-subagents", label: "Background subagents" }, diff --git a/tests/command-palette.test.ts b/tests/command-palette.test.ts index 1e990a9e6..d48a2f1d2 100644 --- a/tests/command-palette.test.ts +++ b/tests/command-palette.test.ts @@ -362,7 +362,6 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () => ); const byTitle = (title: string) => COMMAND_PALETTE_CATALOG.find((group) => group.title === title)?.items.map((item) => item.command); assert.deepEqual(byTitle("Configuration"), [ - "gentle:models", "gentle:profiles", "gentle:persona", "gentle:review-mode", @@ -407,7 +406,7 @@ test("retired SDD commands are absent from the palette and extension registratio test("buildCommandPaletteGroups keeps only registered commands, attaches descriptions and shortcuts, drops empty groups, preserves catalog order", () => { const registered = [ - { name: "gentle:models", description: "Configure models" }, + { name: "gentle:profiles", description: "Configure models" }, { name: "gentle:changes", description: "Browse changes" }, { name: "gentle:status", description: "Show status" }, { name: "skill-registry:refresh", description: "Refresh registry" }, @@ -419,7 +418,8 @@ test("buildCommandPaletteGroups keeps only registered commands, attaches descrip ["Configuration", "Session", "Diagnostics", "Skills"], ); const configuration = groups.find((group) => group.title === "Configuration"); - assert.deepEqual(configuration?.items.map((item) => item.command), ["gentle:models"]); + assert.deepEqual(configuration?.items.map((item) => item.command), ["gentle:profiles"]); + assert.equal(configuration?.items[0]?.label, "Models and profiles"); assert.equal(configuration?.items[0]?.description, "Configure models"); assert.equal(configuration?.items[0]?.shortcut, undefined); const session = groups.find((group) => group.title === "Session"); diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index b0c4b5389..27eb26e7a 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -568,7 +568,7 @@ test("models screen saves and clears provider review roles into the profile stor } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); assert.equal(visits, 2); assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.current, { "review-refuter": { model: "openai/alpha", thinking: "off" }, @@ -596,7 +596,7 @@ test("models screen saves and clears provider review roles into the profile stor panel.handleInput("\x13"); } else panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.current, { "review-refuter": {}, "review-validator": {}, @@ -648,7 +648,7 @@ test("provider review roles skip migration and both projection paths even when d } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); assert.equal(visits, 1); assertReservedUnchanged(); for (const apply of [applyModelConfig, applyModelConfigAsync]) { @@ -662,19 +662,18 @@ test("provider review roles skip migration and both projection paths even when d assert.ok(JSON.parse(readFileSync(profilePaths[0], "utf8")).model_profiles.worker); }); -test("models rejects invalid project routing with its selected source path", async (t) => { +test("an invalid saved routing file does not block the models screen", async (t) => { const fixture = routingConsumerFixture(t); writeFileSync(fixture.projectPath, "[]"); - await fixture.run("gentle:models"); - assert.equal(fixture.notifications[0]?.severity, "warning"); - assert.ok(fixture.notifications[0]?.message.includes(fixture.projectPath)); - assert.equal(fixture.panelVisits(), 0); + fixture.onInput((panel) => panel.handleInput("\x1b")); + await fixture.run("gentle:profiles"); + assert.equal(fixture.panelVisits(), 1, "Make default discloses the unreadable routing instead"); }); test("models export re-reads saved routing and rejects invalid project before creating its destination parent", async (t) => { const fixture = routingConsumerFixture(t); writeFileSync(fixture.projectPath, "[]"); - await fixture.run("gentle:models", "export"); + await fixture.run("gentle:profiles", "export-routing"); assert.equal(fixture.panelVisits(), 0, "export is a subcommand: no screen opens"); const exportWarning = fixture.notifications.find((entry) => entry.severity === "warning"); assert.equal(exportWarning?.severity, "warning"); @@ -699,7 +698,7 @@ test("models exports missing, normalized project, and global-precedence saved ro const fixture = routingConsumerFixture(t); if (source !== "missing") writeFileSync(fixture.projectPath, '{"worker":" openai/gpt-5 ","ignored":null}'); if (source === "global") writeMarkdown(fixture.globalPath, '{"worker":{"model":" anthropic/opus ","thinking":"high"}}'); - await fixture.run("gentle:models", "export"); + await fixture.run("gentle:profiles", "export-routing"); const agents = source === "missing" ? {} : source === "project" ? { worker: { model: "openai/gpt-5" } } : { worker: { model: "anthropic/opus", thinking: "high" } }; @@ -724,12 +723,15 @@ test("invalid global routing overrides valid project in models, status, and expo const fixture = routingConsumerFixture(t); writeFileSync(fixture.projectPath, '{"worker":"openai/gpt-5"}'); writeMarkdown(fixture.globalPath, "[]"); - await fixture.run("gentle:models", atExport ? "export" : ""); - const routingWarning = fixture.notifications.find((entry) => entry.severity === "warning"); - assert.equal(routingWarning?.severity, "warning"); - assert.ok(routingWarning?.message.includes(fixture.globalPath)); - assert.match(routingWarning!.message, atExport ? /export failed/ : /cannot open model config/); - assert.equal(fixture.panelVisits(), 0, "an invalid global config opens no screen"); + fixture.onInput((panel) => panel.handleInput("\x1b")); + await fixture.run("gentle:profiles", atExport ? "export-routing" : ""); + if (atExport) { + const routingWarning = fixture.notifications.find((entry) => entry.severity === "warning"); + assert.equal(routingWarning?.severity, "warning"); + assert.ok(routingWarning?.message.includes(fixture.globalPath)); + assert.match(routingWarning!.message, /export failed/); + } + assert.equal(fixture.panelVisits(), atExport ? 0 : 1, "export opens no screen; the screen opens and Make default discloses the unreadable routing"); assert.equal(existsSync(fixture.exportPath), false); await fixture.run("gentle:status"); const report = fixture.notifications.at(-1)!; @@ -1297,7 +1299,7 @@ test("per-JD-agent model assignment keeps judge-a and judge-b profiles divergent }); // --------------------------------------------------------------------------- -// Models screen (/gentle:models, /gentle:profiles): one table, profiles as tabs. +// Models screen (/gentle:profiles): one table, profiles as tabs. // --------------------------------------------------------------------------- const SCREEN_AGENTS = ["gentle-ai-explore", "gentle-ai-verify", "gentle-ai-worker"]; @@ -2260,10 +2262,10 @@ function tabNames(panel: ScreenPanel): string[] { } /** - * Drives the models screen to a profile, one visit per tab step (a clean tab - * switch finishes the visit and the loop reopens on the new tab), presses `key` - * on it, and closes the screen on every later visit. Without a name it acts on - * the first tab, the profile the old list opened on. + * Drives the models screen to a profile (a clean tab switch redraws the open + * screen in place), presses `key` on it, and closes the screen on every later + * visit. Without a name it acts on the first tab, the profile the old list + * opened on. */ function actOnProfile( fixture: { onInput(action: (panel: ScreenPanel) => void): void }, @@ -2278,11 +2280,10 @@ function actOnProfile( } const tabs = tabNames(panel); const target = name ?? tabs[0]; - const shown = shownProfile(panel); - if (shown !== target) { - panel.handleInput(tabs.indexOf(target) < tabs.indexOf(shown) ? "\x1b[D" : "\x1b[C"); - return; + for (let step = 0; step < tabs.length && shownProfile(panel) !== target; step++) { + panel.handleInput(tabs.indexOf(target) < tabs.indexOf(shownProfile(panel)) ? "\x1b[D" : "\x1b[C"); } + assert.equal(shownProfile(panel), target); acted = true; for (const data of typeof key === "string" ? [key] : key) panel.handleInput(data); }); @@ -3065,7 +3066,7 @@ test("applying a populated profile with explicit confirmation applies the routin test("applying a profile replaces materialized routing for agents the profile omits", async (t) => { const { fixture, writeStore, writeSettings } = profilesStoreFixture(t); writeSettings(); - // Routing materialized earlier (a previous profile, /gentle:models, or a + // Routing materialized earlier (a previous profile, the models screen, or a // migration) for an agent the new profile does not mention. const helperPath = join(fixture.root, ".pi", "agents", "helper.md"); writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\nthinking: high\n---\nbody\n"); @@ -3218,7 +3219,7 @@ test("models screen saves the explicit orchestrator choice into the profile", as } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); const snapshot = JSON.parse(readFileSync(storePath, "utf8")).profiles.team; assert.deepEqual(snapshot.orchestrator, { model: "openai/beta", thinking: "low" }); assert.deepEqual(snapshot.worker, { model: "openai/alpha", thinking: "high" }); @@ -3259,7 +3260,7 @@ test("models screen leave untouched removes a stale profile orchestrator and kee } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); assert.equal(Object.hasOwn(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, "orchestrator"), false); assert.equal(readFileSync(fixture.globalPath, "utf8"), globalBefore, "Save never writes global routing"); assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore); @@ -3290,7 +3291,7 @@ for (const [name, keys] of [["ctrl+s", ["\x13"]], ["esc twice", ["\x1b", "\x1b"] } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); assert.deepEqual(fixture.liveSwitches, []); assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore); if (name === "ctrl+s") { @@ -3322,7 +3323,7 @@ test("models screen Model ▾ and Effort ▾ set every agent to inherit and leav } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); const profile = JSON.parse(readFileSync(storePath, "utf8")).profiles.team; assert.deepEqual(profile.worker, {}); assert.deepEqual(profile.orchestrator, { model: "openai/alpha", thinking: "high" }); @@ -3353,7 +3354,7 @@ test("models screen custom model id: a valid id lands in the draft, an invalid o } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); assert.match(fixture.inputPrompts[0]?.title ?? "", /Custom model id for worker/); assert.equal(JSON.parse(readFileSync(storePath, "utf8")).profiles.team.worker.model, expected); if (expected === "openai/beta") assert.match(fixture.panels[1] ?? "", /Custom model id must be one provider\/model line/); @@ -3382,7 +3383,7 @@ test("models screen Save preserves an absent orchestrator and sibling profiles", } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); assert.equal(existsSync(fixture.globalPath), false, "Save never writes global routing"); const store = JSON.parse(readFileSync(storePath, "utf8")); assert.deepEqual(store.profiles.team, { worker: { model: "openai/alpha" } }); @@ -3393,7 +3394,7 @@ test("models screen Save preserves an absent orchestrator and sibling profiles", assert.equal(visits, 2); }); -test("gentle:models opens on the pinned profile and Save preserves its stored orchestrator", async (t) => { +test("gentle:profiles opens on the pinned profile and Save preserves its stored orchestrator", async (t) => { const { fixture, storePath, settingsPath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t); writeSettings(); const settingsBefore = readFileSync(settingsPath, "utf8"); @@ -3416,7 +3417,7 @@ test("gentle:models opens on the pinned profile and Save preserves its stored or } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); const store = JSON.parse(readFileSync(storePath, "utf8")); assert.deepEqual(store.profiles.other, { worker: { model: "openai/alpha", thinking: "high" }, @@ -3429,7 +3430,7 @@ test("gentle:models opens on the pinned profile and Save preserves its stored or assert.equal(visits, 2); }); -test("gentle:models seeds the profiles store the way /gentle:profiles does when it is missing", async (t) => { +test("gentle:profiles seeds the profiles store when it is missing", async (t) => { const { fixture, storePath, writeSettings } = profilesStoreFixture(t); writeSettings(); assert.equal(existsSync(storePath), false); @@ -3442,7 +3443,7 @@ test("gentle:models seeds the profiles store the way /gentle:profiles does when } panel.handleInput("\x1b"); }); - await fixture.run("gentle:models"); + await fixture.run("gentle:profiles"); const store = JSON.parse(readFileSync(storePath, "utf8")); assert.equal(store.kind, PROFILES_KIND); assert.deepEqual(store.profiles.current, {}); @@ -3642,11 +3643,11 @@ async function runProfilesNameActionOn( let acted = false; fixture.onInput((panel) => { if (acted) return panel.handleInput("\x1b"); - const shown = shownProfile(panel); - if (name !== undefined && shown !== name) { - const tabs = tabNames(panel); - return panel.handleInput(tabs.indexOf(name) < tabs.indexOf(shown) ? "\x1b[D" : "\x1b[C"); + const tabs = tabNames(panel); + for (let step = 0; name !== undefined && step < tabs.length && shownProfile(panel) !== name; step++) { + panel.handleInput(tabs.indexOf(name) < tabs.indexOf(shownProfile(panel)) ? "\x1b[D" : "\x1b[C"); } + if (name !== undefined) assert.equal(shownProfile(panel), name); acted = true; for (const key of keys) panel.handleInput(key); }); @@ -4337,23 +4338,25 @@ test("Enter without a parent session id fails loud and writes nothing", async (t resetSessionProfileBindingsForTesting(); }); -test("file operations are subcommands: models restore, profiles export and import, and usage for anything else", async (t) => { +test("file operations are subcommands: profiles restore-routing, export and import, and usage for anything else", async (t) => { const { fixture, storePath, writeStore } = profilesStoreFixture(t); writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team"); - // /gentle:models restore: confirm, then write models.json and materialize it. + // /gentle:profiles restore-routing: confirm, then write models.json and materialize it. writeMarkdown(fixture.exportPath, `${JSON.stringify({ kind: "gentle-pi.agent_model_routing", version: 1, agents: { worker: { model: "openai/beta", thinking: "low" } } }, null, 2)}\n`); fixture.onConfirm(async () => false); - await fixture.run("gentle:models", "restore"); + await fixture.run("gentle:profiles", "restore-routing"); assert.equal(existsSync(fixture.globalPath), false, "a declined restore writes nothing"); fixture.onConfirm(async () => true); - await fixture.run("gentle:models", "restore"); + await fixture.run("gentle:profiles", "restore-routing"); assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")).worker, { model: "openai/beta", thinking: "low" }); assert.match(fixture.notifications.at(-1)?.message ?? "", /restored global model config/); assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/beta/); - await fixture.run("gentle:models", "reset"); - assert.match(fixture.notifications.at(-1)?.message ?? "", /Usage: \/gentle:models \[export \| restore\]/); + // /gentle:profiles export-routing writes the saved routing, not a profile. + await fixture.run("gentle:profiles", "export-routing"); + assert.deepEqual(JSON.parse(readFileSync(fixture.exportPath, "utf8")).agents, { worker: { model: "openai/beta", thinking: "low" } }); + assert.match(fixture.notifications.at(-1)?.message ?? "", /exported 1 saved model routing entry/); // /gentle:profiles export then import it back over a changed store. await fixture.run("gentle:profiles", "export team"); @@ -4364,9 +4367,79 @@ test("file operations are subcommands: models restore, profiles export and impor await fixture.run("gentle:profiles", "import"); assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, { worker: { model: "openai/alpha", thinking: "high" } }, "import replaces after confirmation"); - for (const args of ["export", "export ghost", "publish", "frobnicate"]) { + for (const args of ["export", "export ghost", "publish", "frobnicate", "restore", "export-routing extra"]) { await fixture.run("gentle:profiles", args); - assert.match(fixture.notifications.at(-1)?.message ?? "", /Usage: \/gentle:profiles \[export \| import \| publish \]/, args); + assert.match(fixture.notifications.at(-1)?.message ?? "", /Usage: \/gentle:profiles \[export \| import \| publish \| export-routing \| restore-routing\]/, args); } assert.equal(fixture.panelVisits(), 0, "no subcommand opens the screen"); }); + +test("gentle:models is gone: gentle:profiles is the only models and profiles command", () => { + const commands = new Map(); + createGentleAiExtension({ nativeReviewCli: null })({ + on() {}, + registerTool() {}, + registerCommand(name: string, command: { description?: string }) { commands.set(name, command); }, + } as unknown as ExtensionAPI); + assert.equal(commands.has("gentle:models"), false); + assert.match(commands.get("gentle:profiles")?.description ?? "", /export-routing, restore-routing/); + for (const [name, command] of commands) { + assert.doesNotMatch(command.description ?? "", /gentle:models/, name); + } +}); + +test("gentle:profiles still opens the screen and keeps export , import, and publish ", async (t) => { + const { fixture, storePath, repoPinPath, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } } }, "team"); + fixture.onInput((panel) => { + assert.equal(shownProfile(panel), "team"); + panel.handleInput("\x1b"); + }); + await fixture.run("gentle:profiles"); + assert.equal(fixture.panelVisits(), 1); + await fixture.run("gentle:profiles", "export team"); + assert.match(fixture.notifications.at(-1)?.message ?? "", /exported profile "team"/); + writeStore({ team: { worker: { model: "openai/gamma" } } }, "team"); + await fixture.run("gentle:profiles", "import"); + assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, { worker: { model: "openai/alpha" } }); + await fixture.run("gentle:profiles", "publish team"); + assert.equal(JSON.parse(readFileSync(repoPinPath, "utf8")).profile, "team"); + assert.equal(fixture.panelVisits(), 1, "subcommands open no screen"); +}); + +test("switching profile tabs redraws in place: the overlay never closes and reopens", async (t) => { + const { fixture, storePath, writeStore } = profilesStoreFixture(t); + writeStore({ + team: { worker: { model: "openai/alpha" } }, + other: { worker: { model: "openai/beta", thinking: "high" } }, + }, "team"); + const frames: string[] = []; + let visits = 0; + fixture.onInput((panel) => { + visits += 1; + if (visits > 1) { + panel.handleInput("\x1b"); + return; + } + frames.push(stripAnsi(renderComponent(panel))); + panel.handleInput(RIGHT_KEY); // clean switch to "other" + frames.push(stripAnsi(renderComponent(panel))); + chooseAgent(panel, "worker"); + chooseModel(panel, "alpha"); + panel.handleInput("\x1b[D"); // back to "team" with an unsaved edit: asks first + frames.push(stripAnsi(renderComponent(panel))); + panel.handleInput("s"); // Save and switch + frames.push(stripAnsi(renderComponent(panel))); + panel.handleInput("\x1b"); + }); + await fixture.run("gentle:profiles"); + assert.equal(fixture.panelVisits(), 1, "a tab switch keeps the one overlay on screen"); + const [first, switched, asking, saved] = frames; + assert.match(first ?? "", /worker\s+openai\/alpha/); + assert.match(switched ?? "", /worker\s+openai\/beta\s+high/, "the switched frame is already the new profile"); + assert.match(profilesPanelFooter(switched ?? ""), /Showing "other"\./); + assert.match(asking ?? "", /other has 1 unsaved change\./); + assert.match(saved ?? "", /worker\s+openai\/alpha/); + assert.match(profilesPanelFooter(saved ?? ""), /Saved "other"\. Showing "team"\./); + assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.other, { worker: { model: "openai/alpha", thinking: "high" } }); +}); diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 04f66a5e7..b25f2a4c9 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -112,7 +112,7 @@ const renderers = new Map(); const FAKE_SOURCE_INFO: SourceInfo = { path: "extensions/gentle-shell.ts", source: "gentle-shell", scope: "project", origin: "top-level" }; const DEFAULT_COMMANDS: SlashCommandInfo[] = [ - { name: "gentle:models", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO }, + { name: "gentle:profiles", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO }, { name: "gentle:changes", description: "Browse changes", source: "extension", sourceInfo: FAKE_SOURCE_INFO }, { name: "gentle:status", description: "Show Gentle AI status", source: "extension", sourceInfo: FAKE_SOURCE_INFO }, { name: "skill-registry:refresh", description: "Regenerate the skill registry", source: "extension", sourceInfo: FAKE_SOURCE_INFO }, @@ -2193,7 +2193,7 @@ test("vim NORMAL slash uses Pi's command and skill completion without displacing fg: (_color, text) => text, bold: (text) => text, requestRender() {}, pending: () => false, now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => INSTALLED_PI, }); - const entries = ["gentle:vim", "gentle:models", "skill:example"]; + const entries = ["gentle:vim", "gentle:profiles", "skill:example"]; const requests: string[] = []; editor.setAutocompleteProvider({ async getSuggestions(lines: string[], line: number, col: number) { @@ -5693,11 +5693,11 @@ test("/gentle:commands shows only curated, registered commands, grouped, by thei assert.match(rendered, /Diagnostics/); assert.match(rendered, /Skills/); assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear"); - assert.match(rendered, /Assign models and effort/); + assert.match(rendered, /Models and profiles/); assert.match(rendered, /Browse changes/); assert.match(rendered, /Gentle AI status/); assert.match(rendered, /Refresh skill registry/); - assert.doesNotMatch(rendered, /gentle:models|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown"); + assert.doesNotMatch(rendered, /gentle:profiles|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown"); assert.doesNotMatch(rendered, /gentle:not-in-catalog/); assert.doesNotMatch(rendered, /skill:foo/); const changesLine = lines.find((line) => line.includes("Browse changes")); @@ -5712,13 +5712,13 @@ test("selecting a command from the palette by its label sends the underlying com const { ctx, ui, overlayReady } = fakeContext(); const opened = commands.get("gentle:commands")!.handler("", ctx); await overlayReady; - // "mod" only matches the "Assign models and effort" label (it contains - // "mod" via "models"); nothing else in the fixture does. + // "mod" only matches the "Models and profiles" label (it contains "mod" + // via "Models"); nothing else in the fixture does. for (const ch of "mod") ui.overlayView!.handleInput(ch); - assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/); + assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/); ui.overlayView!.handleInput("\r"); await opened; - assert.deepEqual(sentMessages, [{ content: "/gentle:models", options: { expandPromptTemplates: true } }]); + assert.deepEqual(sentMessages, [{ content: "/gentle:profiles", options: { expandPromptTemplates: true } }]); }); test("escaping the palette sends no message", async () => { @@ -5761,7 +5761,7 @@ test("the alt+k shortcut opens the same command palette as the command", async ( assert.ok(shortcut, "alt+k not registered"); const opened = shortcut.handler(ctx); await overlayReady; - assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/); + assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/); ui.overlayView!.handleInput("\x1b"); await opened; }); diff --git a/tests/model-profiles-preservation.test.ts b/tests/model-profiles-preservation.test.ts index fcd7e8053..4443ebd61 100644 --- a/tests/model-profiles-preservation.test.ts +++ b/tests/model-profiles-preservation.test.ts @@ -7,7 +7,7 @@ import test from "node:test"; import { applyModelConfig, applyModelConfigAsync, applySavedModelConfig } from "../extensions/gentle-ai.ts"; // gentle-ai#4946: the activation sweep re-applies saved routing without user -// consent. Persisted clear entries (an "inherit" saved from /gentle:models is +// consent. Persisted clear entries (an "inherit" saved from /gentle:profiles is // `{}`) must not delete `model_profiles` entries the user authored in the // agent-home `subagents.json`. Clearing stays owned by the consented flows // that issue it: the panel save and a confirmed profile apply. @@ -66,7 +66,7 @@ function preservedProfilesFixture(t: test.TestContext) { test("startup sweep keeps user model_profiles entries for persisted clears", async (t) => { const fixture = preservedProfilesFixture(t); - // Saved routing as /gentle:models leaves it after pressing "inherit" on + // Saved routing as /gentle:profiles leaves it after pressing "inherit" on // worker: a persisted clear entry. The sweep must not delete the entries. writeFileSync(fixture.globalModelsPath, JSON.stringify({ worker: {} })); const result = await applySavedModelConfig(fixture.context); diff --git a/tests/runtime-harness.mjs b/tests/runtime-harness.mjs index 5d2e2bb44..b0d5c305f 100644 --- a/tests/runtime-harness.mjs +++ b/tests/runtime-harness.mjs @@ -33,7 +33,7 @@ const EXPECTED_BANNER_COMMANDS = [ const EXPECTED_COMMANDS = [ "gentle:install-delegation", "gentle:install-review", - "gentle:models", + "gentle:profiles", "gentle:persona", "gentle:status", "gentle:doctor", @@ -43,6 +43,8 @@ const EXPECTED_COMMANDS = [ const FORBIDDEN_COMPAT_COMMANDS = [ "gentle:install-assets", + // Retired: /gentle:profiles opens the same models screen. + "gentle:models", "gentle-ai:models", "gentleman:models", "gentle-ai:persona", @@ -1301,16 +1303,16 @@ async function run() { assert.doesNotMatch(invalidGlobalSkippedAgent, /model: legacy\/provider-model/); assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning"); assert.match(legacyCtx.ui.notifications.at(-1).message, /skipped model config/); + // An unreadable models.json does not block the models screen (Make default + // discloses it), and closing the screen leaves the file untouched. let modelPanelOpened = false; legacyCtx.ui.custom = () => { modelPanelOpened = true; - return Promise.resolve({ type: "save", config: {} }); + return Promise.resolve({ type: "close" }); }; - await commands.get("gentle:models").handler("", legacyCtx); - assert.equal(modelPanelOpened, false); + await commands.get("gentle:profiles").handler("", legacyCtx); + assert.equal(modelPanelOpened, true); assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json"); - assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning"); - assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/); await writeFile(globalModelsPath, JSON.stringify({}, null, 2)); await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx); const emptyGlobalPreservesAgent = await readFile( @@ -1629,7 +1631,7 @@ async function run() { { provider: "safe", id: "model" }, { provider: "evil\u001b]52;c;Zm9v\u0007", id: "model" }, ]; - // /gentle:models opens the models screen on the profile this repository + // /gentle:profiles opens the models screen on the profile this repository // resolves: one table, profiles as tabs. Its render contract: a bordered // card filling the terminal, scroll indicators for long agent lists, and // sanitized labels everywhere, including the model card. @@ -1671,7 +1673,7 @@ async function run() { panel.handleInput("\u001b"); // close the card panel.handleInput("\u001b"); // close the screen: nothing changed, one esc is enough }); - await commands.get("gentle:models").handler("", ctx); + await commands.get("gentle:profiles").handler("", ctx); assert.equal(panelVisits, 1, "closing an unchanged screen ends the command"); await hooks.get("session_start")[0]({ reason: "startup" }, ctx); @@ -1715,7 +1717,7 @@ async function run() { } panel.handleInput("\u001b"); }); - await commands.get("gentle:models").handler("", ctx); + await commands.get("gentle:profiles").handler("", ctx); assert.equal(saveVisits, 2, "Save reopens the screen once before it closes"); const profilesPath = join(globalConfigHome, "profiles.json"); @@ -1772,7 +1774,7 @@ async function run() { } panel.handleInput("\u001b"); }); - await commands.get("gentle:models").handler("", ctx); + await commands.get("gentle:profiles").handler("", ctx); assert.equal(customVisits, 3); const customSavedStore = JSON.parse(await readFile(profilesPath, "utf8")); assert.deepEqual(customSavedStore.profiles.current["sdd-apply"], { @@ -1797,7 +1799,7 @@ async function run() { } panel.handleInput("\u001b"); // nothing changed: one esc closes }); - await commands.get("gentle:models").handler("", ctx); + await commands.get("gentle:profiles").handler("", ctx); assert.equal(invalidCustomVisits, 2); const rejectedCustomStore = JSON.parse(await readFile(profilesPath, "utf8")); assert.deepEqual(rejectedCustomStore.profiles.current["sdd-apply"], { @@ -1811,7 +1813,7 @@ async function run() { screenOpened = true; return Promise.resolve({ type: "close" }); }; - await commands.get("gentle:models").handler("export", ctx); + await commands.get("gentle:profiles").handler("export-routing", ctx); const exported = JSON.parse(await readFile(join(globalConfigHome, "models.export.json"), "utf8")); assert.equal(exported.kind, "gentle-pi.agent_model_routing"); assert.equal(exported.version, 1); @@ -1826,7 +1828,7 @@ async function run() { }, null, 2), ); ctx.ui.confirm = async () => true; - await commands.get("gentle:models").handler("restore", ctx); + await commands.get("gentle:profiles").handler("restore-routing", ctx); assert.equal(screenOpened, false, "export and restore never open the screen"); const restoredConfig = JSON.parse(await readFile(globalModelsPath, "utf8")); assert.deepEqual(restoredConfig["sdd-apply"], { model: "restore/provider", thinking: "high" }); @@ -1857,7 +1859,7 @@ async function run() { } panel.handleInput("\u001b"); }); - await commands.get("gentle:models").handler("", ctx); + await commands.get("gentle:profiles").handler("", ctx); assert.equal(maxVisits, 2); const maxStore = JSON.parse(await readFile(profilesPath, "utf8")); assert.equal(maxStore.profiles.current["sdd-apply"].thinking, "max"); From 685c4b8e793dbd9a70ed82fe36e2e033c689f922 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 28 Oct 2026 00:42:42 +0100 Subject: [PATCH 4/7] feat(music): offer to install the gentle-stream player inside a bundled Gentle Shell /gentle:music in a bundled install without gentle-stream, uvx or uv now asks to install the player instead of pointing at manual steps. On consent it fetches a pinned, sha256-verified uv 0.13.6 into runtime/ and installs the uv-managed Python 3.13 and gentle-stream from a pinned GitHub archive (no git) under music/, isolated from any user uv, Python or virtualenv settings. A pnpm or npm install keeps the existing hint. --- docs/gentle-music.md | 27 +++++- docs/gentle-shell.md | 2 +- extensions/gentle-music.ts | 87 ++++++++++++++++-- scripts/bundled-install.mjs | 114 +++++++++++++++++++++++- scripts/installer-downloads.mjs | 24 +++++ tests/bundled-install.test.ts | 143 +++++++++++++++++++++++++++++- tests/gentle-music.test.ts | 117 ++++++++++++++++++++++++ tests/installer-downloads.test.ts | 32 +++++++ 8 files changed, 532 insertions(+), 14 deletions(-) create mode 100644 tests/installer-downloads.test.ts diff --git a/docs/gentle-music.md b/docs/gentle-music.md index 4b17da6bc..62bb52be2 100644 --- a/docs/gentle-music.md +++ b/docs/gentle-music.md @@ -45,15 +45,36 @@ When you quit Pi after using `/gentle:music`, Gentle Shell runs `gentle-stream c Gentle Shell looks for gentle-stream in this order: 1. `gentle-stream` on `PATH`. -2. `uvx`, running gentle-stream straight from its repository (the first run downloads it). -3. `uv`, through `uv tool run`. +2. In a bundled Gentle Shell, the gentle-stream it installed inside its own folder (see below). +3. `uvx`, running gentle-stream straight from its repository (the first run downloads it). +4. `uv`, through `uv tool run`. -With none of them, `/gentle:music` explains how to install and does nothing else. Install [uv](https://docs.astral.sh/uv/getting-started/installation/), or install gentle-stream permanently: +With none of them, a pnpm or npm install of Gentle Shell explains how to install and does nothing else. Install [uv](https://docs.astral.sh/uv/getting-started/installation/), or install gentle-stream permanently: ```bash uv tool install 'gentle-stream[samples] @ git+https://github.com/Gentleman-Programming/gentle-stream' ``` +### Installed by a bundled Gentle Shell + +A bundled Gentle Shell (the one the web installer sets up) offers to install the player for you the first time `/gentle:music` finds none: + +> Install the gentle-stream music player inside Gentle Shell? It downloads uv, Python 3.13 and the player (about 150 MB) into the Gentle Shell folder. + +Decline and you get the manual hint above; nothing is downloaded. Accept and Gentle Shell shows its progress, installs the player, and starts the music. Later runs use it directly, with no question. + +Everything stays inside the Gentle Shell folder (`~/.gentle-shell`, or `%LOCALAPPDATA%\gentle-shell` on Windows). Nothing is added to your `PATH`, and your own Python, uv, and uv settings are never used or changed: + +| What | Where | Size on disk | +| :-- | :-- | :-- | +| [uv](https://github.com/astral-sh/uv) 0.13.6, the release archive pinned by sha256 and checked before use | `runtime/uv-0.13.6/` | about 30 MB | +| Python 3.13, managed by that uv | `music/python/` | about 70 MB | +| gentle-stream, from a pinned commit archive of its repository (git is not needed) | `music/tools/`, run from `music/bin/gentle-stream` | about 110 MB | + +uv's download cache (`music/cache/`) is emptied after the install. Removing the bundled install removes all of it. + +The player is installed with its `samples` extra (pyfluidsynth). If that extra cannot be installed, Gentle Shell says so and installs the player without it. Either way, the sampled instruments need the FluidSynth library from your system (for example `brew install fluid-synth` or `apt install libfluidsynth3`) and the soundfont `gentle-stream setup-samples` downloads; without them gentle-stream plays its synthesized instruments. + ## Limits - The player needs the interactive terminal. In RPC, JSON, and print modes, `/gentle:music` shows a notice and does nothing. diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 316fbecae..7a6e41a52 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -397,7 +397,7 @@ The canvas server binds `127.0.0.1` on a random port and needs a random per-serv ### Gentle Music -`/gentle:music [mood]` hands the terminal to the [gentle-stream](https://github.com/Gentleman-Programming/gentle-stream) player, like Pi's external editor, passing the mood and the session model. In the player, `b` returns to Pi with the music playing and `q` returns and stops it. `/gentle:music next`, `pause`, `play`, `stop`, and `status` control the music without leaving Pi. Quitting Pi after using the command stops the music. gentle-stream runs from `PATH`, or through `uvx`/`uv` when it is not installed. See [Gentle Music](gentle-music.md). +`/gentle:music [mood]` hands the terminal to the [gentle-stream](https://github.com/Gentleman-Programming/gentle-stream) player, like Pi's external editor, passing the mood and the session model. In the player, `b` returns to Pi with the music playing and `q` returns and stops it. `/gentle:music next`, `pause`, `play`, `stop`, and `status` control the music without leaving Pi. Quitting Pi after using the command stops the music. gentle-stream runs from `PATH`, or through `uvx`/`uv` when it is not installed. A bundled Gentle Shell without any of them offers, with your consent, to install the player inside its own folder: a pinned, checksum-verified uv 0.13.6 in `runtime/`, and the uv-managed Python 3.13 and gentle-stream in `music/` (about 150 MB to download, about 210 MB on disk). A pnpm or npm install keeps the manual install hint. See [Gentle Music](gentle-music.md). ### Bridge providers diff --git a/extensions/gentle-music.ts b/extensions/gentle-music.ts index 1c53d55da..c2663e9c0 100644 --- a/extensions/gentle-music.ts +++ b/extensions/gentle-music.ts @@ -1,8 +1,10 @@ import { spawn as nodeSpawn } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; +import { homedir } from "node:os"; import { delimiter, join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { Component } from "@earendil-works/pi-tui"; +import { runningBundledPrefix } from "../lib/gentle-shell-bundled.ts"; // Gentle Music: /gentle:music hands the whole terminal to gentle-stream's own // TUI, the way Pi's external editor (ctrl+g) does: stop Pi's TUI, run the @@ -12,12 +14,19 @@ import type { Component } from "@earendil-works/pi-tui"; // `gentle-stream ctl …` with captured output and never take the terminal. // // Runtime imports stay node-only so the command loads without Pi's packages. +// +// A bundled Gentle Shell without any player offers to install one inside its +// own folder (scripts/bundled-install.mjs installMusicPlayer): the pinned uv, +// a uv-managed Python 3.13 and gentle-stream, all under the prefix. Other +// installs keep the manual hint. export const MUSIC_COMMAND_NAME = "gentle:music"; export const GENTLE_STREAM_SPEC = "gentle-stream[samples] @ git+https://github.com/Gentleman-Programming/gentle-stream"; export const MUSIC_CONTROL_COMMANDS = ["next", "pause", "play", "stop", "status"] as const; export type MusicControlCommand = (typeof MUSIC_CONTROL_COMMANDS)[number]; +export const MUSIC_INSTALL_TITLE = "Install the music player?"; +export const MUSIC_INSTALL_OFFER = "Install the gentle-stream music player inside Gentle Shell? It downloads uv, Python 3.13 and the player (about 150 MB) into the Gentle Shell folder."; const UV_INSTALL_URL = "https://docs.astral.sh/uv/getting-started/installation/"; const CTL_TIMEOUT_MS = 30_000; const SHUTDOWN_TIMEOUT_MS = 2_000; @@ -65,8 +74,17 @@ interface TerminalOwner { requestRender(force?: boolean): void; } +/** The player a bundled Gentle Shell installs inside its prefix on request. */ +export interface BundledMusicPlayer { + gentleStream: string; + installed(): boolean; + install(progress: (line: string) => void): Promise<{ gentleStream: string; samples: boolean }>; +} + export interface GentleMusicDeps { which?: MusicWhich; + /** The bundled install's player, or undefined outside a bundled install. */ + bundled?: () => Promise; spawn?: MusicSpawn; signals?: MusicSignals; usage?: MusicUsage; @@ -95,10 +113,11 @@ export function findOnPath(name: string, env: NodeJS.ProcessEnv = process.env, p return undefined; } -/** gentle-stream on PATH, else the published spec through uvx, else through `uv tool run`. */ -export function resolveGentleStream(which: MusicWhich): GentleStreamCommand | undefined { +/** gentle-stream on PATH, else the one installed inside the bundled prefix, else the published spec through uvx, else through `uv tool run`. */ +export function resolveGentleStream(which: MusicWhich, bundledPlayer?: string): GentleStreamCommand | undefined { const installed = which("gentle-stream"); if (installed) return { command: installed, prefix: [] }; + if (bundledPlayer) return { command: bundledPlayer, prefix: [] }; const uvx = which("uvx"); if (uvx) return { command: uvx, prefix: ["--from", GENTLE_STREAM_SPEC, "gentle-stream"] }; const uv = which("uv"); @@ -144,6 +163,28 @@ export function formatMusicStatus(stdout: string): string | undefined { return `♪ ${[...parts, playing === true ? "playing" : "paused"].join(" · ")}`; } +/** The running bundled install's player (GENTLE_SHELL_PREFIX, checked by runningBundledPrefix). */ +async function defaultBundled(): Promise { + const prefix = runningBundledPrefix(process.env); + if (prefix === undefined) return undefined; + const { installMusicPlayer, prefixLayout } = await import("../scripts/bundled-install.mjs"); + const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: prefix }); + return { + gentleStream: layout.gentleStream, + installed: () => { + try { + return statSync(layout.gentleStream).isFile(); + } catch { + return false; + } + }, + install: async (progress) => { + const result = await installMusicPlayer({ layout, platform: process.platform, arch: process.arch, env: process.env, out: progress }); + return { gentleStream: result.gentleStream, samples: result.samples }; + }, + }; +} + const defaultSpawn: MusicSpawn = (command, args, options) => nodeSpawn(command, [...args], { stdio: options.stdio === "inherit" ? "inherit" : [...options.stdio] }); const defaultSignals: MusicSignals = { on: (signal, listener) => process.on(signal, listener), off: (signal, listener) => process.off(signal, listener) }; const USAGE_KEY = Symbol.for("gentle-pi.gentle-music.usage"); @@ -223,6 +264,36 @@ export default function gentleMusic(pi: ExtensionAPI, deps: GentleMusicDeps = {} const signals = deps.signals ?? defaultSignals; const usage = deps.usage ?? processUsage(); const shutdownTimeoutMs = deps.shutdownTimeoutMs ?? SHUTDOWN_TIMEOUT_MS; + const bundled = deps.bundled ?? defaultBundled; + + const findPlayer = async (): Promise<{ stream?: GentleStreamCommand; offer?: BundledMusicPlayer }> => { + let player: BundledMusicPlayer | undefined; + try { + player = await bundled(); + } catch { + player = undefined; + } + const stream = resolveGentleStream(which, player?.installed() ? player.gentleStream : undefined); + return stream ? { stream } : { offer: player }; + }; + + /** The bundled offer: undefined when declined or failed (after telling the user). */ + const offerInstall = async (ctx: ExtensionContext, player: BundledMusicPlayer): Promise => { + if ((await ctx.ui.confirm(MUSIC_INSTALL_TITLE, MUSIC_INSTALL_OFFER)) !== true) { + ctx.ui.notify(installHint(), "warning"); + return undefined; + } + try { + const result = await player.install((line) => ctx.ui.notify(line, "info")); + ctx.ui.notify(result.samples + ? "♪ gentle-stream installed inside Gentle Shell." + : "♪ gentle-stream installed inside Gentle Shell without the sample voices (they need the system FluidSynth library).", "info"); + return { command: result.gentleStream, prefix: [] }; + } catch (error) { + ctx.ui.notify(`/gentle:music could not install gentle-stream: ${errorMessage(error)}`, "error"); + return undefined; + } + }; const openTui = async (ctx: ExtensionContext, stream: GentleStreamCommand, mood: string | undefined) => { const args = [...stream.prefix, ...musicTuiArgs({ model: ctx.model, mood })]; @@ -265,11 +336,11 @@ export default function gentleMusic(pi: ExtensionAPI, deps: GentleMusicDeps = {} ctx.ui.notify("/gentle:music needs the interactive terminal.", "warning"); return; } - const stream = resolveGentleStream(which); - if (!stream) { - ctx.ui.notify(installHint(), "warning"); - return; - } + const found = await findPlayer(); + let stream = found.stream; + if (!stream && found.offer) stream = await offerInstall(ctx, found.offer); + else if (!stream) ctx.ui.notify(installHint(), "warning"); + if (!stream) return; usage.used = true; const request = parseMusicArgs(args); if (request.kind === "ctl") await control(ctx, stream, request.command); @@ -281,7 +352,7 @@ export default function gentleMusic(pi: ExtensionAPI, deps: GentleMusicDeps = {} // Switching or reloading sessions keeps the same process, so the music plays on. pi.on("session_shutdown", async (event) => { if ((event as { reason?: string }).reason !== "quit" || !usage.used) return; - const stream = resolveGentleStream(which); + const { stream } = await findPlayer(); if (!stream) return; usage.used = false; await runCaptured(spawn, stream.command, [...stream.prefix, "ctl", "stop"], shutdownTimeoutMs); diff --git a/scripts/bundled-install.mjs b/scripts/bundled-install.mjs index a7b80c695..3440f2449 100644 --- a/scripts/bundled-install.mjs +++ b/scripts/bundled-install.mjs @@ -11,7 +11,7 @@ import { posix, win32 } from "node:path"; import { spawn } from "node:child_process"; import { pathToFileURL } from "node:url"; import { crc32, gunzipSync, inflateRawSync } from "node:zlib"; -import { acquireGo, artifactFor, engramPinVersion, verifiedDownload } from "./installer-downloads.mjs"; +import { acquireGo, artifactFor, engramPinVersion, uvPinVersion, verifiedDownload } from "./installer-downloads.mjs"; import { ensureWindowsPrivateFolder, readWindowsPnpmArchive, verifyWindowsStorage } from "./installer-windows.mjs"; // The acquisition pins already verified for the installer; never a second copy. @@ -28,6 +28,24 @@ const PI_PACKAGE = "@earendil-works/pi-coding-agent"; const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/; const MAX_EXPANDED = 512 * 1024 * 1024; const INSTALL_TIMEOUT = 15 * 60 * 1000; +// The gentle-stream commit the /gentle:music offer installs, as a GitHub +// archive (no git needed); `samples` adds pyfluidsynth for the sample voices. +export const MUSIC_PLAYER_ARCHIVE = "https://github.com/Gentleman-Programming/gentle-stream/archive/381290451544ad2ca3348a68a81b3056bdac73b7.tar.gz"; +const MUSIC_PYTHON = "3.13"; +const MUSIC_MARKER = ".gentle-shell-music"; +// uv links its tool executables on POSIX (bin/gentle-stream -> tools/…): the +// player counts when it resolves to a regular file inside /music. +function musicPlayer(layout) { + try { + const real = realpathSync(layout.gentleStream); + const path = pathFor(process.platform); + const inside = path.relative(realpathSync(layout.music), real); + return !inside.startsWith("..") && !path.isAbsolute(inside) && lstatSync(real).isFile(); + } catch { + return false; + } +} +const musicSpec = (samples) => `gentle-stream${samples ? "[samples]" : ""} @ ${MUSIC_PLAYER_ARCHIVE}`; const pathFor = (platform) => (platform === "win32" ? win32 : posix); function stat(path) { @@ -79,6 +97,12 @@ export function prefixLayout({ platform, env = {}, home, root }) { // The pinned Engram (ensureEngram), given to Gentle AI and gentle-engram (S9). engramDir: path.join(runtime, `engram-${engramPinVersion}`), engram: path.join(runtime, `engram-${engramPinVersion}`, windows ? "engram.exe" : "engram"), + // Only when /gentle:music's offer is accepted (installMusicPlayer): the + // pinned uv, and the Python and gentle-stream it installs under music/. + uvDir: path.join(runtime, `uv-${uvPinVersion}`), + uv: path.join(runtime, `uv-${uvPinVersion}`, windows ? "uv.exe" : "uv"), + music: path.join(base, "music"), + gentleStream: path.join(base, "music", "bin", windows ? "gentle-stream.exe" : "gentle-stream"), pnpmHome, store: path.join(pnpmHome, "store"), cache: path.join(pnpmHome, "cache"), state: path.join(pnpmHome, "state"), config: path.join(pnpmHome, "config"), npmrc: path.join(pnpmHome, "config", "npmrc"), tmp: path.join(base, "tmp"), versions: path.join(base, "versions"), history: path.join(base, "versions.history"), @@ -414,6 +438,94 @@ export async function ensureEngram({ layout, platform, arch, env = {}, adapters }); } +/** The one executable a verified uv release archive must contain: `uv` in the + * archive's own top folder (`uv-/uv`) of the POSIX `.tar.gz`, or the + * top-level `uv.exe` of the Windows `.zip`. uvx, uvw and anything else are + * never written. + */ +export function uvExecutable(bytes, descriptor) { + const message = "uv archive rejected"; + const url = String(descriptor?.url ?? ""); + if (url.endsWith(".zip")) return zipMember(bytes, "uv.exe", message); + const stem = /\/(uv-[A-Za-z0-9_.-]+)\.tar\.gz$/.exec(url)?.[1]; + if (!stem) throw new Error(message); + return tarMember(bytes, `${stem}/uv`, message); +} + +/** The pinned uv in runtime/uv-/, published like Engram: downloaded through + * verifiedDownload, only its executable written, `uv --version` run from the + * staging folder, marked, then published. Never part of the default install: + * only installMusicPlayer asks for it. Returns whether it was acquired. + */ +export async function ensureUv({ layout, platform, arch, env = {}, adapters = {} }) { + const run = adapters.run ?? runCommand; + const path = pathFor(process.platform); + const childEnv = pnpmEnvironment(layout, { platform: layout.platform, env }); + return publishRuntime(layout, "uv", layout.uvDir, layout.uv, { platform, arch, adapters }, async (stage, bytes, descriptor) => { + const binary = path.join(stage, path.relative(layout.uvDir, layout.uv)); + writeFileSync(binary, uvExecutable(bytes, descriptor), { flag: "wx", mode: 0o700 }); + const result = await run(binary, ["--version"], { cwd: layout.runtime, env: childEnv }); + if (result.status !== 0 || !String(result.stdout).trim().startsWith(`uv ${descriptor.version} `)) throw new Error("Bundled uv verification failed"); + }); +} + +/** The environment the music player's uv runs in: its tools, their executables, + * the uv-managed Python 3.13 and uv's cache all under /music; no system + * Python and no uv.toml are consulted. Inherited uv settings and Python + * environment variables are dropped; the rest (HOME, PATH, proxies, CA + * settings) is kept. + */ +export function musicEnvironment(layout, { env = {} } = {}) { + const path = pathFor(layout.platform); + const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !/^(?:uv_.*|virtual_env|conda_prefix|pythonhome|pythonpath)$/i.test(key))); + return { + ...kept, + UV_TOOL_DIR: path.join(layout.music, "tools"), UV_TOOL_BIN_DIR: path.dirname(layout.gentleStream), + UV_PYTHON_INSTALL_DIR: path.join(layout.music, "python"), UV_PYTHON_BIN_DIR: path.join(layout.music, "python", "bin"), + UV_CACHE_DIR: path.join(layout.music, "cache"), + UV_PYTHON: MUSIC_PYTHON, UV_PYTHON_PREFERENCE: "only-managed", UV_PYTHON_DOWNLOADS: "automatic", UV_NO_CONFIG: "1", + }; +} + +/** S5: the /gentle:music offer, after the user accepted it. Publishes the + * pinned uv (ensureUv), then `uv tool install`s the pinned gentle-stream + * archive with its `samples` extra into /music (musicEnvironment); + * when that fails (pyfluidsynth, which needs the system FluidSynth library), + * once more without the extra. A marker naming the installed spec makes a + * second call a no-op. `out` gets one line per step. Returns the executable, + * whether the extra is in, and whether anything was installed. + */ +export async function installMusicPlayer({ layout, platform = layout.platform, arch = process.arch, env = {}, adapters = {}, out = () => {} }) { + const run = adapters.run ?? runCommand; + const path = pathFor(process.platform); + const marker = path.join(layout.music, MUSIC_MARKER); + const done = read(marker); + if (musicPlayer(layout) && (done === `${musicSpec(true)}\n` || done === `${musicSpec(false)}\n`)) { + return { gentleStream: layout.gentleStream, samples: done === `${musicSpec(true)}\n`, installed: false }; + } + out(`Downloading uv ${uvPinVersion} into Gentle Shell…`); + await ensureUv({ layout, platform, arch, env, adapters }); + ensureDirectory(layout.music, layout.platform); + const childEnv = musicEnvironment(layout, { env }); + const install = (samples) => run(layout.uv, ["tool", "install", "--python", MUSIC_PYTHON, musicSpec(samples)], { cwd: layout.music, env: childEnv, timeout: INSTALL_TIMEOUT }); + out(`Installing Python ${MUSIC_PYTHON} and gentle-stream; this can take a few minutes…`); + let samples = true; + let result = await install(true); + if (result.status !== 0) { + out("The samples extra could not be installed (it needs the system FluidSynth library); retrying without the samples extra…"); + samples = false; + result = await install(false); + } + if (result.status !== 0 || !musicPlayer(layout)) { + const reason = String(result.stderr ?? "").trim().split("\n").filter(Boolean).at(-1); + throw new Error(`uv could not install gentle-stream${reason ? `: ${reason}` : ""}`); + } + replaceAtomically(marker, (temporary) => writeFileSync(temporary, `${musicSpec(samples)}\n`, { flag: "wx", mode: 0o600 })); + // The downloaded wheels and archives are not needed once installed. + await run(layout.uv, ["cache", "clean"], { cwd: layout.music, env: childEnv, timeout: INSTALL_TIMEOUT }); + return { gentleStream: layout.gentleStream, samples, installed: true }; +} + /** Our pinned Node with its bundled npm, and pnpm, in runtime/ (and the pinned * Go only when `go`, the pinned Engram only when `engram`), each downloaded * through verifiedDownload, checked by running it from its staging folder, diff --git a/scripts/installer-downloads.mjs b/scripts/installer-downloads.mjs index de5e739a6..a9bf32fff 100644 --- a/scripts/installer-downloads.mjs +++ b/scripts/installer-downloads.mjs @@ -57,6 +57,23 @@ const engram = Object.freeze({ }); /** The Engram version the bundled install publishes under runtime/. */ export const engramPinVersion = engram.version; +// uv for the /gentle:music offer (installMusicPlayer), never part of the default +// install: the astral-sh/uv release. Each sha256 and size comes from GitHub's +// asset digests; every archive was downloaded and re-hashed before pinning. +const uv = Object.freeze({ + version: "0.13.6", + redirectHosts: engram.redirectHosts, + archives: Object.freeze({ + "darwin-arm64": ["uv-aarch64-apple-darwin.tar.gz", "60fbd5c8a1ed681845eea28d9d622fcde03c51e91a43465b64a1e17db1ed3472", 14235670], + "darwin-x64": ["uv-x86_64-apple-darwin.tar.gz", "63ba17fdda55ad1a230d4b37fc6017ffa10c23688c83818bd3bfdf914c652a02", 17004934], + "linux-x64": ["uv-x86_64-unknown-linux-gnu.tar.gz", "f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645", 16799024], + "linux-arm64": ["uv-aarch64-unknown-linux-gnu.tar.gz", "229e194255c7958d86d56e8790d969a6f74736592133f0dfd0076f754c642c1d", 15794886], + "win32-x64": ["uv-x86_64-pc-windows-msvc.zip", "a2a8820d5eea32f08ef467ef3b7f66883a9b076d5737a2fc2fbb5f458d1bb50f", 15239500], + "win32-arm64": ["uv-aarch64-pc-windows-msvc.zip", "c2426c0c8e28b53d8c440e47a18d96ad4db51e159866c0e81435b196d7f0081c", 15439263], + }), +}); +/** The uv version the music offer publishes under runtime/. */ +export const uvPinVersion = uv.version; /** Fixed allowlist; callers cannot supply download URLs, checksums or commands. */ export function artifactFor(name, platform, arch) { @@ -77,6 +94,13 @@ export function artifactFor(name, platform, arch) { url: `https://github.com/Gentleman-Programming/engram/releases/download/v${engram.version}/engram_${engram.version}_${suffix}`, integrity: `sha256-${sha256}`, size, maxBytes: size, redirectHosts: engram.redirectHosts }); } + if (name === "uv") { + const pin = Object.hasOwn(uv.archives, `${platform}-${arch}`) ? uv.archives[`${platform}-${arch}`] : null; + if (!pin) throw new Error("Unsupported acquisition target"); + const [asset, sha256, size] = pin; + return Object.freeze({ name, version: uv.version, platform, arch, url: `https://github.com/astral-sh/uv/releases/download/${uv.version}/${asset}`, + integrity: `sha256-${sha256}`, size, maxBytes: size, redirectHosts: uv.redirectHosts }); + } if (name === "go") { const pin = Object.hasOwn(go.archives, `${platform}-${arch}`) ? go.archives[`${platform}-${arch}`] : null; if (!pin) throw new Error("Unsupported acquisition target"); diff --git a/tests/bundled-install.test.ts b/tests/bundled-install.test.ts index 88263c917..f8a14df5b 100644 --- a/tests/bundled-install.test.ts +++ b/tests/bundled-install.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { dirname, join, posix, win32 } from "node:path"; import test, { after, type TestContext } from "node:test"; import { crc32, gzipSync } from "node:zlib"; -import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, filterNpmrc, installVersion, nodeExecutable, +import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, ensureUv, filterNpmrc, installMusicPlayer, installVersion, nodeExecutable, uvExecutable, nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, bundledNpmCommand, ensureNpmShim, upgradeBundled, userNpmrc, writeNpmrcAuth, publishedPi, upgradePiLatest, describePathReceipt, readPathReceipt, scheduleWindowsRemoval, writePathReceipt } from "../scripts/bundled-install.mjs"; @@ -383,6 +383,147 @@ test("engramExecutable takes only the top-level engram from the tarball and engr assert.throws(() => engramExecutable(zip([["engram", exe]]), zipped), /Engram archive rejected/); }); +// S5: the /gentle:music offer. Only uv is a new runtime; it, the uv-managed +// Python and gentle-stream all live inside the prefix. +const UV = "0.13.6"; +const uvBytes = Buffer.from("#!/bin/sh\necho fixture uv\n"); +const uvUrl = `https://github.com/astral-sh/uv/releases/download/${UV}/uv-aarch64-apple-darwin.tar.gz`; +const uvArchive = tarGz([ + tarEntry("uv-aarch64-apple-darwin/", { type: "5" }), + tarEntry("uv-aarch64-apple-darwin/uvx", { data: Buffer.from("#!/bin/sh\n"), mode: "0000755" }), + tarEntry("uv-aarch64-apple-darwin/uv", { data: uvBytes, mode: "0000755" }), +]); +const PLAYER_ARCHIVE = "https://github.com/Gentleman-Programming/gentle-stream/archive/381290451544ad2ca3348a68a81b3056bdac73b7.tar.gz"; +// A uv fixture: `--version` answers the pin; `tool install` writes the +// executable into UV_TOOL_BIN_DIR unless `fail(spec)` says the spec fails. +function uvAdapters({ version = `uv ${UV} (fixture 2026-10-26 aarch64-apple-darwin)`, fail = (_spec: string): boolean => false, writes = "inside" }: { version?: string; fail?: (spec: string) => boolean; writes?: "inside" | "outside" | "none" } = {}) { + const calls: { downloads: string[]; runs: { command: string; args: string[]; cwd: string; env: Record; timeout?: number }[] } = { downloads: [], runs: [] }; + return { + calls, + adapters: { + artifact: (name: string) => { + assert.equal(name, "uv"); + return { name, version: UV, maxBytes: 1 << 20, integrity: `sha256-${sha256(uvArchive)}`, url: uvUrl }; + }, + download: async (descriptor: { name: string }) => { calls.downloads.push(descriptor.name); return uvArchive; }, + run: (command: string, args: string[], options: { cwd: string; env: Record; timeout?: number }) => { + calls.runs.push({ command, args, ...options }); + if (args.length === 1 && args[0] === "--version") return { status: 0, stdout: `${version}\n`, stderr: "" }; + if (args[0] === "tool" && args[1] === "install") { + const spec = args.at(-1)!; + if (fail(spec)) return { status: 1, stdout: "", stderr: "error: Failed to build `pyfluidsynth`" }; + // As uv does on POSIX: the executable in the tool's venv, linked from the bin folder. + if (writes !== "none") { + const target = writes === "inside" ? join(options.env.UV_TOOL_DIR, "gentle-stream", "bin", "gentle-stream") : join(dirname(options.cwd), "elsewhere"); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, "#!/bin/sh\n", { mode: 0o755 }); + mkdirSync(options.env.UV_TOOL_BIN_DIR, { recursive: true }); + symlinkSync(target, join(options.env.UV_TOOL_BIN_DIR, "gentle-stream")); + } + return { status: 0, stdout: "Installed 1 executable: gentle-stream\n", stderr: "" }; + } + if (args[0] === "cache" && args[1] === "clean") return { status: 0, stdout: "", stderr: "" }; + return { status: 1, stdout: "", stderr: "unexpected" }; + }, + }, + }; +} + +test("prefixLayout keeps uv, the managed Python and the music player inside the prefix", () => { + const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/me" }); + const root = "/home/me/.gentle-shell"; + assert.equal(layout.uvDir, `${root}/runtime/uv-${UV}`); + assert.equal(layout.uv, `${root}/runtime/uv-${UV}/uv`); + assert.equal(layout.music, `${root}/music`); + assert.equal(layout.gentleStream, `${root}/music/bin/gentle-stream`); + const windows = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local" }, home: "C:\\Users\\me" }); + assert.equal(windows.uv, `C:\\Users\\me\\AppData\\Local\\gentle-shell\\runtime\\uv-${UV}\\uv.exe`); + assert.equal(windows.gentleStream, "C:\\Users\\me\\AppData\\Local\\gentle-shell\\music\\bin\\gentle-stream.exe"); +}); + +test("uvExecutable takes only /uv from the tarball and uv.exe from the zip, regular files only", () => { + assert.deepEqual(uvExecutable(uvArchive, { url: uvUrl }), uvBytes); + const zipped = { url: `https://github.com/astral-sh/uv/releases/download/${UV}/uv-x86_64-pc-windows-msvc.zip` }; + const exe = Buffer.from("MZ uv"); + assert.deepEqual(uvExecutable(zip([["uvx.exe", Buffer.from("MZ uvx")], ["uv.exe", exe], ["uvw.exe", Buffer.from("MZ uvw")]]), zipped), exe); + assert.throws(() => uvExecutable(tarGz([tarEntry("uv-aarch64-apple-darwin/uv", { type: "2", link: "/bin/sh" })]), { url: uvUrl }), /uv archive rejected/); + assert.throws(() => uvExecutable(tarGz([tarEntry("uv", { data: uvBytes })]), { url: uvUrl }), /uv archive rejected/); + assert.throws(() => uvExecutable(zip([["uv", exe]]), zipped), /uv archive rejected/); +}); + +posixTest("ensureUv publishes only the verified uv after running it, once", async () => { + const { layout } = claimed(); + const { calls, adapters: fixture } = uvAdapters(); + assert.equal(await ensureUv({ layout, platform: "darwin", arch: "arm64", adapters: fixture }), true); + assert.deepEqual(readdirSync(layout.uvDir).sort(), [".gentle-shell-runtime", "uv"]); + assert.deepEqual(readFileSync(layout.uv), uvBytes); + assert.equal(lstatSync(layout.uv).mode & 0o777, 0o700); + assert.equal(readFileSync(join(layout.uvDir, ".gentle-shell-runtime"), "utf8"), `${uvUrl}\n`); + assert.match(calls.runs[0]!.command, /\/runtime\/\.stage-[^/]+\/uv$/); + assert.equal(await ensureUv({ layout, platform: "darwin", arch: "arm64", adapters: fixture }), false, "reused"); + assert.deepEqual(calls.downloads, ["uv"]); + // Another version's answer, or a tampered archive, publishes nothing. + for (const broken of [uvAdapters({ version: "uv 0.9.0" }).adapters, { ...uvAdapters().adapters, download: async () => Buffer.from("x") }]) { + const other = claimed().layout; + await assert.rejects(ensureUv({ layout: other, platform: "darwin", arch: "arm64", adapters: broken }), /uv|integrity|size/); + assert.deepEqual(readdirSync(other.runtime), []); + } +}); + +posixTest("installMusicPlayer confines uv, Python and gentle-stream to the prefix and installs from the pinned archive", async () => { + const { layout } = claimed(); + const { calls, adapters: fixture } = uvAdapters(); + const out: string[] = []; + const env = { HOME: "/home/me", PATH: "/usr/bin", UV_TOOL_DIR: "/home/me/.local/share/uv/tools", UV_INDEX_URL: "https://example.invalid", VIRTUAL_ENV: "/venv", HTTPS_PROXY: "http://proxy:3128" }; + const result = await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env, adapters: fixture, out: (line: string) => out.push(line) }); + assert.deepEqual(result, { gentleStream: layout.gentleStream, samples: true, installed: true }); + const install = calls.runs.find((run) => run.args[0] === "tool")!; + assert.equal(install.command, layout.uv); + assert.deepEqual(install.args, ["tool", "install", "--python", "3.13", `gentle-stream[samples] @ ${PLAYER_ARCHIVE}`]); + assert.ok(!install.args.join(" ").includes("git+"), "an archive URL, so git is never needed"); + assert.ok(install.cwd.startsWith(layout.root)); + assert.equal(install.env.UV_TOOL_DIR, join(layout.music, "tools")); + assert.equal(install.env.UV_TOOL_BIN_DIR, join(layout.music, "bin")); + assert.equal(install.env.UV_PYTHON_INSTALL_DIR, join(layout.music, "python")); + assert.equal(install.env.UV_PYTHON_BIN_DIR, join(layout.music, "python", "bin")); + assert.equal(install.env.UV_CACHE_DIR, join(layout.music, "cache")); + assert.equal(install.env.UV_PYTHON, "3.13"); + assert.equal(install.env.UV_PYTHON_PREFERENCE, "only-managed"); + assert.equal(install.env.UV_NO_CONFIG, "1"); + // The user's uv settings and virtualenv never reach it; the network settings do. + assert.equal(install.env.UV_INDEX_URL, undefined); + assert.equal(install.env.VIRTUAL_ENV, undefined); + assert.equal(install.env.HTTPS_PROXY, "http://proxy:3128"); + assert.ok(out.length >= 2, "progress is reported"); + assert.deepEqual(calls.runs.at(-1)!.args, ["cache", "clean"], "the download cache is emptied afterwards"); + // Idempotent: a second call downloads and runs nothing. + const before = calls.runs.length; + assert.deepEqual(await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env, adapters: fixture }), { gentleStream: layout.gentleStream, samples: true, installed: false }); + assert.equal(calls.runs.length, before); + assert.deepEqual(calls.downloads, ["uv"]); +}); + +posixTest("installMusicPlayer retries without the samples extra when it cannot be installed, and says so", async () => { + const { layout } = claimed(); + const { calls, adapters: fixture } = uvAdapters({ fail: (spec) => spec.includes("[samples]") }); + const out: string[] = []; + const result = await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env: {}, adapters: fixture, out: (line: string) => out.push(line) }); + assert.deepEqual(result, { gentleStream: layout.gentleStream, samples: false, installed: true }); + assert.deepEqual(calls.runs.filter((run) => run.args[0] === "tool").map((run) => run.args.at(-1)), [`gentle-stream[samples] @ ${PLAYER_ARCHIVE}`, `gentle-stream @ ${PLAYER_ARCHIVE}`]); + assert.ok(out.some((line) => /without the samples extra/i.test(line) && /FluidSynth/.test(line))); + // Reused without samples too, so no retry loop on every /gentle:music. + assert.deepEqual(await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env: {}, adapters: fixture }), { gentleStream: layout.gentleStream, samples: false, installed: false }); +}); + +posixTest("installMusicPlayer fails clearly when uv cannot install the player at all", async () => { + // uv failing twice, no executable, or one that leaves /music. + for (const fixture of [uvAdapters({ fail: () => true }), uvAdapters({ writes: "none" }), uvAdapters({ writes: "outside" })]) { + const { layout } = claimed(); + await assert.rejects(installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env: {}, adapters: fixture.adapters }), /gentle-stream/); + assert.equal(existsSync(join(layout.music, ".gentle-shell-music")), false); + } +}); + test("nodeExecutable takes only node.exe from the Windows zip and only bin/node from the tarball", () => { const stem = `node-v${NODE}-win-x64`; const exe = Buffer.from("MZ fixture"); diff --git a/tests/gentle-music.test.ts b/tests/gentle-music.test.ts index 270af370b..6d4417d1e 100644 --- a/tests/gentle-music.test.ts +++ b/tests/gentle-music.test.ts @@ -9,10 +9,14 @@ import gentleMusic, { findOnPath, formatMusicStatus, GENTLE_STREAM_SPEC, + installHint, MUSIC_COMMAND_NAME, + MUSIC_INSTALL_OFFER, + MUSIC_INSTALL_TITLE, musicTuiArgs, parseMusicArgs, resolveGentleStream, + type BundledMusicPlayer, type MusicSpawn, } from "../extensions/gentle-music.ts"; @@ -333,3 +337,116 @@ test("Windows .cmd and .bat shims are not picked, so resolution falls back to uv const resolved = resolveGentleStream((name) => findOnPath(name, env, "win32")); assert.equal(resolved?.command, join(dir, "uv.EXE")); }); + +// S5: a bundled Gentle Shell offers to install the player inside its own +// folder; any other install keeps the manual hint. +const PREVIOUS_HINT = "/gentle:music needs gentle-stream, and neither gentle-stream nor uv is on PATH. Install uv (https://docs.astral.sh/uv/getting-started/installation/) and run /gentle:music again, or install gentle-stream with: uv tool install 'gentle-stream[samples] @ git+https://github.com/Gentleman-Programming/gentle-stream'"; + +function withConfirm(context: ReturnType, answer: boolean) { + const asked: Array<[string, string]> = []; + (context.ctx.ui as unknown as { confirm: (title: string, message: string) => Promise }).confirm = async (title, message) => { + asked.push([title, message]); + return answer; + }; + return asked; +} + +function fakeBundled({ installed = false, fail }: { installed?: boolean; fail?: Error } = {}) { + const state = { installed, installs: 0 }; + const player: BundledMusicPlayer = { + gentleStream: "/prefix/music/bin/gentle-stream", + installed: () => state.installed, + install: async (progress) => { + state.installs += 1; + progress("Downloading uv 0.13.6…"); + if (fail) throw fail; + state.installed = true; + return { gentleStream: "/prefix/music/bin/gentle-stream", samples: true }; + }, + }; + return { state, bundled: async () => player }; +} + +test("the manual hint outside a bundled install is unchanged", async () => { + assert.equal(installHint(), PREVIOUS_HINT); + const { pi, commands } = fakePi(); + const { spawn, spawned } = fakeSpawn([]); + gentleMusic(pi, { which: onPath({}), spawn, bundled: async () => undefined }); + const context = fakeContext(); + const asked = withConfirm(context, true); + await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx); + assert.deepEqual(asked, [], "no offer outside a bundled install"); + assert.deepEqual(context.notices, [[PREVIOUS_HINT, "warning"]]); + assert.equal(spawned.length, 0); +}); + +test("a bundled install without the player offers it; declining shows the hint and installs nothing", async () => { + const { pi, commands } = fakePi(); + const { spawn, spawned } = fakeSpawn([]); + const { state, bundled } = fakeBundled(); + gentleMusic(pi, { which: onPath({}), spawn, bundled }); + const context = fakeContext(); + const asked = withConfirm(context, false); + await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx); + assert.deepEqual(asked, [[MUSIC_INSTALL_TITLE, MUSIC_INSTALL_OFFER]]); + assert.equal(MUSIC_INSTALL_OFFER, "Install the gentle-stream music player inside Gentle Shell? It downloads uv, Python 3.13 and the player (about 150 MB) into the Gentle Shell folder."); + assert.equal(state.installs, 0); + assert.deepEqual(context.notices, [[PREVIOUS_HINT, "warning"]]); + assert.equal(spawned.length, 0); +}); + +test("accepting the offer installs the player with progress, then starts the music from the prefix", async () => { + const { pi, commands } = fakePi(); + const context = fakeContext(); + const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls); + const { state, bundled } = fakeBundled(); + gentleMusic(pi, { which: onPath({}), spawn, bundled, signals: fakeSignals().signals }); + withConfirm(context, true); + await commands.get(MUSIC_COMMAND_NAME)!.handler("calm", context.ctx); + assert.equal(state.installs, 1); + assert.deepEqual(context.notices.map(([, type]) => type), ["info", "info"]); + assert.match(context.notices[0]![0], /Downloading uv/); + assert.match(context.notices[1]![0], /installed/i); + assert.equal(spawned[0]!.command, "/prefix/music/bin/gentle-stream"); + assert.deepEqual(spawned[0]!.args, ["--model", "anthropic/claude-sonnet-5-5", "--mood", "calm"]); +}); + +test("an installed prefix player is used without asking; PATH gentle-stream still comes first", async () => { + const { bundled } = fakeBundled({ installed: true }); + for (const [names, expected] of [[{}, "/prefix/music/bin/gentle-stream"], [{ uvx: "/bin/uvx" }, "/prefix/music/bin/gentle-stream"], [{ "gentle-stream": "/bin/gentle-stream" }, "/bin/gentle-stream"]] as const) { + const { pi, commands } = fakePi(); + const context = fakeContext(); + const asked = withConfirm(context, false); + const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls); + gentleMusic(pi, { which: onPath(names), spawn, bundled, signals: fakeSignals().signals }); + await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx); + assert.deepEqual(asked, []); + assert.equal(spawned[0]!.command, expected); + assert.deepEqual(spawned[0]!.args, ["--model", "anthropic/claude-sonnet-5-5"]); + } + assert.deepEqual(resolveGentleStream(onPath({ uv: "/bin/uv" }), "/prefix/music/bin/gentle-stream"), { command: "/prefix/music/bin/gentle-stream", prefix: [] }); +}); + +test("a bundled install with uv on PATH keeps the uvx route and does not offer", async () => { + const { pi, commands } = fakePi(); + const context = fakeContext(); + const asked = withConfirm(context, true); + const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls); + gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn, bundled: fakeBundled().bundled, signals: fakeSignals().signals }); + await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx); + assert.deepEqual(asked, []); + assert.equal(spawned[0]!.command, "/bin/uvx"); +}); + +test("a failed install is reported and nothing starts", async () => { + const { pi, commands } = fakePi(); + const { spawn, spawned } = fakeSpawn([]); + gentleMusic(pi, { which: onPath({}), spawn, bundled: fakeBundled({ fail: new Error("Download integrity mismatch") }).bundled }); + const context = fakeContext(); + withConfirm(context, true); + await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx); + assert.equal(spawned.length, 0); + const [message, type] = context.notices.at(-1)!; + assert.equal(type, "error"); + assert.match(message, /could not install gentle-stream.*Download integrity mismatch/); +}); diff --git a/tests/installer-downloads.test.ts b/tests/installer-downloads.test.ts new file mode 100644 index 000000000..6c1f59a97 --- /dev/null +++ b/tests/installer-downloads.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { artifactFor, uvPinVersion } from "../scripts/installer-downloads.mjs"; + +// The uv the /gentle:music offer installs inside a bundled prefix (S5): the +// astral-sh/uv release archives, each re-hashed after download before pinning. +const uvPins = { + "darwin-arm64": ["uv-aarch64-apple-darwin.tar.gz", "60fbd5c8a1ed681845eea28d9d622fcde03c51e91a43465b64a1e17db1ed3472", 14235670], + "darwin-x64": ["uv-x86_64-apple-darwin.tar.gz", "63ba17fdda55ad1a230d4b37fc6017ffa10c23688c83818bd3bfdf914c652a02", 17004934], + "linux-x64": ["uv-x86_64-unknown-linux-gnu.tar.gz", "f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645", 16799024], + "linux-arm64": ["uv-aarch64-unknown-linux-gnu.tar.gz", "229e194255c7958d86d56e8790d969a6f74736592133f0dfd0076f754c642c1d", 15794886], + "win32-x64": ["uv-x86_64-pc-windows-msvc.zip", "a2a8820d5eea32f08ef467ef3b7f66883a9b076d5737a2fc2fbb5f458d1bb50f", 15239500], + "win32-arm64": ["uv-aarch64-pc-windows-msvc.zip", "c2426c0c8e28b53d8c440e47a18d96ad4db51e159866c0e81435b196d7f0081c", 15439263], +} as const; + +test("fixed uv descriptors are the astral-sh/uv release archives and checksums, and nothing else", () => { + assert.equal(uvPinVersion, "0.13.6"); + for (const [target, [asset, sha256, size]] of Object.entries(uvPins)) { + const [platform, arch] = target.split("-"); + const artifact = artifactFor("uv", platform, arch); + assert.equal(artifact.name, "uv"); + assert.equal(artifact.version, "0.13.6"); + assert.equal(artifact.url, `https://github.com/astral-sh/uv/releases/download/0.13.6/${asset}`); + assert.equal(artifact.integrity, `sha256-${sha256}`); + assert.deepEqual([artifact.size, artifact.maxBytes], [size, size]); + assert.deepEqual(artifact.redirectHosts, ["objects.githubusercontent.com", "release-assets.githubusercontent.com"]); + assert.ok(Object.isFrozen(artifact)); + } + for (const [platform, arch] of [["linux", "ia32"], ["freebsd", "x64"], ["win32", "ia32"], ["darwin", "ppc64"]]) { + assert.throws(() => artifactFor("uv", platform, arch), /Unsupported/); + } +}); From ebf11fb6e03d4c1eec5888c25577f05d2e857c10 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 28 Oct 2026 00:44:28 +0100 Subject: [PATCH 5/7] fix(bundled): never downgrade Pi through 'update --latest' When the latest dist-tag names a Pi older than the active one, --latest now changes nothing instead of installing it. The install test pins the --no-frozen-lockfile flag, the installVersion comment no longer claims pnpm writes no lockfile, and the reference documents --latest outside a bundled install. --- docs/readme-reference.md | 2 +- scripts/bundled-install.mjs | 14 ++++++++++---- tests/bundled-install.test.ts | 13 +++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 761b25fd7..b34e84957 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -556,7 +556,7 @@ every `update` form that updates Pi itself explains that and runs `gentle-shell upgrade` (in a terminal) or names it. Those forms are no target, `--self`, `self` or `pi`, `--all`, and `self` or `pi` with `--extensions`. The extension part of an "all" form, and every package update, still reaches Pi. -`gentle-shell update --latest` (also with `pi`, `self`, `--self` or `--all`) installs the newest published stable Pi with the active Gentle Shell instead, without the release's verified lockfile: it first warns that Gentle Shell features relying on audited Pi internals (such as the Vim editor) may turn off until a Gentle Shell release verifies that Pi, then verifies and switches to it like `upgrade`, keeping the previous version for `gentle-shell upgrade --rollback`. Afterwards `gentle-shell upgrade` never downgrades that Pi: a release of the same Gentle Shell with an older verified Pi changes nothing, and a newer Gentle Shell release upgrades as usual. +`gentle-shell update --latest` (also with `pi`, `self`, `--self` or `--all`) installs the newest published stable Pi with the active Gentle Shell instead, without the release's verified lockfile: it first warns that Gentle Shell features relying on audited Pi internals (such as the Vim editor) may turn off until a Gentle Shell release verifies that Pi, then verifies and switches to it like `upgrade`, keeping the previous version for `gentle-shell upgrade --rollback`. Afterwards `gentle-shell upgrade` never downgrades that Pi: a release of the same Gentle Shell with an older verified Pi changes nothing, and a newer Gentle Shell release upgrades as usual. When the active Pi is already newer than the published `latest` (the dist-tag moved back), `--latest` changes nothing. Outside a bundled install, `gentle-shell update --latest` is the ordinary managed Pi update, which already installs the newest published Pi. In a bundled install Pi and the agent's commands keep your own environment; Pi installs packages with Gentle Shell's npm through the `npmCommand` setting of its own home. Details: [Bundled install](bundled-install.md#launcher-in-a-bundled-install). diff --git a/scripts/bundled-install.mjs b/scripts/bundled-install.mjs index 3440f2449..68360b2e6 100644 --- a/scripts/bundled-install.mjs +++ b/scripts/bundled-install.mjs @@ -786,9 +786,10 @@ function pinnedGo(layout, go) { * other folder is never replaced. On Windows the pinned Go is required, so * gentle-pi's postinstall never builds with the user's go.exe. `unverified` * (`gentle-shell update --latest` only) installs without a release lockfile: - * no pnpm-lock.yaml is written and pnpm resolves the exact versions itself - * (`--no-frozen-lockfile`, so a CI environment cannot turn it frozen); the - * installed versions are still checked exactly. Adapters (run, and + * none is written beforehand and pnpm resolves the exact versions itself + * (`--no-frozen-lockfile`, so a CI environment cannot turn it frozen; the + * pnpm-lock.yaml pnpm leaves behind is never read again); the installed + * versions are still checked exactly. Adapters (run, and * distribution for local fixture packages in place of the registry) are * trusted local test code only. */ @@ -1094,7 +1095,7 @@ export async function publishedPi(layout, { platform = layout.platform, env = {} * `shell` as version `-`, without the release's verified lockfile * (installVersion `unverified`), after a warning; then verified and activated * exactly like upgradeBundled (installAndActivate). An active version that - * already has that Pi changes nothing. Returns what upgradeBundled returns. + * already has that Pi, or a newer one, changes nothing. Returns what upgradeBundled returns. */ export async function upgradePiLatest({ layout, home, shell, platform = layout.platform, arch = process.arch, env = {}, adapters, out = () => {} }) { const latest = await adapters.latestPi(); @@ -1108,6 +1109,11 @@ export async function upgradePiLatest({ layout, home, shell, platform = layout.p } const activePi = piOfVersion(active, shell); if (!activePi) throw new Error(`the active version ${active ?? "(none)"} is not Gentle Shell ${shell}; current stays as it is`); + // A `latest` dist-tag moved back (or a release pinning a Pi newer than it) never downgrades Pi. + if (!newerVersion(pi, activePi)) { + out(`Pi ${activePi} is newer than the latest published Pi (${pi}); nothing changed.`); + return { id: active, changed: false }; + } out(`warning: Pi ${pi} is newer than the Pi verified for this Gentle Shell release (${activePi}); it installs without the release's verified lockfile, and Gentle Shell features that rely on audited Pi internals (such as the Vim editor) may turn off until a Gentle Shell release verifies it.`); return installAndActivate({ layout, home, platform, arch, env, adapters, out, manifest, unverified: true, active, success: `Installed Pi ${pi} with Gentle Shell ${shell} (from ${active}). Run 'gentle-shell upgrade --rollback' to switch back.` }); diff --git a/tests/bundled-install.test.ts b/tests/bundled-install.test.ts index f8a14df5b..4d6520ff8 100644 --- a/tests/bundled-install.test.ts +++ b/tests/bundled-install.test.ts @@ -1351,6 +1351,18 @@ posixTest("upgradePiLatest changes nothing when the active version already has t assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); }); +posixTest("upgradePiLatest never downgrades when the latest published Pi is older than the active Pi", async () => { + const { user, layout } = await activeInstall(); + const { adapters, calls, pnpm } = latestAdapters(layout, { latestPi: "1.0.0" }); + const lines: string[] = []; + const result = await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) }); + assert.deepEqual(result, { id: "4.1.0-1.0.1", changed: false }); + assert.deepEqual(lines, ["Pi 1.0.1 is newer than the latest published Pi (1.0.0); nothing changed."]); + assert.deepEqual([pnpm.runs.length, calls.ensureRuntime.length, calls.verify.length], [0, 0, 0]); + assert.equal(activeVersion(layout), "4.1.0-1.0.1"); + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); +}); + posixTest("upgradePiLatest warns, installs the newest Pi without a lockfile beside the active version, verifies and switches current", async () => { const { user, layout } = await activeInstall(); const { adapters, calls, pnpm } = latestAdapters(layout); @@ -1384,6 +1396,7 @@ posixTest("upgradePiLatest installs with plain pnpm install, never the frozen lo assert.equal(pnpm.runs[0].args[0], layout.pnpm); assert.equal(pnpm.runs[0].args[1], "install"); assert.equal(pnpm.runs[0].args.includes("--frozen-lockfile"), false); + assert.deepEqual(pnpm.runs[0].args.slice(1), ["install", "--no-frozen-lockfile"]); assert.equal(pnpm.runs[0].env.pnpm_config_store_dir, layout.store); }); From 9abed718e23b791e5164f2b5cb58f70e1086ded7 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 28 Oct 2026 00:51:14 +0100 Subject: [PATCH 6/7] fix(music): check the bundled player like the installer and drop Python and pip settings The /gentle:music extension now accepts the prefix player only when it resolves to a regular file inside music/, the same rule the installer uses, and the player's uv no longer inherits any PYTHON* or PIP_* variable. --- extensions/gentle-music.ts | 10 ++-------- scripts/bundled-install.mjs | 13 +++++++------ tests/bundled-install.test.ts | 23 +++++++++++++++++++++-- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/extensions/gentle-music.ts b/extensions/gentle-music.ts index c2663e9c0..30e15fad8 100644 --- a/extensions/gentle-music.ts +++ b/extensions/gentle-music.ts @@ -167,17 +167,11 @@ export function formatMusicStatus(stdout: string): string | undefined { async function defaultBundled(): Promise { const prefix = runningBundledPrefix(process.env); if (prefix === undefined) return undefined; - const { installMusicPlayer, prefixLayout } = await import("../scripts/bundled-install.mjs"); + const { installMusicPlayer, installedMusicPlayer, prefixLayout } = await import("../scripts/bundled-install.mjs"); const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: prefix }); return { gentleStream: layout.gentleStream, - installed: () => { - try { - return statSync(layout.gentleStream).isFile(); - } catch { - return false; - } - }, + installed: () => installedMusicPlayer(layout), install: async (progress) => { const result = await installMusicPlayer({ layout, platform: process.platform, arch: process.arch, env: process.env, out: progress }); return { gentleStream: result.gentleStream, samples: result.samples }; diff --git a/scripts/bundled-install.mjs b/scripts/bundled-install.mjs index 68360b2e6..e2cea0d92 100644 --- a/scripts/bundled-install.mjs +++ b/scripts/bundled-install.mjs @@ -35,7 +35,8 @@ const MUSIC_PYTHON = "3.13"; const MUSIC_MARKER = ".gentle-shell-music"; // uv links its tool executables on POSIX (bin/gentle-stream -> tools/…): the // player counts when it resolves to a regular file inside /music. -function musicPlayer(layout) { +// The /gentle:music extension asks the same question before running it. +export function installedMusicPlayer(layout) { try { const real = realpathSync(layout.gentleStream); const path = pathFor(process.platform); @@ -471,13 +472,13 @@ export async function ensureUv({ layout, platform, arch, env = {}, adapters = {} /** The environment the music player's uv runs in: its tools, their executables, * the uv-managed Python 3.13 and uv's cache all under /music; no system - * Python and no uv.toml are consulted. Inherited uv settings and Python - * environment variables are dropped; the rest (HOME, PATH, proxies, CA + * Python and no uv.toml are consulted. Inherited UV_*, PYTHON*, PIP_*, + * VIRTUAL_ENV and CONDA_PREFIX are dropped; the rest (HOME, PATH, proxies, CA * settings) is kept. */ export function musicEnvironment(layout, { env = {} } = {}) { const path = pathFor(layout.platform); - const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !/^(?:uv_.*|virtual_env|conda_prefix|pythonhome|pythonpath)$/i.test(key))); + const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !/^(?:uv_.*|python.*|pip_.*|virtual_env|conda_prefix)$/i.test(key))); return { ...kept, UV_TOOL_DIR: path.join(layout.music, "tools"), UV_TOOL_BIN_DIR: path.dirname(layout.gentleStream), @@ -500,7 +501,7 @@ export async function installMusicPlayer({ layout, platform = layout.platform, a const path = pathFor(process.platform); const marker = path.join(layout.music, MUSIC_MARKER); const done = read(marker); - if (musicPlayer(layout) && (done === `${musicSpec(true)}\n` || done === `${musicSpec(false)}\n`)) { + if (installedMusicPlayer(layout) && (done === `${musicSpec(true)}\n` || done === `${musicSpec(false)}\n`)) { return { gentleStream: layout.gentleStream, samples: done === `${musicSpec(true)}\n`, installed: false }; } out(`Downloading uv ${uvPinVersion} into Gentle Shell…`); @@ -516,7 +517,7 @@ export async function installMusicPlayer({ layout, platform = layout.platform, a samples = false; result = await install(false); } - if (result.status !== 0 || !musicPlayer(layout)) { + if (result.status !== 0 || !installedMusicPlayer(layout)) { const reason = String(result.stderr ?? "").trim().split("\n").filter(Boolean).at(-1); throw new Error(`uv could not install gentle-stream${reason ? `: ${reason}` : ""}`); } diff --git a/tests/bundled-install.test.ts b/tests/bundled-install.test.ts index 4d6520ff8..47900b173 100644 --- a/tests/bundled-install.test.ts +++ b/tests/bundled-install.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { dirname, join, posix, win32 } from "node:path"; import test, { after, type TestContext } from "node:test"; import { crc32, gzipSync } from "node:zlib"; -import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, ensureUv, filterNpmrc, installMusicPlayer, installVersion, nodeExecutable, uvExecutable, +import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, ensureUv, filterNpmrc, installMusicPlayer, installedMusicPlayer, installVersion, nodeExecutable, uvExecutable, nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, bundledNpmCommand, ensureNpmShim, upgradeBundled, userNpmrc, writeNpmrcAuth, publishedPi, upgradePiLatest, describePathReceipt, readPathReceipt, scheduleWindowsRemoval, writePathReceipt } from "../scripts/bundled-install.mjs"; @@ -470,11 +470,29 @@ posixTest("ensureUv publishes only the verified uv after running it, once", asyn } }); +posixTest("installedMusicPlayer accepts only a player that resolves to a regular file inside music/", () => { + const { layout } = claimed(); + assert.equal(installedMusicPlayer(layout), false, "missing"); + mkdirSync(dirname(layout.gentleStream), { recursive: true }); + const outside = join(mkdtempSync(join(tmpdir(), "gs-player-")), "gentle-stream"); + writeFileSync(outside, "#!/bin/sh\n"); + symlinkSync(outside, layout.gentleStream); + assert.equal(installedMusicPlayer(layout), false, "a link out of music/"); + rmSync(layout.gentleStream); + const tool = join(layout.music, "tools", "gentle-stream", "bin", "gentle-stream"); + mkdirSync(dirname(tool), { recursive: true }); + writeFileSync(tool, "#!/bin/sh\n"); + symlinkSync(tool, layout.gentleStream); + assert.equal(installedMusicPlayer(layout), true, "uv's link into music/tools"); + rmSync(dirname(outside), { recursive: true, force: true }); +}); + posixTest("installMusicPlayer confines uv, Python and gentle-stream to the prefix and installs from the pinned archive", async () => { const { layout } = claimed(); const { calls, adapters: fixture } = uvAdapters(); const out: string[] = []; - const env = { HOME: "/home/me", PATH: "/usr/bin", UV_TOOL_DIR: "/home/me/.local/share/uv/tools", UV_INDEX_URL: "https://example.invalid", VIRTUAL_ENV: "/venv", HTTPS_PROXY: "http://proxy:3128" }; + const env = { HOME: "/home/me", PATH: "/usr/bin", UV_TOOL_DIR: "/home/me/.local/share/uv/tools", UV_INDEX_URL: "https://example.invalid", VIRTUAL_ENV: "/venv", HTTPS_PROXY: "http://proxy:3128", + PYTHONUSERBASE: "/home/me/.pybase", PYTHONSTARTUP: "/home/me/startup.py", PIP_INDEX_URL: "https://pip.invalid" }; const result = await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env, adapters: fixture, out: (line: string) => out.push(line) }); assert.deepEqual(result, { gentleStream: layout.gentleStream, samples: true, installed: true }); const install = calls.runs.find((run) => run.args[0] === "tool")!; @@ -493,6 +511,7 @@ posixTest("installMusicPlayer confines uv, Python and gentle-stream to the prefi // The user's uv settings and virtualenv never reach it; the network settings do. assert.equal(install.env.UV_INDEX_URL, undefined); assert.equal(install.env.VIRTUAL_ENV, undefined); + for (const key of ["PYTHONUSERBASE", "PYTHONSTARTUP", "PIP_INDEX_URL"]) assert.equal(install.env[key], undefined, key); assert.equal(install.env.HTTPS_PROXY, "http://proxy:3128"); assert.ok(out.length >= 2, "progress is reported"); assert.deepEqual(calls.runs.at(-1)!.args, ["cache", "clean"], "the download cache is emptied afterwards"); From e33ce5794ea4d2167998610a5b82b23ec99193c8 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 28 Oct 2026 00:51:23 +0100 Subject: [PATCH 7/7] docs(odd): record the bundled Pi latest feature --- odd/tasks/bundled-pi-latest.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/odd/tasks/bundled-pi-latest.md b/odd/tasks/bundled-pi-latest.md index 84953bed5..c97281ceb 100644 --- a/odd/tasks/bundled-pi-latest.md +++ b/odd/tasks/bundled-pi-latest.md @@ -1,6 +1,6 @@ # Bundled Pi latest update Objective: let bundled Gentle Shell users reach the latest Pi: bump the audited pin to Pi 1.4.0 and add an explicit opt-in latest-Pi update. Branch: fix/bundled-pi-latest (worktree .worktrees/bundled-pi-latest). -Delivery: single-pr; commit, push, merge and release authorized from main (includes unreleased main commits). Runner: `pnpm test` (node --test). +Delivery: single-pr with size:exception; commit, push, merge and release 4.5.0 authorized from main (includes unreleased main commits). Runner: `pnpm test` (node --test). ## Specs S1. User: "bug ... se le tiene que permitir a la persona actualizar pi a la ultima version". Observed: bundled `gentle-shell update` prints "Pi ships pinned with Gentle Shell (Pi 1.2.0): a newer Pi arrives with a Gentle Shell release." then "Gentle Shell 4.4.0 with Pi 1.2.0 is already current." while npm latest Pi is 1.4.0. @@ -15,9 +15,10 @@ S6. User: "hay dos bugs, 1- yo dejaria solo el comando gentle:profiles y por otr ## Tasks T1 | S2 | inline: audit Pi 1.3.0/1.4.0 private surfaces (delegated read-only explorer for coding-agent/tui.js), extend audited versions, bump PI_INSTALL_VERSION, docs, lockfile 1.4.0 | done | focused 587/587 pass | T2 | S1,S3 | delegated (context backstop at ~150k parent tokens): bundled `update --latest` opt-in path with warning, tests, help, docs; task mvrb4jp3-2-jw7v | done (verify pending: Risk item 5 installer) | writer RED/GREEN; focused 587/587 pass | -T4 | S5 | delegated after T2 (shared scripts/bundled-install.mjs; context backstop): /gentle:music offers (consent) to install the player: pinned sha256-verified uv added to the bundled runtime like engram/go, uv-managed Python 3.13 and gentle-stream inside the prefix, from a GitHub archive URL (no git) | pending | -T5 | S6 | delegated (parallel unit, disjoint surfaces, context backstop): remove /gentle:models, move subcommands, fix tab flicker | pending | -T3 | S4 | inline: full suite, commit, push, PR, merge, release per gentle-pi-release | pending | +T4 | S5 | delegated (parallel unit with T5, disjoint surfaces; context backstop): /gentle:music offers (consent) to install the player: pinned sha256-verified uv added to the bundled runtime like engram/go, uv-managed Python 3.13 and gentle-stream inside the prefix, from a GitHub archive URL (no git) | done (independent verify mvrc1q1c-6-935z pending; Risk items 2,5) | 685c4b8e7; writer RED→GREEN 155/155; pins re-hashed (6/6); real macOS arm64 E2E: gentle-stream --help exit 0, idempotent; parent 169/169 incl. uninstall | +T5 | S6 | delegated (parallel unit, disjoint surfaces, context backstop): remove /gentle:models, move subcommands to /gentle:profiles export-routing|restore-routing, fix tab flicker (in-place profile switch) | done | 536277e3c; writer RED 3 fail→GREEN; 766/766 focused, harness exit 0, typecheck 0; parent spot check 177/177 + harness 0; Risk: none (requested removal) | +T2b | S3 | inline: verify advisories (downgrade guard RED→GREEN, --no-frozen-lockfile pinned, comment, non-bundled docs) | done | ebf11fb6e; 238/238 focused | +T3 | S4 | inline: full suite (7649 pass; 36 pre-existing env failures, baseline 38), push, PR size:exception, merge, release 4.5.0 per gentle-pi-release | in_progress | ## Log L1 (2026-10-28) User: "bug [Image#1] se le tiene que permitir a la persona actualizar pi a la ultima version" @@ -29,3 +30,8 @@ L6 S5 added mid-turn. gentle-stream is not on PyPI (git spec, needs git for uv); L7 User rejected install-time provisioning in favor of an opt-in offer from /gentle:music that reuses the bundled runtime and adds only uv inside Gentle Shell. L8 T4 pins gathered (GitHub asset digests, re-hash on download before pinning): uv 0.13.6 — darwin-arm64 uv-aarch64-apple-darwin.tar.gz 60fbd5c8a1ed681845eea28d9d622fcde03c51e91a43465b64a1e17db1ed3472 14235670; darwin-x64 uv-x86_64-apple-darwin.tar.gz 63ba17fdda55ad1a230d4b37fc6017ffa10c23688c83818bd3bfdf914c652a02 17004934; linux-x64 uv-x86_64-unknown-linux-gnu.tar.gz f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 16799024; linux-arm64 uv-aarch64-unknown-linux-gnu.tar.gz 229e194255c7958d86d56e8790d969a6f74736592133f0dfd0076f754c642c1d 15794886; win32-x64 uv-x86_64-pc-windows-msvc.zip a2a8820d5eea32f08ef467ef3b7f66883a9b076d5737a2fc2fbb5f458d1bb50f 15239500; win32-arm64 uv-aarch64-pc-windows-msvc.zip c2426c0c8e28b53d8c440e47a18d96ad4db51e159866c0e81435b196d7f0081c 15439263. gentle-stream archive pin: https://github.com/Gentleman-Programming/gentle-stream/archive/381290451544ad2ca3348a68a81b3056bdac73b7.tar.gz L9 decode2 (author of #2588) has no open work on the screen: last branch commit 2026-10-27 20:05, before the merge; no issue for the two bugs. +L10 User: "size exception y ponlo como versio menor, no es tan breaking change el cambio". Delivery: single PR with size:exception; release version 4.5.0 (minor, despite #2588 feat!). +L11 Independent verify of 697d2e4b6 (risk items 3,5): S1 PASS, S3 PASS, 0 blockers; 404/404 focused tests; 6175-input differential without --latest identical. Advisories to fix after T4 (shared file): (a) upgradePiLatest has no downgrade guard when dist-tag latest < active Pi; (b) installVersion comment claims no lockfile is written but pnpm writes one; (c) test should assert --no-frozen-lockfile; (d) document non-bundled `update --latest` = ordinary managed self update. pnpm 11 minimum-release-age default noted, no change. Verifier scratch worktrees pruned. +L12 Slice boundary: running count passed ~400 authored lines at 536277e3c (807). Delivery strategy is single-pr with size:exception (L10), so no split; PR holds every commit on fix/bundled-pi-latest. +L13 Independent verify of 685c4b8e7+ebf11fb6e (risk items 2,5): S3 PASS, S5 PASS, 0 blockers; 214/214; uv pins match L8 and re-hash; extraction rejects traversal/links; corrupted download publishes nothing; confinement and consent hold; installHint byte-identical. Advisories 1-2 fixed in 9abed718e (extension uses installedMusicPlayer realpath rule; drop PYTHON*/PIP_*). Advisory 3 accepted by design (L6/L7): gentle-stream archive pinned by commit, its Python deps resolve from PyPI at install time without a lock. +L14 Full suite (branch at ebf11fb6e): 7760 tests, 7649 pass, 36 fail, 75 skipped; origin/main baseline in a scratch worktree: 7735 tests, 7622 pass, 38 fail, 75 skipped, failures in the same files (agent-home, gentle-agents, orchestrator-scope, notification-audio-native, notification-pulse-client, notification-pulse-stream; main adds notification-customize). Isolated run of those files: the same 4 failures on main and branch. Pre-existing local-environment failures, none caused by this branch. A suite test leaks odd/checks/issue-1921-*/inert-*/child.mjs into the worktree (pre-existing); removed, not committed.