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,7 +1,7 @@
1
1
  import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
2
2
  import { request as httpRequest } from "node:http";
3
3
  import { createConnection } from "node:net";
4
- import { dirname, posix, win32 } from "node:path";
4
+ import { dirname, isAbsolute, posix, win32 } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  // The launcher in a bundled install (S5, S8): pure decisions over an injected
@@ -27,6 +27,46 @@ export interface BundledInstall {
27
27
 
28
28
  type Env = Record<string, string | undefined>;
29
29
 
30
+ /**
31
+ * Builds the detached shared server's environment without retaining session scratch.
32
+ * Windows Node can reintroduce an omitted TEMP from its parent, so use the
33
+ * original stable ancestor there; POSIX omits scratch temp settings instead.
34
+ */
35
+ export function sharedEngramEnv(base: Env, platform: string = process.platform): Env {
36
+ const path = pathFor(platform);
37
+ const env: Env = {};
38
+ const scratch = (value: string) => /^(gp|ga)[A-Za-z0-9]{6}$/.test(path.basename(path.normalize(value)));
39
+ let stableTemp: string | undefined;
40
+ let tempKey: string | undefined;
41
+ for (const [key, value] of Object.entries(base)) {
42
+ const name = platform === "win32" ? key.toUpperCase() : key;
43
+ if (name.startsWith("ENGRAM_") || name === "GH_TOKEN" || name === "GITHUB_TOKEN") continue;
44
+ if (["TMPDIR", "TMP", "TEMP"].includes(name) && value !== undefined && scratch(value)) {
45
+ if (platform !== "win32") continue;
46
+ // A drive-relative or root-relative path depends on ambient state.
47
+ const root = win32.parse(value).root;
48
+ if (!/^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]?)$/.test(root) || value.split(/[\\/]/).includes("..")) {
49
+ throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
50
+ }
51
+ let stable = win32.normalize(value);
52
+ while (scratch(stable)) {
53
+ const parent = win32.dirname(stable);
54
+ if (parent === stable) throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
55
+ stable = parent;
56
+ }
57
+ env[key] = stable;
58
+ stableTemp ??= stable;
59
+ } else {
60
+ env[key] = value;
61
+ }
62
+ if (name === "TEMP") tempKey = key;
63
+ }
64
+ // Keep an existing custom TEMP, even if TMP/TMPDIR chose another root.
65
+ if (stableTemp !== undefined && (tempKey === undefined || env[tempKey] === undefined)) env[tempKey ?? "TEMP"] = stableTemp;
66
+ env.ENGRAM_NO_UPDATE_CHECK = "1";
67
+ return env;
68
+ }
69
+
30
70
  // The same rules scripts/bundled-install.mjs writes versions and markers with.
31
71
  const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/;
32
72
  const VERSION_MARKER = ".gentle-shell-version";
@@ -232,15 +272,14 @@ export function bundledPi(install: BundledInstall, input: { platform: string; fs
232
272
  // Gentle AI's variables, never set here because the agent's shell would pass
233
273
  // them to a gentle-ai the user runs), and the published Engram as
234
274
  // gentle-engram's ENGRAM_BIN unless the user chose one. With a memory decision
235
- // (S2, S6): the startup policy, and ENGRAM_BIN is the verified core only when
236
- // memory is allowed locally; otherwise the Shell-owned guard, so no provider,
237
- // whatever loaded it, runs an Engram binary against an unverified store.
275
+ // (S2, S6): the startup policy, and ENGRAM_BIN is the Shell-owned guard, so no
276
+ // provider, whatever loaded it, runs an Engram binary against a store; allowed
277
+ // memory reaches the shared server through ENGRAM_URL only.
238
278
  export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
239
- export const ENGRAM_PRIVATE_REQUIRED_NOTICE = "Engram memory disabled: session-owned servers require a supporting published bundled core and companion. This bundle does not establish both capabilities; it will not fall back to a detached shared server. Install a supporting published bundle and restart Shell. Existing shared services are not stopped or adopted as session-owned.";
240
- export const ENGRAM_CORE_CHANGED_NOTICE = "Engram memory disabled: the bundled core changed since Shell recorded this store. Install a published bundle supporting migrate-copy, stop every Engram client, retain a complete backup, then run gentle-shell memory migrate --confirm-quiescent. The command verifies and migrates a copy before updating provenance; older bundles refuse safely. Do not edit coreSha256 or delete the original store. Shell remains usable without memory.";
241
- export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe store or server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server with verified /compatibility evidence or matching Shell-provisioned store identity. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
279
+ export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server on 127.0.0.1:7437 with verified /compatibility evidence; Shell never stops or replaces a server already listening there. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
280
+ export const ENGRAM_START_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not start the shared Engram server, or it did not report ready in time. Restart Shell to retry; if another program holds 127.0.0.1:7437, stop it first. Shell remains usable without memory.";
281
+ export const ENGRAM_NEWER_SCHEMA_NOTICE = "Engram memory disabled: ~/.engram was migrated by a newer Engram than Shell's bundled core, so Shell will not start a server on it. Start that newer Engram's server yourself (engram serve); Shell reuses a running server with verified /compatibility evidence. Back up the store; do not delete or downgrade it. Shell remains usable without memory.";
242
282
  // `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
243
- export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
244
283
  export const ENGRAM_CORE_MISSING_NOTICE = "Engram memory disabled: the bundled Engram core is not installed yet. Run 'gentle-shell setup' to install the verified core, then restart Shell. Shell remains usable without memory.";
245
284
  // The first Engram release whose server answers GET /compatibility (PR #1792).
246
285
  export const ENGRAM_COMPATIBILITY_MIN_VERSION = "3.4.0";
@@ -261,13 +300,14 @@ function outdatedServerVersion(version: string | undefined): string | undefined
261
300
  // The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
262
301
  export const ENGRAM_DEFAULT_PORT = 7437;
263
302
  export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
264
- const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL"];
303
+ const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL", "ENGRAM_SERVER_MODE"];
265
304
  const HEX32 = /^[a-f0-9]{32}$/;
266
305
 
267
306
  export type EngramProbe = { kind: "refused" } | { kind: "unknown" } | { kind: "listening"; compatible: boolean; healthInstanceId?: string; healthVersion?: string };
268
- // `fresh`: Shell provisions a new store this run and may record its provenance.
269
- // `url`: a running server verified through /compatibility, reached explicitly.
270
- export type EngramStartup = { policy: "allow" | "disabled"; notice?: string; url?: string; fresh?: true; owned?: true };
307
+ // `url`: the shared server, reached explicitly once /compatibility verified it.
308
+ // `spawn`: nothing listens yet; Shell starts the bundled core's server and must
309
+ // see it verified before Pi starts, or memory is disabled for that launch.
310
+ export type EngramStartup = { policy: "allow" | "disabled"; notice?: string; url?: string; spawn?: true };
271
311
 
272
312
  // Without an invalid policy or an explicit Engram selection, the default
273
313
  // endpoint may be probed; an explicit selection is never contacted.
@@ -277,26 +317,24 @@ export function engramProbeAllowed(env: Env): boolean {
277
317
  }
278
318
 
279
319
  // Evidence only: never run an executable, open SQLite or infer compatibility
280
- // from versions. `provenance` means Shell's own record matched the store
281
- // (directory, .instance-id, core digest) and its header showed no newer schema.
282
- // A server version only selects the wording of a disabled decision.
283
- export function classifyEngramStartup(input: { env: Env; store: "absent" | "present" | "unknown"; endpoint: EngramProbe; core: boolean;
284
- coreVersion: string; provenance?: { instanceId: string } }): EngramStartup {
320
+ // from versions. Like other agents' hooks, Shell shares the global server: a
321
+ // verified listener is reused, and with nothing listening the verified bundled
322
+ // core starts one on Engram's defaults, unless the default store's header
323
+ // (`userVersion`) shows a newer schema than that core's. A listener that does
324
+ // not verify is never replaced. A server version only selects the wording.
325
+ export function classifyEngramStartup(input: { env: Env; endpoint: EngramProbe; core: boolean; coreVersion: string; userVersion?: number }): EngramStartup {
285
326
  if (!engramProbeAllowed(input.env)) return { policy: "disabled", notice: ENGRAM_RECOVERY_NOTICE };
286
327
  const endpoint = input.endpoint;
287
- const outdated = endpoint.kind === "listening" && !endpoint.compatible ? outdatedServerVersion(endpoint.healthVersion) : undefined;
288
- const unknown: EngramStartup = { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
289
328
  if (endpoint.kind === "listening" && endpoint.compatible) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
290
- if (endpoint.kind === "refused" && input.store === "absent") {
291
- return input.core ? { policy: "allow", fresh: true } : { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
329
+ if (endpoint.kind === "refused") {
330
+ if (!input.core) return { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
331
+ if (input.userVersion !== undefined && !engramHeaderAllows(input.userVersion, bundledEngramSchema(input.coreVersion))) {
332
+ return { policy: "disabled", notice: ENGRAM_NEWER_SCHEMA_NOTICE };
333
+ }
334
+ return { policy: "allow", url: ENGRAM_DEFAULT_URL, spawn: true };
292
335
  }
293
- const own = input.provenance;
294
- if (own === undefined || !input.core || input.store !== "present") return unknown;
295
- // Shell's own store, created by this exact core: offline, the provider starts
296
- // that core; a running server must be that store and that exact core.
297
- if (endpoint.kind === "refused") return { policy: "allow" };
298
- if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
299
- return unknown;
336
+ const outdated = endpoint.kind === "listening" ? outdatedServerVersion(endpoint.healthVersion) : undefined;
337
+ return { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
300
338
  }
301
339
 
302
340
  // The exact contract a startup-verified store reports (Engram GET /compatibility).
@@ -356,8 +394,8 @@ export async function probeEngramEndpoint(input: { port?: number; timeoutMs?: nu
356
394
  return { kind: "listening", compatible: false, healthInstanceId: identified ? body.instance_id : undefined, healthVersion: identified ? body.version : undefined };
357
395
  }
358
396
 
359
- // Shell's own record of a store it provisioned fresh with the verified core.
360
- // Written only after Shell itself provisioned the store's identity, before Pi starts.
397
+ // Shell's record of a store an earlier Shell provisioned with its bundled core,
398
+ // read only by the explicit `gentle-shell memory migrate` recovery.
361
399
  export type EngramProvenance = { schema: 1; dataDir: string; instanceId: string; coreSha256: string };
362
400
 
363
401
  export function engramProvenancePath(prefix: string, platform: string = process.platform): string {
@@ -371,14 +409,10 @@ export function parseEngramProvenance(text: string | undefined): EngramProvenanc
371
409
  return { schema: 1, dataDir: record.dataDir, instanceId: record.instanceId, coreSha256: record.coreSha256 };
372
410
  }
373
411
 
374
- export function engramProvenanceMatches(record: EngramProvenance | undefined, observed: { dataDir: string; instanceId?: string; coreSha256?: string }): boolean {
375
- return record !== undefined && record.dataDir === observed.dataDir && record.instanceId === observed.instanceId && record.coreSha256 === observed.coreSha256;
376
- }
377
-
378
412
  // The bundled core's own schema (SQLite user_version), verified from the
379
- // tagged sources v3.3.2 and v3.4.0: internal/store/store.go `const schemaVersion = 1`.
413
+ // tagged sources v3.3.2, v3.4.0 and v3.5.2: internal/store/store.go `const schemaVersion = 1`.
380
414
  // A pin without a verified entry leaves the header check unavailable.
381
- const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1 };
415
+ const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1, "3.5.2": 1 };
382
416
 
383
417
  export function bundledEngramSchema(coreVersion: string): number | undefined {
384
418
  return Object.hasOwn(BUNDLED_ENGRAM_SCHEMA, coreVersion) ? BUNDLED_ENGRAM_SCHEMA[coreVersion] : undefined;
@@ -426,11 +460,6 @@ export function supportsEngramStartupPolicy(metadata: string | undefined): boole
426
460
  return pkg?.name === "gentle-engram" && pkg.engram?.startupPolicy === 1;
427
461
  }
428
462
 
429
- export function supportsEngramSessionOwned(metadata: string | undefined): boolean {
430
- const pkg = parseJson(metadata);
431
- return supportsEngramStartupPolicy(metadata) && pkg?.engram?.sessionOwned === 1;
432
- }
433
-
434
463
  export function bundledChildEnv(base: Env, input: { node: string; cli: string; prefix: string; engram?: string; guard?: string; memory?: EngramStartup }): Env {
435
464
  const env: Env = { ...base, PI_SKIP_VERSION_CHECK: "1", [GENTLE_SHELL_PREFIX_ENV]: input.prefix };
436
465
  delete env.GENTLE_SHELL_PI;
@@ -453,14 +482,7 @@ export function bundledChildEnv(base: Env, input: { node: string; cli: string; p
453
482
  return env;
454
483
  }
455
484
  if (memory.url !== undefined) env.ENGRAM_URL = memory.url;
456
- const local = memory.policy === "allow" && memory.url === undefined ? input.engram : undefined;
457
- env.ENGRAM_BIN = local ?? input.guard;
458
- if (local && memory.owned) {
459
- env.ENGRAM_SERVER_MODE = "session-owned";
460
- delete env.ENGRAM_URL;
461
- delete env.ENGRAM_PORT;
462
- delete env.ENGRAM_SOCKET;
463
- }
485
+ env.ENGRAM_BIN = input.guard;
464
486
  return env;
465
487
  }
466
488
 
@@ -491,9 +513,64 @@ export function withBundledNpmCommand(settingsText: string | undefined, command:
491
513
  return { status: "write", text: settingsText === undefined || settingsText.endsWith("\n") ? `${serialized}\n` : serialized };
492
514
  }
493
515
 
516
+ export type BundledNpmCommandRemoval = { status: "unchanged" } | { status: "malformed" } | { status: "removed"; text: string };
517
+
518
+ // settings.json without `npmCommand`: every other key kept in order and the
519
+ // file's indentation and final newline kept.
520
+ function settingsWithoutNpmCommand(settingsText: string, settings: Record<string, unknown>): string {
521
+ const indent = settingsText.match(/\{\r?\n([ \t]+)/)?.[1] ?? " ";
522
+ const { npmCommand: _removed, ...rest } = settings;
523
+ const serialized = JSON.stringify(rest, null, indent);
524
+ return settingsText.endsWith("\n") ? `${serialized}\n` : serialized;
525
+ }
526
+
527
+ // The agent home's settings.json without a `npmCommand` naming the prefix a
528
+ // bundled self-uninstall removed (gentle-shell-uninstall.ts): one absolute
529
+ // entry inside `withinRemoved` drops the whole key, every other key kept in
530
+ // order and the file's indentation and final newline kept. A file that is not
531
+ // a JSON object is never rewritten; a command naming nothing inside it stays.
532
+ export function withoutBundledNpmCommand(settingsText: string | undefined, withinRemoved: (path: string) => boolean): BundledNpmCommandRemoval {
533
+ if (settingsText === undefined) return { status: "unchanged" };
534
+ const settings = parseJson(settingsText);
535
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
536
+ const current = settings.npmCommand;
537
+ if (!Array.isArray(current) || !current.some((part) => typeof part === "string" && isAbsolute(part) && withinRemoved(part))) return { status: "unchanged" };
538
+ return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
539
+ }
540
+
541
+ // An `npmCommand` a bundled install wrote (scripts/bundled-install.mjs
542
+ // bundledNpmCommand, with npm-cli.js and --prefix in earlier releases): our
543
+ // node in runtime/node-<v>, then npm's settings and cache flags, and
544
+ // --update-notifier=false last. A command the user chose never matches, even
545
+ // when its program is missing.
546
+ export function isBundledNpmCommand(command: readonly unknown[]): boolean {
547
+ const [node] = command;
548
+ return typeof node === "string" && /[\\/]runtime[\\/]node-[^\\/]+[\\/](node\.exe|bin[\\/]node)$/.test(node)
549
+ && ["--userconfig=", "--globalconfig=", "--cache="].every((flag) => command.some((part) => typeof part === "string" && part.startsWith(flag)))
550
+ && command[command.length - 1] === "--update-notifier=false";
551
+ }
552
+
553
+ // The agent home's settings.json without an `npmCommand` a bundled install
554
+ // wrote whose executable (its first entry) no longer exists, as after a
555
+ // bundled self-uninstall that kept the home (#2469): Pi runs that command for
556
+ // every package install, so a key naming a deleted node.exe fails them all
557
+ // with ENOENT. A command whose executable exists, that names no absolute
558
+ // path, or that the user set themselves is never touched.
559
+ export function withoutMissingBundledNpmCommand(settingsText: string | undefined, exists: (path: string) => boolean): BundledNpmCommandRemoval {
560
+ if (settingsText === undefined) return { status: "unchanged" };
561
+ const settings = parseJson(settingsText);
562
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
563
+ const executable = Array.isArray(settings.npmCommand) ? settings.npmCommand[0] : undefined;
564
+ if (typeof executable !== "string" || !isAbsolute(executable) || exists(executable) || !isBundledNpmCommand(settings.npmCommand as unknown[])) return { status: "unchanged" };
565
+ return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
566
+ }
567
+
494
568
  export interface BundledUpdatePlan {
495
569
  // Pi's own update was asked for: redirect it to `gentle-shell upgrade`.
496
570
  self: boolean;
571
+ // `--latest` asked for the newest published Pi in place of the release's
572
+ // verified one (scripts/bundled-install.mjs upgradePiLatest). Only set then.
573
+ latest?: boolean;
497
574
  // What Pi still runs, if anything: the extension part of `--all`, or the
498
575
  // whole command when it never updates Pi itself.
499
576
  piArgs?: string[];
@@ -504,7 +581,9 @@ export interface BundledUpdatePlan {
504
581
  // `update`, `--self`, or a `self`/`pi` positional, and "all" for `--all`,
505
582
  // `--self --extensions` or `self`/`pi` with `--extensions`. Only Pi's own part
506
583
  // is redirected; "all" still sends `update --extensions` (with the other
507
- // flags) to Pi. Packages, models, help, and forms Pi refuses (conflicting or
584
+ // flags) to Pi. `--latest` marks Pi's own part as the newest published Pi;
585
+ // with any form that is not Pi's own update it is unknown to Pi, so it passes
586
+ // through. Packages, models, help, and forms Pi refuses (conflicting or
508
587
  // unknown options) reach Pi unchanged.
509
588
  export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan {
510
589
  const passThrough = { self: false, piArgs: passthrough };
@@ -513,8 +592,10 @@ export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan {
513
592
  let self = false;
514
593
  let extensions = false;
515
594
  let all = false;
595
+ let latest = false;
516
596
  for (const token of passthrough.slice(1)) {
517
597
  if (token === "--self") self = true;
598
+ else if (token === "--latest") latest = true;
518
599
  else if (token === "--extensions") extensions = true;
519
600
  else if (token === "--all") all = true;
520
601
  else if (["--approve", "-a", "--no-approve", "-na", "--force"].includes(token)) flags.push(token);
@@ -526,19 +607,21 @@ export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan {
526
607
  if ((positional[0] !== "self" && positional[0] !== "pi") || self) return passThrough;
527
608
  self = true;
528
609
  }
529
- if (all || (self && extensions)) return { self: true, piArgs: ["update", "--extensions", ...flags] };
610
+ const marked = latest ? { latest: true } : {};
611
+ if (all || (self && extensions)) return { self: true, ...marked, piArgs: ["update", "--extensions", ...flags] };
530
612
  if (extensions) return passThrough;
531
- return { self: true };
613
+ return { self: true, ...marked };
532
614
  }
533
615
 
534
- // What `gentle-shell update` does after the redirected upgrade (`upgradeCode`
535
- // is undefined when it did not run): with nothing left for Pi it exits with the
536
- // upgrade's code; otherwise Pi still runs, so `update --all` updates the
537
- // extensions even when the upgrade could not complete.
616
+ // What `gentle-shell update` does after the redirected upgrade or the
617
+ // `--latest` Pi update (`upgradeCode` is undefined when it did not run): with
618
+ // nothing left for Pi it exits with that code; otherwise Pi still runs, so
619
+ // `update --all` updates the extensions even when that step could not complete.
538
620
  export function bundledUpdateNext(plan: BundledUpdatePlan, upgradeCode: number | undefined): { exitCode: number } | { piArgs: string[]; note?: string } {
539
621
  if (plan.piArgs === undefined) return { exitCode: upgradeCode ?? 0 };
540
622
  if (plan.self && upgradeCode !== undefined && upgradeCode !== 0) {
541
- return { piArgs: plan.piArgs, note: "gentle-shell: the Gentle Shell upgrade did not complete; updating the extensions anyway." };
623
+ const step = plan.latest ? "latest Pi update" : "Gentle Shell upgrade";
624
+ return { piArgs: plan.piArgs, note: `gentle-shell: the ${step} did not complete; updating the extensions anyway.` };
542
625
  }
543
626
  return { piArgs: plan.piArgs };
544
627
  }
@@ -549,6 +632,7 @@ export function bundledUpdateNotice(input: { piVersion: string; interactive: boo
549
632
  input.interactive
550
633
  ? "Running 'gentle-shell upgrade'..."
551
634
  : "Run 'gentle-shell upgrade' to install the latest Gentle Shell release and its Pi.",
635
+ "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.",
552
636
  ];
553
637
  }
554
638
 
@@ -1118,12 +1118,17 @@ const CMD_EXE_SPECIAL_CHARS = /[\s"&|<>^%()]/;
1118
1118
 
1119
1119
  // cmd.exe quoting is deliberately simple, not a full cmd.exe parser: wrap a
1120
1120
  // token in double quotes when it is empty or contains whitespace or any of
1121
- // `"&|<>^%()`, and escape an inner `"` as `\"` — doubling inner quotes is not
1122
- // reliable in cmd.exe, unlike the `\"` convention Node's own Windows spawn
1123
- // helpers use.
1121
+ // `&|<>^%()`. A token with an inner `"` cannot be represented: cmd.exe has no
1122
+ // backslash escape, so `\"` still ends the quoted section and the rest of the
1123
+ // token is parsed as command syntax (`x" & whoami & rem "` would run
1124
+ // `whoami`). Doubling the quote is not reliable either, so such a token is
1125
+ // refused instead of producing a command line it cannot protect.
1124
1126
  export function quoteForCmdExe(token: string): string {
1127
+ if (token.includes('"')) {
1128
+ throw new Error("cannot pass an argument containing a double quote through cmd.exe: it would end the quoted argument and let the rest run as a command");
1129
+ }
1125
1130
  if (token.length > 0 && !CMD_EXE_SPECIAL_CHARS.test(token)) return token;
1126
- return `"${token.replace(/"/g, '\\"')}"`;
1131
+ return `"${token}"`;
1127
1132
  }
1128
1133
 
1129
1134
  const POSIX_SHELL_SPECIAL_CHARS = /[\s"'`\\$&|;<>(){}*?[\]!#~]/;
@@ -1346,6 +1351,8 @@ export function helpText(): string {
1346
1351
  " gentle-shell update [target] Run pi's own 'update' against the resolved home.",
1347
1352
  " In a bundled install Pi ships pinned: 'update' without a package",
1348
1353
  " runs 'gentle-shell upgrade' instead of updating Pi.",
1354
+ " 'update --latest' installs the newest published Pi instead,",
1355
+ " without the release's verified lockfile.",
1349
1356
  " gentle-shell config Run pi's own 'config' against the resolved home.",
1350
1357
  " gentle-shell auth <command> Run pi's own 'auth' against the resolved home.",
1351
1358
  " gentle-shell mcp <command> Run pi's own 'mcp' (add, list, login, logout) against the",
@@ -19,8 +19,32 @@ export interface StartupUpdateLookupOptions {
19
19
  skipped?: string;
20
20
  lookup: (signal: AbortSignal) => Promise<string | undefined>;
21
21
  timeoutMs?: number;
22
+ /** A recorded previous lookup. Fresh enough, it answers without the network. */
23
+ cache?: StartupReleaseCache;
24
+ /** Injected clock and budget for tests; defaults to now and one day. */
25
+ nowMs?: number;
26
+ ttlMs?: number;
27
+ /** Called once with a live lookup's version; never for a fresh-cache answer or an unanswered lookup. */
28
+ cacheWrite?: (latest: string) => void;
22
29
  }
23
30
 
31
+ /** The launcher config records the last live lookup so the everyday launch stays off the network. */
32
+ export interface StartupReleaseCache {
33
+ checkedAt: number;
34
+ latest: string;
35
+ }
36
+
37
+ /** Reads the config's `startupReleaseCheck` key; anything malformed is no cache. */
38
+ export function parseStartupReleaseCache(value: unknown): StartupReleaseCache | undefined {
39
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
40
+ const { checkedAt, latest } = value as { checkedAt?: unknown; latest?: unknown };
41
+ const ms = typeof checkedAt === "number" ? checkedAt : typeof checkedAt === "string" ? Date.parse(checkedAt) : Number.NaN;
42
+ if (!Number.isFinite(ms) || typeof latest !== "string" || latest === "") return undefined;
43
+ return { checkedAt: ms, latest };
44
+ }
45
+
46
+ export const STARTUP_RELEASE_TTL_MS = 24 * 60 * 60 * 1000;
47
+
24
48
  export function shouldCheckStartupUpdate({ args, env, bundled, inputTTY, outputTTY }: StartupUpdateCheckOptions): boolean {
25
49
  if (!bundled || !inputTTY || !outputTTY || args.command || args.piSubcommand || args.error || args.help || args.version || args.packageRoot !== undefined) {
26
50
  return false;
@@ -61,9 +85,30 @@ function isNewer(latest: string[], current: string[]): boolean {
61
85
  return false;
62
86
  }
63
87
 
64
- export async function findStartupUpdate({ current, skipped, lookup, timeoutMs = 1500 }: StartupUpdateLookupOptions): Promise<StartupUpdateOffer | undefined> {
88
+ function offerFor(current: string, latest: string | undefined, skipped: string | undefined): StartupUpdateOffer | undefined {
65
89
  const currentParts = stableParts(current);
66
90
  if (!currentParts) return undefined;
91
+ if (latest === undefined || latest === skipped) return undefined;
92
+ const latestParts = stableParts(latest);
93
+ if (!latestParts || !isNewer(latestParts, currentParts)) return undefined;
94
+ return {
95
+ current,
96
+ latest,
97
+ notesUrl: `https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v${latest}`,
98
+ };
99
+ }
100
+
101
+ // A malformed cached latest is never trusted — it must not suppress the
102
+ // network check for a whole TTL window — so it counts as no cache at all.
103
+ function usableCache(cache: StartupReleaseCache | undefined, ttlMs: number, nowMs: number): StartupReleaseCache | undefined {
104
+ if (cache === undefined) return undefined;
105
+ if (!Number.isFinite(cache.checkedAt) || typeof cache.latest !== "string" || cache.latest === "" || stableParts(cache.latest) === undefined) return undefined;
106
+ return nowMs - cache.checkedAt < ttlMs ? cache : undefined;
107
+ }
108
+
109
+ export async function findStartupUpdate({ current, skipped, lookup, timeoutMs = 1500, cache, nowMs = Date.now(), ttlMs = STARTUP_RELEASE_TTL_MS, cacheWrite }: StartupUpdateLookupOptions): Promise<StartupUpdateOffer | undefined> {
110
+ const cached = usableCache(cache, ttlMs, nowMs);
111
+ if (cached !== undefined) return offerFor(current, cached.latest, skipped);
67
112
  const controller = new AbortController();
68
113
  const deadline = Number.isFinite(timeoutMs) && timeoutMs >= 0 && timeoutMs <= 2_147_483_647 ? timeoutMs : 1500;
69
114
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -76,17 +121,24 @@ export async function findStartupUpdate({ current, skipped, lookup, timeoutMs =
76
121
  });
77
122
  // The race bounds startup even if the injected lookup ignores cancellation.
78
123
  const latest = await Promise.race([lookup(controller.signal), timeout]);
79
- if (latest === undefined || latest === skipped) return undefined;
80
- const latestParts = stableParts(latest);
81
- if (!latestParts || !isNewer(latestParts, currentParts)) return undefined;
82
- return {
83
- current,
84
- latest,
85
- notesUrl: `https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v${latest}`,
86
- };
124
+ // Only a real answer is worth a day's trust: a timeout or rejection must
125
+ // not suppress tomorrow's check.
126
+ if (typeof latest === "string") cacheWrite?.(latest);
127
+ return offerFor(current, latest, skipped);
87
128
  } catch {
88
129
  return undefined;
89
130
  } finally {
90
131
  clearTimeout(timer);
91
132
  }
92
133
  }
134
+
135
+ // Mirrors omitStartupVersion: preserves every other config key, fails loudly on
136
+ // an invalid config, and records the lookup's answer with an injected clock.
137
+ export function writeStartupReleaseCache(read: () => string | undefined, write: (text: string) => void, version: string, nowMs = Date.now()): void {
138
+ const text = read();
139
+ const config = text === undefined ? {} : JSON.parse(text);
140
+ if (config === null || typeof config !== "object" || Array.isArray(config)) throw new Error("Invalid launcher config");
141
+ const cache: StartupReleaseCache = { checkedAt: nowMs, latest: version };
142
+ const serialized = JSON.stringify({ ...config, startupReleaseCheck: { checkedAt: new Date(cache.checkedAt).toISOString(), latest: cache.latest } }, null, 2);
143
+ write(`${serialized}\n`);
144
+ }
@@ -1,6 +1,8 @@
1
- import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync } from "node:fs";
1
+ import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { createInterface } from "node:readline";
3
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { withoutBundledNpmCommand } from "./gentle-shell-bundled.ts";
4
6
  import { isolatedDir, launcherConfigPath, parseRawLauncherConfig, provisionedEntry, userPiHome } from "./gentle-shell-launcher.ts";
5
7
 
6
8
  // `gentle-shell self-uninstall`: removes what Gentle Shell itself created, then
@@ -778,6 +780,18 @@ function printBundledPlan(plan: BundledPlan, input: BundledSelfUninstallInput, o
778
780
  }
779
781
  }
780
782
 
783
+ /** Replaces `path`'s text through a same-directory temporary file, so a crash never leaves it half-written. */
784
+ function writeTextAtomically(path: string, text: string): void {
785
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
786
+ try {
787
+ writeFileSync(temporary, text);
788
+ renameSync(temporary, path);
789
+ } catch (error) {
790
+ try { unlinkSync(temporary); } catch { /* The temporary file is best effort. */ }
791
+ throw error;
792
+ }
793
+ }
794
+
781
795
  /**
782
796
  * Runs `gentle-shell self-uninstall` for the bundled install at `root`. Exit
783
797
  * codes as runSelfUninstall: 0 done or dry run, 1 refused, cancelled or failed,
@@ -837,6 +851,24 @@ export async function runBundledSelfUninstall(input: BundledSelfUninstallInput):
837
851
  } catch {
838
852
  // A leftover empty folder is harmless.
839
853
  }
854
+ // A home that stays must not keep Pi pointed at the npm this uninstall removed
855
+ // (#2469): an `npmCommand` naming the prefix fails every later `pi install`
856
+ // with ENOENT naming the deleted node.exe.
857
+ if (!confirmation.includeShared) {
858
+ const settingsPath = join(plan.shellHome, "agent", "settings.json");
859
+ let settingsText: string | undefined;
860
+ try { settingsText = readFileSync(settingsPath, "utf8"); } catch { settingsText = undefined; }
861
+ const removal = withoutBundledNpmCommand(settingsText, (path) => within(root, path, platform));
862
+ if (removal.status === "removed") {
863
+ try {
864
+ writeTextAtomically(settingsPath, removal.text);
865
+ out(`Removed npmCommand from ${settingsPath}: it named the removed install.`);
866
+ } catch (error) {
867
+ err(`gentle-shell self-uninstall: could not remove npmCommand from ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`);
868
+ err(`Pi package installs keep failing until the npmCommand naming ${root} is deleted from it.`);
869
+ }
870
+ }
871
+ }
840
872
  if (plan.held.length > 0) {
841
873
  try {
842
874
  if (!input.deferRemoval) throw new Error("no deferred removal on this system");