gentle-pi 4.2.0 → 4.4.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 (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,262 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { formatImportPiReport, importPi, nodeImportPiFs } from "../lib/gentle-shell-import-pi.ts";
8
+
9
+ // `gentle-shell home import-pi` core: real temp directories, never the real
10
+ // ~/.pi or ~/.gentle-shell. Credential contents never appear in assertions.
11
+
12
+ const AUTH = JSON.stringify({ anthropic: { type: "oauth", refresh: "dummy-refresh", access: "dummy-access" } });
13
+ const MCP = JSON.stringify({ mcpServers: { demo: { command: "demo" } } }, null, 2);
14
+
15
+ function fixture(t: test.TestContext) {
16
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-import-pi-"));
17
+ t.after(() => rmSync(root, { recursive: true, force: true }));
18
+ const source = join(root, "pi-agent");
19
+ const target = join(root, "gentle-agent");
20
+ mkdirSync(source, { recursive: true });
21
+ mkdirSync(target, { recursive: true });
22
+ return { root, source, target };
23
+ }
24
+
25
+ function seedSource(source: string) {
26
+ writeFileSync(join(source, "auth.json"), AUTH, { mode: 0o644 });
27
+ chmodSync(join(source, "auth.json"), 0o644);
28
+ writeFileSync(join(source, "mcp.json"), MCP);
29
+ chmodSync(join(source, "mcp.json"), 0o640);
30
+ writeFileSync(join(source, "settings.json"), `${JSON.stringify({
31
+ defaultProvider: "anthropic",
32
+ defaultModel: "claude-opus-5-5",
33
+ defaultThinkingLevel: "high",
34
+ packages: ["npm:foreign"],
35
+ extensions: ["./x.ts"],
36
+ theme: "dark",
37
+ npmCommand: ["npm"],
38
+ }, null, 2)}\n`);
39
+ writeFileSync(join(source, "mcp-cache.json"), "{}");
40
+ writeFileSync(join(source, "mcp-npx-cache.json"), "{}");
41
+ writeFileSync(join(source, "AGENTS.md"), "# agents\n");
42
+ mkdirSync(join(source, "sessions"));
43
+ }
44
+
45
+ function snapshot(dir: string) {
46
+ return Object.fromEntries(readdirSync(dir).sort().map((name) => {
47
+ const stat = statSync(join(dir, name));
48
+ const hash = stat.isFile() ? createHash("sha256").update(readFileSync(join(dir, name))).digest("hex") : "dir";
49
+ return [name, { hash, mode: stat.mode, mtimeMs: stat.mtimeMs }];
50
+ }));
51
+ }
52
+
53
+ function statusOf(result: ReturnType<typeof importPi>, item: string) {
54
+ return result.items.find((entry) => entry.item === item)?.status;
55
+ }
56
+
57
+ const modeOf = (path: string) => statSync(path).mode & 0o777;
58
+
59
+ test("copies auth.json (0600) and mcp.json (source mode) only when absent in the target", (t) => {
60
+ const f = fixture(t);
61
+ seedSource(f.source);
62
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
63
+ assert.equal(result.failed, false);
64
+ assert.equal(statusOf(result, "auth.json"), "copied");
65
+ assert.equal(statusOf(result, "mcp.json"), "copied");
66
+ assert.ok(readFileSync(join(f.target, "auth.json"), "utf8") === AUTH, "auth.json bytes copied verbatim");
67
+ assert.ok(readFileSync(join(f.target, "mcp.json"), "utf8") === MCP);
68
+ if (process.platform !== "win32") {
69
+ assert.equal(modeOf(join(f.target, "auth.json")), 0o600);
70
+ assert.equal(modeOf(join(f.target, "mcp.json")), 0o640);
71
+ }
72
+ });
73
+
74
+ test("never overwrites a target file that already exists and is idempotent", (t) => {
75
+ const f = fixture(t);
76
+ seedSource(f.source);
77
+ writeFileSync(join(f.target, "auth.json"), "{\"mine\":true}");
78
+ const first = importPi({ source: f.source, target: f.target, dryRun: false });
79
+ assert.equal(statusOf(first, "auth.json"), "present");
80
+ assert.ok(readFileSync(join(f.target, "auth.json"), "utf8") === "{\"mine\":true}", "existing target auth.json kept");
81
+ const before = snapshot(f.target);
82
+ const second = importPi({ source: f.source, target: f.target, dryRun: false });
83
+ assert.equal(second.failed, false);
84
+ assert.ok(second.items.every((entry) => entry.status !== "copied"), "a second run copies nothing");
85
+ assert.deepEqual(snapshot(f.target), before);
86
+ });
87
+
88
+ test("never modifies the source: content, mode and mtime stay the same", (t) => {
89
+ const f = fixture(t);
90
+ seedSource(f.source);
91
+ const before = snapshot(f.source);
92
+ importPi({ source: f.source, target: f.target, dryRun: false });
93
+ assert.deepEqual(snapshot(f.source), before);
94
+ });
95
+
96
+ test("never copies caches, AGENTS.md, sessions or any other file", (t) => {
97
+ const f = fixture(t);
98
+ seedSource(f.source);
99
+ importPi({ source: f.source, target: f.target, dryRun: false });
100
+ assert.deepEqual(readdirSync(f.target).sort(), ["auth.json", "mcp.json", "settings.json"]);
101
+ });
102
+
103
+ test("missing source files are reported as not found, not errors", (t) => {
104
+ const f = fixture(t);
105
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
106
+ assert.equal(result.failed, false);
107
+ assert.equal(statusOf(result, "auth.json"), "not found");
108
+ assert.equal(statusOf(result, "mcp.json"), "not found");
109
+ assert.equal(statusOf(result, "settings.json"), "not found");
110
+ assert.deepEqual(readdirSync(f.target), []);
111
+ });
112
+
113
+ test("skips a symlinked or non-regular source file", { skip: process.platform === "win32" }, (t) => {
114
+ const f = fixture(t);
115
+ writeFileSync(join(f.root, "elsewhere.json"), AUTH);
116
+ symlinkSync(join(f.root, "elsewhere.json"), join(f.source, "auth.json"));
117
+ mkdirSync(join(f.source, "mcp.json"));
118
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
119
+ assert.equal(statusOf(result, "auth.json"), "skipped");
120
+ assert.equal(statusOf(result, "mcp.json"), "skipped");
121
+ assert.equal(existsSync(join(f.target, "auth.json")), false);
122
+ assert.equal(existsSync(join(f.target, "mcp.json")), false);
123
+ });
124
+
125
+ test("settings: copies only the default provider/model/thinking keys absent in the target, preserving every target key and order", (t) => {
126
+ const f = fixture(t);
127
+ seedSource(f.source);
128
+ writeFileSync(join(f.target, "settings.json"), `${JSON.stringify({ tuiMode: "fullscreen", theme: "Gentleman-Cute", defaultModel: "kept-model", packages: ["npm:own"] }, null, 2)}\n`);
129
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
130
+ assert.equal(result.failed, false);
131
+ assert.equal(statusOf(result, "settings.json defaultProvider"), "copied");
132
+ assert.equal(statusOf(result, "settings.json defaultModel"), "present");
133
+ assert.equal(statusOf(result, "settings.json defaultThinkingLevel"), "copied");
134
+ const text = readFileSync(join(f.target, "settings.json"), "utf8");
135
+ assert.equal(text, `${JSON.stringify({
136
+ tuiMode: "fullscreen", theme: "Gentleman-Cute", defaultModel: "kept-model", packages: ["npm:own"],
137
+ defaultProvider: "anthropic", defaultThinkingLevel: "high",
138
+ }, null, 2)}\n`);
139
+ });
140
+
141
+ test("settings: the target keeps its own indentation and trailing-newline style", (t) => {
142
+ const f = fixture(t);
143
+ seedSource(f.source);
144
+ writeFileSync(join(f.target, "settings.json"), `{\n\t"tuiMode": "fullscreen",\n\t"zeta": 1\n}`);
145
+ importPi({ source: f.source, target: f.target, dryRun: false });
146
+ assert.equal(readFileSync(join(f.target, "settings.json"), "utf8"), JSON.stringify({
147
+ tuiMode: "fullscreen", zeta: 1, defaultProvider: "anthropic", defaultModel: "claude-opus-5-5", defaultThinkingLevel: "high",
148
+ }, null, "\t"));
149
+ });
150
+
151
+ test("settings: an unreadable settings.json is reported per item while the other items still run", (t) => {
152
+ if (process.platform === "win32" || process.getuid?.() === 0) return t.skip("needs POSIX permissions as a non-root user");
153
+ const f = fixture(t);
154
+ seedSource(f.source);
155
+ const settings = join(f.target, "settings.json");
156
+ writeFileSync(settings, "{}\n");
157
+ chmodSync(settings, 0o000);
158
+ try {
159
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
160
+ assert.equal(statusOf(result, "auth.json"), "copied");
161
+ assert.equal(statusOf(result, "settings.json"), "failed");
162
+ assert.equal(result.failed, true);
163
+ assert.match(formatImportPiReport(result, { source: f.source, target: f.target, dryRun: false }).join("\n"), /settings\.json: failed/);
164
+ } finally {
165
+ chmodSync(settings, 0o600);
166
+ }
167
+ });
168
+
169
+ test("a destination that cannot be classified is one failed item while the other items still run", (t) => {
170
+ const f = fixture(t);
171
+ seedSource(f.source);
172
+ const blocked = join(f.target, "auth.json");
173
+ const fs = { ...nodeImportPiFs, kind: (path: string) => {
174
+ if (path === blocked) throw Object.assign(new Error(`ENOTDIR: not a directory, lstat '${path}'`), { code: "ENOTDIR" });
175
+ return nodeImportPiFs.kind(path);
176
+ } };
177
+ for (const dryRun of [true, false]) {
178
+ const result = importPi({ source: f.source, target: f.target, dryRun, fs });
179
+ assert.equal(statusOf(result, "auth.json"), "failed", `dryRun=${dryRun}`);
180
+ assert.equal(statusOf(result, "mcp.json"), dryRun ? "would copy" : "copied");
181
+ assert.equal(result.failed, true);
182
+ }
183
+ });
184
+
185
+ test("settings: a missing target settings.json gets only the allowlisted keys", (t) => {
186
+ const f = fixture(t);
187
+ seedSource(f.source);
188
+ importPi({ source: f.source, target: f.target, dryRun: false });
189
+ assert.deepEqual(JSON.parse(readFileSync(join(f.target, "settings.json"), "utf8")), {
190
+ defaultProvider: "anthropic", defaultModel: "claude-opus-5-5", defaultThinkingLevel: "high",
191
+ });
192
+ });
193
+
194
+ test("settings: nothing to add leaves the target settings.json untouched", (t) => {
195
+ const f = fixture(t);
196
+ writeFileSync(join(f.source, "settings.json"), JSON.stringify({ packages: ["npm:x"] }));
197
+ writeFileSync(join(f.target, "settings.json"), "{\"tuiMode\":\"fullscreen\"}");
198
+ const before = snapshot(f.target);
199
+ importPi({ source: f.source, target: f.target, dryRun: false });
200
+ assert.deepEqual(snapshot(f.target), before);
201
+ });
202
+
203
+ test("settings: malformed source or target JSON is reported and nothing is written", (t) => {
204
+ const f = fixture(t);
205
+ writeFileSync(join(f.source, "settings.json"), "{ not json");
206
+ const badSource = importPi({ source: f.source, target: f.target, dryRun: false });
207
+ assert.equal(statusOf(badSource, "settings.json"), "skipped");
208
+ assert.equal(badSource.failed, false);
209
+ assert.equal(existsSync(join(f.target, "settings.json")), false);
210
+
211
+ writeFileSync(join(f.source, "settings.json"), JSON.stringify({ defaultProvider: "anthropic" }));
212
+ writeFileSync(join(f.target, "settings.json"), "[1, 2]");
213
+ const badTarget = importPi({ source: f.source, target: f.target, dryRun: false });
214
+ assert.equal(statusOf(badTarget, "settings.json"), "skipped");
215
+ assert.equal(readFileSync(join(f.target, "settings.json"), "utf8"), "[1, 2]");
216
+ });
217
+
218
+ test("dry run reports the same plan and writes nothing, not even the target directory", (t) => {
219
+ const f = fixture(t);
220
+ seedSource(f.source);
221
+ const target = join(f.root, "not-created", "agent");
222
+ const result = importPi({ source: f.source, target, dryRun: true });
223
+ assert.equal(result.failed, false);
224
+ assert.equal(statusOf(result, "auth.json"), "would copy");
225
+ assert.equal(statusOf(result, "settings.json defaultProvider"), "would copy");
226
+ assert.equal(existsSync(join(f.root, "not-created")), false);
227
+ });
228
+
229
+ test("a write failure is reported per item and keeps what was already copied", { skip: process.platform === "win32" }, (t) => {
230
+ const f = fixture(t);
231
+ seedSource(f.source);
232
+ // A read-only target makes every write fail; nothing is half-written.
233
+ writeFileSync(join(f.target, "settings.json"), "{}");
234
+ chmodSync(f.target, 0o500);
235
+ let result;
236
+ try {
237
+ result = importPi({ source: f.source, target: f.target, dryRun: false });
238
+ } finally {
239
+ chmodSync(f.target, 0o700); // before the fixture's own cleanup
240
+ }
241
+ assert.equal(result.failed, true);
242
+ assert.equal(statusOf(result, "auth.json"), "failed");
243
+ assert.equal(statusOf(result, "settings.json defaultProvider"), "failed");
244
+ });
245
+
246
+ test("the report has one line per item and a summary, and never prints credential contents", (t) => {
247
+ const f = fixture(t);
248
+ seedSource(f.source);
249
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
250
+ const lines = formatImportPiReport(result, { source: f.source, target: f.target, dryRun: false });
251
+ const text = lines.join("\n");
252
+ assert.match(text, /auth\.json: copied \(0600\)/);
253
+ assert.match(text, /mcp\.json: copied/);
254
+ assert.match(text, /settings\.json defaultModel: copied/);
255
+ assert.match(text, /Imported 5 item\(s\)/);
256
+ assert.doesNotMatch(text, /dummy-refresh|dummy-access/);
257
+
258
+ const again = formatImportPiReport(importPi({ source: f.source, target: f.target, dryRun: true }), { source: f.source, target: f.target, dryRun: true });
259
+ assert.match(again.join("\n"), /auth\.json: already present, kept/);
260
+ assert.match(again.join("\n"), /Dry run/);
261
+ assert.match(again.join("\n"), /Nothing to import/);
262
+ });
@@ -5,7 +5,9 @@ import { fileURLToPath } from "node:url";
5
5
  import test from "node:test";
6
6
  import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, parseChildPackageInjection } from "../lib/child-package-injection.ts";
7
7
  import {
8
+ GENTLE_AI_PI_EXCLUDE_PACKAGES_ENV,
8
9
  MIN_PI_VERSION,
10
+ withLauncherPackagesExcluded,
9
11
  MIN_SETUP_GENTLE_AI_VERSION,
10
12
  PI_SUBCOMMANDS,
11
13
  buildPiInvocation,
@@ -37,6 +39,7 @@ import {
37
39
  userPiHome,
38
40
  resolvePiRuntime,
39
41
  restoreJsonField,
42
+ separateHomeHint,
40
43
  settingsDeclareGentlePi,
41
44
  shellQuote,
42
45
  type PackageJsonPeerShape,
@@ -66,6 +69,20 @@ test("parseLauncherArgs returns all-false defaults for an empty argv", () => {
66
69
  });
67
70
  });
68
71
 
72
+ test("parseLauncherArgs reserves explicit memory recovery without consuming prior commands or escaped prompts", () => {
73
+ const memory = parseLauncherArgs(["memory", "migrate", "--confirm-quiescent"]);
74
+ assert.equal(memory.command, "memory");
75
+ assert.deepEqual(memory.commandArgs, ["migrate", "--confirm-quiescent"]);
76
+ assert.deepEqual(memory.passthrough, []);
77
+ for (const command of ["home", "upgrade", "self-uninstall"] as const) {
78
+ const parsed = parseLauncherArgs([command, "--help"]);
79
+ assert.equal(parsed.command, command);
80
+ assert.deepEqual(parsed.commandArgs, ["--help"]);
81
+ }
82
+ assert.deepEqual(parseLauncherArgs(["--", "memory"]).passthrough, ["memory"]);
83
+ assert.match(helpText(), /gentle-shell memory migrate --confirm-quiescent/);
84
+ });
85
+
69
86
  test("parseLauncherArgs captures a --package-root value from the next argument", () => {
70
87
  assert.equal(parseLauncherArgs(["--package-root", "/custom/root"]).packageRoot, "/custom/root");
71
88
  });
@@ -205,6 +222,40 @@ test("parseLauncherArgs treats home as a plain passthrough token when it is not
205
222
  assert.deepEqual(parsed.passthrough, ["home"]);
206
223
  });
207
224
 
225
+ test("parseLauncherArgs accepts home selectors ahead of home import-pi only", () => {
226
+ const link = parseLauncherArgs(["--link", "home", "import-pi"]);
227
+ assert.equal(link.link, true);
228
+ assert.equal(link.command, "home");
229
+ assert.deepEqual(link.commandArgs, ["import-pi"]);
230
+ assert.deepEqual(link.passthrough, []);
231
+ const path = parseLauncherArgs(["--home", "/x", "home", "import-pi", "--dry-run"]);
232
+ assert.equal(path.home, "/x");
233
+ assert.equal(path.command, "home");
234
+ assert.deepEqual(path.commandArgs, ["import-pi", "--dry-run"]);
235
+ const first = parseLauncherArgs(["home", "import-pi", "--dry-run"]);
236
+ assert.equal(first.command, "home");
237
+ assert.deepEqual(first.commandArgs, ["import-pi", "--dry-run"]);
238
+ // Anything else after a selector stays pi's, as before.
239
+ assert.deepEqual(parseLauncherArgs(["--link", "home", "link"]).passthrough, ["home", "link"]);
240
+ assert.deepEqual(parseLauncherArgs(["-p", "home", "import-pi"]).passthrough, ["-p", "home", "import-pi"]);
241
+ });
242
+
243
+ test("help text documents gentle-shell home import-pi", () => {
244
+ const text = helpText();
245
+ assert.ok(text.includes(" gentle-shell [home selectors] home import-pi [--dry-run]"));
246
+ assert.match(text, /import-pi\s+Copy your pi sign-ins \(auth\.json\) and MCP servers \(mcp\.json\)/);
247
+ });
248
+
249
+ test("separateHomeHint mentions home import-pi only when the user's pi home has sign-ins", () => {
250
+ const isolated: ResolvedHome = { mode: "isolated", dir: "/h/.gentle-shell/agent", source: "default" };
251
+ const without = separateHomeHint(isolated, { piSignIns: false });
252
+ assert.equal(without, "gentle-shell: using a separate home at /h/.gentle-shell/agent. Run 'gentle-shell --link' to reuse your pi sign-ins and chats.");
253
+ const withSignIns = separateHomeHint(isolated, { piSignIns: true });
254
+ assert.match(withSignIns, /Run 'gentle-shell --link' to reuse your pi sign-ins and chats, or 'gentle-shell home import-pi' to copy your sign-ins and MCP servers into this home\.$/);
255
+ const path = separateHomeHint({ mode: "path", dir: "/custom", source: "flag" }, { piSignIns: true });
256
+ assert.match(path, /'gentle-shell --home \/custom home import-pi'/);
257
+ });
258
+
208
259
  // --- setup subcommand ----------------------------------------------------
209
260
  //
210
261
  // Unlike `home`, `setup` is not restricted to argv[0]: it accepts the home
@@ -293,6 +344,19 @@ test("parseLauncherArgs recognises a pi subcommand as the first token after --",
293
344
  assert.deepEqual(parsed.passthrough, ["install", "npm:x"]);
294
345
  });
295
346
 
347
+ test("parseLauncherArgs keeps a separator that follows a pi subcommand (#2140)", () => {
348
+ const parsed = parseLauncherArgs(["mcp", "add", "demo", "--", "npx", "demo-server"]);
349
+ assert.equal(parsed.piSubcommand, "mcp");
350
+ assert.deepEqual(parsed.passthrough, ["mcp", "add", "demo", "--", "npx", "demo-server"]);
351
+ });
352
+
353
+ test("parseLauncherArgs still consumes a later separator when no pi subcommand started", () => {
354
+ const parsed = parseLauncherArgs(["hello", "--", "--link"]);
355
+ assert.equal(parsed.piSubcommand, undefined);
356
+ assert.equal(parsed.link, false);
357
+ assert.deepEqual(parsed.passthrough, ["hello", "--link"]);
358
+ });
359
+
296
360
  test("parseLauncherArgs errors when --link is combined with --isolated", () => {
297
361
  const parsed = parseLauncherArgs(["--link", "--isolated"]);
298
362
  assert.match(parsed.error ?? "", /--link/);
@@ -1826,8 +1890,34 @@ test("quoteForCmdExe leaves a plain token untouched", () => {
1826
1890
  assert.equal(quoteForCmdExe("pi"), "pi");
1827
1891
  });
1828
1892
 
1829
- test("quoteForCmdExe quotes a token with a space and escapes an inner double quote", () => {
1830
- assert.equal(quoteForCmdExe('say "hi" now'), '"say \\"hi\\" now"');
1893
+ test("quoteForCmdExe quotes a token with a space and the other cmd.exe specials", () => {
1894
+ assert.equal(quoteForCmdExe("say hi now"), '"say hi now"');
1895
+ assert.equal(quoteForCmdExe("a&b"), '"a&b"');
1896
+ });
1897
+
1898
+ // cmd.exe has no backslash escape: a `"` inside a quoted token ends the
1899
+ // quoted section, so everything after it is parsed as command syntax. The
1900
+ // simple quoting cannot represent such a token, so it refuses it instead of
1901
+ // emitting a command line that lets the argument inject commands.
1902
+ test("quoteForCmdExe rejects a token with a double quote", () => {
1903
+ for (const token of ['say "hi" now', 'x" & whoami & rem "', '"', 'a"b']) {
1904
+ assert.throws(() => quoteForCmdExe(token), /double quote/);
1905
+ }
1906
+ });
1907
+
1908
+ test("planSpawn refuses a win32 .cmd launch whose argument carries a double quote", () => {
1909
+ const hostile = 'x" & whoami & rem "';
1910
+ for (const command of ["C:\\Program Files\\nodejs\\pi.cmd", "C:\\pi\\pi.BAT"]) {
1911
+ assert.throws(() => planSpawn({ command, args: ["--version", hostile], platform: "win32" }), /double quote/);
1912
+ }
1913
+ assert.throws(() => planSpawn({ command: 'C:\\pi\\a"b.cmd', args: [], platform: "win32" }), /double quote/);
1914
+ });
1915
+
1916
+ test("planSpawn passes a double quote through when no cmd.exe command line is built", () => {
1917
+ const args = ["--prompt", 'say "hi"'];
1918
+ assert.deepEqual(planSpawn({ command: "C:\\pi\\pi.exe", args, platform: "win32" }), { command: "C:\\pi\\pi.exe", args, shell: false });
1919
+ assert.deepEqual(planSpawn({ command: "/usr/bin/pi", args, platform: "linux" }), { command: "/usr/bin/pi", args, shell: false });
1920
+ assert.deepEqual(planSpawn({ command: "/usr/local/bin/pi.cmd", args, platform: "darwin" }), { command: "/usr/local/bin/pi.cmd", args, shell: false });
1831
1921
  });
1832
1922
 
1833
1923
  test("quoteForCmdExe quotes an empty token", () => {
@@ -2032,3 +2122,18 @@ test("the isolated-home bootstrap creates ~/.gentle-shell as 0700 under umask 00
2032
2122
  rmSync(root, { recursive: true, force: true });
2033
2123
  }
2034
2124
  });
2125
+
2126
+ // #2143: Gentle AI installs npm:gentle-pi into the home only for the launcher
2127
+ // to remove it again; a Gentle AI with package exclusion (gentle-ai#5503)
2128
+ // skips it, and an older one ignores the variable.
2129
+ test("withLauncherPackagesExcluded asks Gentle AI to skip npm:gentle-pi, keeping a caller's own exclusions", () => {
2130
+ assert.equal(GENTLE_AI_PI_EXCLUDE_PACKAGES_ENV, "GENTLE_AI_PI_EXCLUDE_PACKAGES");
2131
+ assert.equal(withLauncherPackagesExcluded({ PATH: "/bin" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:gentle-pi");
2132
+ assert.equal(withLauncherPackagesExcluded({ GENTLE_AI_PI_EXCLUDE_PACKAGES: "" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:gentle-pi");
2133
+ assert.equal(withLauncherPackagesExcluded({ GENTLE_AI_PI_EXCLUDE_PACKAGES: "npm:pi-btw" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:pi-btw,npm:gentle-pi");
2134
+ assert.equal(withLauncherPackagesExcluded({ GENTLE_AI_PI_EXCLUDE_PACKAGES: "npm:gentle-pi, npm:pi-btw" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:gentle-pi,npm:pi-btw");
2135
+ const input = { PATH: "/bin" };
2136
+ assert.equal(withLauncherPackagesExcluded(input).PATH, "/bin");
2137
+ assert.deepEqual(input, { PATH: "/bin" }, "the caller's env is not mutated");
2138
+ });
2139
+
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
  import { parseLauncherArgs } from "../lib/gentle-shell-launcher.ts";
4
- import { findStartupUpdate, shouldCheckStartupUpdate } from "../lib/gentle-shell-startup-update.ts";
4
+ import { findStartupUpdate, parseStartupReleaseCache, shouldCheckStartupUpdate, writeStartupReleaseCache } from "../lib/gentle-shell-startup-update.ts";
5
5
 
6
6
  const eligible = {
7
7
  args: parseLauncherArgs([]), env: {}, bundled: true, inputTTY: true, outputTTY: true,
@@ -83,3 +83,79 @@ test("custom deadline is bounded and settled lookups clear the timeout", async (
83
83
  t.mock.timers.tick(20);
84
84
  assert.equal(settledSignal?.aborted, false);
85
85
  });
86
+
87
+ // A fresh cache keeps the everyday launch off the network entirely: the offer
88
+ // (or its absence) is decided from the recorded lookup, and the live lookup —
89
+ // whose result is the only thing worth caching — runs only when the cache is
90
+ // stale, missing, or malformed.
91
+ test("a fresh cache decides the offer without touching the network", async () => {
92
+ const offline = async (): Promise<string> => { throw new Error("lookup must not run on a fresh cache"); };
93
+ const fresh = { checkedAt: Date.now() - 60 * 60 * 1000, latest: "2.0.0" };
94
+ assert.deepEqual(await findStartupUpdate({ current: "1.0.0", lookup: offline, cache: fresh, ttlMs: 24 * 60 * 60 * 1000 }), {
95
+ current: "1.0.0", latest: "2.0.0", notesUrl: "https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v2.0.0",
96
+ });
97
+ assert.equal(await findStartupUpdate({ current: "2.0.0", lookup: offline, cache: fresh, ttlMs: 24 * 60 * 60 * 1000 }), undefined);
98
+ });
99
+
100
+ test("a fresh cache respects the skipped release exactly like a live lookup", async () => {
101
+ const offline = async (): Promise<string> => { throw new Error("lookup must not run on a fresh cache"); };
102
+ assert.equal(await findStartupUpdate({ current: "1.0.0", skipped: "2.0.0", lookup: offline, cache: { checkedAt: Date.now() - 1000, latest: "2.0.0" } }), undefined);
103
+ assert.equal((await findStartupUpdate({ current: "1.0.0", skipped: "2.0.0", lookup: offline, cache: { checkedAt: Date.now() - 1000, latest: "2.0.1" } }))?.latest, "2.0.1");
104
+ });
105
+
106
+ test("a stale, missing or malformed cache runs the live lookup and records its answer", async () => {
107
+ const written: string[] = [];
108
+ const opts = {
109
+ current: "1.0.0",
110
+ lookup: async () => "2.0.1",
111
+ ttlMs: 1000,
112
+ cacheWrite: (latest: string) => { written.push(latest); },
113
+ };
114
+ // Stale beyond the TTL.
115
+ assert.equal((await findStartupUpdate({ ...opts, cache: { checkedAt: Date.now() - 1001, latest: "2.0.0" } }))?.latest, "2.0.1");
116
+ // No cache at all.
117
+ assert.equal((await findStartupUpdate({ ...opts }))?.latest, "2.0.1");
118
+ // A cache whose latest never parsed: never trusted, refreshed instead.
119
+ assert.equal((await findStartupUpdate({ ...opts, cache: { checkedAt: Date.now() - 1, latest: "garbage" } }))?.latest, "2.0.1");
120
+ assert.deepEqual(written, ["2.0.1", "2.0.1", "2.0.1"]);
121
+ });
122
+
123
+ test("an unanswered lookup is never cached", async () => {
124
+ const written: string[] = [];
125
+ assert.equal(await findStartupUpdate({ current: "1.0.0", lookup: async () => undefined, cacheWrite: (latest) => { written.push(latest); } }), undefined);
126
+ assert.equal(await findStartupUpdate({ current: "1.0.0", lookup: async () => { throw new Error("offline"); }, cacheWrite: (latest) => { written.push(latest); } }), undefined);
127
+ assert.deepEqual(written, []);
128
+ });
129
+
130
+ test("the cache ages against the injected clock, not the wall", async () => {
131
+ const now = 1_700_000_000_000;
132
+ const offline = async (): Promise<string> => { throw new Error("lookup must not run on a fresh cache"); };
133
+ // Exactly at the TTL boundary the cache is already stale: the check errs
134
+ // toward the live lookup, never toward skipping it.
135
+ assert.equal((await findStartupUpdate({ current: "1.0.0", lookup: async () => "2.0.0", ttlMs: 1000, nowMs: now, cache: { checkedAt: now - 1000, latest: "2.0.0" } }))?.latest, "2.0.0");
136
+ assert.deepEqual(await findStartupUpdate({ current: "1.0.0", lookup: offline, ttlMs: 1000, nowMs: now - 1, cache: { checkedAt: now - 1000, latest: "2.0.0" } }).then((offer) => offer?.latest), "2.0.0");
137
+ });
138
+
139
+ test("parseStartupReleaseCache accepts the recorded shape and rejects everything else", () => {
140
+ assert.deepEqual(parseStartupReleaseCache({ checkedAt: "2026-01-01T00:00:00.000Z", latest: "2.0.1" }), { checkedAt: Date.parse("2026-01-01T00:00:00.000Z"), latest: "2.0.1" });
141
+ assert.deepEqual(parseStartupReleaseCache({ checkedAt: 1_700_000_000_000, latest: "2.0.1" }), { checkedAt: 1_700_000_000_000, latest: "2.0.1" });
142
+ for (const bad of [undefined, null, "x", [], {}, { checkedAt: "nope", latest: "2.0.1" }, { checkedAt: 1_700_000_000_000 }, { checkedAt: 1_700_000_000_000, latest: "" }]) {
143
+ assert.equal(parseStartupReleaseCache(bad), undefined);
144
+ }
145
+ });
146
+
147
+ test("writeStartupReleaseCache records one lookup alongside the existing config keys", () => {
148
+ const stored: string[] = [];
149
+ const config = JSON.stringify({ skippedStartupRelease: "9.9.9", other: { nested: true } });
150
+ writeStartupReleaseCache(() => config, (text) => stored.push(text), "2.0.1", 1_700_000_000_000);
151
+ const saved = JSON.parse(stored[0]);
152
+ assert.equal(saved.skippedStartupRelease, "9.9.9");
153
+ assert.deepEqual(saved.other, { nested: true });
154
+ assert.equal(saved.startupReleaseCheck.latest, "2.0.1");
155
+ assert.equal(saved.startupReleaseCheck.checkedAt, new Date(1_700_000_000_000).toISOString());
156
+ // An absent config file starts a minimal one.
157
+ writeStartupReleaseCache(() => undefined, (text) => stored.push(text), "2.0.1");
158
+ assert.deepEqual(JSON.parse(stored[1]).startupReleaseCheck.latest, "2.0.1");
159
+ // An invalid config must fail loudly, never overwrite it silently.
160
+ assert.throws(() => writeStartupReleaseCache(() => "{ broken", () => {}, "2.0.1"));
161
+ });
@@ -976,3 +976,92 @@ test("bundled on Windows: the user Path entry goes first, the running node.exe,
976
976
  assert.equal(existsSync(join(w.shared.agent, "auth.json")), true);
977
977
  } finally { w.cleanup(); }
978
978
  });
979
+
980
+ // #2469: a home that self-uninstall keeps must not keep Pi pointed at the npm
981
+ // the uninstall removed: a stale `npmCommand` makes every later `pi install`
982
+ // fail with ENOENT naming the deleted node.exe.
983
+ const bundledNpmCommand = (w: BundledWorld) => [w.node, join(dirname(w.node), "gentle-shell", "npm"), "--update-notifier=false"];
984
+
985
+ const bundledNpmSettings = (w: BundledWorld, npmCommand: unknown) => `${JSON.stringify({ theme: "dark", npmCommand }, null, " ")}\n`;
986
+
987
+ test("bundled: keeping the home removes the npmCommand that names the removed prefix and keeps the rest of settings.json", posixOnly, async () => {
988
+ const w = bundledWorld();
989
+ try {
990
+ const settings = join(w.shared.agent, "settings.json");
991
+ write(settings, bundledNpmSettings(w, bundledNpmCommand(w)));
992
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
993
+ assert.deepEqual(JSON.parse(readFileSync(settings, "utf8")), { theme: "dark" });
994
+ assert.ok(w.out.join("\n").includes(`npmCommand from ${settings}`), w.out.join("\n"));
995
+ } finally { w.cleanup(); }
996
+ });
997
+
998
+ test("bundled: keeping the home leaves settings.json alone when npmCommand does not name the prefix", posixOnly, async () => {
999
+ const w = bundledWorld();
1000
+ try {
1001
+ const settings = join(w.shared.agent, "settings.json");
1002
+ const text = bundledNpmSettings(w, ["/usr/local/bin/node", "--update-notifier=false"]);
1003
+ write(settings, text);
1004
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
1005
+ assert.equal(readFileSync(settings, "utf8"), text);
1006
+ } finally { w.cleanup(); }
1007
+ });
1008
+
1009
+ test("bundled: keeping the home never rewrites a settings.json that is not a JSON object", posixOnly, async () => {
1010
+ const w = bundledWorld();
1011
+ try {
1012
+ const settings = join(w.shared.agent, "settings.json");
1013
+ write(settings, "not json at all\n");
1014
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
1015
+ assert.equal(readFileSync(settings, "utf8"), "not json at all\n");
1016
+ } finally { w.cleanup(); }
1017
+ });
1018
+
1019
+ test("bundled on Windows: keeping the home also removes the npmCommand naming the removed prefix", async () => {
1020
+ const w = bundledWorld({ platform: "win32" });
1021
+ try {
1022
+ const settings = join(w.shared.agent, "settings.json");
1023
+ write(settings, `${JSON.stringify({ npmCommand: bundledNpmCommand(w) }, null, " ")}\n`);
1024
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
1025
+ assert.equal(readFileSync(settings, "utf8"), "{}\n");
1026
+ assert.ok(w.out.join("\n").includes(`npmCommand from ${settings}`), w.out.join("\n"));
1027
+ } finally { w.cleanup(); }
1028
+ });
1029
+
1030
+ test("bundled: --include-shared removes the home with its settings, so no npmCommand cleanup is needed", posixOnly, async () => {
1031
+ const w = bundledWorld();
1032
+ try {
1033
+ write(join(w.shared.agent, "settings.json"), bundledNpmSettings(w, bundledNpmCommand(w)));
1034
+ assert.equal(await w.uninstall(["--yes", "--include-shared"]), 0, w.err.join("\n"));
1035
+ assert.equal(existsSync(join(w.shared.agent, "settings.json")), false);
1036
+ } finally { w.cleanup(); }
1037
+ });
1038
+
1039
+ // #2201: the standalone isolated home keeps its config in ~/.gentle-shell/gentle-ai.
1040
+ test("Gentle Shell's own config folder next to the isolated home is removed; the shared config home keeps its rule", async () => {
1041
+ const w = world();
1042
+ try {
1043
+ const own = join(w.shell, "gentle-ai");
1044
+ write(join(own, "profiles.json"));
1045
+ write(join(own, "persona.json"));
1046
+ assert.equal(await w.uninstall(["--dry-run"]), 0);
1047
+ assert.ok(section(w.out, "Remove Gentle Shell data:").some((line) => line.includes(own)), w.out.join("\n"));
1048
+ assert.equal(await w.uninstall(["--yes"]), 0);
1049
+ assert.equal(existsSync(own), false);
1050
+ assert.equal(existsSync(w.shell), false);
1051
+ for (const name of ["profiles.json", "persona.json"]) assert.equal(existsSync(join(w.config, name)), true, name);
1052
+ } finally { w.cleanup(); }
1053
+ });
1054
+
1055
+ test("the gentle-ai folder next to a custom GENTLE_SHELL_HOME is kept: it may not be Gentle Shell's", async () => {
1056
+ const w = world();
1057
+ try {
1058
+ const custom = join(w.root, "elsewhere", "agent");
1059
+ const sibling = join(w.root, "elsewhere", "gentle-ai");
1060
+ write(join(custom, ".gentle-shell-home"));
1061
+ write(join(sibling, "notes.json"));
1062
+ assert.equal(await w.uninstall(["--yes"], { env: { GENTLE_SHELL_HOME: custom } }), 0);
1063
+ assert.equal(existsSync(custom), false);
1064
+ assert.equal(existsSync(join(sibling, "notes.json")), true);
1065
+ assert.ok(section(w.out, "Kept:").some((line) => line.includes(sibling)), w.out.join("\n"));
1066
+ } finally { w.cleanup(); }
1067
+ });