gentle-pi 4.4.0 → 4.5.0

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 (44) hide show
  1. package/README.md +2 -2
  2. package/bin/gentle-shell.mjs +37 -4
  3. package/docs/gentle-music.md +24 -3
  4. package/docs/gentle-shell.md +5 -5
  5. package/docs/readme-reference.md +58 -45
  6. package/extensions/gentle-agents.ts +14 -2
  7. package/extensions/gentle-ai.ts +1672 -1237
  8. package/extensions/gentle-music.ts +73 -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/gentle-agents.test.ts +4 -4
  29. package/tests/gentle-ai.test.ts +810 -609
  30. package/tests/gentle-music.test.ts +117 -0
  31. package/tests/gentle-shell-bundled.test.ts +20 -0
  32. package/tests/gentle-shell-launcher.test.ts +1 -0
  33. package/tests/gentle-shell.test.ts +44 -19
  34. package/tests/installer-downloads.test.ts +32 -0
  35. package/tests/installer-server.test.ts +2 -2
  36. package/tests/jobs-widget.test.ts +138 -0
  37. package/tests/model-profiles-preservation.test.ts +2 -2
  38. package/tests/package-manifest.test.ts +2 -2
  39. package/tests/pi-update.test.ts +5 -0
  40. package/tests/runtime-harness.mjs +188 -211
  41. package/tests/session-profile-freeze.test.ts +6 -4
  42. package/tests/shell-bar.test.ts +4 -4
  43. package/tests/shell-sidebar-layout.test.ts +17 -0
  44. package/tests/vim-editor-adapter.test.ts +2 -2
@@ -6,9 +6,9 @@ import { tmpdir } from "node:os";
6
6
  import { dirname, join, posix, win32 } from "node:path";
7
7
  import test, { after, type TestContext } from "node:test";
8
8
  import { crc32, gzipSync } from "node:zlib";
9
- import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, filterNpmrc, installVersion, nodeExecutable,
9
+ import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, ensureUv, filterNpmrc, installMusicPlayer, installedMusicPlayer, installVersion, nodeExecutable, uvExecutable,
10
10
  nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, bundledNpmCommand, ensureNpmShim, upgradeBundled, userNpmrc, writeNpmrcAuth,
11
- describePathReceipt, readPathReceipt, scheduleWindowsRemoval, writePathReceipt }
11
+ publishedPi, upgradePiLatest, describePathReceipt, readPathReceipt, scheduleWindowsRemoval, writePathReceipt }
12
12
  from "../scripts/bundled-install.mjs";
13
13
 
14
14
  // Real directories, modes, symlinks and uids need a POSIX host.
@@ -383,6 +383,166 @@ test("engramExecutable takes only the top-level engram from the tarball and engr
383
383
  assert.throws(() => engramExecutable(zip([["engram", exe]]), zipped), /Engram archive rejected/);
384
384
  });
385
385
 
386
+ // S5: the /gentle:music offer. Only uv is a new runtime; it, the uv-managed
387
+ // Python and gentle-stream all live inside the prefix.
388
+ const UV = "0.13.6";
389
+ const uvBytes = Buffer.from("#!/bin/sh\necho fixture uv\n");
390
+ const uvUrl = `https://github.com/astral-sh/uv/releases/download/${UV}/uv-aarch64-apple-darwin.tar.gz`;
391
+ const uvArchive = tarGz([
392
+ tarEntry("uv-aarch64-apple-darwin/", { type: "5" }),
393
+ tarEntry("uv-aarch64-apple-darwin/uvx", { data: Buffer.from("#!/bin/sh\n"), mode: "0000755" }),
394
+ tarEntry("uv-aarch64-apple-darwin/uv", { data: uvBytes, mode: "0000755" }),
395
+ ]);
396
+ const PLAYER_ARCHIVE = "https://github.com/Gentleman-Programming/gentle-stream/archive/381290451544ad2ca3348a68a81b3056bdac73b7.tar.gz";
397
+ // A uv fixture: `--version` answers the pin; `tool install` writes the
398
+ // executable into UV_TOOL_BIN_DIR unless `fail(spec)` says the spec fails.
399
+ 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" } = {}) {
400
+ const calls: { downloads: string[]; runs: { command: string; args: string[]; cwd: string; env: Record<string, string>; timeout?: number }[] } = { downloads: [], runs: [] };
401
+ return {
402
+ calls,
403
+ adapters: {
404
+ artifact: (name: string) => {
405
+ assert.equal(name, "uv");
406
+ return { name, version: UV, maxBytes: 1 << 20, integrity: `sha256-${sha256(uvArchive)}`, url: uvUrl };
407
+ },
408
+ download: async (descriptor: { name: string }) => { calls.downloads.push(descriptor.name); return uvArchive; },
409
+ run: (command: string, args: string[], options: { cwd: string; env: Record<string, string>; timeout?: number }) => {
410
+ calls.runs.push({ command, args, ...options });
411
+ if (args.length === 1 && args[0] === "--version") return { status: 0, stdout: `${version}\n`, stderr: "" };
412
+ if (args[0] === "tool" && args[1] === "install") {
413
+ const spec = args.at(-1)!;
414
+ if (fail(spec)) return { status: 1, stdout: "", stderr: "error: Failed to build `pyfluidsynth`" };
415
+ // As uv does on POSIX: the executable in the tool's venv, linked from the bin folder.
416
+ if (writes !== "none") {
417
+ const target = writes === "inside" ? join(options.env.UV_TOOL_DIR, "gentle-stream", "bin", "gentle-stream") : join(dirname(options.cwd), "elsewhere");
418
+ mkdirSync(dirname(target), { recursive: true });
419
+ writeFileSync(target, "#!/bin/sh\n", { mode: 0o755 });
420
+ mkdirSync(options.env.UV_TOOL_BIN_DIR, { recursive: true });
421
+ symlinkSync(target, join(options.env.UV_TOOL_BIN_DIR, "gentle-stream"));
422
+ }
423
+ return { status: 0, stdout: "Installed 1 executable: gentle-stream\n", stderr: "" };
424
+ }
425
+ if (args[0] === "cache" && args[1] === "clean") return { status: 0, stdout: "", stderr: "" };
426
+ return { status: 1, stdout: "", stderr: "unexpected" };
427
+ },
428
+ },
429
+ };
430
+ }
431
+
432
+ test("prefixLayout keeps uv, the managed Python and the music player inside the prefix", () => {
433
+ const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/me" });
434
+ const root = "/home/me/.gentle-shell";
435
+ assert.equal(layout.uvDir, `${root}/runtime/uv-${UV}`);
436
+ assert.equal(layout.uv, `${root}/runtime/uv-${UV}/uv`);
437
+ assert.equal(layout.music, `${root}/music`);
438
+ assert.equal(layout.gentleStream, `${root}/music/bin/gentle-stream`);
439
+ const windows = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local" }, home: "C:\\Users\\me" });
440
+ assert.equal(windows.uv, `C:\\Users\\me\\AppData\\Local\\gentle-shell\\runtime\\uv-${UV}\\uv.exe`);
441
+ assert.equal(windows.gentleStream, "C:\\Users\\me\\AppData\\Local\\gentle-shell\\music\\bin\\gentle-stream.exe");
442
+ });
443
+
444
+ test("uvExecutable takes only <stem>/uv from the tarball and uv.exe from the zip, regular files only", () => {
445
+ assert.deepEqual(uvExecutable(uvArchive, { url: uvUrl }), uvBytes);
446
+ const zipped = { url: `https://github.com/astral-sh/uv/releases/download/${UV}/uv-x86_64-pc-windows-msvc.zip` };
447
+ const exe = Buffer.from("MZ uv");
448
+ assert.deepEqual(uvExecutable(zip([["uvx.exe", Buffer.from("MZ uvx")], ["uv.exe", exe], ["uvw.exe", Buffer.from("MZ uvw")]]), zipped), exe);
449
+ assert.throws(() => uvExecutable(tarGz([tarEntry("uv-aarch64-apple-darwin/uv", { type: "2", link: "/bin/sh" })]), { url: uvUrl }), /uv archive rejected/);
450
+ assert.throws(() => uvExecutable(tarGz([tarEntry("uv", { data: uvBytes })]), { url: uvUrl }), /uv archive rejected/);
451
+ assert.throws(() => uvExecutable(zip([["uv", exe]]), zipped), /uv archive rejected/);
452
+ });
453
+
454
+ posixTest("ensureUv publishes only the verified uv after running it, once", async () => {
455
+ const { layout } = claimed();
456
+ const { calls, adapters: fixture } = uvAdapters();
457
+ assert.equal(await ensureUv({ layout, platform: "darwin", arch: "arm64", adapters: fixture }), true);
458
+ assert.deepEqual(readdirSync(layout.uvDir).sort(), [".gentle-shell-runtime", "uv"]);
459
+ assert.deepEqual(readFileSync(layout.uv), uvBytes);
460
+ assert.equal(lstatSync(layout.uv).mode & 0o777, 0o700);
461
+ assert.equal(readFileSync(join(layout.uvDir, ".gentle-shell-runtime"), "utf8"), `${uvUrl}\n`);
462
+ assert.match(calls.runs[0]!.command, /\/runtime\/\.stage-[^/]+\/uv$/);
463
+ assert.equal(await ensureUv({ layout, platform: "darwin", arch: "arm64", adapters: fixture }), false, "reused");
464
+ assert.deepEqual(calls.downloads, ["uv"]);
465
+ // Another version's answer, or a tampered archive, publishes nothing.
466
+ for (const broken of [uvAdapters({ version: "uv 0.9.0" }).adapters, { ...uvAdapters().adapters, download: async () => Buffer.from("x") }]) {
467
+ const other = claimed().layout;
468
+ await assert.rejects(ensureUv({ layout: other, platform: "darwin", arch: "arm64", adapters: broken }), /uv|integrity|size/);
469
+ assert.deepEqual(readdirSync(other.runtime), []);
470
+ }
471
+ });
472
+
473
+ posixTest("installedMusicPlayer accepts only a player that resolves to a regular file inside music/", () => {
474
+ const { layout } = claimed();
475
+ assert.equal(installedMusicPlayer(layout), false, "missing");
476
+ mkdirSync(dirname(layout.gentleStream), { recursive: true });
477
+ const outside = join(mkdtempSync(join(tmpdir(), "gs-player-")), "gentle-stream");
478
+ writeFileSync(outside, "#!/bin/sh\n");
479
+ symlinkSync(outside, layout.gentleStream);
480
+ assert.equal(installedMusicPlayer(layout), false, "a link out of music/");
481
+ rmSync(layout.gentleStream);
482
+ const tool = join(layout.music, "tools", "gentle-stream", "bin", "gentle-stream");
483
+ mkdirSync(dirname(tool), { recursive: true });
484
+ writeFileSync(tool, "#!/bin/sh\n");
485
+ symlinkSync(tool, layout.gentleStream);
486
+ assert.equal(installedMusicPlayer(layout), true, "uv's link into music/tools");
487
+ rmSync(dirname(outside), { recursive: true, force: true });
488
+ });
489
+
490
+ posixTest("installMusicPlayer confines uv, Python and gentle-stream to the prefix and installs from the pinned archive", async () => {
491
+ const { layout } = claimed();
492
+ const { calls, adapters: fixture } = uvAdapters();
493
+ const out: string[] = [];
494
+ 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",
495
+ PYTHONUSERBASE: "/home/me/.pybase", PYTHONSTARTUP: "/home/me/startup.py", PIP_INDEX_URL: "https://pip.invalid" };
496
+ const result = await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env, adapters: fixture, out: (line: string) => out.push(line) });
497
+ assert.deepEqual(result, { gentleStream: layout.gentleStream, samples: true, installed: true });
498
+ const install = calls.runs.find((run) => run.args[0] === "tool")!;
499
+ assert.equal(install.command, layout.uv);
500
+ assert.deepEqual(install.args, ["tool", "install", "--python", "3.13", `gentle-stream[samples] @ ${PLAYER_ARCHIVE}`]);
501
+ assert.ok(!install.args.join(" ").includes("git+"), "an archive URL, so git is never needed");
502
+ assert.ok(install.cwd.startsWith(layout.root));
503
+ assert.equal(install.env.UV_TOOL_DIR, join(layout.music, "tools"));
504
+ assert.equal(install.env.UV_TOOL_BIN_DIR, join(layout.music, "bin"));
505
+ assert.equal(install.env.UV_PYTHON_INSTALL_DIR, join(layout.music, "python"));
506
+ assert.equal(install.env.UV_PYTHON_BIN_DIR, join(layout.music, "python", "bin"));
507
+ assert.equal(install.env.UV_CACHE_DIR, join(layout.music, "cache"));
508
+ assert.equal(install.env.UV_PYTHON, "3.13");
509
+ assert.equal(install.env.UV_PYTHON_PREFERENCE, "only-managed");
510
+ assert.equal(install.env.UV_NO_CONFIG, "1");
511
+ // The user's uv settings and virtualenv never reach it; the network settings do.
512
+ assert.equal(install.env.UV_INDEX_URL, undefined);
513
+ assert.equal(install.env.VIRTUAL_ENV, undefined);
514
+ for (const key of ["PYTHONUSERBASE", "PYTHONSTARTUP", "PIP_INDEX_URL"]) assert.equal(install.env[key], undefined, key);
515
+ assert.equal(install.env.HTTPS_PROXY, "http://proxy:3128");
516
+ assert.ok(out.length >= 2, "progress is reported");
517
+ assert.deepEqual(calls.runs.at(-1)!.args, ["cache", "clean"], "the download cache is emptied afterwards");
518
+ // Idempotent: a second call downloads and runs nothing.
519
+ const before = calls.runs.length;
520
+ assert.deepEqual(await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env, adapters: fixture }), { gentleStream: layout.gentleStream, samples: true, installed: false });
521
+ assert.equal(calls.runs.length, before);
522
+ assert.deepEqual(calls.downloads, ["uv"]);
523
+ });
524
+
525
+ posixTest("installMusicPlayer retries without the samples extra when it cannot be installed, and says so", async () => {
526
+ const { layout } = claimed();
527
+ const { calls, adapters: fixture } = uvAdapters({ fail: (spec) => spec.includes("[samples]") });
528
+ const out: string[] = [];
529
+ const result = await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env: {}, adapters: fixture, out: (line: string) => out.push(line) });
530
+ assert.deepEqual(result, { gentleStream: layout.gentleStream, samples: false, installed: true });
531
+ 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}`]);
532
+ assert.ok(out.some((line) => /without the samples extra/i.test(line) && /FluidSynth/.test(line)));
533
+ // Reused without samples too, so no retry loop on every /gentle:music.
534
+ assert.deepEqual(await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env: {}, adapters: fixture }), { gentleStream: layout.gentleStream, samples: false, installed: false });
535
+ });
536
+
537
+ posixTest("installMusicPlayer fails clearly when uv cannot install the player at all", async () => {
538
+ // uv failing twice, no executable, or one that leaves <prefix>/music.
539
+ for (const fixture of [uvAdapters({ fail: () => true }), uvAdapters({ writes: "none" }), uvAdapters({ writes: "outside" })]) {
540
+ const { layout } = claimed();
541
+ await assert.rejects(installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env: {}, adapters: fixture.adapters }), /gentle-stream/);
542
+ assert.equal(existsSync(join(layout.music, ".gentle-shell-music")), false);
543
+ }
544
+ });
545
+
386
546
  test("nodeExecutable takes only node.exe from the Windows zip and only bin/node from the tarball", () => {
387
547
  const stem = `node-v${NODE}-win-x64`;
388
548
  const exe = Buffer.from("MZ fixture");
@@ -1175,6 +1335,117 @@ posixTest("upgradeBundled reports success with a warning when only pruning fails
1175
1335
  assert.match(lines.join("\n"), /Upgraded to Gentle Shell 4\.2\.0.*warning: older versions were not removed \(EBUSY\)/s);
1176
1336
  });
1177
1337
 
1338
+ posixTest("upgradeBundled never downgrades a newer Pi installed with update --latest, and a newer Gentle Shell release still upgrades", async () => {
1339
+ const { user, layout } = await activeInstall();
1340
+ await installed(layout, [["4.1.0", "1.0.5"]]);
1341
+ activateVersion(layout, "4.1.0-1.0.5");
1342
+ const same = upgradeAdapters(layout, { release: { shell: "4.1.0", pi: "1.0.1" } });
1343
+ const lines: string[] = [];
1344
+ const result = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: same.adapters, out: (line: string) => lines.push(line) });
1345
+ assert.deepEqual(result, { id: "4.1.0-1.0.5", changed: false });
1346
+ 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."]);
1347
+ assert.deepEqual([same.calls.ensureRuntime.length, same.calls.verify.length, same.calls.loaded.length], [0, 0, 0]);
1348
+ assert.equal(activeVersion(layout), "4.1.0-1.0.5");
1349
+ const newer = upgradeAdapters(layout);
1350
+ const upgraded = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: newer.adapters });
1351
+ assert.equal(upgraded.id, "4.2.0-1.0.2");
1352
+ assert.equal(activeVersion(layout), "4.2.0-1.0.2");
1353
+ });
1354
+
1355
+ function latestAdapters(layout: ReturnType<typeof prefixLayout>, { latestPi = "1.0.5", reported = { shell: "4.1.0", pi: latestPi } } = {}) {
1356
+ const upgrade = upgradeAdapters(layout, { release: { shell: "4.1.0", pi: latestPi }, reported });
1357
+ const pnpm = fakePnpm({ shell: "4.1.0", pi: latestPi });
1358
+ const { latest: _latest, distribution: _distribution, ...rest } = upgrade.adapters;
1359
+ return { calls: upgrade.calls, pnpm, adapters: { ...rest, install: { run: pnpm.run }, latestPi: async () => latestPi } };
1360
+ }
1361
+
1362
+ posixTest("upgradePiLatest changes nothing when the active version already has the latest published Pi", async () => {
1363
+ const { user, layout } = await activeInstall();
1364
+ const { adapters, calls, pnpm } = latestAdapters(layout, { latestPi: "1.0.1" });
1365
+ const lines: string[] = [];
1366
+ const result = await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) });
1367
+ assert.deepEqual(result, { id: "4.1.0-1.0.1", changed: false });
1368
+ assert.deepEqual(lines, ["Pi 1.0.1 is already the latest published Pi."]);
1369
+ assert.deepEqual([pnpm.runs.length, calls.ensureRuntime.length, calls.verify.length], [0, 0, 0]);
1370
+ assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
1371
+ });
1372
+
1373
+ posixTest("upgradePiLatest never downgrades when the latest published Pi is older than the active Pi", async () => {
1374
+ const { user, layout } = await activeInstall();
1375
+ const { adapters, calls, pnpm } = latestAdapters(layout, { latestPi: "1.0.0" });
1376
+ const lines: string[] = [];
1377
+ const result = await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) });
1378
+ assert.deepEqual(result, { id: "4.1.0-1.0.1", changed: false });
1379
+ assert.deepEqual(lines, ["Pi 1.0.1 is newer than the latest published Pi (1.0.0); nothing changed."]);
1380
+ assert.deepEqual([pnpm.runs.length, calls.ensureRuntime.length, calls.verify.length], [0, 0, 0]);
1381
+ assert.equal(activeVersion(layout), "4.1.0-1.0.1");
1382
+ assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
1383
+ });
1384
+
1385
+ posixTest("upgradePiLatest warns, installs the newest Pi without a lockfile beside the active version, verifies and switches current", async () => {
1386
+ const { user, layout } = await activeInstall();
1387
+ const { adapters, calls, pnpm } = latestAdapters(layout);
1388
+ const lines: string[] = [];
1389
+ adapters.install.run = (command: string, args: string[], options: { cwd: string; env: Record<string, string> }) => {
1390
+ lines.push("<install>");
1391
+ return fakePnpm({ shell: "4.1.0", pi: "1.0.5" }).run(command, args, options);
1392
+ };
1393
+ const result = await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) });
1394
+ 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"] });
1395
+ assert.equal(activeVersion(layout), "4.1.0-1.0.5");
1396
+ assert.deepEqual(lines, [
1397
+ "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.",
1398
+ "<install>",
1399
+ "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.",
1400
+ ]);
1401
+ const folder = join(layout.versions, "4.1.0-1.0.5");
1402
+ assert.equal(existsSync(join(folder, "pnpm-lock.yaml")), false);
1403
+ 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);
1404
+ assert.deepEqual(calls.loaded, [releaseModuleFile(layout, "4.1.0-1.0.5")]);
1405
+ assert.equal(calls.verify[0].env.GENTLE_SHELL_VERIFY_VERSION, "4.1.0-1.0.5");
1406
+ assert.equal(pnpm.runs.length, 0);
1407
+ });
1408
+
1409
+ posixTest("upgradePiLatest installs with plain pnpm install, never the frozen lockfile", async () => {
1410
+ const { user, layout } = await activeInstall();
1411
+ const { adapters, pnpm } = latestAdapters(layout);
1412
+ await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters });
1413
+ assert.equal(pnpm.runs.length, 1);
1414
+ assert.equal(pnpm.runs[0].command, layout.node);
1415
+ assert.equal(pnpm.runs[0].args[0], layout.pnpm);
1416
+ assert.equal(pnpm.runs[0].args[1], "install");
1417
+ assert.equal(pnpm.runs[0].args.includes("--frozen-lockfile"), false);
1418
+ assert.deepEqual(pnpm.runs[0].args.slice(1), ["install", "--no-frozen-lockfile"]);
1419
+ assert.equal(pnpm.runs[0].env.pnpm_config_store_dir, layout.store);
1420
+ });
1421
+
1422
+ posixTest("upgradePiLatest leaves current untouched and removes the new folder when verification fails, and refuses a prerelease", async () => {
1423
+ const { user, layout } = await activeInstall();
1424
+ const wrong = latestAdapters(layout, { reported: { shell: "4.1.0", pi: "1.0.1" } });
1425
+ 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);
1426
+ assert.equal(activeVersion(layout), "4.1.0-1.0.1");
1427
+ assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
1428
+ const beta = latestAdapters(layout, { latestPi: "1.1.0-beta.1" });
1429
+ await assert.rejects(upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters: beta.adapters }), /latest published Pi.*rejected/);
1430
+ assert.equal(beta.pnpm.runs.length, 0);
1431
+ assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
1432
+ });
1433
+
1434
+ posixTest("publishedPi asks our pnpm for Pi's latest dist-tag with the prefix npmrc and accepts only an exact stable version", async () => {
1435
+ const { layout } = claimed();
1436
+ const runs: { command: string; args: string[]; env: Record<string, string> }[] = [];
1437
+ const run = (stdout: string, status = 0) => async (command: string, args: string[], options: { env: Record<string, string> }) => {
1438
+ runs.push({ command, args, env: options.env });
1439
+ return { status, stdout, stderr: "" };
1440
+ };
1441
+ assert.equal(await publishedPi(layout, { platform: "darwin", env: {}, run: run("1.4.0\n") }), "1.4.0");
1442
+ assert.equal(runs[0].command, layout.node);
1443
+ assert.deepEqual(runs[0].args, [layout.pnpm, "view", "@earendil-works/pi-coding-agent@latest", "version"]);
1444
+ assert.equal(runs[0].env.pnpm_config_npmrc_auth_file, layout.npmrc);
1445
+ 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);
1446
+ await assert.rejects(publishedPi(layout, { platform: "darwin", env: {}, run: run("", 1) }), /exit 1/);
1447
+ });
1448
+
1178
1449
  function rollbackAdapters(module: Record<string, unknown> = {}) {
1179
1450
  const calls: { loaded: string[]; runtimes: unknown[]; engram: unknown[]; launchers: unknown[] } = { loaded: [], runtimes: [], engram: [], launchers: [] };
1180
1451
  const releaseModule = { prefixLayout, ensureRuntime: async (options: { layout: unknown; engram?: boolean }) => {
@@ -362,7 +362,6 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
362
362
  );
363
363
  const byTitle = (title: string) => COMMAND_PALETTE_CATALOG.find((group) => group.title === title)?.items.map((item) => item.command);
364
364
  assert.deepEqual(byTitle("Configuration"), [
365
- "gentle:models",
366
365
  "gentle:profiles",
367
366
  "gentle:persona",
368
367
  "gentle:review-mode",
@@ -407,7 +406,7 @@ test("retired SDD commands are absent from the palette and extension registratio
407
406
 
408
407
  test("buildCommandPaletteGroups keeps only registered commands, attaches descriptions and shortcuts, drops empty groups, preserves catalog order", () => {
409
408
  const registered = [
410
- { name: "gentle:models", description: "Configure models" },
409
+ { name: "gentle:profiles", description: "Configure models" },
411
410
  { name: "gentle:changes", description: "Browse changes" },
412
411
  { name: "gentle:status", description: "Show status" },
413
412
  { name: "skill-registry:refresh", description: "Refresh registry" },
@@ -419,7 +418,8 @@ test("buildCommandPaletteGroups keeps only registered commands, attaches descrip
419
418
  ["Configuration", "Session", "Diagnostics", "Skills"],
420
419
  );
421
420
  const configuration = groups.find((group) => group.title === "Configuration");
422
- assert.deepEqual(configuration?.items.map((item) => item.command), ["gentle:models"]);
421
+ assert.deepEqual(configuration?.items.map((item) => item.command), ["gentle:profiles"]);
422
+ assert.equal(configuration?.items[0]?.label, "Models and profiles");
423
423
  assert.equal(configuration?.items[0]?.description, "Configure models");
424
424
  assert.equal(configuration?.items[0]?.shortcut, undefined);
425
425
  const session = groups.find((group) => group.title === "Session");
@@ -3473,7 +3473,7 @@ test("session_start freezes the inherited pin: a later pin change does not move
3473
3473
  assert.equal(await session.launch(), "openai/alpha:minimal");
3474
3474
  assert.equal(await session.launch(), "openai/alpha:minimal");
3475
3475
  assert.deepEqual(session.driftNotices(), [
3476
- 'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
3476
+ 'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
3477
3477
  ], "one notice for one change, across two launches");
3478
3478
  base.writeStore(DRIFT_PROFILES, "local");
3479
3479
  base.writeDeclaration("local");
@@ -3502,7 +3502,7 @@ test("a session with no profile anywhere keeps today's routing even after a pin
3502
3502
  base.writePin("frontier");
3503
3503
  assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low");
3504
3504
  assert.deepEqual(session.driftNotices(), [
3505
- 'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Press Enter on a profile in /gentle:profiles to adopt it.',
3505
+ 'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Open /gentle:profiles and choose Use in this session to adopt one.',
3506
3506
  ]);
3507
3507
  });
3508
3508
 
@@ -6654,7 +6654,7 @@ test("/gentle:jobs opens this session's jobs overlay, stops the selected job, an
6654
6654
  gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6655
6655
  const { ctx, overlays } = fakeContext();
6656
6656
  const statuses: Array<string | undefined> = [];
6657
- const snapshots: Array<{ sessionId: string; snapshot: { jobs: Array<{ id: string; label: string; startedAt: number }> } }> = [];
6657
+ const snapshots: Array<{ sessionId: string; snapshot: { jobs: Array<{ id: string; label: string; startedAt: number; status?: string }> } }> = [];
6658
6658
  const unsubscribe = pi.events.on("gentle-ai:jobs-sidebar", (value) => { snapshots.push(value as typeof snapshots[number]); });
6659
6659
  Object.assign(ctx.ui, { setStatus: (key: string, value: string | undefined) => { if (key === "gentle-jobs") statuses.push(value); } });
6660
6660
  try {
@@ -6672,7 +6672,7 @@ test("/gentle:jobs opens this session's jobs overlay, stops the selected job, an
6672
6672
  view.handleInput("s");
6673
6673
  assert.equal(jobs.runs[0]!.signal?.aborted, true, "s stops the selected job");
6674
6674
  assert.equal(statuses.at(-1), undefined, "the footer count clears when nothing runs");
6675
- assert.deepEqual(snapshots.at(-1)?.snapshot.jobs, [], "the Status descriptions clear when nothing runs");
6675
+ assert.deepEqual(snapshots.at(-1)?.snapshot.jobs.map(({ status }) => status), ["stopped"], "the finished tail keeps the stopped job visible once the count clears");
6676
6676
  await jobIo();
6677
6677
  const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
6678
6678
  assert.equal(notices.length, 1, "the agent was told it would be notified, so a human stop is reported once");