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
@@ -21,6 +21,30 @@
21
21
 
22
22
 
23
23
 
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+ /** The launcher config records the last live lookup so the everyday launch stays off the network. */
33
+
34
+
35
+
36
+
37
+
38
+ /** Reads the config's `startupReleaseCheck` key; anything malformed is no cache. */
39
+ export function parseStartupReleaseCache(value ) {
40
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
41
+ const { checkedAt, latest } = value ;
42
+ const ms = typeof checkedAt === "number" ? checkedAt : typeof checkedAt === "string" ? Date.parse(checkedAt) : Number.NaN;
43
+ if (!Number.isFinite(ms) || typeof latest !== "string" || latest === "") return undefined;
44
+ return { checkedAt: ms, latest };
45
+ }
46
+
47
+ export const STARTUP_RELEASE_TTL_MS = 24 * 60 * 60 * 1000;
24
48
 
25
49
  export function shouldCheckStartupUpdate({ args, env, bundled, inputTTY, outputTTY } ) {
26
50
  if (!bundled || !inputTTY || !outputTTY || args.command || args.piSubcommand || args.error || args.help || args.version || args.packageRoot !== undefined) {
@@ -62,9 +86,30 @@ function isNewer(latest , current ) {
62
86
  return false;
63
87
  }
64
88
 
65
- export async function findStartupUpdate({ current, skipped, lookup, timeoutMs = 1500 } ) {
89
+ function offerFor(current , latest , skipped ) {
66
90
  const currentParts = stableParts(current);
67
91
  if (!currentParts) return undefined;
92
+ if (latest === undefined || latest === skipped) return undefined;
93
+ const latestParts = stableParts(latest);
94
+ if (!latestParts || !isNewer(latestParts, currentParts)) return undefined;
95
+ return {
96
+ current,
97
+ latest,
98
+ notesUrl: `https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v${latest}`,
99
+ };
100
+ }
101
+
102
+ // A malformed cached latest is never trusted — it must not suppress the
103
+ // network check for a whole TTL window — so it counts as no cache at all.
104
+ function usableCache(cache , ttlMs , nowMs ) {
105
+ if (cache === undefined) return undefined;
106
+ if (!Number.isFinite(cache.checkedAt) || typeof cache.latest !== "string" || cache.latest === "" || stableParts(cache.latest) === undefined) return undefined;
107
+ return nowMs - cache.checkedAt < ttlMs ? cache : undefined;
108
+ }
109
+
110
+ export async function findStartupUpdate({ current, skipped, lookup, timeoutMs = 1500, cache, nowMs = Date.now(), ttlMs = STARTUP_RELEASE_TTL_MS, cacheWrite } ) {
111
+ const cached = usableCache(cache, ttlMs, nowMs);
112
+ if (cached !== undefined) return offerFor(current, cached.latest, skipped);
68
113
  const controller = new AbortController();
69
114
  const deadline = Number.isFinite(timeoutMs) && timeoutMs >= 0 && timeoutMs <= 2_147_483_647 ? timeoutMs : 1500;
70
115
  let timer ;
@@ -77,17 +122,24 @@ export async function findStartupUpdate({ current, skipped, lookup, timeoutMs =
77
122
  });
78
123
  // The race bounds startup even if the injected lookup ignores cancellation.
79
124
  const latest = await Promise.race([lookup(controller.signal), timeout]);
80
- if (latest === undefined || latest === skipped) return undefined;
81
- const latestParts = stableParts(latest);
82
- if (!latestParts || !isNewer(latestParts, currentParts)) return undefined;
83
- return {
84
- current,
85
- latest,
86
- notesUrl: `https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v${latest}`,
87
- };
125
+ // Only a real answer is worth a day's trust: a timeout or rejection must
126
+ // not suppress tomorrow's check.
127
+ if (typeof latest === "string") cacheWrite?.(latest);
128
+ return offerFor(current, latest, skipped);
88
129
  } catch {
89
130
  return undefined;
90
131
  } finally {
91
132
  clearTimeout(timer);
92
133
  }
93
134
  }
135
+
136
+ // Mirrors omitStartupVersion: preserves every other config key, fails loudly on
137
+ // an invalid config, and records the lookup's answer with an injected clock.
138
+ export function writeStartupReleaseCache(read , write , version , nowMs = Date.now()) {
139
+ const text = read();
140
+ const config = text === undefined ? {} : JSON.parse(text);
141
+ if (config === null || typeof config !== "object" || Array.isArray(config)) throw new Error("Invalid launcher config");
142
+ const cache = { checkedAt: nowMs, latest: version };
143
+ const serialized = JSON.stringify({ ...config, startupReleaseCheck: { checkedAt: new Date(cache.checkedAt).toISOString(), latest: cache.latest } }, null, 2);
144
+ write(`${serialized}\n`);
145
+ }
@@ -1,7 +1,9 @@
1
1
  // Generated by scripts/build-runtime-modules.mjs. Do not edit.
2
- import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync } from "node:fs";
2
+ import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { createInterface } from "node:readline";
4
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { withoutBundledNpmCommand } from "./gentle-shell-bundled.mjs";
5
7
  import { isolatedDir, launcherConfigPath, parseRawLauncherConfig, provisionedEntry, userPiHome } from "./gentle-shell-launcher.mjs";
6
8
 
7
9
  // `gentle-shell self-uninstall`: removes what Gentle Shell itself created, then
@@ -779,6 +781,18 @@ function printBundledPlan(plan , input , o
779
781
  }
780
782
  }
781
783
 
784
+ /** Replaces `path`'s text through a same-directory temporary file, so a crash never leaves it half-written. */
785
+ function writeTextAtomically(path , text ) {
786
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
787
+ try {
788
+ writeFileSync(temporary, text);
789
+ renameSync(temporary, path);
790
+ } catch (error) {
791
+ try { unlinkSync(temporary); } catch { /* The temporary file is best effort. */ }
792
+ throw error;
793
+ }
794
+ }
795
+
782
796
  /**
783
797
  * Runs `gentle-shell self-uninstall` for the bundled install at `root`. Exit
784
798
  * codes as runSelfUninstall: 0 done or dry run, 1 refused, cancelled or failed,
@@ -838,6 +852,24 @@ export async function runBundledSelfUninstall(input )
838
852
  } catch {
839
853
  // A leftover empty folder is harmless.
840
854
  }
855
+ // A home that stays must not keep Pi pointed at the npm this uninstall removed
856
+ // (#2469): an `npmCommand` naming the prefix fails every later `pi install`
857
+ // with ENOENT naming the deleted node.exe.
858
+ if (!confirmation.includeShared) {
859
+ const settingsPath = join(plan.shellHome, "agent", "settings.json");
860
+ let settingsText ;
861
+ try { settingsText = readFileSync(settingsPath, "utf8"); } catch { settingsText = undefined; }
862
+ const removal = withoutBundledNpmCommand(settingsText, (path) => within(root, path, platform));
863
+ if (removal.status === "removed") {
864
+ try {
865
+ writeTextAtomically(settingsPath, removal.text);
866
+ out(`Removed npmCommand from ${settingsPath}: it named the removed install.`);
867
+ } catch (error) {
868
+ err(`gentle-shell self-uninstall: could not remove npmCommand from ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`);
869
+ err(`Pi package installs keep failing until the npmCommand naming ${root} is deleted from it.`);
870
+ }
871
+ }
872
+ }
841
873
  if (plan.held.length > 0) {
842
874
  try {
843
875
  if (!input.deferRemoval) throw new Error("no deferred removal on this system");
@@ -203,10 +203,10 @@ export const NATIVE_REVIEW_MODE_SCOPE = {
203
203
  // stream while still succeeding. That last qualifier is what keeps this list
204
204
  // honest: v2.4.0 deleted reviewConsentSkippedDefaultProvenance ("Reviews are
205
205
  // on by default; this was never explicitly chosen. ..."), which used to ride
206
- // with the skip notice whenever the resolved mode source was `default`. Under
207
- // opt-in receipt-driven development a default-source clone is refused long
208
- // before the consent ceremony runs, so the pinned binary can no longer emit
209
- // that line and it is removed here rather than left as dead tolerance. Multi-
206
+ // with the skip notice whenever the resolved mode source was `default`. Reviews
207
+ // are on by default again since gentle-ai v4.0.0, but the pinned binary no
208
+ // longer has that function and cannot emit the line, so it is removed here
209
+ // rather than left as dead tolerance. Multi-
210
210
  // line stderr is still expected in principle — these are separate Fprintln
211
211
  // calls, never one joined string — which is why membership is per line.
212
212
  export const REVIEW_CONSENT_NOTICES = Object.freeze([
@@ -1091,6 +1091,13 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
1091
1091
  // 9d2927a84ba9a596f69029e3f854b16f86ca4c8b32cc5d63683764ccf2fb394b.
1092
1092
  // No new negotiated capability identity is asserted.
1093
1093
  "4.2.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1094
+ // v4.3.1 repeats 4.2.0: the published binary still advertises
1095
+ // capabilities/v2.6 under review-integration/v2, and the provider contract
1096
+ // stays at 1.4.0, with published tarball SHA-256
1097
+ // 58450471280aeb357443d6a28c819132d8c8eb78dd25b12bac64296b2f8c27c2.
1098
+ // The v4.3.0 tag has no release and is never pinned.
1099
+ // No new negotiated capability identity is asserted.
1100
+ "4.3.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1094
1101
  });
1095
1102
 
1096
1103
 
@@ -43,12 +43,6 @@ function coreHelp(core, env) {
43
43
  function supportsMigration(core, env) {
44
44
  return coreHelp(core, env).includes("migrate-copy --data-dir <absolute-source> --output-dir <new-absolute-destination> --confirm-quiescent --json");
45
45
  }
46
- export function supportsBundledSessionOwned(core, env) {
47
- if (!core) return false;
48
- const clean = Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith("ENGRAM_") && key !== "GH_TOKEN" && key !== "GITHUB_TOKEN"));
49
- try { return coreHelp(core, { ...clean, ENGRAM_NO_UPDATE_CHECK: "1" }).some((line) => /^serve --session-owned(?:\s|$)/.test(line)); }
50
- catch { return false; }
51
- }
52
46
 
53
47
  export function migrateBundledEngram({ prefix, core, home, env }) {
54
48
  let markerOwned = false;
@@ -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).
@@ -37,7 +37,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
37
37
  // version check below) derives from this constant instead of repeating the
38
38
  // literal, so a pin bump cannot leave a stale copy behind. See
39
39
  // scripts/install-gentle-ai.mjs for the incident that motivated this.
40
- export const INSTALLER_VERSION = "4.2.0";
40
+ export const INSTALLER_VERSION = "4.3.1";
41
41
  export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
42
42
  export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
43
43
  SIGNED_RELEASE_ASSET: "signed-release-asset",
@@ -46,11 +46,11 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
46
46
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai";
47
47
  export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v4";
48
48
  export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
49
- // `go mod download -json github.com/gentleman-programming/gentle-ai/v4@v4.2.0`
49
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v4@v4.3.1`
50
50
  // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
51
- // tag resolves to commit 4acaf4241beae1e0c977c92dfe406a270f8d40de, the published v4.2.0 release head.
51
+ // tag resolves to commit 2dba7784114fff57864519c4c0d067e952a8c98b, the published v4.3.1 release head.
52
52
  // v4.0.0 moved the Go module path to the /v4 major-version suffix.
53
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:J8EluS7QrfM+m3vmQlF26B/shZRjD6rBfsKtrABL5mc=";
53
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:00p8dnjJrhYFq1Qikk2oY6K/UXIty9A/IBUUOUiX9XA=";
54
54
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
55
55
  export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
56
56
  export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
@@ -69,7 +69,7 @@ export class GentleAiInstallerError extends Error {
69
69
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
70
70
  // sentinel digest can never match a real SHA-256, so installation fails closed,
71
71
  // and verify-package-files.mjs refuses to pack/publish while any digest below
72
- // still holds it. The v4.2.0 digests are pinned from the published release:
72
+ // still holds it. The v4.3.1 digests are pinned from the published release:
73
73
  // archive sha256 values verified against the minisign-signed checksums.txt and
74
74
  // freshly computed hashes; binary sha256 values computed from the extracted
75
75
  // executables.
@@ -111,15 +111,15 @@ async function downloadPinnedGentleAiAsset(asset, destination, options) {
111
111
  }
112
112
 
113
113
  // Windows is absent from signed release archives on purpose. gentle-ai stopped
114
- // distributing unsigned Windows builds in c4b764d0, so v4.2.0 publishes signed
114
+ // distributing unsigned Windows builds in c4b764d0, so v4.3.1 publishes signed
115
115
  // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
116
116
  // exact-tag Go SumDB source-build path below; restore archive rows only when
117
117
  // upstream ships signed Windows assets.
118
118
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
119
- "darwin/amd64": asset("gentle-ai_4.2.0_darwin_amd64.tar.gz", "8f6a91e0f99cac3aea138705d7d222ea342f53a47602d48cb01488952a1c12f8", "784855e99f1bc45579f01420c923b7dc1dc6d63492cdc0174a9a32e611849e57", "gentle-ai"),
120
- "darwin/arm64": asset("gentle-ai_4.2.0_darwin_arm64.tar.gz", "d5e9a6521de5f08a393635f083f64dc7afc1b8e97884f7aab5ae5eb1f17c7f37", "8ea490287ef796848b0d305cf2b26265e360d3f400efc016972844498fb87097", "gentle-ai"),
121
- "linux/amd64": asset("gentle-ai_4.2.0_linux_amd64.tar.gz", "552c1642fd10f5d96cfe9bb1cc3b66b30eb47d3074cc7ca0559b598eb086defc", "9212c7b16ea9abfe93e3a16b29d8390e7762b5c07730cf05df1f3e7892ff96a4", "gentle-ai"),
122
- "linux/arm64": asset("gentle-ai_4.2.0_linux_arm64.tar.gz", "2bebe91b8808ce48d86c4d3700d098e37cb6b0658f196c666d9deeeb7c7c1a7a", "e1b88bd3dcdc8a15ddbcaf8731fd44561f05b06fbcb59d9385bbe82c4b96b5c4", "gentle-ai"),
119
+ "darwin/amd64": asset("gentle-ai_4.3.1_darwin_amd64.tar.gz", "6cf57fee0574daef2abed07f54a355fec3b93ac8f01733dc6c4d3674f1746112", "ca44c3fce186b63a7e7ad455c338133ba1982f4a13485301ce4d66edb73d2928", "gentle-ai"),
120
+ "darwin/arm64": asset("gentle-ai_4.3.1_darwin_arm64.tar.gz", "13e8828447f9081465ee0c35a61b24fb49296d51a310c6999227e2aed178956b", "749d08062b0dec13681678a1a1c018613d223ddcca47b5f27681b1d8ab5d14c2", "gentle-ai"),
121
+ "linux/amd64": asset("gentle-ai_4.3.1_linux_amd64.tar.gz", "04ec685c56b42c29dff6db10fc7dc2c730217dc9c96736e53ad2acf3226dafb7", "86248792bde3fe5e9ca93b128c07e7caa6a5b31fac0f9fc84b5b3011b123cb90", "gentle-ai"),
122
+ "linux/arm64": asset("gentle-ai_4.3.1_linux_arm64.tar.gz", "99e7d313a2ff85eaf23a6415e9b3aae67bf74c2fa169b21b93070ada7613f6a0", "df3580506bb5ea63cc4ef5ccebe8b68cfedf66b72f3b51f84ff393183d8cea2b", "gentle-ai"),
123
123
  });
124
124
 
125
125
  // A pinned asset is either a signed archive or, for a prerelease pin only,
@@ -44,19 +44,36 @@ export const goPinVersion = go.version;
44
44
  // checksums.txt and GitHub's asset digests; every archive was downloaded and
45
45
  // re-hashed before pinning. GitHub serves the assets from its asset hosts.
46
46
  const engram = Object.freeze({
47
- version: "3.4.0",
47
+ version: "3.5.2",
48
48
  redirectHosts: Object.freeze(["objects.githubusercontent.com", "release-assets.githubusercontent.com"]),
49
49
  archives: Object.freeze({
50
- "darwin-arm64": ["darwin_arm64.tar.gz", "d0aa59c9a83d17ae3fc7f5dd31fdab6a26fd8c00fc5e692195753cd0719294f7", 7470202],
51
- "darwin-x64": ["darwin_amd64.tar.gz", "3c5bdc73d226af5629cf4f4a781e223142e37c1ebb443c4b596f2e284352e381", 7961674],
52
- "linux-x64": ["linux_amd64.tar.gz", "715fda625556e46d65ddb4526bb7f88a3fee5339ca3c44ce44cc652f17fa57f1", 7821191],
53
- "linux-arm64": ["linux_arm64.tar.gz", "3309e9c0bbb539d183fd5aa9f9a13cd7aa0e81ec2512b5bedefb8b2bdd35b3e5", 7176726],
54
- "win32-x64": ["windows_amd64.zip", "3065736ec7ff8137eb3fc607275f542ea13bb743a5157318c8a8770f11ce3289", 8021225],
55
- "win32-arm64": ["windows_arm64.zip", "7463872a85a5e5434cf958bdffc71dda1926bced30d3b6b38c80fec9bb98fbba", 7231641],
50
+ "darwin-arm64": ["darwin_arm64.tar.gz", "43d398b2fe3327a4a6436c26b2a17d3285ba80bf2f11a22d0796ea2a7fd2b78c", 7749520],
51
+ "darwin-x64": ["darwin_amd64.tar.gz", "aea28ebf461a4ea86c52b793c29311539d7123875354ed7411eae982fc82ec87", 8181300],
52
+ "linux-x64": ["linux_amd64.tar.gz", "106a43ca8f43a27c2c260a6787151c3be6d91c5ed3d5e454e6140527adf92e01", 8064598],
53
+ "linux-arm64": ["linux_arm64.tar.gz", "b0ba49cf5a7d5af436c9744a9a0d638b3f9ebb2a7321c8736d30e96305c2993c", 7472279],
54
+ "win32-x64": ["windows_amd64.zip", "ba56a36d37b2da85fcb1581bbdbed9ae1ee690e642aab8dbe1e18209bf264afe", 8272809],
55
+ "win32-arm64": ["windows_arm64.zip", "df5acc5dd6ee407d484074e570c77a79567c0b8dccf55e25ae25a644c1e7db5f", 7530282],
56
56
  }),
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");