gentle-pi 4.4.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +2 -2
  2. package/bin/gentle-shell.mjs +37 -4
  3. package/docs/gentle-music.md +28 -4
  4. package/docs/gentle-shell.md +5 -5
  5. package/docs/readme-reference.md +61 -46
  6. package/extensions/gentle-agents.ts +14 -2
  7. package/extensions/gentle-ai.ts +1677 -1240
  8. package/extensions/gentle-music.ts +83 -8
  9. package/extensions/gentle-shell.ts +88 -8
  10. package/lib/command-palette-catalog.ts +1 -2
  11. package/lib/gentle-shell-bundled.ts +18 -8
  12. package/lib/gentle-shell-launcher.ts +2 -0
  13. package/lib/jobs-sidebar-state.ts +22 -3
  14. package/lib/jobs-widget.ts +112 -0
  15. package/lib/session-profile-freeze.ts +2 -2
  16. package/lib/shell-bar.ts +2 -4
  17. package/lib/shell-sidebar-layout.ts +1 -1
  18. package/lib/vim-editor-adapter.ts +3 -2
  19. package/package.json +1 -1
  20. package/runtime/gentle-shell-bundled.mjs +18 -8
  21. package/runtime/gentle-shell-launcher.mjs +2 -0
  22. package/scripts/bundled-install.mjs +209 -13
  23. package/scripts/installer-downloads.mjs +24 -0
  24. package/scripts/installer-preflight.mjs +1 -1
  25. package/scripts/pi-update.mjs +3 -1
  26. package/tests/bundled-install.test.ts +273 -2
  27. package/tests/command-palette.test.ts +3 -3
  28. package/tests/design-canvas-server.test.ts +6 -2
  29. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +1 -1
  30. package/tests/gentle-agents.test.ts +4 -4
  31. package/tests/gentle-ai.test.ts +810 -609
  32. package/tests/gentle-music.test.ts +153 -1
  33. package/tests/gentle-shell-bundled.test.ts +20 -0
  34. package/tests/gentle-shell-launcher.test.ts +1 -0
  35. package/tests/gentle-shell.test.ts +44 -19
  36. package/tests/installer-downloads.test.ts +32 -0
  37. package/tests/installer-server.test.ts +2 -2
  38. package/tests/jobs-widget.test.ts +138 -0
  39. package/tests/model-profiles-preservation.test.ts +2 -2
  40. package/tests/package-manifest.test.ts +2 -2
  41. package/tests/persona-reply-language.test.ts +42 -0
  42. package/tests/pi-update.test.ts +5 -0
  43. package/tests/runtime-harness.mjs +188 -211
  44. package/tests/session-profile-freeze.test.ts +6 -4
  45. package/tests/shell-bar.test.ts +4 -4
  46. package/tests/shell-sidebar-layout.test.ts +17 -0
  47. package/tests/vim-editor-adapter.test.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "4.4.0",
3
+ "version": "4.5.1",
4
4
  "description": "Turn Pi into el Gentleman: an ODD development harness with focused subagents, configured TDD evidence, native review, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -574,12 +574,17 @@ export function withoutMissingBundledNpmCommand(settingsText
574
574
 
575
575
 
576
576
 
577
+
578
+
579
+
577
580
  // `gentle-shell update [target]` in a bundled install, read the way Pi's
578
581
  // package-manager-cli.js reads it: the target is Pi itself for a bare
579
582
  // `update`, `--self`, or a `self`/`pi` positional, and "all" for `--all`,
580
583
  // `--self --extensions` or `self`/`pi` with `--extensions`. Only Pi's own part
581
584
  // is redirected; "all" still sends `update --extensions` (with the other
582
- // flags) to Pi. Packages, models, help, and forms Pi refuses (conflicting or
585
+ // flags) to Pi. `--latest` marks Pi's own part as the newest published Pi;
586
+ // with any form that is not Pi's own update it is unknown to Pi, so it passes
587
+ // through. Packages, models, help, and forms Pi refuses (conflicting or
583
588
  // unknown options) reach Pi unchanged.
584
589
  export function bundledUpdatePlan(passthrough ) {
585
590
  const passThrough = { self: false, piArgs: passthrough };
@@ -588,8 +593,10 @@ export function bundledUpdatePlan(passthrough ) {
588
593
  let self = false;
589
594
  let extensions = false;
590
595
  let all = false;
596
+ let latest = false;
591
597
  for (const token of passthrough.slice(1)) {
592
598
  if (token === "--self") self = true;
599
+ else if (token === "--latest") latest = true;
593
600
  else if (token === "--extensions") extensions = true;
594
601
  else if (token === "--all") all = true;
595
602
  else if (["--approve", "-a", "--no-approve", "-na", "--force"].includes(token)) flags.push(token);
@@ -601,19 +608,21 @@ export function bundledUpdatePlan(passthrough ) {
601
608
  if ((positional[0] !== "self" && positional[0] !== "pi") || self) return passThrough;
602
609
  self = true;
603
610
  }
604
- if (all || (self && extensions)) return { self: true, piArgs: ["update", "--extensions", ...flags] };
611
+ const marked = latest ? { latest: true } : {};
612
+ if (all || (self && extensions)) return { self: true, ...marked, piArgs: ["update", "--extensions", ...flags] };
605
613
  if (extensions) return passThrough;
606
- return { self: true };
614
+ return { self: true, ...marked };
607
615
  }
608
616
 
609
- // What `gentle-shell update` does after the redirected upgrade (`upgradeCode`
610
- // is undefined when it did not run): with nothing left for Pi it exits with the
611
- // upgrade's code; otherwise Pi still runs, so `update --all` updates the
612
- // extensions even when the upgrade could not complete.
617
+ // What `gentle-shell update` does after the redirected upgrade or the
618
+ // `--latest` Pi update (`upgradeCode` is undefined when it did not run): with
619
+ // nothing left for Pi it exits with that code; otherwise Pi still runs, so
620
+ // `update --all` updates the extensions even when that step could not complete.
613
621
  export function bundledUpdateNext(plan , upgradeCode ) {
614
622
  if (plan.piArgs === undefined) return { exitCode: upgradeCode ?? 0 };
615
623
  if (plan.self && upgradeCode !== undefined && upgradeCode !== 0) {
616
- return { piArgs: plan.piArgs, note: "gentle-shell: the Gentle Shell upgrade did not complete; updating the extensions anyway." };
624
+ const step = plan.latest ? "latest Pi update" : "Gentle Shell upgrade";
625
+ return { piArgs: plan.piArgs, note: `gentle-shell: the ${step} did not complete; updating the extensions anyway.` };
617
626
  }
618
627
  return { piArgs: plan.piArgs };
619
628
  }
@@ -624,6 +633,7 @@ export function bundledUpdateNotice(input
624
633
  input.interactive
625
634
  ? "Running 'gentle-shell upgrade'..."
626
635
  : "Run 'gentle-shell upgrade' to install the latest Gentle Shell release and its Pi.",
636
+ "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.",
627
637
  ];
628
638
  }
629
639
 
@@ -1352,6 +1352,8 @@ export function helpText() {
1352
1352
  " gentle-shell update [target] Run pi's own 'update' against the resolved home.",
1353
1353
  " In a bundled install Pi ships pinned: 'update' without a package",
1354
1354
  " runs 'gentle-shell upgrade' instead of updating Pi.",
1355
+ " 'update --latest' installs the newest published Pi instead,",
1356
+ " without the release's verified lockfile.",
1355
1357
  " gentle-shell config Run pi's own 'config' against the resolved home.",
1356
1358
  " gentle-shell auth <command> Run pi's own 'auth' against the resolved home.",
1357
1359
  " gentle-shell mcp <command> Run pi's own 'mcp' (add, list, login, logout) against the",
@@ -11,7 +11,7 @@ import { posix, win32 } from "node:path";
11
11
  import { spawn } from "node:child_process";
12
12
  import { pathToFileURL } from "node:url";
13
13
  import { crc32, gunzipSync, inflateRawSync } from "node:zlib";
14
- import { acquireGo, artifactFor, engramPinVersion, verifiedDownload } from "./installer-downloads.mjs";
14
+ import { acquireGo, artifactFor, engramPinVersion, uvPinVersion, verifiedDownload } from "./installer-downloads.mjs";
15
15
  import { ensureWindowsPrivateFolder, readWindowsPnpmArchive, verifyWindowsStorage } from "./installer-windows.mjs";
16
16
 
17
17
  // The acquisition pins already verified for the installer; never a second copy.
@@ -23,9 +23,30 @@ const LAUNCHER_MARK = "gentle-shell bundled launcher (generated; do not edit)";
23
23
  const LAUNCHER_MISSING = "gentle-shell: no active Gentle Shell version; run the Gentle Shell installer again.";
24
24
  const PROFILE_MARK = "# gentle-shell bundled install";
25
25
  const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
26
+ const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
27
+ const PI_PACKAGE = "@earendil-works/pi-coding-agent";
26
28
  const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/;
27
29
  const MAX_EXPANDED = 512 * 1024 * 1024;
28
30
  const INSTALL_TIMEOUT = 15 * 60 * 1000;
31
+ // The gentle-stream commit the /gentle:music offer installs, as a GitHub
32
+ // archive (no git needed); `samples` adds pyfluidsynth for the sample voices.
33
+ export const MUSIC_PLAYER_ARCHIVE = "https://github.com/Gentleman-Programming/gentle-stream/archive/381290451544ad2ca3348a68a81b3056bdac73b7.tar.gz";
34
+ const MUSIC_PYTHON = "3.13";
35
+ const MUSIC_MARKER = ".gentle-shell-music";
36
+ // uv links its tool executables on POSIX (bin/gentle-stream -> tools/…): the
37
+ // player counts when it resolves to a regular file inside <prefix>/music.
38
+ // The /gentle:music extension asks the same question before running it.
39
+ export function installedMusicPlayer(layout) {
40
+ try {
41
+ const real = realpathSync(layout.gentleStream);
42
+ const path = pathFor(process.platform);
43
+ const inside = path.relative(realpathSync(layout.music), real);
44
+ return !inside.startsWith("..") && !path.isAbsolute(inside) && lstatSync(real).isFile();
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+ const musicSpec = (samples) => `gentle-stream${samples ? "[samples]" : ""} @ ${MUSIC_PLAYER_ARCHIVE}`;
29
50
 
30
51
  const pathFor = (platform) => (platform === "win32" ? win32 : posix);
31
52
  function stat(path) {
@@ -77,6 +98,12 @@ export function prefixLayout({ platform, env = {}, home, root }) {
77
98
  // The pinned Engram (ensureEngram), given to Gentle AI and gentle-engram (S9).
78
99
  engramDir: path.join(runtime, `engram-${engramPinVersion}`),
79
100
  engram: path.join(runtime, `engram-${engramPinVersion}`, windows ? "engram.exe" : "engram"),
101
+ // Only when /gentle:music's offer is accepted (installMusicPlayer): the
102
+ // pinned uv, and the Python and gentle-stream it installs under music/.
103
+ uvDir: path.join(runtime, `uv-${uvPinVersion}`),
104
+ uv: path.join(runtime, `uv-${uvPinVersion}`, windows ? "uv.exe" : "uv"),
105
+ music: path.join(base, "music"),
106
+ gentleStream: path.join(base, "music", "bin", windows ? "gentle-stream.exe" : "gentle-stream"),
80
107
  pnpmHome, store: path.join(pnpmHome, "store"), cache: path.join(pnpmHome, "cache"), state: path.join(pnpmHome, "state"),
81
108
  config: path.join(pnpmHome, "config"), npmrc: path.join(pnpmHome, "config", "npmrc"), tmp: path.join(base, "tmp"),
82
109
  versions: path.join(base, "versions"), history: path.join(base, "versions.history"),
@@ -412,6 +439,94 @@ export async function ensureEngram({ layout, platform, arch, env = {}, adapters
412
439
  });
413
440
  }
414
441
 
442
+ /** The one executable a verified uv release archive must contain: `uv` in the
443
+ * archive's own top folder (`uv-<target>/uv`) of the POSIX `.tar.gz`, or the
444
+ * top-level `uv.exe` of the Windows `.zip`. uvx, uvw and anything else are
445
+ * never written.
446
+ */
447
+ export function uvExecutable(bytes, descriptor) {
448
+ const message = "uv archive rejected";
449
+ const url = String(descriptor?.url ?? "");
450
+ if (url.endsWith(".zip")) return zipMember(bytes, "uv.exe", message);
451
+ const stem = /\/(uv-[A-Za-z0-9_.-]+)\.tar\.gz$/.exec(url)?.[1];
452
+ if (!stem) throw new Error(message);
453
+ return tarMember(bytes, `${stem}/uv`, message);
454
+ }
455
+
456
+ /** The pinned uv in runtime/uv-<v>/, published like Engram: downloaded through
457
+ * verifiedDownload, only its executable written, `uv --version` run from the
458
+ * staging folder, marked, then published. Never part of the default install:
459
+ * only installMusicPlayer asks for it. Returns whether it was acquired.
460
+ */
461
+ export async function ensureUv({ layout, platform, arch, env = {}, adapters = {} }) {
462
+ const run = adapters.run ?? runCommand;
463
+ const path = pathFor(process.platform);
464
+ const childEnv = pnpmEnvironment(layout, { platform: layout.platform, env });
465
+ return publishRuntime(layout, "uv", layout.uvDir, layout.uv, { platform, arch, adapters }, async (stage, bytes, descriptor) => {
466
+ const binary = path.join(stage, path.relative(layout.uvDir, layout.uv));
467
+ writeFileSync(binary, uvExecutable(bytes, descriptor), { flag: "wx", mode: 0o700 });
468
+ const result = await run(binary, ["--version"], { cwd: layout.runtime, env: childEnv });
469
+ if (result.status !== 0 || !String(result.stdout).trim().startsWith(`uv ${descriptor.version} `)) throw new Error("Bundled uv verification failed");
470
+ });
471
+ }
472
+
473
+ /** The environment the music player's uv runs in: its tools, their executables,
474
+ * the uv-managed Python 3.13 and uv's cache all under <prefix>/music; no system
475
+ * Python and no uv.toml are consulted. Inherited UV_*, PYTHON*, PIP_*,
476
+ * VIRTUAL_ENV and CONDA_PREFIX are dropped; the rest (HOME, PATH, proxies, CA
477
+ * settings) is kept.
478
+ */
479
+ export function musicEnvironment(layout, { env = {} } = {}) {
480
+ const path = pathFor(layout.platform);
481
+ const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !/^(?:uv_.*|python.*|pip_.*|virtual_env|conda_prefix)$/i.test(key)));
482
+ return {
483
+ ...kept,
484
+ UV_TOOL_DIR: path.join(layout.music, "tools"), UV_TOOL_BIN_DIR: path.dirname(layout.gentleStream),
485
+ UV_PYTHON_INSTALL_DIR: path.join(layout.music, "python"), UV_PYTHON_BIN_DIR: path.join(layout.music, "python", "bin"),
486
+ UV_CACHE_DIR: path.join(layout.music, "cache"),
487
+ UV_PYTHON: MUSIC_PYTHON, UV_PYTHON_PREFERENCE: "only-managed", UV_PYTHON_DOWNLOADS: "automatic", UV_NO_CONFIG: "1",
488
+ };
489
+ }
490
+
491
+ /** S5: the /gentle:music offer, after the user accepted it. Publishes the
492
+ * pinned uv (ensureUv), then `uv tool install`s the pinned gentle-stream
493
+ * archive with its `samples` extra into <prefix>/music (musicEnvironment);
494
+ * when that fails (pyfluidsynth, which needs the system FluidSynth library),
495
+ * once more without the extra. A marker naming the installed spec makes a
496
+ * second call a no-op. `out` gets one line per step. Returns the executable,
497
+ * whether the extra is in, and whether anything was installed.
498
+ */
499
+ export async function installMusicPlayer({ layout, platform = layout.platform, arch = process.arch, env = {}, adapters = {}, out = () => {} }) {
500
+ const run = adapters.run ?? runCommand;
501
+ const path = pathFor(process.platform);
502
+ const marker = path.join(layout.music, MUSIC_MARKER);
503
+ const done = read(marker);
504
+ if (installedMusicPlayer(layout) && (done === `${musicSpec(true)}\n` || done === `${musicSpec(false)}\n`)) {
505
+ return { gentleStream: layout.gentleStream, samples: done === `${musicSpec(true)}\n`, installed: false };
506
+ }
507
+ out(`Downloading uv ${uvPinVersion} into Gentle Shell…`);
508
+ await ensureUv({ layout, platform, arch, env, adapters });
509
+ ensureDirectory(layout.music, layout.platform);
510
+ const childEnv = musicEnvironment(layout, { env });
511
+ const install = (samples) => run(layout.uv, ["tool", "install", "--python", MUSIC_PYTHON, musicSpec(samples)], { cwd: layout.music, env: childEnv, timeout: INSTALL_TIMEOUT });
512
+ out(`Installing Python ${MUSIC_PYTHON} and gentle-stream; this can take a few minutes…`);
513
+ let samples = true;
514
+ let result = await install(true);
515
+ if (result.status !== 0) {
516
+ out("The samples extra could not be installed (it needs the system FluidSynth library); retrying without the samples extra…");
517
+ samples = false;
518
+ result = await install(false);
519
+ }
520
+ if (result.status !== 0 || !installedMusicPlayer(layout)) {
521
+ const reason = String(result.stderr ?? "").trim().split("\n").filter(Boolean).at(-1);
522
+ throw new Error(`uv could not install gentle-stream${reason ? `: ${reason}` : ""}`);
523
+ }
524
+ replaceAtomically(marker, (temporary) => writeFileSync(temporary, `${musicSpec(samples)}\n`, { flag: "wx", mode: 0o600 }));
525
+ // The downloaded wheels and archives are not needed once installed.
526
+ await run(layout.uv, ["cache", "clean"], { cwd: layout.music, env: childEnv, timeout: INSTALL_TIMEOUT });
527
+ return { gentleStream: layout.gentleStream, samples, installed: true };
528
+ }
529
+
415
530
  /** Our pinned Node with its bundled npm, and pnpm, in runtime/ (and the pinned
416
531
  * Go only when `go`, the pinned Engram only when `engram`), each downloaded
417
532
  * through verifiedDownload, checked by running it from its staging folder,
@@ -670,11 +785,16 @@ function pinnedGo(layout, go) {
670
785
  * not allowed. An installed version is reused; a folder left by an interrupted
671
786
  * install of this id (its process gone) is removed and installed again; any
672
787
  * other folder is never replaced. On Windows the pinned Go is required, so
673
- * gentle-pi's postinstall never builds with the user's go.exe. Adapters (run,
674
- * and distribution for local fixture packages in place of the registry) are
788
+ * gentle-pi's postinstall never builds with the user's go.exe. `unverified`
789
+ * (`gentle-shell update --latest` only) installs without a release lockfile:
790
+ * none is written beforehand and pnpm resolves the exact versions itself
791
+ * (`--no-frozen-lockfile`, so a CI environment cannot turn it frozen; the
792
+ * pnpm-lock.yaml pnpm leaves behind is never read again); the installed
793
+ * versions are still checked exactly. Adapters (run, and
794
+ * distribution for local fixture packages in place of the registry) are
675
795
  * trusted local test code only.
676
796
  */
677
- export async function installVersion({ layout, manifest, lockfile, platform = layout.platform, env = {}, go = null, adapters = {} }) {
797
+ export async function installVersion({ layout, manifest, lockfile, unverified = false, platform = layout.platform, env = {}, go = null, adapters = {} }) {
678
798
  const files = (adapters.distribution ?? distributionFiles)(manifest);
679
799
  const { shell, pi, id } = versionsOf(manifest);
680
800
  const destination = versionDirectory(layout, id);
@@ -682,7 +802,7 @@ export async function installVersion({ layout, manifest, lockfile, platform = la
682
802
  const marker = path.join(destination, VERSION_MARKER);
683
803
  if (installedVersion(layout, id)) return { id, path: destination, installed: false };
684
804
  if (platform === "win32" && !pinnedGo(layout, go)) throw new Error("A Windows version install needs the pinned Go from ensureRuntime({ go: true })");
685
- if (typeof lockfile !== "string" || lockfile.length === 0) throw new Error("Release lockfile missing");
805
+ if (!unverified && (typeof lockfile !== "string" || lockfile.length === 0)) throw new Error("Release lockfile missing");
686
806
  const existing = stat(destination);
687
807
  if (existing) {
688
808
  const pending = existing.isDirectory() ? /^installing (\S+) ([1-9]\d*)\n$/.exec(read(marker) ?? "") : null;
@@ -695,8 +815,8 @@ export async function installVersion({ layout, manifest, lockfile, platform = la
695
815
  mkdirSync(destination, { mode: 0o700 });
696
816
  try {
697
817
  writeFileSync(marker, `installing ${id} ${process.pid}\n`, { flag: "wx", mode: 0o600 });
698
- for (const [name, text] of Object.entries({ ...files, "pnpm-lock.yaml": lockfile })) writeFileSync(path.join(destination, name), text, { flag: "wx", mode: 0o600 });
699
- const result = await (adapters.run ?? runCommand)(layout.node, [layout.pnpm, "install", "--frozen-lockfile"],
818
+ for (const [name, text] of Object.entries(unverified ? files : { ...files, "pnpm-lock.yaml": lockfile })) writeFileSync(path.join(destination, name), text, { flag: "wx", mode: 0o600 });
819
+ const result = await (adapters.run ?? runCommand)(layout.node, [layout.pnpm, "install", unverified ? "--no-frozen-lockfile" : "--frozen-lockfile"],
700
820
  { cwd: destination, env: pnpmEnvironment(layout, { platform, env, go }), timeout: INSTALL_TIMEOUT });
701
821
  // pnpm prints install and postinstall failures on stdout: keep both tails.
702
822
  if (result.status !== 0) {
@@ -704,7 +824,7 @@ export async function installVersion({ layout, manifest, lockfile, platform = la
704
824
  { stderr: [String(result.stdout ?? "").slice(-6000), String(result.stderr ?? "").slice(-4000)].filter(Boolean).join("\n") });
705
825
  }
706
826
  const shellDirectory = installedPackage(destination, "gentle-pi", shell, "gentle-pi");
707
- installedPackage(destination, "@earendil-works/pi-coding-agent", pi, "Pi");
827
+ installedPackage(destination, PI_PACKAGE, pi, "Pi");
708
828
  if (!regular(path.join(shellDirectory, "bin", "gentle-shell.mjs"))) throw new Error("Installed gentle-pi has no launcher entry");
709
829
  replaceAtomically(marker, (temp) => writeFileSync(temp, `${id}\n`, { flag: "wx", mode: 0o600 }));
710
830
  return { id, path: destination, installed: true };
@@ -882,11 +1002,12 @@ function activateWithLauncher(layout, id, active, { module, next }) {
882
1002
  * print exactly the release's gentle-pi and Pi before `current` switches; the
883
1003
  * release's launcher is then written (a failure puts `current` back) and two
884
1004
  * versions are kept (a pruning failure is only a warning). An already current
885
- * release changes nothing; any failure leaves `current` as it was and removes a
886
- * version this call installed. Returns { id, changed, previous?, removed?, warning? }.
1005
+ * release changes nothing, and so does a release of the active Gentle Shell
1006
+ * whose Pi is older than the one `gentle-shell update --latest` installed (no
1007
+ * downgrade); any failure leaves `current` as it was and removes a version this
1008
+ * call installed. Returns { id, changed, previous?, removed?, warning? }.
887
1009
  */
888
1010
  export async function upgradeBundled({ layout, home, platform = layout.platform, arch = process.arch, env = {}, adapters, out = () => {} }) {
889
- const path = pathFor(process.platform);
890
1011
  const latest = await adapters.latest();
891
1012
  const release = await adapters.distribution(latest);
892
1013
  if (release?.status !== "published") throw new Error((UNPUBLISHED[release?.status] ?? UNPUBLISHED.unavailable)(latest));
@@ -897,9 +1018,40 @@ export async function upgradeBundled({ layout, home, platform = layout.platform,
897
1018
  out(`Gentle Shell ${shell} with Pi ${pi} is already current.`);
898
1019
  return { id, changed: false };
899
1020
  }
1021
+ const activePi = piOfVersion(active, shell);
1022
+ if (activePi && newerVersion(activePi, pi)) {
1023
+ out(`Gentle Shell ${shell} is current; Pi ${activePi} (installed with 'gentle-shell update --latest') is newer than this release's verified Pi ${pi}.`);
1024
+ return { id: active, changed: false };
1025
+ }
1026
+ return installAndActivate({ layout, home, platform, arch, env, adapters, out, manifest, lockfile, active,
1027
+ success: `Upgraded to Gentle Shell ${shell} with Pi ${pi} (from ${active ?? "no active version"}). Run 'gentle-shell upgrade --rollback' to switch back.` });
1028
+ }
1029
+
1030
+ /** The Pi of version id `<shell>-<pi>` for exactly this `shell`, or null. */
1031
+ function piOfVersion(id, shell) {
1032
+ const pi = typeof id === "string" && id.startsWith(`${shell}-`) ? id.slice(shell.length + 1) : "";
1033
+ return SEMVER.test(pi) ? pi : null;
1034
+ }
1035
+ /** Whether version `a` is newer than `b` by major, minor and patch. */
1036
+ function newerVersion(a, b) {
1037
+ const [left, right] = [a, b].map((version) => SEMVER.exec(version).slice(1, 4).map(Number));
1038
+ const index = left.findIndex((part, at) => part !== right[at]);
1039
+ return index !== -1 && left[index] > right[index];
1040
+ }
1041
+
1042
+ /** Installs `manifest` beside the active version (with the release lockfile,
1043
+ * or `unverified` without one) and switches to it the way upgradeBundled
1044
+ * always has: our runtime first, then the new version's own module and pinned
1045
+ * runtime, its launcher checked for exactly `shell` and `pi`, `current`
1046
+ * switched with that launcher, `success` printed and two versions kept (a
1047
+ * pruning failure is only a warning). Any failure leaves `current` as it was
1048
+ * and removes a version this call installed.
1049
+ */
1050
+ async function installAndActivate({ layout, home, platform, arch, env, adapters, out, manifest, lockfile, unverified = false, active, success }) {
1051
+ const { shell, pi, id } = versionsOf(manifest);
900
1052
  const go = platform === "win32";
901
1053
  const runtime = await (adapters.ensureRuntime ?? ensureRuntime)({ layout, platform, arch, go, env });
902
- const version = await installVersion({ layout, manifest, lockfile, platform, env, go: runtime.go, adapters: adapters.install ?? {} });
1054
+ const version = await installVersion({ layout, manifest, lockfile, unverified, platform, env, go: runtime.go, adapters: adapters.install ?? {} });
903
1055
  try {
904
1056
  const loaded = await loadRelease({ layout, id, home, platform, env, adapters, active });
905
1057
  // The release's own pins, its Engram included (an older module ignores `engram`).
@@ -914,7 +1066,7 @@ export async function upgradeBundled({ layout, home, platform = layout.platform,
914
1066
  if (version.installed && activeVersion(layout) !== id) rmSync(version.path, { recursive: true, force: true });
915
1067
  throw error;
916
1068
  }
917
- out(`Upgraded to Gentle Shell ${shell} with Pi ${pi} (from ${active ?? "no active version"}). Run 'gentle-shell upgrade --rollback' to switch back.`);
1069
+ out(success);
918
1070
  try {
919
1071
  return { id, changed: true, previous: active, removed: (adapters.prune ?? pruneVersions)(layout, 2) };
920
1072
  } catch (error) {
@@ -924,6 +1076,50 @@ export async function upgradeBundled({ layout, home, platform = layout.platform,
924
1076
  }
925
1077
  }
926
1078
 
1079
+ /** The newest published stable Pi: our pnpm's `view` of Pi's `latest`
1080
+ * dist-tag, with the prefix npmrc (its registry and credentials). Only an
1081
+ * exact major.minor.patch is accepted.
1082
+ */
1083
+ export async function publishedPi(layout, { platform = layout.platform, env = {}, run = runCommand } = {}) {
1084
+ const result = await (run ?? runCommand)(layout.node, [layout.pnpm, "view", `${PI_PACKAGE}@latest`, "version"],
1085
+ { cwd: layout.root, env: pnpmEnvironment(layout, { platform, env }), timeout: 60000 });
1086
+ if (result.status !== 0) {
1087
+ throw Object.assign(new Error(`the latest published Pi could not be resolved (pnpm view exit ${result.status})`), { stderr: String(result.stderr ?? "").slice(-4000) });
1088
+ }
1089
+ const version = String(result.stdout ?? "").trim();
1090
+ if (!STABLE.test(version)) throw new Error(`the latest published Pi version ${JSON.stringify(version)} was rejected`);
1091
+ return version;
1092
+ }
1093
+
1094
+ /** `gentle-shell update --latest` in a bundled install (S3): the newest
1095
+ * published stable Pi (adapters.latestPi) installed with this Gentle Shell
1096
+ * `shell` as version `<shell>-<pi>`, without the release's verified lockfile
1097
+ * (installVersion `unverified`), after a warning; then verified and activated
1098
+ * exactly like upgradeBundled (installAndActivate). An active version that
1099
+ * already has that Pi, or a newer one, changes nothing. Returns what upgradeBundled returns.
1100
+ */
1101
+ export async function upgradePiLatest({ layout, home, shell, platform = layout.platform, arch = process.arch, env = {}, adapters, out = () => {} }) {
1102
+ const latest = await adapters.latestPi();
1103
+ if (!STABLE.test(String(latest))) throw new Error(`the latest published Pi version ${JSON.stringify(latest)} was rejected`);
1104
+ const manifest = { shell, pi: latest };
1105
+ const { pi, id } = versionsOf(manifest);
1106
+ const active = activeVersion(layout);
1107
+ if (active === id) {
1108
+ out(`Pi ${pi} is already the latest published Pi.`);
1109
+ return { id, changed: false };
1110
+ }
1111
+ const activePi = piOfVersion(active, shell);
1112
+ if (!activePi) throw new Error(`the active version ${active ?? "(none)"} is not Gentle Shell ${shell}; current stays as it is`);
1113
+ // A `latest` dist-tag moved back (or a release pinning a Pi newer than it) never downgrades Pi.
1114
+ if (!newerVersion(pi, activePi)) {
1115
+ out(`Pi ${activePi} is newer than the latest published Pi (${pi}); nothing changed.`);
1116
+ return { id: active, changed: false };
1117
+ }
1118
+ 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.`);
1119
+ return installAndActivate({ layout, home, platform, arch, env, adapters, out, manifest, unverified: true, active,
1120
+ success: `Installed Pi ${pi} with Gentle Shell ${shell} (from ${active}). Run 'gentle-shell upgrade --rollback' to switch back.` });
1121
+ }
1122
+
927
1123
  /** Whether version `id` reports exactly `gentle-shell <shell>` and `pi <pi>` to
928
1124
  * `--version`: its entry run by our Node, or with `launcher` on POSIX the
929
1125
  * bin/gentle-shell launcher itself (it runs the version `current` names).
@@ -57,6 +57,23 @@ const engram = Object.freeze({
57
57
  });
58
58
  /** The Engram version the bundled install publishes under runtime/. */
59
59
  export const engramPinVersion = engram.version;
60
+ // uv for the /gentle:music offer (installMusicPlayer), never part of the default
61
+ // install: the astral-sh/uv release. Each sha256 and size comes from GitHub's
62
+ // asset digests; every archive was downloaded and re-hashed before pinning.
63
+ const uv = Object.freeze({
64
+ version: "0.13.6",
65
+ redirectHosts: engram.redirectHosts,
66
+ archives: Object.freeze({
67
+ "darwin-arm64": ["uv-aarch64-apple-darwin.tar.gz", "60fbd5c8a1ed681845eea28d9d622fcde03c51e91a43465b64a1e17db1ed3472", 14235670],
68
+ "darwin-x64": ["uv-x86_64-apple-darwin.tar.gz", "63ba17fdda55ad1a230d4b37fc6017ffa10c23688c83818bd3bfdf914c652a02", 17004934],
69
+ "linux-x64": ["uv-x86_64-unknown-linux-gnu.tar.gz", "f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645", 16799024],
70
+ "linux-arm64": ["uv-aarch64-unknown-linux-gnu.tar.gz", "229e194255c7958d86d56e8790d969a6f74736592133f0dfd0076f754c642c1d", 15794886],
71
+ "win32-x64": ["uv-x86_64-pc-windows-msvc.zip", "a2a8820d5eea32f08ef467ef3b7f66883a9b076d5737a2fc2fbb5f458d1bb50f", 15239500],
72
+ "win32-arm64": ["uv-aarch64-pc-windows-msvc.zip", "c2426c0c8e28b53d8c440e47a18d96ad4db51e159866c0e81435b196d7f0081c", 15439263],
73
+ }),
74
+ });
75
+ /** The uv version the music offer publishes under runtime/. */
76
+ export const uvPinVersion = uv.version;
60
77
 
61
78
  /** Fixed allowlist; callers cannot supply download URLs, checksums or commands. */
62
79
  export function artifactFor(name, platform, arch) {
@@ -77,6 +94,13 @@ export function artifactFor(name, platform, arch) {
77
94
  url: `https://github.com/Gentleman-Programming/engram/releases/download/v${engram.version}/engram_${engram.version}_${suffix}`,
78
95
  integrity: `sha256-${sha256}`, size, maxBytes: size, redirectHosts: engram.redirectHosts });
79
96
  }
97
+ if (name === "uv") {
98
+ const pin = Object.hasOwn(uv.archives, `${platform}-${arch}`) ? uv.archives[`${platform}-${arch}`] : null;
99
+ if (!pin) throw new Error("Unsupported acquisition target");
100
+ const [asset, sha256, size] = pin;
101
+ return Object.freeze({ name, version: uv.version, platform, arch, url: `https://github.com/astral-sh/uv/releases/download/${uv.version}/${asset}`,
102
+ integrity: `sha256-${sha256}`, size, maxBytes: size, redirectHosts: uv.redirectHosts });
103
+ }
80
104
  if (name === "go") {
81
105
  const pin = Object.hasOwn(go.archives, `${platform}-${arch}`) ? go.archives[`${platform}-${arch}`] : null;
82
106
  if (!pin) throw new Error("Unsupported acquisition target");
@@ -25,7 +25,7 @@ export const requirements = Object.freeze({
25
25
  /** Pi version the installer installs next to gentle-pi, and the version an older
26
26
  * Pi is updated to (never below requirements.pi; the runner asserts it).
27
27
  */
28
- export const PI_INSTALL_VERSION = "1.2.0";
28
+ export const PI_INSTALL_VERSION = "1.4.0";
29
29
 
30
30
  /** Runtime persisted under PNPM_HOME. Bootstrap-only Node: `pnpm runtime set
31
31
  * node <node> -g`, then `pnpm add -g npm@<npm> pnpm@<pnpm>`, or only npm when
@@ -21,7 +21,9 @@ const SHIM_TARGET = /"((?:\$basedir[\\/]|%~?dp0%?[\\/])?[^"]*?[\\/]dist[\\/]bund
21
21
  export function managedUpdatePlan(args) {
22
22
  // The legacy extension-only alias must never trigger self-update.
23
23
  if (args.includes('--extensions-only')) return {self:false, piArgs:args};
24
- return bundledUpdatePlan(args);
24
+ // Its self update already installs the latest Pi, so --latest changes nothing here.
25
+ const {latest: _latest, ...plan} = bundledUpdatePlan(args);
26
+ return plan;
25
27
  }
26
28
 
27
29
  function pathsFor(platform) {