gentle-pi 4.3.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 (218) hide show
  1. package/README.md +10 -7
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +138 -155
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +82 -0
  16. package/docs/gentle-shell.md +80 -10
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +72 -55
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +187 -40
  25. package/extensions/gentle-ai.ts +1765 -1185
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +354 -0
  28. package/extensions/gentle-shell.ts +418 -79
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +3 -2
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +141 -57
  52. package/lib/gentle-shell-launcher.ts +11 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/jobs-sidebar-state.ts +22 -3
  58. package/lib/jobs-widget.ts +112 -0
  59. package/lib/nan-provider.ts +3 -0
  60. package/lib/native-review-cli.ts +11 -4
  61. package/lib/notification-audio-native.ts +149 -13
  62. package/lib/notification-audio.ts +4 -0
  63. package/lib/notification-pulse-worker.ts +82 -18
  64. package/lib/notification-service.ts +5 -1
  65. package/lib/orchestrator-presence.ts +78 -8
  66. package/lib/orchestrator-scope.ts +10 -0
  67. package/lib/orchestrator-state.ts +11 -3
  68. package/lib/permission-mode-policy.ts +2 -2
  69. package/lib/review-host-relay.ts +11 -2
  70. package/lib/review-reminder-receipt.ts +24 -1
  71. package/lib/session-profile-append-controller.ts +713 -0
  72. package/lib/session-profile-disk-reader.ts +182 -0
  73. package/lib/session-profile-freeze.ts +2 -2
  74. package/lib/session-profile-persistence.ts +15 -7
  75. package/lib/session-scratch.ts +52 -3
  76. package/lib/session-worktree-registry.ts +7 -0
  77. package/lib/shell-bar.ts +6 -7
  78. package/lib/shell-changes.ts +81 -5
  79. package/lib/shell-prompt.ts +4 -4
  80. package/lib/shell-sidebar-layout.ts +27 -7
  81. package/lib/shell-todo.ts +1 -1
  82. package/lib/verifier-admission.ts +5 -4
  83. package/lib/vim-editor-adapter.ts +14 -10
  84. package/lib/voice-artifacts.ts +188 -0
  85. package/lib/voice-command.ts +576 -0
  86. package/lib/voice-install.ts +455 -0
  87. package/lib/voice-platform.ts +99 -0
  88. package/lib/voice-push-to-talk.ts +262 -0
  89. package/lib/voice-session.ts +460 -0
  90. package/lib/voice-settings.ts +50 -0
  91. package/lib/voice-ui.ts +158 -0
  92. package/lib/yolo-session-policy.ts +58 -22
  93. package/package.json +1 -1
  94. package/runtime/gentle-shell-bundled.mjs +140 -56
  95. package/runtime/gentle-shell-launcher.mjs +11 -4
  96. package/runtime/gentle-shell-startup-update.mjs +61 -9
  97. package/runtime/gentle-shell-uninstall.mjs +33 -1
  98. package/runtime/native-review-cli.mjs +11 -4
  99. package/scripts/bundled-engram-migration.mjs +0 -6
  100. package/scripts/bundled-install.mjs +209 -13
  101. package/scripts/gentle-ai-installer.mjs +10 -10
  102. package/scripts/installer-downloads.mjs +31 -7
  103. package/scripts/installer-preflight.mjs +6 -4
  104. package/scripts/installer-windows.mjs +37 -7
  105. package/scripts/main-channel.mjs +91 -17
  106. package/scripts/pi-update.mjs +37 -7
  107. package/scripts/verify-package-files.mjs +26 -2
  108. package/scripts/voice-worker.cjs +476 -0
  109. package/skills/design/SKILL.md +110 -0
  110. package/tests/agent-profiles.test.ts +24 -0
  111. package/tests/agents-config.test.ts +61 -0
  112. package/tests/agents-fake-child.ts +8 -1
  113. package/tests/agents-protocol.test.ts +32 -0
  114. package/tests/agents-runner.test.ts +226 -5
  115. package/tests/agents-session-transport.test.ts +53 -3
  116. package/tests/ask-user-choice.test.ts +30 -0
  117. package/tests/auto-mode.test.ts +17 -0
  118. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  119. package/tests/auto-permission-runtime.test.ts +85 -1
  120. package/tests/autonomous-guard.test.ts +20 -0
  121. package/tests/bundled-install.test.ts +276 -5
  122. package/tests/clipboard-image.test.ts +114 -0
  123. package/tests/command-palette.test.ts +4 -4
  124. package/tests/context-backstop.test.ts +70 -4
  125. package/tests/delegate-for-reason-contract.test.ts +2 -1
  126. package/tests/design-canvas-assets.test.ts +710 -0
  127. package/tests/design-canvas-server.test.ts +1096 -0
  128. package/tests/design-comments.test.ts +201 -0
  129. package/tests/design-handoffs.test.ts +282 -0
  130. package/tests/design-open-browser.test.ts +70 -0
  131. package/tests/design-sources.test.ts +132 -0
  132. package/tests/engram-safety.test.ts +132 -57
  133. package/tests/external-editor.test.ts +84 -0
  134. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  135. package/tests/gentle-agents.test.ts +369 -24
  136. package/tests/gentle-ai-binary.test.ts +1 -1
  137. package/tests/gentle-ai-installer.test.ts +51 -51
  138. package/tests/gentle-ai.test.ts +928 -448
  139. package/tests/gentle-design.test.ts +1313 -0
  140. package/tests/gentle-music.test.ts +452 -0
  141. package/tests/gentle-shell-bundled.test.ts +321 -248
  142. package/tests/gentle-shell-launcher.test.ts +29 -2
  143. package/tests/gentle-shell-startup-update.test.ts +77 -1
  144. package/tests/gentle-shell-uninstall.test.ts +59 -0
  145. package/tests/gentle-shell.test.ts +227 -27
  146. package/tests/gentle-todo.test.ts +11 -0
  147. package/tests/image-attachments.test.ts +119 -0
  148. package/tests/image-paste-chips.test.ts +236 -0
  149. package/tests/inprocess-reviewer.test.ts +55 -2
  150. package/tests/installer-bundles.test.ts +12 -6
  151. package/tests/installer-downloads.test.ts +32 -0
  152. package/tests/installer-posix-bootstrap.test.ts +8 -8
  153. package/tests/installer-preflight.test.ts +16 -0
  154. package/tests/installer-server.test.ts +2 -2
  155. package/tests/installer-windows-bootstrap.test.ts +61 -3
  156. package/tests/installer-windows-diagnostics.test.ts +111 -0
  157. package/tests/jobs-widget.test.ts +138 -0
  158. package/tests/main-channel-upgrade.test.ts +232 -2
  159. package/tests/main-channel.test.ts +140 -21
  160. package/tests/model-profiles-preservation.test.ts +2 -2
  161. package/tests/nan-provider.test.ts +36 -0
  162. package/tests/native-review-capability-contract.test.ts +11 -4
  163. package/tests/native-review-parity.test.ts +1 -1
  164. package/tests/notification-audio-native.test.ts +128 -9
  165. package/tests/notification-customize.test.ts +17 -5
  166. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  167. package/tests/orchestrator-budget.test.ts +5 -2
  168. package/tests/orchestrator-presence.test.ts +100 -2
  169. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  170. package/tests/orchestrator-scope.test.ts +14 -0
  171. package/tests/orchestrator-state.test.ts +29 -0
  172. package/tests/package-files.test.ts +34 -0
  173. package/tests/package-manifest.test.ts +6 -6
  174. package/tests/permission-mode-policy.test.ts +10 -1
  175. package/tests/pi-update.test.ts +104 -4
  176. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  177. package/tests/review-host-relay-routing.test.ts +88 -1
  178. package/tests/review-host-relay.test.ts +9 -2
  179. package/tests/review-json-arguments.test.ts +20 -3
  180. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  181. package/tests/review-relay-transport-agent.test.ts +26 -0
  182. package/tests/review-reminder-receipt.test.ts +31 -1
  183. package/tests/runtime-harness.mjs +188 -211
  184. package/tests/session-changes.test.ts +2 -1
  185. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  186. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  187. package/tests/session-profile-append-controller.test.ts +731 -0
  188. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  189. package/tests/session-profile-disk-reader.test.ts +311 -0
  190. package/tests/session-profile-freeze.test.ts +6 -4
  191. package/tests/session-scratch.test.ts +68 -2
  192. package/tests/session-worktree-registry.test.ts +13 -1
  193. package/tests/shell-bar.test.ts +18 -6
  194. package/tests/shell-changes.test.ts +148 -1
  195. package/tests/shell-prompt.test.ts +3 -3
  196. package/tests/shell-render-cache.test.ts +134 -1
  197. package/tests/shell-sidebar-layout.test.ts +67 -0
  198. package/tests/short-check-routing-contract.test.ts +1 -1
  199. package/tests/skill-collision-prefixes.test.ts +3 -1
  200. package/tests/skill-registry-watcher.test.ts +17 -5
  201. package/tests/skill-registry.test.ts +47 -3
  202. package/tests/task-size-routing-contract.test.ts +1 -1
  203. package/tests/test-strength-contract.test.ts +1 -1
  204. package/tests/verifier-admission.test.ts +18 -1
  205. package/tests/vim-editor-adapter.test.ts +2 -2
  206. package/tests/voice-command.test.ts +773 -0
  207. package/tests/voice-install.test.ts +323 -0
  208. package/tests/voice-platform.test.ts +176 -0
  209. package/tests/voice-push-to-talk.test.ts +444 -0
  210. package/tests/voice-session.test.ts +434 -0
  211. package/tests/voice-settings.test.ts +49 -0
  212. package/tests/voice-ui.test.ts +97 -0
  213. package/tests/voice-worker.test.ts +445 -0
  214. package/tests/weak-model-economy-prompt.test.ts +32 -0
  215. package/tests/windows-hidden-processes.test.ts +25 -2
  216. package/tests/yolo-customize.test.ts +5 -1
  217. package/tests/yolo-mode.test.ts +1 -1
  218. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { mkdtempSync, mkdirSync, symlinkSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, mkdirSync, symlinkSync, linkSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { AutoPermissionRuntime } from "../lib/auto-permission-runtime.ts";
@@ -258,6 +258,90 @@ test("Native consent, human questions and delegation remain with their original
258
258
  f.setMode("auto");assert.equal((await f.execute("custom_tool",{},false)).executions,1);assert.equal(f.requests.length,0);
259
259
  });
260
260
 
261
+ test("Companion update files are not permission authority and still receive ordinary checks", async t=>{
262
+ const f=fixture(t);f.input("Update the installed companion tools from main.");
263
+ for(const mode of ["normal","auto","yolo"]) {
264
+ f.setMode(mode);
265
+ for(const path of ["dev-binary.json","channel.json","main/gentle-ai","tools/go/bin/go"]) {
266
+ const input={path:join(f.cwd,"config",path),content:"updated companion data"};
267
+ for(const toolName of ["write","edit"]) {
268
+ assert.equal(f.runtime.guard({toolName,input},f.ctx),undefined,`${mode}: ${path} is not policy`);
269
+ assert.equal((await f.execute(toolName,input)).executions,1);
270
+ }
271
+ }
272
+ }
273
+ assert.equal(f.requests.length,8,"only Auto requests are classified; other modes keep their own behavior");
274
+ f.setMode("auto");f.setResponse({status:"available",decision:"deny",reason:"Update not authorized"});
275
+ assert.equal((await f.execute("write",{path:join(f.cwd,"config","dev-binary.json"),content:"unapproved"})).executions,0);
276
+ assert.equal(f.requests.length,9,"an update path is not an automatic allow");
277
+ });
278
+
279
+ test("Permission authority, aliases and parent directories remain protected in every mode", async t=>{
280
+ const f=fixture(t);f.input("Update companion tools without changing permissions.");
281
+ const home=join(f.cwd,"config");mkdirSync(home);
282
+ const projectHome=join(f.cwd,".pi","gentle-ai");mkdirSync(projectHome,{recursive:true});
283
+ for(const name of ["permission-mode.json","runtime-guardrails.json"])writeFileSync(join(home,name),"{}");
284
+ writeFileSync(join(projectHome,"runtime-guardrails.json"),"{}");
285
+ symlinkSync(home,join(f.cwd,"config-alias"));
286
+ symlinkSync(join(home,"permission-mode.json"),join(f.cwd,"policy-alias"));
287
+ symlinkSync(join(home,"runtime-guardrails.json"),join(home,"dev-binary.json"));
288
+ for(const mode of ["normal","auto","yolo"]) {
289
+ f.setMode(mode);
290
+ for(const path of [join(home,"permission-mode.json"),join(home,"runtime-guardrails.json"),
291
+ join(f.cwd,"config-alias","permission-mode.json"),join(f.cwd,"policy-alias"),join(home,"dev-binary.json"),
292
+ join(projectHome,"runtime-guardrails.json"),projectHome,home,f.cwd,
293
+ ...["profiles.json","models.json","background-subagents.json","persona.json","builtin-codemode-optout.json",
294
+ "history-capture.json","scratch/session.json","tools/other-tool"].map(path=>join(home,path))]) {
295
+ for(const toolName of ["write","edit"]) {
296
+ const input={path,content:"{}"};
297
+ assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,`${mode}: ${path} stays protected`);
298
+ assert.equal((await f.execute(toolName,input)).executions,0);
299
+ }
300
+ }
301
+ for(const toolName of ["bash","bash_background","monitor"]) {
302
+ for(const command of [`rm -rf '${home}'`,`rm -rf '${home}/'`,`rm -rf '${home}'/*`,
303
+ `printf '{}' > '${join(home,"permission-mode.json")}'`,`printf '{}' > '${join(home,"runtime-guardrails.json")}'`,
304
+ `printf '{}' > '${join(projectHome,"runtime-guardrails.json")}'`]) {
305
+ assert.equal(f.runtime.guard({toolName,input:{command}},f.ctx)?.block,true);
306
+ assert.equal((await f.execute(toolName,{command})).executions,0);
307
+ }
308
+ }
309
+ }
310
+ assert.equal(f.requests.length,0,"a classifier allow never overrides the policy guard");
311
+ });
312
+
313
+ test("Updater exceptions cannot overwrite authority through hardlinks", async t=>{
314
+ const f=fixture(t);f.input("Update companion tools without changing permission or routing policy.");
315
+ const home=join(f.cwd,"config");mkdirSync(join(home,"main"),{recursive:true});
316
+ for(const [policy,alias] of [["permission-mode.json","dev-binary.json"],["models.json","main/routing-alias"]]) {
317
+ writeFileSync(join(home,policy),"{}");linkSync(join(home,policy),join(home,alias));
318
+ for(const toolName of ["write","edit"]) {
319
+ const input={path:join(home,alias),content:"{}"};
320
+ assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,alias);
321
+ assert.equal((await f.execute(toolName,input)).executions,0);
322
+ }
323
+ }
324
+ assert.equal(f.requests.length,0);
325
+ });
326
+
327
+ test("Dangling updater aliases cannot create absent permission authority", async t=>{
328
+ const f=fixture(t);f.input("Update companion tools without creating or changing permission policy.");
329
+ const home=join(f.cwd,"config");mkdirSync(join(home,"tools"),{recursive:true});
330
+ for(const [alias,policy] of [["dev-binary.json","permission-mode.json"],["channel.json","runtime-guardrails.json"],
331
+ ["main","permission-mode.json"],["tools/go","runtime-guardrails.json"]]) {
332
+ symlinkSync(join(home,policy),join(home,alias));
333
+ for(const mode of ["normal","auto","yolo"]) {
334
+ f.setMode(mode);
335
+ for(const toolName of ["write","edit"]) {
336
+ const input={path:join(home,alias),content:"{}"};
337
+ assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,`${mode}: ${alias} stays protected`);
338
+ assert.equal((await f.execute(toolName,input)).executions,0);
339
+ }
340
+ }
341
+ }
342
+ assert.equal(f.requests.length,0,"unresolved aliases never reach an allowing classifier");
343
+ });
344
+
261
345
  test("Policy files cannot be modified by model file tools; symlink escapes are reviewed", async t=>{
262
346
  const f=fixture(t);f.input("Edit source code");mkdirSync(join(f.cwd,"config"));
263
347
  assert.equal((await f.execute("write",{path:join(f.cwd,"config","permission-mode.json"),content:"yolo"})).executions,0);
@@ -910,3 +910,23 @@ test("loadRuntimeGuardrailsConfig: autonomousMode:{} (object) in JSON does NOT a
910
910
  rmSync(dir, { recursive: true, force: true });
911
911
  }
912
912
  });
913
+
914
+ test("evaluateSuperYoloCommand: waives data-loss and configured confirmations, keeps only catastrophic denies", () => {
915
+ const { evaluateSuperYoloCommand } = __testing;
916
+ // Ordinary data-loss commands run without confirmation.
917
+ assert.equal(evaluateSuperYoloCommand("rm -rf node_modules build"), "allow");
918
+ assert.equal(evaluateSuperYoloCommand("git rebase origin/main"), "allow");
919
+ assert.equal(evaluateSuperYoloCommand("git branch -D feature/x"), "allow");
920
+ assert.equal(evaluateSuperYoloCommand("npm publish"), "allow");
921
+ assert.equal(evaluateSuperYoloCommand("git push -u origin feature"), "allow");
922
+ // Code-level catastrophic denies survive: root/home removal and hard-coded
923
+ // destructive Git forms (reset --hard, forced clean, force push).
924
+ assert.equal(evaluateSuperYoloCommand("rm -rf /"), "block");
925
+ assert.equal(evaluateSuperYoloCommand("rm -rf ~"), "block");
926
+ assert.equal(evaluateSuperYoloCommand("rm -rf --no-preserve-root /"), "block");
927
+ assert.equal(evaluateSuperYoloCommand("git reset --hard HEAD~1"), "block");
928
+ assert.equal(evaluateSuperYoloCommand("git push --force origin main"), "block");
929
+ assert.equal(evaluateSuperYoloCommand("git clean -fd"), "block");
930
+ // Harmless commands are unaffected.
931
+ assert.equal(evaluateSuperYoloCommand("git status && npm test"), "allow");
932
+ });
@@ -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.
@@ -17,7 +17,7 @@ const posixTest = (name: string, fn: (t: TestContext) => void | Promise<void>) =
17
17
  const NODE = "24.21.0";
18
18
  const PNPM = "11.1.1";
19
19
  const NPM = "11.19.0";
20
- const ENGRAM = "3.4.0";
20
+ const ENGRAM = "3.5.2";
21
21
 
22
22
  // Every temporary home this file creates is removed when the file's tests end.
23
23
  const roots: string[] = [];
@@ -373,8 +373,8 @@ posixTest("ensureEngram downloads nothing when the prefix is missing", async ()
373
373
  });
374
374
 
375
375
  test("engramExecutable takes only the top-level engram from the tarball and engram.exe from the zip, regular files only", () => {
376
- const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_linux_amd64.tar.gz" };
377
- const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_windows_amd64.zip" };
376
+ const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.5.2/engram_3.5.2_linux_amd64.tar.gz" };
377
+ const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.5.2/engram_3.5.2_windows_amd64.zip" };
378
378
  assert.deepEqual(engramExecutable(engramArchive, tarball), engramBytes);
379
379
  const exe = Buffer.from("MZ engram");
380
380
  assert.deepEqual(engramExecutable(zip([["tools/cloud-sync-projects.ps1", Buffer.from("x")], ["engram.exe", exe]]), zipped), exe);
@@ -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 }) => {
@@ -0,0 +1,114 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { isWsl, readClipboardImage, sniffImageMimeType, type ClipboardCommandRunner } from "../lib/clipboard-image.ts";
4
+
5
+ // A 2×1 PNG header: signature, IHDR length/type, width 2, height 1.
6
+ const PNG_2X1 = new Uint8Array([
7
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
8
+ 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
9
+ 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01,
10
+ 0x08, 0x06, 0x00, 0x00, 0x00,
11
+ ]);
12
+
13
+ type Call = { command: string; args: readonly string[] };
14
+ type Reply = Buffer | undefined | ((call: Call) => Buffer | undefined);
15
+
16
+ // Scripted backends keyed by `command firstArg`; anything unscripted is "not installed".
17
+ function runner(replies: Record<string, Reply>): { run: ClipboardCommandRunner; calls: Call[] } {
18
+ const calls: Call[] = [];
19
+ const run: ClipboardCommandRunner = async (command, args) => {
20
+ const call = { command, args };
21
+ calls.push(call);
22
+ const reply = replies[`${command} ${args[0] ?? ""}`.trim()] ?? replies[command];
23
+ return typeof reply === "function" ? reply(call) : reply;
24
+ };
25
+ return { run, calls };
26
+ }
27
+
28
+ const BMP = new Uint8Array([0x42, 0x4d, 0x00, 0x00]);
29
+ const noProc = () => undefined;
30
+
31
+ test("WSL reads a Windows screenshot through PowerShell when the Linux clipboard has none", async () => {
32
+ const { run, calls } = runner({
33
+ "wl-paste --list-types": Buffer.from("text/plain\n"),
34
+ "powershell.exe": Buffer.from(`${Buffer.from(PNG_2X1).toString("base64")}\r\n`),
35
+ });
36
+ const image = await readClipboardImage({ env: { WSL_DISTRO_NAME: "Ubuntu" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
37
+ assert.deepEqual(image && { mimeType: image.mimeType, bytes: [...image.bytes] }, { mimeType: "image/png", bytes: [...PNG_2X1] });
38
+ const powershell = calls.find((call) => call.command === "powershell.exe");
39
+ assert.ok(powershell, "PowerShell was consulted");
40
+ // The image travels on stdout as base64: no temp file path is ever passed.
41
+ const script = powershell.args.at(-1) ?? "";
42
+ assert.match(script, /ToBase64String/);
43
+ assert.doesNotMatch(script, /\$path|Save\('|tmp/i);
44
+ });
45
+
46
+ test("WSL falls back to PowerShell when wl-paste and xclip are not installed", async () => {
47
+ const { run, calls } = runner({ "powershell.exe": Buffer.from(Buffer.from(PNG_2X1).toString("base64")) });
48
+ const image = await readClipboardImage({ env: { WSL_INTEROP: "/run/WSL/1_interop" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
49
+ assert.equal(image?.mimeType, "image/png");
50
+ assert.deepEqual(calls.map((call) => call.command), ["wl-paste", "xclip", "powershell.exe"]);
51
+ });
52
+
53
+ test("Wayland prefers PNG and never asks Windows on a native Linux desktop", async () => {
54
+ const { run, calls } = runner({
55
+ "wl-paste --list-types": Buffer.from("image/jpeg\nimage/png\ntext/plain\n"),
56
+ "wl-paste --type": (call) => call.args[1] === "image/png" ? Buffer.from(PNG_2X1) : undefined,
57
+ });
58
+ const image = await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
59
+ assert.equal(image?.mimeType, "image/png");
60
+ assert.ok(!calls.some((call) => call.command === "powershell.exe"));
61
+ });
62
+
63
+ test("an empty Windows clipboard, an unsupported format, or Termux leave the paste to Pi", async () => {
64
+ const empty = runner({ "powershell.exe": Buffer.from("\r\n") });
65
+ assert.equal(await readClipboardImage({ env: { WSL_DISTRO_NAME: "Ubuntu" }, platform: "linux", run: empty.run, native: () => undefined, procVersion: noProc }), null);
66
+ const bmp = runner({ "xclip -selection": (call) => call.args.includes("TARGETS") ? Buffer.from("image/png\n") : Buffer.from(BMP) });
67
+ assert.equal(await readClipboardImage({ env: {}, platform: "linux", run: bmp.run, native: () => undefined, procVersion: noProc }), null);
68
+ const termux = runner({});
69
+ assert.equal(await readClipboardImage({ env: { TERMUX_VERSION: "0.118" }, platform: "linux", run: termux.run, procVersion: noProc }), null);
70
+ assert.equal(termux.calls.length, 0);
71
+ });
72
+
73
+ test("macOS reads the native clipboard but leaves copied files to Pi", async () => {
74
+ const native = (files: string[] | null) => () => ({
75
+ getText: async () => null,
76
+ getImage: async () => PNG_2X1,
77
+ getFilePaths: async () => files,
78
+ });
79
+ const image = await readClipboardImage({ env: {}, platform: "darwin", native: native(null) });
80
+ assert.equal(image?.mimeType, "image/png");
81
+ assert.equal(await readClipboardImage({ env: {}, platform: "darwin", native: native(["/Users/me/shot.png"]) }), null);
82
+ assert.equal(await readClipboardImage({ env: {}, platform: "win32", native: () => undefined }), null);
83
+ });
84
+
85
+ test("a failed wl-paste read falls through to xclip, as Pi does", async () => {
86
+ const { run, calls } = runner({
87
+ "wl-paste --list-types": Buffer.from("image/png\n"),
88
+ "xclip -selection": (call) => call.args.includes("TARGETS") ? Buffer.from("image/png\n") : Buffer.from(PNG_2X1),
89
+ });
90
+ const image = await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
91
+ assert.equal(image?.mimeType, "image/png");
92
+ assert.deepEqual(calls.map((call) => call.command), ["wl-paste", "wl-paste", "xclip", "xclip"]);
93
+ });
94
+
95
+ test("files copied in a Linux file manager stay Pi's even when an image is offered too", async () => {
96
+ const { run, calls } = runner({ "wl-paste --list-types": Buffer.from("image/png\ntext/uri-list\n") });
97
+ const native = () => ({ getText: async () => null, getImage: async () => PNG_2X1, getFilePaths: async () => ["/home/me/shot.png"] });
98
+ assert.equal(await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform: "linux", run, native, procVersion: noProc }), null);
99
+ assert.equal(calls.length, 0);
100
+ });
101
+
102
+ test("image formats are identified by signature", () => {
103
+ assert.equal(sniffImageMimeType(PNG_2X1), "image/png");
104
+ assert.equal(sniffImageMimeType(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])), "image/jpeg");
105
+ assert.equal(sniffImageMimeType(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])), "image/gif");
106
+ assert.equal(sniffImageMimeType(new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50])), "image/webp");
107
+ assert.equal(sniffImageMimeType(BMP), undefined);
108
+ });
109
+
110
+ test("WSL is detected from its environment or the kernel version", () => {
111
+ assert.equal(isWsl({ WSL_DISTRO_NAME: "Ubuntu" }, noProc), true);
112
+ assert.equal(isWsl({}, () => "Linux version 6.6.87.2-microsoft-standard-WSL2"), true);
113
+ assert.equal(isWsl({}, () => "Linux version 6.8.0-generic"), false);
114
+ });
@@ -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",
@@ -374,7 +373,7 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
374
373
  "gentle:telemetry",
375
374
  "gentle:dev-binary",
376
375
  ]);
377
- assert.deepEqual(byTitle("Session"), ["gentle:yolo", "gentle:changes", "gentle:agents", "gentle:jobs", "gentle:usage", "gentle:review-session-permission"]);
376
+ assert.deepEqual(byTitle("Session"), ["gentle:yolo", "gentle:changes", "gentle:agents", "gentle:jobs", "gentle:usage", "gentle:review-session-permission", "gentle:voice", "gentle:design"]);
378
377
  assert.deepEqual(byTitle("Diagnostics"), ["gentle:status", "gentle:doctor"]);
379
378
  assert.equal(byTitle("SDD"), undefined);
380
379
  assert.deepEqual(byTitle("Skills"), ["skill-registry:refresh"]);
@@ -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");